Fix cron dashboard rendering for partial jobs

This commit is contained in:
LeonSGP43 2026-05-07 13:19:54 +08:00 committed by kshitij
parent e407376c50
commit e572737274
2 changed files with 139 additions and 87 deletions

View file

@ -553,13 +553,14 @@ export interface ModelsAnalyticsResponse {
export interface CronJob { export interface CronJob {
id: string; id: string;
name?: string; name?: string | null;
prompt: string; prompt?: string | null;
schedule: { kind: string; expr: string; display: string }; script?: string | null;
schedule_display: string; schedule?: { kind?: string; expr?: string; display?: string };
schedule_display?: string | null;
enabled: boolean; enabled: boolean;
state: string; state?: string | null;
deliver?: string; deliver?: string | null;
last_run_at?: string | null; last_run_at?: string | null;
next_run_at?: string | null; next_run_at?: string | null;
last_error?: string | null; last_error?: string | null;

View file

@ -23,6 +23,50 @@ function formatTime(iso?: string | null): string {
return d.toLocaleString(); return d.toLocaleString();
} }
function asText(value: unknown): string {
return typeof value === "string" ? value : "";
}
function truncateText(value: string, maxLength: number): string {
return value.length > maxLength
? value.slice(0, maxLength) + "..."
: value;
}
function getJobPrompt(job: CronJob): string {
return asText(job.prompt);
}
function getJobName(job: CronJob): string {
return asText(job.name).trim();
}
function getJobTitle(job: CronJob): string {
const name = getJobName(job);
if (name) return name;
const prompt = getJobPrompt(job);
if (prompt) return truncateText(prompt, 60);
const script = asText(job.script);
if (script) return truncateText(script, 60);
return job.id || "Cron job";
}
function getJobScheduleDisplay(job: CronJob): string {
return (
asText(job.schedule_display) ||
asText(job.schedule?.display) ||
asText(job.schedule?.expr) ||
"—"
);
}
function getJobState(job: CronJob): string {
return asText(job.state) || (job.enabled === false ? "disabled" : "scheduled");
}
const STATUS_TONE: Record<string, "success" | "warning" | "destructive"> = { const STATUS_TONE: Record<string, "success" | "warning" | "destructive"> = {
enabled: "success", enabled: "success",
scheduled: "success", scheduled: "success",
@ -88,13 +132,13 @@ export default function CronPage() {
if (isPaused) { if (isPaused) {
await api.resumeCronJob(job.id); await api.resumeCronJob(job.id);
showToast( showToast(
`${t.cron.resume}: "${job.name || job.prompt.slice(0, 30)}"`, `${t.cron.resume}: "${truncateText(getJobTitle(job), 30)}"`,
"success", "success",
); );
} else { } else {
await api.pauseCronJob(job.id); await api.pauseCronJob(job.id);
showToast( showToast(
`${t.cron.pause}: "${job.name || job.prompt.slice(0, 30)}"`, `${t.cron.pause}: "${truncateText(getJobTitle(job), 30)}"`,
"success", "success",
); );
} }
@ -108,7 +152,7 @@ export default function CronPage() {
try { try {
await api.triggerCronJob(job.id); await api.triggerCronJob(job.id);
showToast( showToast(
`${t.cron.triggerNow}: "${job.name || job.prompt.slice(0, 30)}"`, `${t.cron.triggerNow}: "${truncateText(getJobTitle(job), 30)}"`,
"success", "success",
); );
loadJobs(); loadJobs();
@ -124,7 +168,7 @@ export default function CronPage() {
try { try {
await api.deleteCronJob(id); await api.deleteCronJob(id);
showToast( showToast(
`${t.common.delete}: "${job?.name || (job?.prompt ?? "").slice(0, 30) || id}"`, `${t.common.delete}: "${job ? truncateText(getJobTitle(job), 30) : id}"`,
"success", "success",
); );
loadJobs(); loadJobs();
@ -161,7 +205,9 @@ export default function CronPage() {
title={t.cron.confirmDeleteTitle} title={t.cron.confirmDeleteTitle}
description={ description={
pendingJob pendingJob
? `"${pendingJob.name || pendingJob.prompt.slice(0, 40)}" — ${t.cron.confirmDeleteMessage}` ? `"${truncateText(getJobTitle(pendingJob), 40)}" — ${
t.cron.confirmDeleteMessage
}`
: t.cron.confirmDeleteMessage : t.cron.confirmDeleteMessage
} }
loading={jobDelete.isDeleting} loading={jobDelete.isDeleting}
@ -265,85 +311,90 @@ export default function CronPage() {
</Card> </Card>
)} )}
{jobs.map((job) => ( {jobs.map((job) => {
<Card key={job.id}> const state = getJobState(job);
<CardContent className="flex items-center gap-4 py-4"> const promptText = getJobPrompt(job);
<div className="flex-1 min-w-0"> const title = getJobTitle(job);
<div className="flex items-center gap-2 mb-1"> const hasName = Boolean(getJobName(job));
<span className="font-medium text-sm truncate"> const deliver = asText(job.deliver);
{job.name ||
job.prompt.slice(0, 60) + return (
(job.prompt.length > 60 ? "..." : "")} <Card key={job.id}>
</span> <CardContent className="flex items-center gap-4 py-4">
<Badge tone={STATUS_TONE[job.state] ?? "secondary"}> <div className="flex-1 min-w-0">
{job.state} <div className="flex items-center gap-2 mb-1">
</Badge> <span className="font-medium text-sm truncate">
{job.deliver && job.deliver !== "local" && ( {title}
<Badge tone="outline">{job.deliver}</Badge> </span>
<Badge tone={STATUS_TONE[state] ?? "secondary"}>
{state}
</Badge>
{deliver && deliver !== "local" && (
<Badge tone="outline">{deliver}</Badge>
)}
</div>
{hasName && promptText && (
<p className="text-xs text-muted-foreground truncate mb-1">
{truncateText(promptText, 100)}
</p>
)}
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span className="font-mono">{getJobScheduleDisplay(job)}</span>
<span>
{t.cron.last}: {formatTime(job.last_run_at)}
</span>
<span>
{t.cron.next}: {formatTime(job.next_run_at)}
</span>
</div>
{job.last_error && (
<p className="text-xs text-destructive mt-1">
{job.last_error}
</p>
)} )}
</div> </div>
{job.name && (
<p className="text-xs text-muted-foreground truncate mb-1"> <div className="flex items-center gap-1 shrink-0">
{job.prompt.slice(0, 100)} <Button
{job.prompt.length > 100 ? "..." : ""} ghost
</p> size="icon"
)} title={state === "paused" ? t.cron.resume : t.cron.pause}
<div className="flex items-center gap-4 text-xs text-muted-foreground"> aria-label={
<span className="font-mono">{job.schedule_display}</span> state === "paused" ? t.cron.resume : t.cron.pause
<span> }
{t.cron.last}: {formatTime(job.last_run_at)} onClick={() => handlePauseResume(job)}
</span> className={
<span> state === "paused" ? "text-success" : "text-warning"
{t.cron.next}: {formatTime(job.next_run_at)} }
</span> >
{state === "paused" ? <Play /> : <Pause />}
</Button>
<Button
ghost
size="icon"
title={t.cron.triggerNow}
aria-label={t.cron.triggerNow}
onClick={() => handleTrigger(job)}
>
<Zap />
</Button>
<Button
ghost
destructive
size="icon"
title={t.common.delete}
aria-label={t.common.delete}
onClick={() => jobDelete.requestDelete(job.id)}
>
<Trash2 />
</Button>
</div> </div>
{job.last_error && ( </CardContent>
<p className="text-xs text-destructive mt-1"> </Card>
{job.last_error} );
</p> })}
)}
</div>
<div className="flex items-center gap-1 shrink-0">
<Button
ghost
size="icon"
title={job.state === "paused" ? t.cron.resume : t.cron.pause}
aria-label={
job.state === "paused" ? t.cron.resume : t.cron.pause
}
onClick={() => handlePauseResume(job)}
className={
job.state === "paused" ? "text-success" : "text-warning"
}
>
{job.state === "paused" ? <Play /> : <Pause />}
</Button>
<Button
ghost
size="icon"
title={t.cron.triggerNow}
aria-label={t.cron.triggerNow}
onClick={() => handleTrigger(job)}
>
<Zap />
</Button>
<Button
ghost
destructive
size="icon"
title={t.common.delete}
aria-label={t.common.delete}
onClick={() => jobDelete.requestDelete(job.id)}
>
<Trash2 />
</Button>
</div>
</CardContent>
</Card>
))}
</div> </div>
<PluginSlot name="cron:bottom" /> <PluginSlot name="cron:bottom" />