fix: sync cron run history selection in control ui

This commit is contained in:
Peter Steinberger
2026-01-15 08:38:12 +00:00
parent 4e6fb47a3f
commit 11d4fc101e
4 changed files with 149 additions and 11 deletions

View File

@@ -35,6 +35,7 @@
- Config: add `channels.<provider>.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.

View File

@@ -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;

View File

@@ -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> = {}): 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");
});
});

View File

@@ -260,13 +260,19 @@ export function renderCron(props: CronProps) {
<section class="card" style="margin-top: 18px;">
<div class="card-title">Run history</div>
<div class="card-sub">Latest runs for ${props.runsJobId ?? "(select a job)"}.</div>
${props.runs.length === 0
? html`<div class="muted" style="margin-top: 12px;">No runs yet.</div>`
: html`
<div class="list" style="margin-top: 12px;">
${props.runs.map((entry) => renderRun(entry))}
${props.runsJobId == null
? html`
<div class="muted" style="margin-top: 12px;">
Select a job to inspect run history.
</div>
`}
`
: props.runs.length === 0
? html`<div class="muted" style="margin-top: 12px;">No runs yet.</div>`
: html`
<div class="list" style="margin-top: 12px;">
${props.runs.map((entry) => renderRun(entry))}
</div>
`}
</section>
`;
}
@@ -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`
<div class="list-item">
<div class=${itemClass} @click=${() => props.onLoadRuns(job.id)}>
<div class="list-main">
<div class="list-title">${job.name}</div>
<div class="list-sub">${formatCronSchedule(job)}</div>
@@ -360,24 +368,40 @@ function renderJob(job: CronJob, props: CronProps) {
<button
class="btn"
?disabled=${props.busy}
@click=${() => props.onToggle(job, !job.enabled)}
@click=${(event: Event) => {
event.stopPropagation();
props.onToggle(job, !job.enabled);
}}
>
${job.enabled ? "Disable" : "Enable"}
</button>
<button class="btn" ?disabled=${props.busy} @click=${() => props.onRun(job)}>
<button
class="btn"
?disabled=${props.busy}
@click=${(event: Event) => {
event.stopPropagation();
props.onRun(job);
}}
>
Run
</button>
<button
class="btn"
?disabled=${props.busy}
@click=${() => props.onLoadRuns(job.id)}
@click=${(event: Event) => {
event.stopPropagation();
props.onLoadRuns(job.id);
}}
>
Runs
</button>
<button
class="btn danger"
?disabled=${props.busy}
@click=${() => props.onRemove(job)}
@click=${(event: Event) => {
event.stopPropagation();
props.onRemove(job);
}}
>
Remove
</button>