From 11d4fc101e3938444c092683bebf534bc269284f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 15 Jan 2026 08:38:12 +0000 Subject: [PATCH] fix: sync cron run history selection in control ui --- CHANGELOG.md | 1 + ui/src/styles/components.css | 14 +++++ ui/src/ui/views/cron.test.ts | 99 ++++++++++++++++++++++++++++++++++++ ui/src/ui/views/cron.ts | 46 +++++++++++++---- 4 files changed, 149 insertions(+), 11 deletions(-) create mode 100644 ui/src/ui/views/cron.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e4a97c50d..4589a4777 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,7 @@ - Config: add `channels..configWrites` gating for channel-initiated config writes; migrate Slack channel IDs. ### Fixes +- Control UI: load cron run history on job selection and clarify empty-state messaging. (#866) - Slack: isolate thread history and avoid inheriting channel transcripts for new threads by default. (#758) - Gateway: forward termination signals to respawned CLI child processes to avoid orphaned systemd runs. (#933) — thanks @roshanasingh4. - Browser: add tests for snapshot labels/efficient query params and labeled image responses. diff --git a/ui/src/styles/components.css b/ui/src/styles/components.css index ef5446cf8..83bc13953 100644 --- a/ui/src/styles/components.css +++ b/ui/src/styles/components.css @@ -441,6 +441,20 @@ background: rgba(0, 0, 0, 0.2); } +.list-item-clickable { + cursor: pointer; + transition: border-color 0.15s ease, box-shadow 0.15s ease; +} + +.list-item-clickable:hover { + border-color: var(--border-strong); +} + +.list-item-selected { + border-color: var(--accent); + box-shadow: 0 0 0 1px var(--focus); +} + .list-main { display: grid; gap: 6px; diff --git a/ui/src/ui/views/cron.test.ts b/ui/src/ui/views/cron.test.ts new file mode 100644 index 000000000..79553f4fc --- /dev/null +++ b/ui/src/ui/views/cron.test.ts @@ -0,0 +1,99 @@ +import { render } from "lit"; +import { describe, expect, it, vi } from "vitest"; + +import { DEFAULT_CRON_FORM } from "../app-defaults"; +import type { CronJob } from "../types"; +import { renderCron, type CronProps } from "./cron"; + +function createJob(id: string): CronJob { + return { + id, + name: "Daily ping", + enabled: true, + createdAtMs: 0, + updatedAtMs: 0, + schedule: { kind: "cron", expr: "0 9 * * *" }, + sessionTarget: "main", + wakeMode: "next-heartbeat", + payload: { kind: "systemEvent", text: "ping" }, + }; +} + +function createProps(overrides: Partial = {}): CronProps { + return { + loading: false, + status: null, + jobs: [], + error: null, + busy: false, + form: { ...DEFAULT_CRON_FORM }, + runsJobId: null, + runs: [], + onFormChange: () => undefined, + onRefresh: () => undefined, + onAdd: () => undefined, + onToggle: () => undefined, + onRun: () => undefined, + onRemove: () => undefined, + onLoadRuns: () => undefined, + ...overrides, + }; +} + +describe("cron view", () => { + it("prompts to select a job before showing run history", () => { + const container = document.createElement("div"); + render(renderCron(createProps()), container); + + expect(container.textContent).toContain("Select a job to inspect run history."); + }); + + it("loads run history when clicking a job row", () => { + const container = document.createElement("div"); + const onLoadRuns = vi.fn(); + const job = createJob("job-1"); + render( + renderCron( + createProps({ + jobs: [job], + onLoadRuns, + }), + ), + container, + ); + + const row = container.querySelector(".list-item-clickable") as HTMLElement | null; + expect(row).not.toBeNull(); + row?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + + expect(onLoadRuns).toHaveBeenCalledWith("job-1"); + }); + + it("marks the selected job and keeps Runs button to a single call", () => { + const container = document.createElement("div"); + const onLoadRuns = vi.fn(); + const job = createJob("job-1"); + render( + renderCron( + createProps({ + jobs: [job], + runsJobId: "job-1", + onLoadRuns, + }), + ), + container, + ); + + const selected = container.querySelector(".list-item-selected"); + expect(selected).not.toBeNull(); + + const runsButton = Array.from(container.querySelectorAll("button")).find( + (btn) => btn.textContent?.trim() === "Runs", + ); + expect(runsButton).not.toBeUndefined(); + runsButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + + expect(onLoadRuns).toHaveBeenCalledTimes(1); + expect(onLoadRuns).toHaveBeenCalledWith("job-1"); + }); +}); diff --git a/ui/src/ui/views/cron.ts b/ui/src/ui/views/cron.ts index 3bd5a827c..106ec96b2 100644 --- a/ui/src/ui/views/cron.ts +++ b/ui/src/ui/views/cron.ts @@ -260,13 +260,19 @@ export function renderCron(props: CronProps) {
Run history
Latest runs for ${props.runsJobId ?? "(select a job)"}.
- ${props.runs.length === 0 - ? html`
No runs yet.
` - : html` -
- ${props.runs.map((entry) => renderRun(entry))} + ${props.runsJobId == null + ? html` +
+ Select a job to inspect run history.
- `} + ` + : props.runs.length === 0 + ? html`
No runs yet.
` + : html` +
+ ${props.runs.map((entry) => renderRun(entry))} +
+ `}
`; } @@ -341,8 +347,10 @@ function renderScheduleFields(props: CronProps) { } function renderJob(job: CronJob, props: CronProps) { + const isSelected = props.runsJobId === job.id; + const itemClass = `list-item list-item-clickable${isSelected ? " list-item-selected" : ""}`; return html` -
+
props.onLoadRuns(job.id)}>
${job.name}
${formatCronSchedule(job)}
@@ -360,24 +368,40 @@ function renderJob(job: CronJob, props: CronProps) { -