enable scheduled jobs (#199)

- one-off scheduled jobs
- recurring jobs
This commit is contained in:
Ramnique Singh 2025-08-12 18:40:04 +05:30 committed by GitHub
parent fcfe5593b4
commit eda3f3821f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
52 changed files with 3833 additions and 71 deletions

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
)}
</>
);
}

View file

@ -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>
);
}

View 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} />;
}

View file

@ -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} />;
}

View file

@ -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} />;
}

View file

@ -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} />;
}

View file

@ -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>
);
}

View file

@ -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>
)}
</>
);
}

View file

@ -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>
);
}

View file

@ -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} />;
}

View file

@ -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>

View file

@ -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>

View file

@ -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',