mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 12:16:54 +00:00
fix(ui): fix cron page crash + redesign detail with markdown
- Add key props to cron routes (matching Agents/Sessions pattern) - Fix React Rules of Hooks violation in CronPage (hooks after early return) - Redesign overview tab: markdown preview/edit toggle for message, info grid with icons, agent select dropdown instead of raw UUID input - Redesign run history: expandable entries with markdown for summary/error - Show agent display name instead of UUID in list row, header, and overview - Add i18n keys (edit, preview, noMessage) for en/vi/zh
This commit is contained in:
1 parent
e156e47343
commit
f9b8ae1b76
10 files changed
+250
-111
No files matched your search
@@ -79,6 +79,9 @@
|
||||
"deliverDirect": "Deliver: direct",
|
||||
"channel": "Channel: {{name}}",
|
||||
"to": "To: {{name}}",
|
||||
"edit": "Edit",
|
||||
"preview": "Preview",
|
||||
"noMessage": "No message configured",
|
||||
"lastError": "Last Error",
|
||||
"runHistory": "Run History",
|
||||
"refresh": "Refresh",
|
||||
|
||||
@@ -79,6 +79,9 @@
|
||||
"deliverDirect": "Gửi: trực tiếp",
|
||||
"channel": "Channel: {{name}}",
|
||||
"to": "Đến: {{name}}",
|
||||
"edit": "Sửa",
|
||||
"preview": "Xem trước",
|
||||
"noMessage": "Chưa có tin nhắn",
|
||||
"lastError": "Lỗi gần nhất",
|
||||
"runHistory": "Lịch sử chạy",
|
||||
"refresh": "Làm mới",
|
||||
|
||||
@@ -79,6 +79,9 @@
|
||||
"deliverDirect": "投递:直接",
|
||||
"channel": "Channel:{{name}}",
|
||||
"to": "至:{{name}}",
|
||||
"edit": "编辑",
|
||||
"preview": "预览",
|
||||
"noMessage": "未配置消息",
|
||||
"lastError": "最后错误",
|
||||
"runHistory": "运行历史",
|
||||
"refresh": "刷新",
|
||||
|
||||
@@ -69,8 +69,8 @@ export function CronDetailPage({
|
||||
onDelete={() => setConfirmDelete(true)}
|
||||
/>
|
||||
|
||||
<div className="p-3 sm:p-4">
|
||||
<div className="max-w-4xl">
|
||||
<div className="p-3 pb-10 sm:p-4 sm:pb-10">
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList className="w-full justify-start overflow-x-auto overflow-y-hidden">
|
||||
<TabsTrigger value="overview">{t("detail.tabs.overview")}</TabsTrigger>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/comp
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { CronJob } from "../hooks/use-cron";
|
||||
import { formatSchedule } from "../cron-utils";
|
||||
import { useAgents } from "@/pages/agents/hooks/use-agents";
|
||||
|
||||
interface CronHeaderProps {
|
||||
job: CronJob;
|
||||
@@ -20,6 +21,9 @@ interface CronHeaderProps {
|
||||
|
||||
export function CronHeader({ job, isRunning, onBack, onRun, onAdvanced, onToggle, onDelete }: CronHeaderProps) {
|
||||
const { t } = useTranslation("cron");
|
||||
const { agents } = useAgents();
|
||||
const agent = job.agentId ? agents.find((a) => a.id === job.agentId) : null;
|
||||
const agentLabel = agent?.display_name || agent?.agent_key || job.agentId;
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
@@ -61,7 +65,7 @@ export function CronHeader({ job, isRunning, onBack, onRun, onAdvanced, onToggle
|
||||
{job.agentId && (
|
||||
<>
|
||||
<span className="text-border">·</span>
|
||||
<span className="font-mono text-[11px]">{job.agentId}</span>
|
||||
<span className="text-[11px]">{agentLabel}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Calendar, Clock, AlertTriangle, Globe, Pencil } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { StickySaveBar } from "@/components/shared/sticky-save-bar";
|
||||
import { MarkdownRenderer } from "@/components/shared/markdown-renderer";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import type { CronJob } from "../hooks/use-cron";
|
||||
import { CronStatusBadge } from "../cron-utils";
|
||||
import { useAgents } from "@/pages/agents/hooks/use-agents";
|
||||
|
||||
interface CronOverviewTabProps {
|
||||
job: CronJob;
|
||||
@@ -23,17 +28,9 @@ function getEverySeconds(job: CronJob): string {
|
||||
return "60";
|
||||
}
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
<span className="text-muted-foreground text-xs">{label}</span>
|
||||
<div className="mt-0.5 text-sm font-medium">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CronOverviewTab({ job, onUpdate }: CronOverviewTabProps) {
|
||||
const { t } = useTranslation("cron");
|
||||
const { agents } = useAgents();
|
||||
const readonly = !onUpdate;
|
||||
|
||||
const [scheduleKind, setScheduleKind] = useState<ScheduleKind>(job.schedule.kind as ScheduleKind);
|
||||
@@ -42,7 +39,7 @@ export function CronOverviewTab({ job, onUpdate }: CronOverviewTabProps) {
|
||||
const [message, setMessage] = useState(job.payload?.message ?? "");
|
||||
const [agentId, setAgentId] = useState(job.agentId ?? "");
|
||||
const [enabled, setEnabled] = useState(job.enabled);
|
||||
|
||||
const [editingMessage, setEditingMessage] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const handleSave = async () => {
|
||||
@@ -63,6 +60,7 @@ export function CronOverviewTab({ job, onUpdate }: CronOverviewTabProps) {
|
||||
agentId: agentId.trim() || undefined,
|
||||
enabled,
|
||||
});
|
||||
setEditingMessage(false);
|
||||
} catch {
|
||||
// toast shown by hook
|
||||
} finally {
|
||||
@@ -136,20 +134,40 @@ export function CronOverviewTab({ job, onUpdate }: CronOverviewTabProps) {
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Message section */}
|
||||
{/* Message section — markdown preview or textarea */}
|
||||
<section className="space-y-3 rounded-lg border p-3 sm:p-4 overflow-hidden">
|
||||
<h3 className="text-sm font-medium">{t("detail.messageSection")}</h3>
|
||||
<div className="space-y-2">
|
||||
<Label>{t("create.message")}</Label>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium">{t("detail.messageSection")}</h3>
|
||||
{!readonly && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 gap-1 text-xs text-muted-foreground"
|
||||
onClick={() => setEditingMessage(!editingMessage)}
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
{editingMessage ? t("detail.preview") : t("detail.edit")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{editingMessage ? (
|
||||
<Textarea
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
rows={4}
|
||||
disabled={readonly}
|
||||
rows={6}
|
||||
placeholder={t("create.messagePlaceholder")}
|
||||
className="text-base md:text-sm resize-none"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border bg-muted/30 p-3 sm:p-4">
|
||||
{message ? (
|
||||
<MarkdownRenderer content={message} className="prose-sm max-w-none" />
|
||||
) : (
|
||||
<p className="text-sm italic text-muted-foreground">{t("detail.noMessage")}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Agent & Status section */}
|
||||
@@ -158,13 +176,19 @@ export function CronOverviewTab({ job, onUpdate }: CronOverviewTabProps) {
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>{t("create.agentId")}</Label>
|
||||
<Input
|
||||
value={agentId}
|
||||
onChange={(e) => setAgentId(e.target.value)}
|
||||
disabled={readonly}
|
||||
placeholder={t("create.agentIdPlaceholder")}
|
||||
className="text-base md:text-sm"
|
||||
/>
|
||||
<Select value={agentId || "__default__"} onValueChange={(v) => setAgentId(v === "__default__" ? "" : v)} disabled={readonly}>
|
||||
<SelectTrigger className="text-base md:text-sm">
|
||||
<SelectValue placeholder={t("create.agentIdPlaceholder")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__default__">{t("create.agentIdPlaceholder")}</SelectItem>
|
||||
{agents.map((a) => (
|
||||
<SelectItem key={a.id} value={a.id}>
|
||||
{a.display_name || a.agent_key || a.id}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t("columns.enabled")}</Label>
|
||||
@@ -179,33 +203,65 @@ export function CronOverviewTab({ job, onUpdate }: CronOverviewTabProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info rows */}
|
||||
<div className="grid grid-cols-1 gap-4 rounded-md bg-muted/50 p-4 sm:grid-cols-2">
|
||||
{/* Info grid */}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
{job.state?.nextRunAtMs && (
|
||||
<InfoRow label={t("detail.infoRows.nextRun")} value={formatDate(new Date(job.state.nextRunAtMs))} />
|
||||
<div className="rounded-md bg-muted/50 p-3">
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Calendar className="h-3 w-3" />
|
||||
{t("detail.infoRows.nextRun")}
|
||||
</div>
|
||||
<div className="mt-1 text-sm font-medium">{formatDate(new Date(job.state.nextRunAtMs))}</div>
|
||||
</div>
|
||||
)}
|
||||
{job.state?.lastRunAtMs && (
|
||||
<InfoRow label={t("detail.infoRows.lastRun")} value={formatDate(new Date(job.state.lastRunAtMs))} />
|
||||
<div className="rounded-md bg-muted/50 p-3">
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Clock className="h-3 w-3" />
|
||||
{t("detail.infoRows.lastRun")}
|
||||
</div>
|
||||
<div className="mt-1 text-sm font-medium">{formatDate(new Date(job.state.lastRunAtMs))}</div>
|
||||
</div>
|
||||
)}
|
||||
{job.state?.lastStatus && (
|
||||
<InfoRow label={t("detail.infoRows.lastStatus")} value={job.state.lastStatus} />
|
||||
<div className="rounded-md bg-muted/50 p-3">
|
||||
<div className="text-xs text-muted-foreground">{t("detail.infoRows.lastStatus")}</div>
|
||||
<div className="mt-1"><CronStatusBadge status={job.state.lastStatus} /></div>
|
||||
</div>
|
||||
)}
|
||||
<InfoRow label={t("detail.infoRows.created")} value={formatDate(new Date(job.createdAtMs))} />
|
||||
<InfoRow label={t("detail.infoRows.updated")} value={formatDate(new Date(job.updatedAtMs))} />
|
||||
<div className="rounded-md bg-muted/50 p-3">
|
||||
<div className="text-xs text-muted-foreground">{t("detail.infoRows.created")}</div>
|
||||
<div className="mt-1 text-sm font-medium">{formatDate(new Date(job.createdAtMs))}</div>
|
||||
</div>
|
||||
<div className="rounded-md bg-muted/50 p-3">
|
||||
<div className="text-xs text-muted-foreground">{t("detail.infoRows.updated")}</div>
|
||||
<div className="mt-1 text-sm font-medium">{formatDate(new Date(job.updatedAtMs))}</div>
|
||||
</div>
|
||||
{job.schedule.tz && (
|
||||
<InfoRow label={t("detail.infoRows.timezone")} value={job.schedule.tz} />
|
||||
<div className="rounded-md bg-muted/50 p-3">
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Globe className="h-3 w-3" />
|
||||
{t("detail.infoRows.timezone")}
|
||||
</div>
|
||||
<div className="mt-1 text-sm font-medium">{job.schedule.tz}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Last error */}
|
||||
{job.state?.lastError && (
|
||||
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-4 text-sm">
|
||||
<h4 className="mb-1 font-medium text-destructive">{t("detail.lastError")}</h4>
|
||||
<p className="text-xs text-destructive/80">{job.state.lastError}</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Last error */}
|
||||
{job.state?.lastError && (
|
||||
<section className="rounded-lg border border-destructive/30 bg-destructive/5 p-3 sm:p-4 overflow-hidden">
|
||||
<div className="mb-2 flex items-center gap-1.5">
|
||||
<AlertTriangle className="h-4 w-4 text-destructive" />
|
||||
<h3 className="text-sm font-medium text-destructive">{t("detail.lastError")}</h3>
|
||||
</div>
|
||||
<div className="rounded-md bg-background/50 p-3">
|
||||
<MarkdownRenderer content={job.state.lastError} className="prose-sm max-w-none text-destructive/80" />
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{!readonly && (
|
||||
<StickySaveBar
|
||||
onSave={handleSave}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { RefreshCw, Clock, AlertTriangle, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Pagination } from "@/components/shared/pagination";
|
||||
import { MarkdownRenderer } from "@/components/shared/markdown-renderer";
|
||||
import { formatDate, formatTokens } from "@/lib/format";
|
||||
import type { CronJob, CronRunLogEntry } from "../hooks/use-cron";
|
||||
|
||||
@@ -19,6 +21,93 @@ function formatDuration(ms?: number): string {
|
||||
return `${(ms / 60000).toFixed(1)}m`;
|
||||
}
|
||||
|
||||
function RunEntry({ entry }: { entry: CronRunLogEntry }) {
|
||||
const { t } = useTranslation("cron");
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const isSuccess = entry.status === "ok" || entry.status === "success";
|
||||
const hasDetails = !!(entry.summary || entry.error);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-card overflow-hidden">
|
||||
{/* Header row — always visible */}
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-3 px-3 py-2.5 text-left hover:bg-muted/30 transition-colors sm:px-4"
|
||||
onClick={() => hasDetails && setExpanded(!expanded)}
|
||||
disabled={!hasDetails}
|
||||
>
|
||||
{/* Status dot */}
|
||||
<span className={`h-2.5 w-2.5 shrink-0 rounded-full ${isSuccess ? "bg-emerald-500" : "bg-destructive"}`} />
|
||||
|
||||
{/* Timestamp + duration */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="font-medium">{formatDate(new Date(entry.ts))}</span>
|
||||
{entry.durationMs != null && entry.durationMs > 0 && (
|
||||
<span className="text-xs text-muted-foreground">({formatDuration(entry.durationMs)})</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Summary preview (collapsed) */}
|
||||
{!expanded && entry.summary && (
|
||||
<p className="mt-0.5 truncate text-xs text-muted-foreground">{entry.summary}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tokens */}
|
||||
{entry.inputTokens != null && entry.inputTokens > 0 && (
|
||||
<span className="hidden shrink-0 text-xs text-muted-foreground sm:block">
|
||||
{t("detail.inOut", {
|
||||
input: formatTokens(entry.inputTokens),
|
||||
output: formatTokens(entry.outputTokens ?? 0),
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Status badge */}
|
||||
<Badge variant={isSuccess ? "success" : "destructive"} className="shrink-0">
|
||||
{entry.status || "unknown"}
|
||||
</Badge>
|
||||
|
||||
{/* Expand chevron */}
|
||||
{hasDetails && (
|
||||
<span className="shrink-0 text-muted-foreground">
|
||||
{expanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Expanded details */}
|
||||
{expanded && (
|
||||
<div className="border-t px-3 py-3 sm:px-4">
|
||||
{entry.summary && (
|
||||
<div className="rounded-md bg-muted/30 p-3">
|
||||
<MarkdownRenderer content={entry.summary} className="prose-sm max-w-none" />
|
||||
</div>
|
||||
)}
|
||||
{entry.error && (
|
||||
<div className="mt-2 rounded-md border border-destructive/20 bg-destructive/5 p-3">
|
||||
<div className="mb-1 flex items-center gap-1.5">
|
||||
<AlertTriangle className="h-3.5 w-3.5 text-destructive" />
|
||||
<span className="text-xs font-medium text-destructive">{t("detail.lastError")}</span>
|
||||
</div>
|
||||
<MarkdownRenderer content={entry.error} className="prose-sm max-w-none text-destructive/80" />
|
||||
</div>
|
||||
)}
|
||||
{/* Token details on mobile */}
|
||||
{entry.inputTokens != null && entry.inputTokens > 0 && (
|
||||
<div className="mt-2 text-xs text-muted-foreground sm:hidden">
|
||||
{t("detail.inOut", {
|
||||
input: formatTokens(entry.inputTokens),
|
||||
output: formatTokens(entry.outputTokens ?? 0),
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CronRunHistoryTab({ job, getRunLog, onRefresh }: CronRunHistoryTabProps) {
|
||||
const { t } = useTranslation("cron");
|
||||
const [runLog, setRunLog] = useState<CronRunLogEntry[]>([]);
|
||||
@@ -48,7 +137,7 @@ export function CronRunHistoryTab({ job, getRunLog, onRefresh }: CronRunHistoryT
|
||||
loadRunLog();
|
||||
}, [loadRunLog]);
|
||||
|
||||
// Poll while running to detect completion
|
||||
// Poll while running
|
||||
useEffect(() => {
|
||||
if (!isRunning) return;
|
||||
const interval = setInterval(onRefresh, 3000);
|
||||
@@ -58,8 +147,15 @@ export function CronRunHistoryTab({ job, getRunLog, onRefresh }: CronRunHistoryT
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h4 className="font-medium">{t("detail.runHistory")}</h4>
|
||||
<Button variant="ghost" size="sm" onClick={() => loadRunLog()} className="text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||
<h4 className="font-medium">{t("detail.runHistory")}</h4>
|
||||
{runLogTotal > 0 && (
|
||||
<span className="text-xs text-muted-foreground">({runLogTotal})</span>
|
||||
)}
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => loadRunLog()} className="gap-1 text-xs">
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
{t("detail.refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -74,51 +170,21 @@ export function CronRunHistoryTab({ job, getRunLog, onRefresh }: CronRunHistoryT
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
{runLog.map((entry, i) => (
|
||||
<div key={i} className="rounded-md border p-3 text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">{formatDate(new Date(entry.ts))}</span>
|
||||
{entry.durationMs != null && entry.durationMs > 0 && (
|
||||
<span className="text-xs text-muted-foreground">({formatDuration(entry.durationMs)})</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{entry.inputTokens != null && entry.inputTokens > 0 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("detail.inOut", {
|
||||
input: formatTokens(entry.inputTokens),
|
||||
output: formatTokens(entry.outputTokens ?? 0),
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
<Badge
|
||||
variant={
|
||||
entry.status === "ok" || entry.status === "success" ? "success" : "destructive"
|
||||
}
|
||||
>
|
||||
{entry.status || "unknown"}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
{entry.summary && (
|
||||
<p className="mt-1 line-clamp-3 text-muted-foreground">{entry.summary}</p>
|
||||
)}
|
||||
{entry.error && (
|
||||
<p className="mt-1 text-destructive">{entry.error}</p>
|
||||
)}
|
||||
</div>
|
||||
<RunEntry key={`${entry.ts}-${i}`} entry={entry} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Pagination
|
||||
page={runLogPage}
|
||||
pageSize={runLogPageSize}
|
||||
total={runLogTotal}
|
||||
totalPages={runLogTotalPages}
|
||||
onPageChange={(p) => { setRunLogPage(p); loadRunLog(p); }}
|
||||
onPageSizeChange={(s) => { setRunLogPageSize(s); setRunLogPage(1); loadRunLog(1, s); }}
|
||||
pageSizes={[10, 20, 50]}
|
||||
/>
|
||||
<div className="mt-4">
|
||||
<Pagination
|
||||
page={runLogPage}
|
||||
pageSize={runLogPageSize}
|
||||
total={runLogTotal}
|
||||
totalPages={runLogTotalPages}
|
||||
onPageChange={(p) => { setRunLogPage(p); loadRunLog(p); }}
|
||||
onPageSizeChange={(s) => { setRunLogPageSize(s); setRunLogPage(1); loadRunLog(1, s); }}
|
||||
pageSizes={[10, 20, 50]}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { CronJob } from "./hooks/use-cron";
|
||||
import { formatSchedule, CronStatusBadge } from "./cron-utils";
|
||||
import { useAgents } from "@/pages/agents/hooks/use-agents";
|
||||
|
||||
interface CronListRowProps {
|
||||
job: CronJob;
|
||||
@@ -14,6 +15,8 @@ interface CronListRowProps {
|
||||
|
||||
export function CronListRow({ job, onClick, onRun, onDelete }: CronListRowProps) {
|
||||
const { t } = useTranslation("cron");
|
||||
const { agents } = useAgents();
|
||||
const agent = job.agentId ? agents.find((a) => a.id === job.agentId) : null;
|
||||
const isRunning = job.state?.lastStatus === "running";
|
||||
|
||||
return (
|
||||
@@ -46,7 +49,7 @@ export function CronListRow({ job, onClick, onRun, onDelete }: CronListRowProps)
|
||||
|
||||
{/* Agent */}
|
||||
<div className="hidden shrink-0 text-xs text-muted-foreground md:block md:w-28 md:truncate">
|
||||
{job.agentId || t("card.defaultAgent")}
|
||||
{agent?.display_name || agent?.agent_key || job.agentId || t("card.defaultAgent")}
|
||||
</div>
|
||||
|
||||
{/* Message preview */}
|
||||
|
||||
@@ -31,26 +31,7 @@ export function CronPage() {
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<CronJob | null>(null);
|
||||
|
||||
// Detail routing
|
||||
const detailJob = detailId ? jobs.find((j) => j.id === detailId) : null;
|
||||
if (detailJob) {
|
||||
return (
|
||||
<CronDetailPage
|
||||
job={detailJob}
|
||||
onBack={() => navigate("/cron")}
|
||||
onRun={runJob}
|
||||
onToggle={toggleJob}
|
||||
onDelete={async (id) => {
|
||||
await deleteJob(id);
|
||||
navigate("/cron");
|
||||
}}
|
||||
onUpdate={updateJob}
|
||||
getRunLog={getRunLog}
|
||||
onRefresh={refresh}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// All hooks must be called before any conditional return
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.toLowerCase();
|
||||
if (!q) return jobs;
|
||||
@@ -70,6 +51,26 @@ export function CronPage() {
|
||||
resetPage();
|
||||
}
|
||||
|
||||
// Detail routing — after all hooks
|
||||
const detailJob = detailId ? jobs.find((j) => j.id === detailId) : null;
|
||||
if (detailJob) {
|
||||
return (
|
||||
<CronDetailPage
|
||||
job={detailJob}
|
||||
onBack={() => navigate("/cron")}
|
||||
onRun={runJob}
|
||||
onToggle={toggleJob}
|
||||
onDelete={async (id) => {
|
||||
await deleteJob(id);
|
||||
navigate("/cron");
|
||||
}}
|
||||
onUpdate={updateJob}
|
||||
getRunLog={getRunLog}
|
||||
onRefresh={refresh}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 sm:p-6 pb-10">
|
||||
<PageHeader
|
||||
|
||||
@@ -155,8 +155,8 @@ export function AppRoutes() {
|
||||
<Route path={ROUTES.SESSION_DETAIL} element={<SessionsPage key="detail" />} />
|
||||
<Route path={ROUTES.SKILLS} element={<SkillsPage key="list" />} />
|
||||
<Route path={ROUTES.SKILL_DETAIL} element={<SkillsPage key="detail" />} />
|
||||
<Route path={ROUTES.CRON} element={<CronPage />} />
|
||||
<Route path={ROUTES.CRON_DETAIL} element={<CronPage />} />
|
||||
<Route path={ROUTES.CRON} element={<CronPage key="list" />} />
|
||||
<Route path={ROUTES.CRON_DETAIL} element={<CronPage key="detail" />} />
|
||||
{/* Admin-only pages */}
|
||||
<Route path={ROUTES.CONFIG} element={<RequireCrossTenant><ConfigPage /></RequireCrossTenant>} />
|
||||
<Route path={ROUTES.PROVIDERS} element={<RequireAdmin><ProvidersPage key="list" /></RequireAdmin>} />
|
||||
|
||||
Reference in new issue
Block a user