From 9fa14ff61a8573d6819065c96cc81293ed818ead Mon Sep 17 00:00:00 2001 From: Wynne668 Date: Tue, 23 Jun 2026 09:13:19 +0800 Subject: [PATCH] fix(control-ui): exclude disabled cron jobs from failed count (#95723) Overview surfaces counted any job whose lastRunStatus was "error" as a current failure, so an intentionally disabled job that previously failed kept inflating the top-level "failed cron" badge and attention callout. Add a shared isCronJobActiveFailure predicate that gates the error status on enabled, matching the adjacent overdue filter, and use it in both the overview card and the attention items list. Historical status stays visible in detail views via resolveCronJobLastRunStatus. --- ui/src/ui/app-settings.ts | 4 ++-- ui/src/ui/cron-status.test.ts | 40 +++++++++++++++++++++++++++++++ ui/src/ui/cron-status.ts | 8 +++++++ ui/src/ui/views/overview-cards.ts | 6 ++--- 4 files changed, 52 insertions(+), 6 deletions(-) create mode 100644 ui/src/ui/cron-status.test.ts diff --git a/ui/src/ui/app-settings.ts b/ui/src/ui/app-settings.ts index 8bdcb5a08a7..4a8af8e498a 100644 --- a/ui/src/ui/app-settings.ts +++ b/ui/src/ui/app-settings.ts @@ -64,7 +64,7 @@ import { stopWorkboardLifecycleRefresh, stopWorkboardPolling, } from "./controllers/workboard.ts"; -import { resolveCronJobLastRunStatus } from "./cron-status.ts"; +import { isCronJobActiveFailure } from "./cron-status.ts"; import { syncCustomThemeStyleTag } from "./custom-theme.ts"; import { isMonitoredAuthProvider } from "./model-auth-helpers.ts"; import { @@ -941,7 +941,7 @@ function buildAttentionItems(host: SettingsAppHost) { } const cronJobs = host.cronJobs ?? []; - const failedCron = cronJobs.filter((j) => resolveCronJobLastRunStatus(j) === "error"); + const failedCron = cronJobs.filter(isCronJobActiveFailure); if (failedCron.length > 0) { items.push({ severity: "error", diff --git a/ui/src/ui/cron-status.test.ts b/ui/src/ui/cron-status.test.ts new file mode 100644 index 00000000000..018ae4668b0 --- /dev/null +++ b/ui/src/ui/cron-status.test.ts @@ -0,0 +1,40 @@ +// Control UI tests cover cron status derivation behavior. +import { describe, expect, it } from "vitest"; +import { isCronJobActiveFailure, resolveCronJobLastRunStatus } from "./cron-status.ts"; +import type { CronJob } from "./types.ts"; + +function job(overrides: Partial = {}): CronJob { + return { + id: "job", + name: "Job", + enabled: true, + createdAtMs: 0, + updatedAtMs: 0, + schedule: { kind: "every", everyMs: 60_000 }, + sessionTarget: "main", + wakeMode: "next-heartbeat", + payload: { kind: "systemEvent", text: "test" }, + ...overrides, + }; +} + +describe("isCronJobActiveFailure", () => { + it("counts an enabled job whose last run errored", () => { + expect(isCronJobActiveFailure(job({ state: { lastRunStatus: "error" } }))).toBe(true); + }); + + it("ignores a disabled job that retains historical error state", () => { + const disabled = job({ + enabled: false, + state: { lastRunStatus: "error", consecutiveErrors: 6, nextRunAtMs: undefined }, + }); + // Historical status is still preserved for detail views. + expect(resolveCronJobLastRunStatus(disabled)).toBe("error"); + expect(isCronJobActiveFailure(disabled)).toBe(false); + }); + + it("does not count enabled jobs whose last run succeeded or is unknown", () => { + expect(isCronJobActiveFailure(job({ state: { lastRunStatus: "ok" } }))).toBe(false); + expect(isCronJobActiveFailure(job())).toBe(false); + }); +}); diff --git a/ui/src/ui/cron-status.ts b/ui/src/ui/cron-status.ts index f6b17bf571b..616b83cb906 100644 --- a/ui/src/ui/cron-status.ts +++ b/ui/src/ui/cron-status.ts @@ -6,3 +6,11 @@ export type CronJobLastRunStatus = CronRunStatus | "unknown"; export function resolveCronJobLastRunStatus(job: CronJob): CronJobLastRunStatus { return job.state?.lastRunStatus ?? job.state?.lastStatus ?? "unknown"; } + +// Overview "failed cron" surfaces track current actionability, so a failure only +// counts while the job is still enabled. Disabled jobs keep their historical +// `lastRunStatus: "error"` for detail views, but a retired job must not be +// reported as an active operational problem. +export function isCronJobActiveFailure(job: CronJob): boolean { + return job.enabled && resolveCronJobLastRunStatus(job) === "error"; +} diff --git a/ui/src/ui/views/overview-cards.ts b/ui/src/ui/views/overview-cards.ts index 4a1c99f59de..1b38e875a13 100644 --- a/ui/src/ui/views/overview-cards.ts +++ b/ui/src/ui/views/overview-cards.ts @@ -3,7 +3,7 @@ import { asDateTimestampMs } from "@openclaw/normalization-core/number-coercion" import { html, nothing, type TemplateResult } from "lit"; import { unsafeHTML } from "lit/directives/unsafe-html.js"; import { t } from "../../i18n/index.ts"; -import { resolveCronJobLastRunStatus } from "../cron-status.ts"; +import { isCronJobActiveFailure } from "../cron-status.ts"; import { formatCost, formatTokens, formatRelativeTimestamp } from "../format.ts"; import { isMonitoredAuthProvider } from "../model-auth-helpers.ts"; import { formatNextRun } from "../presenter.ts"; @@ -134,9 +134,7 @@ export function renderOverviewCards(props: OverviewCardsProps) { const cronEnabled = props.cronStatus?.enabled ?? null; const cronNext = props.cronStatus?.nextWakeAtMs ?? null; const cronJobCount = props.cronJobs.length; - const failedCronCount = props.cronJobs.filter( - (j) => resolveCronJobLastRunStatus(j) === "error", - ).length; + const failedCronCount = props.cronJobs.filter(isCronJobActiveFailure).length; const authLoading = props.modelAuthStatus === null; const authProviders = props.modelAuthStatus?.providers ?? []; const monitoredProviders = authProviders.filter(isMonitoredAuthProvider);