fix(webui): make automation history diagnostic

This commit is contained in:
chengyongru
2026-06-15 21:54:09 +08:00
parent acf408ced2
commit 3aa90e539c
12 changed files with 189 additions and 76 deletions
+90 -41
View File
@@ -3703,8 +3703,7 @@ function AutomationDetailPanel({
? `#/chat/${encodeURIComponent(job.origin.session_key)}` ? `#/chat/${encodeURIComponent(job.origin.session_key)}`
: null; : null;
const history = job.state.run_history ?? []; const history = job.state.run_history ?? [];
const latestRun = history[history.length - 1]; const runSummary = automationRunSummary(job, history, locale, tx);
const lastResult = automationLastResult(job, latestRun, locale, tx);
const needsRecreation = automationNeedsRecreation(job); const needsRecreation = automationNeedsRecreation(job);
const created = job.created_at_ms ? fmtDateTime(job.created_at_ms, locale) : null; const created = job.created_at_ms ? fmtDateTime(job.created_at_ms, locale) : null;
const updated = job.updated_at_ms ? fmtDateTime(job.updated_at_ms, locale) : null; const updated = job.updated_at_ms ? fmtDateTime(job.updated_at_ms, locale) : null;
@@ -3782,16 +3781,16 @@ function AutomationDetailPanel({
{formatAutomationNext(job, tx)} {formatAutomationNext(job, tx)}
</AutomationDetail> </AutomationDetail>
<AutomationDetail <AutomationDetail
label={tx("settings.automations.history.recent", "Last result")} label={tx("settings.automations.history.health", "Recent health")}
title={lastResult.title} title={runSummary.title}
secondary={lastResult.secondary} secondary={runSummary.secondary}
> >
<span className="inline-flex min-w-0 items-center gap-1.5"> <span className="inline-flex min-w-0 items-center gap-1.5">
<span <span
className={cn("h-1.5 w-1.5 shrink-0 rounded-full", automationResultDotClass(lastResult.tone))} className={cn("h-1.5 w-1.5 shrink-0 rounded-full", automationResultDotClass(runSummary.tone))}
aria-hidden aria-hidden
/> />
<span>{lastResult.primary}</span> <span>{runSummary.primary}</span>
</span> </span>
</AutomationDetail> </AutomationDetail>
<AutomationDetail label={tx("settings.automations.labels.origin", "Linked chat")} title={origin}> <AutomationDetail label={tx("settings.automations.labels.origin", "Linked chat")} title={origin}>
@@ -3966,20 +3965,30 @@ function AutomationRunHistory({
}) { }) {
if (!history.length) return null; if (!history.length) return null;
const visible = [...history].reverse(); const visible = [...history].reverse();
const issueCount = history.filter((run) => automationRunNeedsAttention(run.status)).length;
const summary = tx("settings.automations.history.summary", "Runs: {{total}} · Issues: {{issues}}", {
total: history.length,
issues: issueCount,
});
return ( return (
<section className="rounded-[18px] bg-muted/32 px-3 py-3"> <section className="rounded-[18px] bg-muted/32 px-3 py-3">
<button <button
type="button" type="button"
className="flex w-full items-center justify-between gap-3 text-left" className="flex w-full min-w-0 items-center justify-between gap-3 text-left"
aria-expanded={expanded} aria-expanded={expanded}
onClick={() => onExpandedChange(!expanded)} onClick={() => onExpandedChange(!expanded)}
> >
<span className="text-[12px] font-medium leading-none text-foreground"> <span className="shrink-0 text-[12px] font-medium leading-none text-foreground">
{tx("settings.automations.history.timeline", "Run history")} {tx("settings.automations.history.timeline", "Run history")}
</span> </span>
<span className="inline-flex items-center gap-2 text-[11px] leading-none text-muted-foreground"> <span
<span className="tabular-nums">{history.length}</span> className={cn(
"inline-flex min-w-0 items-center gap-2 text-[11px] leading-none",
issueCount ? "text-amber-700 dark:text-amber-300" : "text-muted-foreground",
)}
>
<span className="truncate tabular-nums">{summary}</span>
<ChevronDown <ChevronDown
className={cn("h-3.5 w-3.5 transition-transform", expanded && "rotate-180")} className={cn("h-3.5 w-3.5 transition-transform", expanded && "rotate-180")}
aria-hidden aria-hidden
@@ -3990,9 +3999,21 @@ function AutomationRunHistory({
<div className="mt-3 space-y-2"> <div className="mt-3 space-y-2">
{visible.map((run) => { {visible.map((run) => {
const status = automationRunStatusLabel(run.status, tx); const status = automationRunStatusLabel(run.status, tx);
const needsAttention = automationRunNeedsAttention(run.status);
const duration = run.duration_ms === undefined const duration = run.duration_ms === undefined
? null ? null
: formatAutomationRunDuration(run.duration_ms, locale, tx); : formatAutomationRunDuration(run.duration_ms, locale, tx);
const runTime = fmtDateTime(run.run_at_ms, locale);
const runMeta = duration
? tx("settings.automations.history.runMetaWithDuration", "{{time}} · {{duration}}", {
time: runTime,
duration,
})
: runTime;
const primary = needsAttention
? run.error || status
: tx("settings.automations.history.noError", "No error recorded");
const secondary = needsAttention ? `${status} · ${runMeta}` : runMeta;
return ( return (
<div <div
key={`${run.run_at_ms}:${run.status}:${run.duration_ms ?? "none"}`} key={`${run.run_at_ms}:${run.status}:${run.duration_ms ?? "none"}`}
@@ -4007,18 +4028,15 @@ function AutomationRunHistory({
className="block line-clamp-2 text-[12.5px] leading-5 text-foreground/82" className="block line-clamp-2 text-[12.5px] leading-5 text-foreground/82"
title={run.error ?? undefined} title={run.error ?? undefined}
> >
{status} {primary}
{run.error ? ` · ${run.error}` : ""}
</span> </span>
<span className="block truncate text-[11.5px] leading-4 text-muted-foreground"> <span className="block truncate text-[11.5px] leading-4 text-muted-foreground">
{fmtDateTime(run.run_at_ms, locale)} {secondary}
</span> </span>
</span> </span>
{duration ? (
<span className="rounded-full bg-muted px-2 py-0.5 text-[11px] leading-4 text-muted-foreground"> <span className="rounded-full bg-muted px-2 py-0.5 text-[11px] leading-4 text-muted-foreground">
{duration} {status}
</span> </span>
) : null}
</div> </div>
); );
})} })}
@@ -4028,6 +4046,10 @@ function AutomationRunHistory({
); );
} }
function automationRunNeedsAttention(status: string | null | undefined): boolean {
return Boolean(status) && status !== "ok";
}
function AutomationDetail({ function AutomationDetail({
label, label,
title, title,
@@ -4678,9 +4700,9 @@ function formatAutomationNextTitle(
return fmtDateTime(job.state.next_run_at_ms, locale); return fmtDateTime(job.state.next_run_at_ms, locale);
} }
function automationLastResult( function automationRunSummary(
job: SessionAutomationJob, job: SessionAutomationJob,
latestRun: NonNullable<SessionAutomationJob["state"]["run_history"]>[number] | undefined, history: NonNullable<SessionAutomationJob["state"]["run_history"]>,
locale: string, locale: string,
tx: (key: string, fallback: string, values?: Record<string, unknown>) => string, tx: (key: string, fallback: string, values?: Record<string, unknown>) => string,
): { ): {
@@ -4700,37 +4722,64 @@ function automationLastResult(
tone: "warning", tone: "warning",
}; };
} }
if (latestRun) { const latestRun = history[history.length - 1];
const status = automationRunStatusLabel(latestRun.status, tx); const issueRuns = history.filter((run) => automationRunNeedsAttention(run.status));
const duration = latestRun.duration_ms === undefined const latestIssue = issueRuns[issueRuns.length - 1];
? null if (latestIssue) {
: formatAutomationRunDuration(latestRun.duration_ms, locale, tx); const status = automationRunStatusLabel(latestIssue.status, tx);
const primary = duration const runTime = fmtDateTime(latestIssue.run_at_ms, locale);
? tx("settings.automations.history.statusWithDuration", "{{status}} · {{duration}}", { const primary = tx("settings.automations.history.issueCount", "Issues: {{count}}", {
status, count: issueRuns.length,
duration, });
}) const secondary = latestIssue.error
: status; ? `${latestIssue.error} · ${runTime}`
const secondary = fmtDateTime(latestRun.run_at_ms, locale); : `${status} · ${runTime}`;
return { return {
primary, primary,
secondary, secondary,
title: latestRun.error ? `${secondary} · ${latestRun.error}` : secondary, title: secondary,
tone: latestRun.status === "error" tone: latestIssue.status === "error" ? "danger" : "warning",
? "danger" };
: latestRun.status === "skipped" }
? "warning" if (latestRun) {
: "success", const duration = latestRun.duration_ms === undefined
? null
: formatAutomationRunDuration(latestRun.duration_ms, locale, tx);
const runTime = fmtDateTime(latestRun.run_at_ms, locale);
const secondary = duration
? tx("settings.automations.history.lastRanWithDuration", "Last ran {{time}} · {{duration}}", {
time: runTime,
duration,
})
: tx("settings.automations.history.lastRan", "Last ran {{time}}", { time: runTime });
return {
primary: tx("settings.automations.history.noRecentIssues", "No recent issues"),
secondary,
title: secondary,
tone: "success",
}; };
} }
if (job.state.last_run_at_ms) { if (job.state.last_run_at_ms) {
const status = automationRunStatusLabel(job.state.last_status, tx); const status = automationRunStatusLabel(job.state.last_status, tx);
const secondary = fmtDateTime(job.state.last_run_at_ms, locale); const runTime = fmtDateTime(job.state.last_run_at_ms, locale);
if (!automationRunNeedsAttention(job.state.last_status)) {
return { return {
primary: status, primary: tx("settings.automations.history.noRecentIssues", "No recent issues"),
secondary: tx("settings.automations.history.lastRan", "Last ran {{time}}", {
time: runTime,
}),
title: runTime,
tone: "success",
};
}
const secondary = job.state.last_error ? `${job.state.last_error} · ${runTime}` : `${status} · ${runTime}`;
return {
primary: tx("settings.automations.history.issueCount", "Issues: {{count}}", {
count: 1,
}),
secondary, secondary,
title: secondary, title: secondary,
tone: job.state.last_status === "error" ? "danger" : "success", tone: job.state.last_status === "error" ? "danger" : "warning",
}; };
} }
const never = tx("settings.automations.last.never", "Never"); const never = tx("settings.automations.last.never", "Never");
@@ -4763,7 +4812,7 @@ function automationRunStatusLabel(
status: string | null | undefined, status: string | null | undefined,
tx: (key: string, fallback: string, values?: Record<string, unknown>) => string, tx: (key: string, fallback: string, values?: Record<string, unknown>) => string,
): string { ): string {
if (status === "ok") return tx("settings.automations.history.ok", "Completed"); if (status === "ok") return tx("settings.automations.history.ok", "Healthy");
if (status === "error") return tx("settings.automations.history.error", "Error"); if (status === "error") return tx("settings.automations.history.error", "Error");
if (status === "skipped") return tx("settings.automations.history.skipped", "Skipped"); if (status === "skipped") return tx("settings.automations.history.skipped", "Skipped");
return status || tx("settings.automations.last.unknown", "unknown"); return status || tx("settings.automations.last.unknown", "unknown");
+9 -3
View File
@@ -577,12 +577,18 @@
"unknown": "unknown" "unknown": "unknown"
}, },
"history": { "history": {
"ok": "Completed", "ok": "Healthy",
"error": "Error", "error": "Error",
"skipped": "Skipped", "skipped": "Skipped",
"recent": "Last result", "health": "Recent health",
"timeline": "Run history", "timeline": "Run history",
"statusWithDuration": "{{status}} · {{duration}}" "summary": "Runs: {{total}} · Issues: {{issues}}",
"issueCount": "Issues: {{count}}",
"noRecentIssues": "No recent issues",
"lastRan": "Last ran {{time}}",
"lastRanWithDuration": "Last ran {{time}} · {{duration}}",
"runMetaWithDuration": "{{time}} · {{duration}}",
"noError": "No error recorded"
}, },
"message": { "message": {
"showMore": "Show full message", "showMore": "Show full message",
+9 -3
View File
@@ -577,12 +577,18 @@
"unknown": "desconocido" "unknown": "desconocido"
}, },
"history": { "history": {
"ok": "Completada", "ok": "Sin errores",
"error": "Error", "error": "Error",
"skipped": "Omitida", "skipped": "Omitida",
"recent": "Último resultado", "health": "Estado reciente",
"timeline": "Historial de ejecuciones", "timeline": "Historial de ejecuciones",
"statusWithDuration": "{{status}} · {{duration}}" "summary": "Ejecuciones: {{total}} · Problemas: {{issues}}",
"issueCount": "Problemas: {{count}}",
"noRecentIssues": "Sin problemas recientes",
"lastRan": "Última ejecución: {{time}}",
"lastRanWithDuration": "Última ejecución: {{time}} · {{duration}}",
"runMetaWithDuration": "{{time}} · {{duration}}",
"noError": "No se registraron errores"
}, },
"message": { "message": {
"showMore": "Mostrar mensaje completo", "showMore": "Mostrar mensaje completo",
+9 -3
View File
@@ -577,12 +577,18 @@
"unknown": "inconnu" "unknown": "inconnu"
}, },
"history": { "history": {
"ok": "Terminée", "ok": "Sans erreur",
"error": "Erreur", "error": "Erreur",
"skipped": "Ignorée", "skipped": "Ignorée",
"recent": "Dernier résultat", "health": "État récent",
"timeline": "Historique dexécution", "timeline": "Historique dexécution",
"statusWithDuration": "{{status}} · {{duration}}" "summary": "Exécutions : {{total}} · Problèmes : {{issues}}",
"issueCount": "Problèmes : {{count}}",
"noRecentIssues": "Aucun problème récent",
"lastRan": "Dernière exécution : {{time}}",
"lastRanWithDuration": "Dernière exécution : {{time}} · {{duration}}",
"runMetaWithDuration": "{{time}} · {{duration}}",
"noError": "Aucune erreur enregistrée"
}, },
"message": { "message": {
"showMore": "Afficher le message complet", "showMore": "Afficher le message complet",
+9 -3
View File
@@ -577,12 +577,18 @@
"unknown": "tidak dikenal" "unknown": "tidak dikenal"
}, },
"history": { "history": {
"ok": "Selesai", "ok": "Tanpa error",
"error": "Error", "error": "Error",
"skipped": "Dilewati", "skipped": "Dilewati",
"recent": "Hasil terakhir", "health": "Status terbaru",
"timeline": "Riwayat eksekusi", "timeline": "Riwayat eksekusi",
"statusWithDuration": "{{status}} · {{duration}}" "summary": "Eksekusi: {{total}} · Masalah: {{issues}}",
"issueCount": "Masalah: {{count}}",
"noRecentIssues": "Tidak ada masalah terbaru",
"lastRan": "Terakhir berjalan {{time}}",
"lastRanWithDuration": "Terakhir berjalan {{time}} · {{duration}}",
"runMetaWithDuration": "{{time}} · {{duration}}",
"noError": "Tidak ada error tercatat"
}, },
"message": { "message": {
"showMore": "Tampilkan pesan lengkap", "showMore": "Tampilkan pesan lengkap",
+9 -3
View File
@@ -577,12 +577,18 @@
"unknown": "不明" "unknown": "不明"
}, },
"history": { "history": {
"ok": "完了", "ok": "正常",
"error": "エラー", "error": "エラー",
"skipped": "スキップ", "skipped": "スキップ",
"recent": "最新結果", "health": "最近の状態",
"timeline": "実行履歴", "timeline": "実行履歴",
"statusWithDuration": "{{status}} · {{duration}}" "summary": "実行: {{total}} · 問題: {{issues}}",
"issueCount": "問題: {{count}}",
"noRecentIssues": "最近の問題なし",
"lastRan": "最終実行: {{time}}",
"lastRanWithDuration": "最終実行: {{time}} · {{duration}}",
"runMetaWithDuration": "{{time}} · {{duration}}",
"noError": "エラー記録なし"
}, },
"message": { "message": {
"showMore": "メッセージ全文を表示", "showMore": "メッセージ全文を表示",
+9 -3
View File
@@ -577,12 +577,18 @@
"unknown": "알 수 없음" "unknown": "알 수 없음"
}, },
"history": { "history": {
"ok": "완료", "ok": "정상",
"error": "오류", "error": "오류",
"skipped": "건너뜀", "skipped": "건너뜀",
"recent": "최근 결과", "health": "최근 상태",
"timeline": "실행 기록", "timeline": "실행 기록",
"statusWithDuration": "{{status}} · {{duration}}" "summary": "실행: {{total}} · 문제: {{issues}}",
"issueCount": "문제: {{count}}",
"noRecentIssues": "최근 문제 없음",
"lastRan": "마지막 실행: {{time}}",
"lastRanWithDuration": "마지막 실행: {{time}} · {{duration}}",
"runMetaWithDuration": "{{time}} · {{duration}}",
"noError": "기록된 오류 없음"
}, },
"message": { "message": {
"showMore": "전체 메시지 보기", "showMore": "전체 메시지 보기",
+9 -3
View File
@@ -577,12 +577,18 @@
"unknown": "không xác định" "unknown": "không xác định"
}, },
"history": { "history": {
"ok": "Hoàn tất", "ok": "Ổn",
"error": "Lỗi", "error": "Lỗi",
"skipped": "Đã bỏ qua", "skipped": "Đã bỏ qua",
"recent": "Kết quả gần nhất", "health": "Tình trạng gần đây",
"timeline": "Lịch sử chạy", "timeline": "Lịch sử chạy",
"statusWithDuration": "{{status}} · {{duration}}" "summary": "Lượt chạy: {{total}} · Vấn đề: {{issues}}",
"issueCount": "Vấn đề: {{count}}",
"noRecentIssues": "Không có vấn đề gần đây",
"lastRan": "Chạy lần cuối {{time}}",
"lastRanWithDuration": "Chạy lần cuối {{time}} · {{duration}}",
"runMetaWithDuration": "{{time}} · {{duration}}",
"noError": "Không ghi nhận lỗi"
}, },
"message": { "message": {
"showMore": "Hiển thị toàn bộ tin nhắn", "showMore": "Hiển thị toàn bộ tin nhắn",
+9 -3
View File
@@ -577,12 +577,18 @@
"unknown": "未知" "unknown": "未知"
}, },
"history": { "history": {
"ok": "完成", "ok": "正常",
"error": "错误", "error": "错误",
"skipped": "已跳过", "skipped": "已跳过",
"recent": "最近结果", "health": "最近健康状态",
"timeline": "运行记录", "timeline": "运行记录",
"statusWithDuration": "{{status}} · {{duration}}" "summary": "运行:{{total}} · 问题:{{issues}}",
"issueCount": "问题:{{count}}",
"noRecentIssues": "近期无问题",
"lastRan": "最后运行于 {{time}}",
"lastRanWithDuration": "最后运行于 {{time}} · {{duration}}",
"runMetaWithDuration": "{{time}} · {{duration}}",
"noError": "未记录错误"
}, },
"message": { "message": {
"showMore": "查看完整消息", "showMore": "查看完整消息",
+9 -3
View File
@@ -577,12 +577,18 @@
"unknown": "未知" "unknown": "未知"
}, },
"history": { "history": {
"ok": "完成", "ok": "正常",
"error": "錯誤", "error": "錯誤",
"skipped": "已略過", "skipped": "已略過",
"recent": "最近結果", "health": "最近健康狀態",
"timeline": "執行記錄", "timeline": "執行記錄",
"statusWithDuration": "{{status}} · {{duration}}" "summary": "執行:{{total}} · 問題:{{issues}}",
"issueCount": "問題:{{count}}",
"noRecentIssues": "近期無問題",
"lastRan": "最後執行於 {{time}}",
"lastRanWithDuration": "最後執行於 {{time}} · {{duration}}",
"runMetaWithDuration": "{{time}} · {{duration}}",
"noError": "未記錄錯誤"
}, },
"message": { "message": {
"showMore": "查看完整訊息", "showMore": "查看完整訊息",
+12 -5
View File
@@ -628,15 +628,21 @@ describe("App layout", () => {
expect(within(detailPanel).getByRole("button", { name: "Show less" })).toBeInTheDocument(); expect(within(detailPanel).getByRole("button", { name: "Show less" })).toBeInTheDocument();
expect(message!).not.toHaveClass("line-clamp-6"); expect(message!).not.toHaveClass("line-clamp-6");
expect(screen.queryByText(/oldest failure/)).not.toBeInTheDocument(); expect(within(detailPanel).getByText("Recent health")).toBeInTheDocument();
expect(within(detailPanel).getByText("Issues: 2")).toBeInTheDocument();
const historyToggle = within(detailPanel).getByRole("button", { name: /Run history/ }); const historyToggle = within(detailPanel).getByRole("button", { name: /Run history/ });
const historySection = historyToggle.closest("section") as HTMLElement;
expect(historySection).not.toBeNull();
expect(within(historySection).queryByText(/oldest failure/)).not.toBeInTheDocument();
expect(historyToggle).toHaveAttribute("aria-expanded", "false"); expect(historyToggle).toHaveAttribute("aria-expanded", "false");
expect(historyToggle).toHaveTextContent("Runs: 6 · Issues: 2");
fireEvent.click(historyToggle); fireEvent.click(historyToggle);
expect(historyToggle).toHaveAttribute("aria-expanded", "true"); expect(historyToggle).toHaveAttribute("aria-expanded", "true");
expect(screen.getAllByText(/oldest failure/)).toHaveLength(2); expect(within(historySection).getAllByText(/oldest failure/)).toHaveLength(2);
expect(within(detailPanel).getAllByText("No error recorded").length).toBeGreaterThanOrEqual(1);
fireEvent.click(historyToggle); fireEvent.click(historyToggle);
expect(historyToggle).toHaveAttribute("aria-expanded", "false"); expect(historyToggle).toHaveAttribute("aria-expanded", "false");
expect(screen.queryByText(/oldest failure/)).not.toBeInTheDocument(); expect(within(historySection).queryByText(/oldest failure/)).not.toBeInTheDocument();
}); });
it("localizes the Automations surface", async () => { it("localizes the Automations surface", async () => {
@@ -697,8 +703,9 @@ describe("App layout", () => {
expect(screen.getAllByText("每日检查").length).toBeGreaterThanOrEqual(1); expect(screen.getAllByText("每日检查").length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText("检查仓库状态").length).toBeGreaterThanOrEqual(1); expect(screen.getAllByText("检查仓库状态").length).toBeGreaterThanOrEqual(1);
expect(screen.getByText("每 1天")).toBeInTheDocument(); expect(screen.getByText("每 1天")).toBeInTheDocument();
expect(screen.getByText("最近结果")).toBeInTheDocument(); expect(screen.getByText("最近健康状态")).toBeInTheDocument();
expect(screen.getByText("完成 · 不到 1 秒")).toBeInTheDocument(); expect(screen.getByText("近期无问题")).toBeInTheDocument();
expect(screen.getByText(/最后运行于/)).toBeInTheDocument();
expect(screen.queryByText("Workspace automations")).not.toBeInTheDocument(); expect(screen.queryByText("Workspace automations")).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "刷新" })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "刷新" })).not.toBeInTheDocument();
expect(document.title).toBe("自动任务 · nanobot"); expect(document.title).toBe("自动任务 · nanobot");
+4 -1
View File
@@ -56,7 +56,10 @@ const LOCALIZED_SETTINGS_COPY_KEYS = [
"settings.automations.labels.schedule", "settings.automations.labels.schedule",
"settings.automations.status.active", "settings.automations.status.active",
"settings.automations.history.ok", "settings.automations.history.ok",
"settings.automations.history.recent", "settings.automations.history.health",
"settings.automations.history.summary",
"settings.automations.history.noRecentIssues",
"settings.automations.history.noError",
"settings.automations.duration.lessThanSecond", "settings.automations.duration.lessThanSecond",
"settings.automations.deleteTitle", "settings.automations.deleteTitle",
"settings.sections.interface", "settings.sections.interface",