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:
viettranx committed 2026-03-23 15:07:11 +07:00
1 parent e156e47343
commit f9b8ae1b76
10 files changed
+250 -111

No files matched your search

+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -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 -1
View File
@@ -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 */}
+21 -20
View File
@@ -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
+2 -2
View File
@@ -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>} />