mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-25 18:55:19 +02:00
parent
fcfe5593b4
commit
eda3f3821f
52 changed files with 3833 additions and 71 deletions
|
|
@ -0,0 +1,244 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Panel } from "@/components/common/panel-common";
|
||||
import { createRecurringJobRule } from "@/app/actions/recurring-job-rules.actions";
|
||||
import { ArrowLeftIcon, PlusIcon, TrashIcon, InfoIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
// Define a simpler message type for the form that only includes the fields we need
|
||||
type FormMessage = {
|
||||
role: "system" | "user" | "assistant";
|
||||
content: string;
|
||||
};
|
||||
|
||||
const commonCronExamples = [
|
||||
{ label: "Every minute", value: "* * * * *" },
|
||||
{ label: "Every 5 minutes", value: "*/5 * * * *" },
|
||||
{ label: "Every hour", value: "0 * * * *" },
|
||||
{ label: "Every 2 hours", value: "0 */2 * * *" },
|
||||
{ label: "Daily at midnight", value: "0 0 * * *" },
|
||||
{ label: "Daily at 9 AM", value: "0 9 * * *" },
|
||||
{ label: "Weekly on Sunday at midnight", value: "0 0 * * 0" },
|
||||
{ label: "Monthly on the 1st at midnight", value: "0 0 1 * *" },
|
||||
];
|
||||
|
||||
export function CreateRecurringJobRuleForm({ projectId }: { projectId: string }) {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [messages, setMessages] = useState<FormMessage[]>([
|
||||
{ role: "user", content: "" }
|
||||
]);
|
||||
const [cronExpression, setCronExpression] = useState("* * * * *");
|
||||
const [showCronHelp, setShowCronHelp] = useState(false);
|
||||
|
||||
const addMessage = () => {
|
||||
setMessages([...messages, { role: "user", content: "" }]);
|
||||
};
|
||||
|
||||
const removeMessage = (index: number) => {
|
||||
if (messages.length > 1) {
|
||||
setMessages(messages.filter((_, i) => i !== index));
|
||||
}
|
||||
};
|
||||
|
||||
const updateMessage = (index: number, field: keyof FormMessage, value: string) => {
|
||||
const newMessages = [...messages];
|
||||
newMessages[index] = { ...newMessages[index], [field]: value };
|
||||
setMessages(newMessages);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Validate required fields
|
||||
if (!cronExpression.trim()) {
|
||||
alert("Please enter a cron expression");
|
||||
return;
|
||||
}
|
||||
|
||||
if (messages.some(msg => !msg.content?.trim())) {
|
||||
alert("Please fill in all message content");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
// Convert FormMessage to the expected Message type
|
||||
const convertedMessages = messages.map(msg => {
|
||||
if (msg.role === "assistant") {
|
||||
return {
|
||||
role: msg.role,
|
||||
content: msg.content,
|
||||
agentName: null,
|
||||
responseType: "internal" as const,
|
||||
timestamp: undefined
|
||||
};
|
||||
}
|
||||
return {
|
||||
role: msg.role,
|
||||
content: msg.content,
|
||||
timestamp: undefined
|
||||
};
|
||||
});
|
||||
|
||||
await createRecurringJobRule({
|
||||
projectId,
|
||||
input: { messages: convertedMessages },
|
||||
cron: cronExpression,
|
||||
});
|
||||
router.push(`/projects/${projectId}/job-rules`);
|
||||
} catch (error) {
|
||||
console.error("Failed to create recurring job rule:", error);
|
||||
alert("Failed to create recurring job rule");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Panel
|
||||
title={
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href={`/projects/${projectId}/job-rules`}>
|
||||
<Button variant="secondary" size="sm">
|
||||
<ArrowLeftIcon className="w-4 h-4 mr-2" />
|
||||
Back
|
||||
</Button>
|
||||
</Link>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
CREATE RECURRING JOB RULE
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="h-full overflow-auto px-4 py-4">
|
||||
<div className="max-w-[800px] mx-auto">
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Cron Expression */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Cron Expression *
|
||||
</label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setShowCronHelp(!showCronHelp)}
|
||||
className="p-1"
|
||||
>
|
||||
<InfoIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
value={cronExpression}
|
||||
onChange={(e) => setCronExpression(e.target.value)}
|
||||
placeholder="* * * * *"
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white font-mono"
|
||||
required
|
||||
/>
|
||||
|
||||
{showCronHelp && (
|
||||
<div className="mt-3 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-md">
|
||||
<div className="text-sm text-blue-800 dark:text-blue-200 mb-2">
|
||||
<strong>Format:</strong> minute hour day month dayOfWeek
|
||||
</div>
|
||||
<div className="text-sm text-blue-700 dark:text-blue-300 mb-3">
|
||||
<strong>Examples:</strong>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
{commonCronExamples.map((example, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<code className="text-xs bg-blue-100 dark:bg-blue-800 px-2 py-1 rounded">
|
||||
{example.value}
|
||||
</code>
|
||||
<span className="text-xs text-blue-600 dark:text-blue-300">
|
||||
{example.label}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-xs text-blue-600 dark:text-blue-300 mt-2">
|
||||
<strong>Note:</strong> All times are in UTC timezone
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Messages *
|
||||
</label>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={addMessage}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<PlusIcon className="w-4 h-4" />
|
||||
Add Message
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{messages.map((message, index) => (
|
||||
<div key={index} className="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<select
|
||||
value={message.role}
|
||||
onChange={(e) => updateMessage(index, "role", e.target.value)}
|
||||
className="px-3 py-1 border border-gray-300 dark:border-gray-600 rounded-md text-sm dark:bg-gray-700 dark:text-white"
|
||||
>
|
||||
<option value="system">System</option>
|
||||
<option value="user">User</option>
|
||||
<option value="assistant">Assistant</option>
|
||||
</select>
|
||||
{messages.length > 1 && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => removeMessage(index)}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
|
||||
>
|
||||
<TrashIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<textarea
|
||||
value={message.content}
|
||||
onChange={(e) => updateMessage(index, "content", e.target.value)}
|
||||
placeholder={`Enter ${message.role} message...`}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
|
||||
rows={3}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="px-6 py-2"
|
||||
>
|
||||
{loading ? "Creating..." : "Create Rule"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from "react";
|
||||
import { Tabs, Tab } from "@/components/ui/tabs";
|
||||
import { ScheduledJobRulesList } from "../scheduled/components/scheduled-job-rules-list";
|
||||
import { RecurringJobRulesList } from "./recurring-job-rules-list";
|
||||
|
||||
export function JobRulesTabs({ projectId }: { projectId: string }) {
|
||||
const [activeTab, setActiveTab] = useState<string>("scheduled");
|
||||
|
||||
const handleTabChange = (key: React.Key) => {
|
||||
setActiveTab(key.toString());
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<Tabs
|
||||
selectedKey={activeTab}
|
||||
onSelectionChange={handleTabChange}
|
||||
aria-label="Job Rules"
|
||||
fullWidth
|
||||
>
|
||||
<Tab key="scheduled" title="Scheduled Rules">
|
||||
<ScheduledJobRulesList projectId={projectId} />
|
||||
</Tab>
|
||||
<Tab key="recurring" title="Recurring Rules">
|
||||
<RecurringJobRulesList projectId={projectId} />
|
||||
</Tab>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,312 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Panel } from "@/components/common/panel-common";
|
||||
import { fetchRecurringJobRule, toggleRecurringJobRule, deleteRecurringJobRule } from "@/app/actions/recurring-job-rules.actions";
|
||||
import { ArrowLeftIcon, PlayIcon, PauseIcon, ClockIcon, AlertCircleIcon, Trash2Icon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { RecurringJobRule } from "@/src/entities/models/recurring-job-rule";
|
||||
import { Spinner } from "@heroui/react";
|
||||
import { z } from "zod";
|
||||
|
||||
export function RecurringJobRuleView({ projectId, ruleId }: { projectId: string; ruleId: string }) {
|
||||
const router = useRouter();
|
||||
const [rule, setRule] = useState<z.infer<typeof RecurringJobRule> | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [updating, setUpdating] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const loadRule = async () => {
|
||||
try {
|
||||
const fetchedRule = await fetchRecurringJobRule({ ruleId });
|
||||
setRule(fetchedRule);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch rule:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadRule();
|
||||
}, [ruleId]);
|
||||
|
||||
const handleToggleStatus = async () => {
|
||||
if (!rule) return;
|
||||
|
||||
setUpdating(true);
|
||||
try {
|
||||
const updatedRule = await toggleRecurringJobRule({
|
||||
ruleId: rule.id,
|
||||
disabled: !rule.disabled,
|
||||
});
|
||||
setRule(updatedRule);
|
||||
} catch (error) {
|
||||
console.error("Failed to update rule:", error);
|
||||
alert("Failed to update rule status");
|
||||
} finally {
|
||||
setUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!rule) return;
|
||||
|
||||
setDeleting(true);
|
||||
try {
|
||||
await deleteRecurringJobRule({
|
||||
projectId,
|
||||
ruleId: rule.id,
|
||||
});
|
||||
// Redirect back to job rules list
|
||||
router.push(`/projects/${projectId}/job-rules`);
|
||||
} catch (error) {
|
||||
console.error("Failed to delete rule:", error);
|
||||
alert("Failed to delete rule");
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
setShowDeleteConfirm(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatCronExpression = (cron: string) => {
|
||||
// Simple cron formatting for display
|
||||
const parts = cron.split(' ');
|
||||
if (parts.length === 5) {
|
||||
const [minute, hour, day, month, dayOfWeek] = parts;
|
||||
if (minute === '*' && hour === '*' && day === '*' && month === '*' && dayOfWeek === '*') {
|
||||
return 'Every minute';
|
||||
}
|
||||
if (minute === '0' && hour === '*' && day === '*' && month === '*' && dayOfWeek === '*') {
|
||||
return 'Every hour';
|
||||
}
|
||||
if (minute === '0' && hour === '0' && day === '*' && month === '*' && dayOfWeek === '*') {
|
||||
return 'Daily at midnight';
|
||||
}
|
||||
if (minute === '0' && hour === '0' && day === '1' && month === '*' && dayOfWeek === '*') {
|
||||
return 'Monthly on the 1st';
|
||||
}
|
||||
if (minute === '0' && hour === '0' && day === '*' && month === '*' && dayOfWeek === '0') {
|
||||
return 'Weekly on Sunday';
|
||||
}
|
||||
}
|
||||
return cron;
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleString();
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Panel title="Loading...">
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
if (!rule) {
|
||||
return (
|
||||
<Panel title="Rule Not Found">
|
||||
<div className="text-center py-8">
|
||||
<p className="text-gray-500 dark:text-gray-400">The requested rule could not be found.</p>
|
||||
<Link href={`/projects/${projectId}/job-rules`}>
|
||||
<Button variant="secondary" className="mt-4">
|
||||
Back to Job Rules
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Panel
|
||||
title={
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href={`/projects/${projectId}/job-rules`}>
|
||||
<Button variant="secondary" size="sm">
|
||||
<ArrowLeftIcon className="w-4 h-4 mr-2" />
|
||||
Back
|
||||
</Button>
|
||||
</Link>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
RECURRING JOB RULE
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
rightActions={
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
onClick={handleToggleStatus}
|
||||
disabled={updating}
|
||||
variant={rule.disabled ? "secondary" : "primary"}
|
||||
size="sm"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
{updating ? (
|
||||
<Spinner size="sm" />
|
||||
) : rule.disabled ? (
|
||||
<>
|
||||
<PlayIcon className="w-4 h-4" />
|
||||
Enable
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<PauseIcon className="w-4 h-4" />
|
||||
Disable
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="flex items-center gap-2 bg-red-50 hover:bg-red-100 text-red-700 dark:bg-red-950 dark:hover:bg-red-900 dark:text-red-400 border border-red-200 dark:border-red-800"
|
||||
>
|
||||
<Trash2Icon className="w-4 h-4" />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="h-full overflow-auto px-4 py-4">
|
||||
<div className="max-w-[800px] mx-auto space-y-6">
|
||||
{/* Status */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className={`w-3 h-3 rounded-full ${rule.disabled ? 'bg-red-500' : 'bg-green-500'}`} />
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
Status: {rule.disabled ? 'Disabled' : 'Active'}
|
||||
</span>
|
||||
</div>
|
||||
{rule.lastError && (
|
||||
<div className="flex items-start gap-2 mt-2 p-2 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded">
|
||||
<AlertCircleIcon className="w-4 h-4 text-red-500 mt-0.5 flex-shrink-0" />
|
||||
<div className="text-sm text-red-700 dark:text-red-300">
|
||||
<strong>Last Error:</strong> {rule.lastError}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Schedule Information */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-3">
|
||||
Schedule Information
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<ClockIcon className="w-4 h-4 text-gray-500" />
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">Cron Expression:</span>
|
||||
<code className="px-2 py-1 bg-gray-100 dark:bg-gray-700 rounded text-sm font-mono">
|
||||
{rule.cron}
|
||||
</code>
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
<strong>Human Readable:</strong> {formatCronExpression(rule.cron)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
<strong>Next Run:</strong> {formatDate(rule.nextRunAt)}
|
||||
</div>
|
||||
{rule.lastProcessedAt && (
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
<strong>Last Processed:</strong> {formatDate(rule.lastProcessedAt)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-3">
|
||||
Messages
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{rule.input.messages.map((message, index) => (
|
||||
<div key={index} className="border border-gray-200 dark:border-gray-600 rounded-lg p-3">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className={`px-2 py-1 rounded text-xs font-medium ${
|
||||
message.role === 'system'
|
||||
? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200'
|
||||
: message.role === 'user'
|
||||
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
|
||||
: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200'
|
||||
}`}>
|
||||
{message.role.charAt(0).toUpperCase() + message.role.slice(1)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap">
|
||||
{message.content}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metadata */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-3">
|
||||
Metadata
|
||||
</h3>
|
||||
<div className="space-y-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<div><strong>Created:</strong> {formatDate(rule.createdAt)}</div>
|
||||
{rule.updatedAt && (
|
||||
<div><strong>Last Updated:</strong> {formatDate(rule.updatedAt)}</div>
|
||||
)}
|
||||
<div><strong>Rule ID:</strong> <code className="bg-gray-100 dark:bg-gray-700 px-1 rounded">{rule.id}</code></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
{showDeleteConfirm && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-md mx-4">
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-4">
|
||||
Delete Recurring Job Rule
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-6">
|
||||
Are you sure you want to delete this recurring job rule? This action cannot be undone and will permanently remove the rule and all its associated data.
|
||||
</p>
|
||||
<div className="flex gap-3 justify-end">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
disabled={deleting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleDelete}
|
||||
disabled={deleting}
|
||||
className="flex items-center gap-2 bg-red-50 hover:bg-red-100 text-red-700 dark:bg-red-950 dark:hover:bg-red-900 dark:text-red-400 border border-red-200 dark:border-red-800"
|
||||
>
|
||||
{deleting ? (
|
||||
<>
|
||||
<Spinner size="sm" />
|
||||
Deleting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Trash2Icon className="w-4 h-4" />
|
||||
Delete
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,215 @@
|
|||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Link, Spinner } from "@heroui/react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Panel } from "@/components/common/panel-common";
|
||||
import { listRecurringJobRules } from "@/app/actions/recurring-job-rules.actions";
|
||||
import { z } from "zod";
|
||||
import { ListedRecurringRuleItem } from "@/src/application/repositories/recurring-job-rules.repository.interface";
|
||||
import { isToday, isThisWeek, isThisMonth } from "@/lib/utils/date";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
|
||||
type ListedItem = z.infer<typeof ListedRecurringRuleItem>;
|
||||
|
||||
export function RecurringJobRulesList({ projectId }: { projectId: string }) {
|
||||
const [items, setItems] = useState<ListedItem[]>([]);
|
||||
const [cursor, setCursor] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [loadingMore, setLoadingMore] = useState<boolean>(false);
|
||||
const [hasMore, setHasMore] = useState<boolean>(false);
|
||||
|
||||
const fetchPage = useCallback(async (cursorArg?: string | null) => {
|
||||
const res = await listRecurringJobRules({ projectId, cursor: cursorArg ?? undefined, limit: 20 });
|
||||
return res;
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
let ignore = false;
|
||||
(async () => {
|
||||
setLoading(true);
|
||||
const res = await fetchPage(null);
|
||||
if (ignore) return;
|
||||
setItems(res.items);
|
||||
setCursor(res.nextCursor);
|
||||
setHasMore(Boolean(res.nextCursor));
|
||||
setLoading(false);
|
||||
})();
|
||||
return () => { ignore = true; };
|
||||
}, [fetchPage]);
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
if (!cursor) return;
|
||||
setLoadingMore(true);
|
||||
const res = await fetchPage(cursor);
|
||||
setItems(prev => [...prev, ...res.items]);
|
||||
setCursor(res.nextCursor);
|
||||
setHasMore(Boolean(res.nextCursor));
|
||||
setLoadingMore(false);
|
||||
}, [cursor, fetchPage]);
|
||||
|
||||
const sections = useMemo(() => {
|
||||
const groups: Record<string, ListedItem[]> = {
|
||||
Today: [],
|
||||
'This week': [],
|
||||
'This month': [],
|
||||
Older: [],
|
||||
};
|
||||
for (const item of items) {
|
||||
const d = new Date(item.nextRunAt);
|
||||
if (isToday(d)) groups['Today'].push(item);
|
||||
else if (isThisWeek(d)) groups['This week'].push(item);
|
||||
else if (isThisMonth(d)) groups['This month'].push(item);
|
||||
else groups['Older'].push(item);
|
||||
}
|
||||
return groups;
|
||||
}, [items]);
|
||||
|
||||
const getStatusColor = (disabled: boolean, lastError: string | null) => {
|
||||
if (disabled) return 'text-red-600 dark:text-red-400';
|
||||
if (lastError) return 'text-yellow-600 dark:text-yellow-400';
|
||||
return 'text-green-600 dark:text-green-400';
|
||||
};
|
||||
|
||||
const getStatusText = (disabled: boolean, lastError: string | null) => {
|
||||
if (disabled) return 'Disabled';
|
||||
if (lastError) return 'Error';
|
||||
return 'Active';
|
||||
};
|
||||
|
||||
const formatNextRunAt = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString();
|
||||
};
|
||||
|
||||
const formatCronExpression = (cron: string) => {
|
||||
// Simple cron formatting for display
|
||||
const parts = cron.split(' ');
|
||||
if (parts.length === 5) {
|
||||
const [minute, hour, day, month, dayOfWeek] = parts;
|
||||
if (minute === '*' && hour === '*' && day === '*' && month === '*' && dayOfWeek === '*') {
|
||||
return 'Every minute';
|
||||
}
|
||||
if (minute === '0' && hour === '*' && day === '*' && month === '*' && dayOfWeek === '*') {
|
||||
return 'Every hour';
|
||||
}
|
||||
if (minute === '0' && hour === '0' && day === '*' && month === '*' && dayOfWeek === '*') {
|
||||
return 'Daily at midnight';
|
||||
}
|
||||
if (minute === '0' && hour === '0' && day === '1' && month === '*' && dayOfWeek === '*') {
|
||||
return 'Monthly on the 1st';
|
||||
}
|
||||
if (minute === '0' && hour === '0' && day === '*' && month === '*' && dayOfWeek === '0') {
|
||||
return 'Weekly on Sunday';
|
||||
}
|
||||
}
|
||||
return cron;
|
||||
};
|
||||
|
||||
return (
|
||||
<Panel
|
||||
title={
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
RECURRING JOB RULES
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
rightActions={
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href={`/projects/${projectId}/job-rules/recurring/new`}>
|
||||
<Button size="sm" className="flex items-center gap-2">
|
||||
<PlusIcon className="w-4 h-4" />
|
||||
New Rule
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="h-full overflow-auto px-4 py-4">
|
||||
<div className="max-w-[1024px] mx-auto">
|
||||
{loading && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Spinner size="sm" />
|
||||
<div>Loading...</div>
|
||||
</div>
|
||||
)}
|
||||
{!loading && (
|
||||
<div className="flex flex-col gap-6">
|
||||
{Object.entries(sections).map(([sectionName, sectionItems]) => {
|
||||
if (sectionItems.length === 0) return null;
|
||||
return (
|
||||
<div key={sectionName} className="space-y-3">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
{sectionName}
|
||||
</h3>
|
||||
<div className="grid gap-3">
|
||||
{sectionItems.map((item) => (
|
||||
<Link
|
||||
key={item.id}
|
||||
href={`/projects/${projectId}/job-rules/recurring/${item.id}`}
|
||||
className="block p-4 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600 transition-colors"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<span className={`text-sm font-medium ${getStatusColor(item.disabled, item.lastError || null)}`}>
|
||||
{getStatusText(item.disabled, item.lastError || null)}
|
||||
</span>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Next run: {formatNextRunAt(item.nextRunAt)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400 mb-1">
|
||||
Schedule: {formatCronExpression(item.cron)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Created: {new Date(item.createdAt).toLocaleDateString()}
|
||||
</div>
|
||||
{item.lastError && (
|
||||
<div className="text-sm text-red-600 dark:text-red-400 mt-1">
|
||||
Last error: {item.lastError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{new Date(item.createdAt).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{items.length === 0 && !loading && (
|
||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
No recurring job rules found. Create your first rule to get started.
|
||||
</div>
|
||||
)}
|
||||
{hasMore && (
|
||||
<div className="text-center">
|
||||
<Button
|
||||
onClick={loadMore}
|
||||
disabled={loadingMore}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
>
|
||||
{loadingMore ? (
|
||||
<>
|
||||
<Spinner size="sm" />
|
||||
Loading...
|
||||
</>
|
||||
) : (
|
||||
'Load More'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
17
apps/rowboat/app/projects/[projectId]/job-rules/page.tsx
Normal file
17
apps/rowboat/app/projects/[projectId]/job-rules/page.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { Metadata } from "next";
|
||||
import { requireActiveBillingSubscription } from '@/app/lib/billing';
|
||||
import { JobRulesTabs } from "./components/job-rules-tabs";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Job Rules",
|
||||
};
|
||||
|
||||
export default async function Page(
|
||||
props: {
|
||||
params: Promise<{ projectId: string }>
|
||||
}
|
||||
) {
|
||||
const params = await props.params;
|
||||
await requireActiveBillingSubscription();
|
||||
return <JobRulesTabs projectId={params.projectId} />;
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
import { Metadata } from "next";
|
||||
import { requireActiveBillingSubscription } from '@/app/lib/billing';
|
||||
import { RecurringJobRuleView } from "../../components/recurring-job-rule-view";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Recurring Job Rule",
|
||||
};
|
||||
|
||||
export default async function Page(
|
||||
props: {
|
||||
params: Promise<{ projectId: string; ruleId: string }>
|
||||
}
|
||||
) {
|
||||
const params = await props.params;
|
||||
await requireActiveBillingSubscription();
|
||||
return <RecurringJobRuleView projectId={params.projectId} ruleId={params.ruleId} />;
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
import { Metadata } from "next";
|
||||
import { requireActiveBillingSubscription } from '@/app/lib/billing';
|
||||
import { CreateRecurringJobRuleForm } from "../../components/create-recurring-job-rule-form";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Recurring Job Rule",
|
||||
};
|
||||
|
||||
export default async function Page(
|
||||
props: {
|
||||
params: Promise<{ projectId: string }>
|
||||
}
|
||||
) {
|
||||
const params = await props.params;
|
||||
await requireActiveBillingSubscription();
|
||||
return <CreateRecurringJobRuleForm projectId={params.projectId} />;
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
import { Metadata } from "next";
|
||||
import { requireActiveBillingSubscription } from '@/app/lib/billing';
|
||||
import { ScheduledJobRuleView } from "../components/scheduled-job-rule-view";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Scheduled Job Rule",
|
||||
};
|
||||
|
||||
export default async function Page(
|
||||
props: {
|
||||
params: Promise<{ projectId: string; ruleId: string }>
|
||||
}
|
||||
) {
|
||||
const params = await props.params;
|
||||
await requireActiveBillingSubscription();
|
||||
return <ScheduledJobRuleView projectId={params.projectId} ruleId={params.ruleId} />;
|
||||
}
|
||||
|
|
@ -0,0 +1,210 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Panel } from "@/components/common/panel-common";
|
||||
import { createScheduledJobRule } from "@/app/actions/scheduled-job-rules.actions";
|
||||
import { ArrowLeftIcon, PlusIcon, TrashIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { DatePicker } from "@heroui/react";
|
||||
import { ZonedDateTime, now, getLocalTimeZone } from "@internationalized/date";
|
||||
|
||||
// Define a simpler message type for the form that only includes the fields we need
|
||||
type FormMessage = {
|
||||
role: "system" | "user" | "assistant";
|
||||
content: string;
|
||||
};
|
||||
|
||||
export function CreateScheduledJobRuleForm({ projectId }: { projectId: string }) {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [messages, setMessages] = useState<FormMessage[]>([
|
||||
{ role: "user", content: "" }
|
||||
]);
|
||||
// Set default to 30 minutes from now with timezone info
|
||||
const getDefaultDateTime = () => {
|
||||
const localTimeZone = getLocalTimeZone();
|
||||
const currentTime = now(localTimeZone);
|
||||
const thirtyMinutesFromNow = currentTime.add({ minutes: 30 });
|
||||
return thirtyMinutesFromNow;
|
||||
};
|
||||
|
||||
const [scheduledDateTime, setScheduledDateTime] = useState<ZonedDateTime | null>(getDefaultDateTime());
|
||||
|
||||
const addMessage = () => {
|
||||
setMessages([...messages, { role: "user", content: "" }]);
|
||||
};
|
||||
|
||||
const removeMessage = (index: number) => {
|
||||
if (messages.length > 1) {
|
||||
setMessages(messages.filter((_, i) => i !== index));
|
||||
}
|
||||
};
|
||||
|
||||
const updateMessage = (index: number, field: keyof FormMessage, value: string) => {
|
||||
const newMessages = [...messages];
|
||||
newMessages[index] = { ...newMessages[index], [field]: value };
|
||||
setMessages(newMessages);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Validate required fields
|
||||
if (!scheduledDateTime) {
|
||||
alert("Please select date and time");
|
||||
return;
|
||||
}
|
||||
|
||||
if (messages.some(msg => !msg.content?.trim())) {
|
||||
alert("Please fill in all message content");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
// Convert FormMessage to the expected Message type
|
||||
const convertedMessages = messages.map(msg => {
|
||||
if (msg.role === "assistant") {
|
||||
return {
|
||||
role: msg.role,
|
||||
content: msg.content,
|
||||
agentName: null,
|
||||
responseType: "internal" as const,
|
||||
timestamp: undefined
|
||||
};
|
||||
}
|
||||
return {
|
||||
role: msg.role,
|
||||
content: msg.content,
|
||||
timestamp: undefined
|
||||
};
|
||||
});
|
||||
|
||||
// Convert ZonedDateTime to ISO string (already in UTC)
|
||||
const scheduledTimeString = scheduledDateTime.toDate().toISOString();
|
||||
|
||||
await createScheduledJobRule({
|
||||
projectId,
|
||||
input: { messages: convertedMessages },
|
||||
scheduledTime: scheduledTimeString,
|
||||
});
|
||||
router.push(`/projects/${projectId}/job-rules`);
|
||||
} catch (error) {
|
||||
console.error("Failed to create scheduled job rule:", error);
|
||||
alert("Failed to create scheduled job rule");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<Panel
|
||||
title={
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href={`/projects/${projectId}/job-rules`}>
|
||||
<Button variant="secondary" size="sm">
|
||||
<ArrowLeftIcon className="w-4 h-4 mr-2" />
|
||||
Back
|
||||
</Button>
|
||||
</Link>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
CREATE SCHEDULED JOB RULE
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="h-full overflow-auto px-4 py-4">
|
||||
<div className="max-w-[800px] mx-auto">
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Scheduled Date & Time */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Scheduled Date & Time *
|
||||
</label>
|
||||
<DatePicker
|
||||
value={scheduledDateTime}
|
||||
onChange={setScheduledDateTime}
|
||||
placeholderValue={getDefaultDateTime()}
|
||||
minValue={now(getLocalTimeZone())}
|
||||
granularity="minute"
|
||||
isRequired
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Messages *
|
||||
</label>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={addMessage}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<PlusIcon className="w-4 h-4" />
|
||||
Add Message
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{messages.map((message, index) => (
|
||||
<div key={index} className="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<select
|
||||
value={message.role}
|
||||
onChange={(e) => updateMessage(index, "role", e.target.value)}
|
||||
className="px-3 py-1 border border-gray-300 dark:border-gray-600 rounded-md text-sm dark:bg-gray-700 dark:text-white"
|
||||
>
|
||||
<option value="system">System</option>
|
||||
<option value="user">User</option>
|
||||
<option value="assistant">Assistant</option>
|
||||
</select>
|
||||
{messages.length > 1 && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => removeMessage(index)}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
|
||||
>
|
||||
<TrashIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<textarea
|
||||
value={message.content}
|
||||
onChange={(e) => updateMessage(index, "content", e.target.value)}
|
||||
placeholder={`Enter ${message.role} message...`}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
|
||||
rows={3}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="px-6 py-2"
|
||||
>
|
||||
{loading ? "Creating..." : "Create Rule"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,234 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Spinner } from "@heroui/react";
|
||||
import { Panel } from "@/components/common/panel-common";
|
||||
import { fetchScheduledJobRule, deleteScheduledJobRule } from "@/app/actions/scheduled-job-rules.actions";
|
||||
import { ScheduledJobRule } from "@/src/entities/models/scheduled-job-rule";
|
||||
import { z } from "zod";
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ArrowLeftIcon, Trash2Icon } from "lucide-react";
|
||||
import { MessageDisplay } from "@/app/lib/components/message-display";
|
||||
|
||||
export function ScheduledJobRuleView({ projectId, ruleId }: { projectId: string; ruleId: string; }) {
|
||||
const router = useRouter();
|
||||
const [rule, setRule] = useState<z.infer<typeof ScheduledJobRule> | null>(null);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let ignore = false;
|
||||
(async () => {
|
||||
setLoading(true);
|
||||
const res = await fetchScheduledJobRule({ ruleId });
|
||||
if (ignore) return;
|
||||
setRule(res);
|
||||
setLoading(false);
|
||||
})();
|
||||
return () => { ignore = true; };
|
||||
}, [ruleId]);
|
||||
|
||||
const title = useMemo(() => {
|
||||
if (!rule) return 'Scheduled Job Rule';
|
||||
return `Scheduled Job Rule ${rule.id}`;
|
||||
}, [rule]);
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!rule) return;
|
||||
|
||||
setDeleting(true);
|
||||
try {
|
||||
await deleteScheduledJobRule({
|
||||
projectId,
|
||||
ruleId: rule.id,
|
||||
});
|
||||
// Redirect back to job rules list
|
||||
router.push(`/projects/${projectId}/job-rules`);
|
||||
} catch (error) {
|
||||
console.error("Failed to delete rule:", error);
|
||||
alert("Failed to delete rule");
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
setShowDeleteConfirm(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string, processedAt: string | null) => {
|
||||
if (processedAt) return 'text-green-600 dark:text-green-400';
|
||||
if (status === 'processing') return 'text-yellow-600 dark:text-yellow-400';
|
||||
if (status === 'triggered') return 'text-blue-600 dark:text-blue-400';
|
||||
return 'text-gray-600 dark:text-gray-400'; // pending
|
||||
};
|
||||
|
||||
const getStatusText = (status: string, processedAt: string | null) => {
|
||||
if (processedAt) return 'Completed';
|
||||
if (status === 'processing') return 'Processing';
|
||||
if (status === 'triggered') return 'Triggered';
|
||||
return 'Pending';
|
||||
};
|
||||
|
||||
const formatDateTime = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Panel
|
||||
title={
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href={`/projects/${projectId}/job-rules`}>
|
||||
<Button variant="secondary" size="sm">
|
||||
<ArrowLeftIcon className="w-4 h-4 mr-2" />
|
||||
Back
|
||||
</Button>
|
||||
</Link>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{title}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
rightActions={
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="flex items-center gap-2 bg-red-50 hover:bg-red-100 text-red-700 dark:bg-red-950 dark:hover:bg-red-900 dark:text-red-400 border border-red-200 dark:border-red-800"
|
||||
>
|
||||
<Trash2Icon className="w-4 h-4" />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="h-full overflow-auto px-4 py-4">
|
||||
<div className="max-w-[1024px] mx-auto">
|
||||
{loading && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Spinner size="sm" />
|
||||
<div>Loading...</div>
|
||||
</div>
|
||||
)}
|
||||
{!loading && rule && (
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Rule Metadata */}
|
||||
<div className="bg-gray-50 dark:bg-gray-800/50 p-4 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="font-semibold text-gray-700 dark:text-gray-300">Rule ID:</span>
|
||||
<span className="ml-2 font-mono text-gray-600 dark:text-gray-400">{rule.id}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold text-gray-700 dark:text-gray-300">Status:</span>
|
||||
<span className={`ml-2 font-mono ${getStatusColor(rule.status, rule.processedAt || null)}`}>
|
||||
{getStatusText(rule.status, rule.processedAt || null)}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold text-gray-700 dark:text-gray-300">Next Run:</span>
|
||||
<span className="ml-2 font-mono text-gray-600 dark:text-gray-400">
|
||||
{formatDateTime(rule.nextRunAt)}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold text-gray-700 dark:text-gray-300">Created:</span>
|
||||
<span className="ml-2 font-mono text-gray-600 dark:text-gray-400">
|
||||
{formatDateTime(rule.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
{rule.processedAt && (
|
||||
<div>
|
||||
<span className="font-semibold text-gray-700 dark:text-gray-300">Processed:</span>
|
||||
<span className="ml-2 font-mono text-gray-600 dark:text-gray-400">
|
||||
{formatDateTime(rule.processedAt)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{rule.output?.jobId && (
|
||||
<div>
|
||||
<span className="font-semibold text-gray-700 dark:text-gray-300">Job ID:</span>
|
||||
<span className="ml-2 font-mono text-gray-600 dark:text-gray-400">
|
||||
<Link
|
||||
href={`/projects/${projectId}/jobs/${rule.output.jobId}`}
|
||||
className="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
>
|
||||
{rule.output.jobId}
|
||||
</Link>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{rule.workerId && (
|
||||
<div>
|
||||
<span className="font-semibold text-gray-700 dark:text-gray-300">Worker ID:</span>
|
||||
<span className="ml-2 font-mono text-gray-600 dark:text-gray-400">{rule.workerId}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
Messages
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
{rule.input.messages.map((message, index) => (
|
||||
<div key={index} className="bg-white dark:bg-gray-800 p-4 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<MessageDisplay message={message} index={index} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
{showDeleteConfirm && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-md mx-4">
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-4">
|
||||
Delete Scheduled Job Rule
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-6">
|
||||
Are you sure you want to delete this scheduled job rule? This action cannot be undone and will permanently remove the rule and all its associated data.
|
||||
</p>
|
||||
<div className="flex gap-3 justify-end">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
disabled={deleting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleDelete}
|
||||
disabled={deleting}
|
||||
className="flex items-center gap-2 bg-red-50 hover:bg-red-100 text-red-700 dark:bg-red-950 dark:hover:bg-red-900 dark:text-red-400 border border-red-200 dark:border-red-800"
|
||||
>
|
||||
{deleting ? (
|
||||
<>
|
||||
<Spinner size="sm" />
|
||||
Deleting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Trash2Icon className="w-4 h-4" />
|
||||
Delete
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,185 @@
|
|||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Link, Spinner } from "@heroui/react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Panel } from "@/components/common/panel-common";
|
||||
import { listScheduledJobRules } from "@/app/actions/scheduled-job-rules.actions";
|
||||
import { z } from "zod";
|
||||
import { ListedRuleItem } from "@/src/application/repositories/scheduled-job-rules.repository.interface";
|
||||
import { isToday, isThisWeek, isThisMonth } from "@/lib/utils/date";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
|
||||
type ListedItem = z.infer<typeof ListedRuleItem>;
|
||||
|
||||
export function ScheduledJobRulesList({ projectId }: { projectId: string }) {
|
||||
const [items, setItems] = useState<ListedItem[]>([]);
|
||||
const [cursor, setCursor] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [loadingMore, setLoadingMore] = useState<boolean>(false);
|
||||
const [hasMore, setHasMore] = useState<boolean>(false);
|
||||
|
||||
const fetchPage = useCallback(async (cursorArg?: string | null) => {
|
||||
const res = await listScheduledJobRules({ projectId, cursor: cursorArg ?? undefined, limit: 20 });
|
||||
return res;
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
let ignore = false;
|
||||
(async () => {
|
||||
setLoading(true);
|
||||
const res = await fetchPage(null);
|
||||
if (ignore) return;
|
||||
setItems(res.items);
|
||||
setCursor(res.nextCursor);
|
||||
setHasMore(Boolean(res.nextCursor));
|
||||
setLoading(false);
|
||||
})();
|
||||
return () => { ignore = true; };
|
||||
}, [fetchPage]);
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
if (!cursor) return;
|
||||
setLoadingMore(true);
|
||||
const res = await fetchPage(cursor);
|
||||
setItems(prev => [...prev, ...res.items]);
|
||||
setCursor(res.nextCursor);
|
||||
setHasMore(Boolean(res.nextCursor));
|
||||
setLoadingMore(false);
|
||||
}, [cursor, fetchPage]);
|
||||
|
||||
const sections = useMemo(() => {
|
||||
const groups: Record<string, ListedItem[]> = {
|
||||
Today: [],
|
||||
'This week': [],
|
||||
'This month': [],
|
||||
Older: [],
|
||||
};
|
||||
for (const item of items) {
|
||||
const d = new Date(item.nextRunAt);
|
||||
if (isToday(d)) groups['Today'].push(item);
|
||||
else if (isThisWeek(d)) groups['This week'].push(item);
|
||||
else if (isThisMonth(d)) groups['This month'].push(item);
|
||||
else groups['Older'].push(item);
|
||||
}
|
||||
return groups;
|
||||
}, [items]);
|
||||
|
||||
const getStatusColor = (status: string, processedAt: string | null) => {
|
||||
if (processedAt) return 'text-green-600 dark:text-green-400';
|
||||
if (status === 'processing') return 'text-yellow-600 dark:text-yellow-400';
|
||||
if (status === 'triggered') return 'text-blue-600 dark:text-blue-400';
|
||||
return 'text-gray-600 dark:text-gray-400'; // pending
|
||||
};
|
||||
|
||||
const getStatusText = (status: string, processedAt: string | null) => {
|
||||
if (processedAt) return 'Completed';
|
||||
if (status === 'processing') return 'Processing';
|
||||
if (status === 'triggered') return 'Triggered';
|
||||
return 'Pending';
|
||||
};
|
||||
|
||||
const formatNextRunAt = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString();
|
||||
};
|
||||
|
||||
return (
|
||||
<Panel
|
||||
title={
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
SCHEDULED JOB RULES
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
rightActions={
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href={`/projects/${projectId}/job-rules/scheduled/new`}>
|
||||
<Button size="sm" className="flex items-center gap-2">
|
||||
<PlusIcon className="w-4 h-4" />
|
||||
New Rule
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="h-full overflow-auto px-4 py-4">
|
||||
<div className="max-w-[1024px] mx-auto">
|
||||
{loading && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Spinner size="sm" />
|
||||
<div>Loading...</div>
|
||||
</div>
|
||||
)}
|
||||
{!loading && (
|
||||
<div className="flex flex-col gap-6">
|
||||
{Object.entries(sections).map(([sectionName, sectionItems]) => {
|
||||
if (sectionItems.length === 0) return null;
|
||||
return (
|
||||
<div key={sectionName} className="space-y-3">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
{sectionName}
|
||||
</h3>
|
||||
<div className="grid gap-3">
|
||||
{sectionItems.map((item) => (
|
||||
<Link
|
||||
key={item.id}
|
||||
href={`/projects/${projectId}/job-rules/scheduled/${item.id}`}
|
||||
className="block p-4 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600 transition-colors"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<span className={`text-sm font-medium ${getStatusColor(item.status, item.processedAt || null)}`}>
|
||||
{getStatusText(item.status, item.processedAt || null)}
|
||||
</span>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Next run: {formatNextRunAt(item.nextRunAt)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Created: {new Date(item.createdAt).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{new Date(item.createdAt).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{items.length === 0 && !loading && (
|
||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
No scheduled job rules found. Create your first rule to get started.
|
||||
</div>
|
||||
)}
|
||||
{hasMore && (
|
||||
<div className="text-center">
|
||||
<Button
|
||||
onClick={loadMore}
|
||||
disabled={loadingMore}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
>
|
||||
{loadingMore ? (
|
||||
<>
|
||||
<Spinner size="sm" />
|
||||
Loading...
|
||||
</>
|
||||
) : (
|
||||
'Load More'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
import { Metadata } from "next";
|
||||
import { requireActiveBillingSubscription } from '@/app/lib/billing';
|
||||
import { CreateScheduledJobRuleForm } from "../components/create-scheduled-job-rule-form";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Scheduled Job Rule",
|
||||
};
|
||||
|
||||
export default async function Page(
|
||||
props: {
|
||||
params: Promise<{ projectId: string }>
|
||||
}
|
||||
) {
|
||||
const params = await props.params;
|
||||
await requireActiveBillingSubscription();
|
||||
return <CreateScheduledJobRuleForm projectId={params.projectId} />;
|
||||
}
|
||||
|
|
@ -54,13 +54,35 @@ export function JobView({ projectId, jobId }: { projectId: string; jobId: string
|
|||
'Trigger ID': reason.triggerId,
|
||||
'Deployment ID': reason.triggerDeploymentId,
|
||||
},
|
||||
payload: reason.payload
|
||||
payload: reason.payload,
|
||||
link: null
|
||||
};
|
||||
}
|
||||
if (reason.type === 'scheduled_job_rule') {
|
||||
return {
|
||||
type: 'Scheduled Job Rule',
|
||||
details: {
|
||||
'Rule ID': reason.ruleId,
|
||||
},
|
||||
payload: null,
|
||||
link: `/projects/${projectId}/job-rules/scheduled/${reason.ruleId}`
|
||||
};
|
||||
}
|
||||
if (reason.type === 'recurring_job_rule') {
|
||||
return {
|
||||
type: 'Recurring Job Rule',
|
||||
details: {
|
||||
'Rule ID': reason.ruleId,
|
||||
},
|
||||
payload: null,
|
||||
link: `/projects/${projectId}/job-rules/recurring/${reason.ruleId}`
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: 'Unknown',
|
||||
details: {},
|
||||
payload: null
|
||||
payload: null,
|
||||
link: null
|
||||
};
|
||||
};
|
||||
|
||||
|
|
@ -164,7 +186,7 @@ export function JobView({ projectId, jobId }: { projectId: string; jobId: string
|
|||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{reasonInfo.payload && Object.keys(reasonInfo.payload).length > 0 && (
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-gray-600 dark:text-gray-400 mb-2 uppercase tracking-wide">
|
||||
|
|
@ -175,6 +197,19 @@ export function JobView({ projectId, jobId }: { projectId: string; jobId: string
|
|||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{reasonInfo.link && (
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-gray-600 dark:text-gray-400 mb-2 uppercase tracking-wide">
|
||||
Related Link
|
||||
</div>
|
||||
<Link
|
||||
href={reasonInfo.link}
|
||||
className="text-blue-600 dark:text-blue-400 hover:underline font-medium"
|
||||
>
|
||||
{reasonInfo.type === 'Scheduled Job Rule' ? 'View Scheduled Job Rule' : 'View Details'}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -197,15 +232,7 @@ export function JobView({ projectId, jobId }: { projectId: string; jobId: string
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Workflow */}
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-gray-600 dark:text-gray-400 mb-2 uppercase tracking-wide">
|
||||
Workflow
|
||||
</div>
|
||||
<pre className="bg-gray-100 dark:bg-gray-900 p-3 rounded text-xs overflow-x-auto border border-gray-200 dark:border-gray-700 font-mono max-h-[400px]">
|
||||
{JSON.stringify(job.input.workflow, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -81,9 +81,31 @@ export function JobsList({ projectId }: { projectId: string }) {
|
|||
|
||||
const getReasonDisplay = (reason: any) => {
|
||||
if (reason.type === 'composio_trigger') {
|
||||
return `Composio: ${reason.triggerTypeSlug}`;
|
||||
return {
|
||||
type: 'Composio Trigger',
|
||||
display: `Composio: ${reason.triggerTypeSlug}`,
|
||||
link: null
|
||||
};
|
||||
}
|
||||
return 'Unknown';
|
||||
if (reason.type === 'scheduled_job_rule') {
|
||||
return {
|
||||
type: 'Scheduled Job Rule',
|
||||
display: `Scheduled Rule`,
|
||||
link: `/projects/${projectId}/job-rules/scheduled/${reason.ruleId}`
|
||||
};
|
||||
}
|
||||
if (reason.type === 'recurring_job_rule') {
|
||||
return {
|
||||
type: 'Recurring Job Rule',
|
||||
display: `Recurring Rule`,
|
||||
link: `/projects/${projectId}/job-rules/recurring/${reason.ruleId}`
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: 'Unknown',
|
||||
display: 'Unknown',
|
||||
link: null
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -129,33 +151,46 @@ export function JobsList({ projectId }: { projectId: string }) {
|
|||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{group.map((job) => (
|
||||
<tr key={job.id} className="hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors">
|
||||
<td className="px-6 py-4 text-left">
|
||||
<Link
|
||||
href={`/projects/${projectId}/jobs/${job.id}`}
|
||||
size="lg"
|
||||
isBlock
|
||||
className="text-sm text-gray-900 dark:text-gray-100 hover:text-blue-600 dark:hover:text-blue-400 truncate block"
|
||||
>
|
||||
{job.id}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-left">
|
||||
<span className={`text-sm font-medium ${getStatusColor(job.status)}`}>
|
||||
{job.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-left">
|
||||
<span className="text-sm text-gray-600 dark:text-gray-300 font-mono">
|
||||
{getReasonDisplay(job.reason)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-left text-sm text-gray-600 dark:text-gray-300">
|
||||
{new Date(job.createdAt).toLocaleString()}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{group.map((job) => {
|
||||
const reasonInfo = getReasonDisplay(job.reason);
|
||||
return (
|
||||
<tr key={job.id} className="hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors">
|
||||
<td className="px-6 py-4 text-left">
|
||||
<Link
|
||||
href={`/projects/${projectId}/jobs/${job.id}`}
|
||||
size="lg"
|
||||
isBlock
|
||||
className="text-sm text-gray-900 dark:text-gray-100 hover:text-blue-600 dark:hover:text-blue-400 truncate block"
|
||||
>
|
||||
{job.id}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-left">
|
||||
<span className={`text-sm font-medium ${getStatusColor(job.status)}`}>
|
||||
{job.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-left">
|
||||
{reasonInfo.link ? (
|
||||
<Link
|
||||
href={reasonInfo.link}
|
||||
size="sm"
|
||||
className="text-sm text-blue-600 dark:text-blue-400 hover:underline font-mono"
|
||||
>
|
||||
{reasonInfo.display}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-sm text-gray-600 dark:text-gray-300 font-mono">
|
||||
{reasonInfo.display}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-left text-sm text-gray-600 dark:text-gray-300">
|
||||
{new Date(job.createdAt).toLocaleString()}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -16,7 +16,8 @@ import {
|
|||
Sun,
|
||||
HelpCircle,
|
||||
MessageSquareIcon,
|
||||
LogsIcon
|
||||
LogsIcon,
|
||||
Clock
|
||||
} from "lucide-react";
|
||||
import { getProjectConfig } from "@/app/actions/project_actions";
|
||||
import { createProjectWithOptions } from "../../lib/project-creation-utils";
|
||||
|
|
@ -113,6 +114,12 @@ export default function Sidebar({ projectId, useAuth, collapsed = false, onToggl
|
|||
icon: LogsIcon,
|
||||
requiresProject: true
|
||||
},
|
||||
{
|
||||
href: 'job-rules',
|
||||
label: 'Job Rules',
|
||||
icon: Clock,
|
||||
requiresProject: true
|
||||
},
|
||||
{
|
||||
href: 'config',
|
||||
label: 'Settings',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue