@runwisp/ui 0.14.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -175,7 +175,7 @@
175
175
  if (run.status === "running") return "live";
176
176
  if (run.status === "pending") return "queued";
177
177
  if (displayed === "failed" || displayed === "crashed") {
178
- return "exit " + String(run.exit_code);
178
+ return "exit " + String(run.exitCode);
179
179
  }
180
180
  return runDuration(run) ?? "—";
181
181
  }
@@ -277,30 +277,30 @@
277
277
  // (page-injected or popover-set) always applies.
278
278
  function buildSelectorFilter(): NonNullable<RunSelector["filter"]> {
279
279
  const filter: NonNullable<RunSelector["filter"]> = {};
280
- if (taskNameFilter) filter.task_name = taskNameFilter;
281
- else if (filters.task_name) filter.task_name = filters.task_name;
280
+ if (taskNameFilter) filter.taskName = taskNameFilter;
281
+ else if (filters.taskName) filter.taskName = filters.taskName;
282
282
  if (!showFilters) return filter;
283
283
  if (filters.statuses.length > 0) filter.status = filters.statuses.join(",");
284
284
  const query = filters.search.trim();
285
285
  if (query) filter.search = query;
286
- if (filters.created_after) filter.created_after = filters.created_after;
287
- if (filters.created_before) filter.created_before = filters.created_before;
288
- if (filters.triggered_by) filter.triggered_by = filters.triggered_by;
289
- const exit = exitCodeRange(filters.exit_code);
290
- if (exit.min !== undefined) filter.exit_code_min = exit.min;
291
- if (exit.max !== undefined) filter.exit_code_max = exit.max;
292
- if (filters.retries_only) filter.retries_only = true;
286
+ if (filters.createdAfter) filter.createdAfter = filters.createdAfter;
287
+ if (filters.createdBefore) filter.createdBefore = filters.createdBefore;
288
+ if (filters.triggeredBy) filter.triggeredBy = filters.triggeredBy;
289
+ const exit = exitCodeRange(filters.exitCode);
290
+ if (exit.min !== undefined) filter.exitCodeMin = exit.min;
291
+ if (exit.max !== undefined) filter.exitCodeMax = exit.max;
292
+ if (filters.retriesOnly) filter.retriesOnly = true;
293
293
  return filter;
294
294
  }
295
295
 
296
296
  function buildSelector(): RunSelector {
297
297
  if (!selectAllMode) {
298
- return { match_all: false, ids: [...explicitIds] };
298
+ return { matchAll: false, ids: [...explicitIds] };
299
299
  }
300
300
  return {
301
- match_all: true,
301
+ matchAll: true,
302
302
  filter: buildSelectorFilter(),
303
- except_ids: [...exceptIds],
303
+ exceptIds: [...exceptIds],
304
304
  };
305
305
  }
306
306
 
@@ -319,19 +319,19 @@
319
319
  case "time":
320
320
  return timeChipLabel();
321
321
  case "task":
322
- return filters.task_name ?? "";
323
- case "triggered_by":
324
- return "Trigger: " + triggerDescription(filters.triggered_by ?? "");
325
- case "exit_code":
326
- return exitCodeChipLabel(filters.exit_code);
322
+ return filters.taskName ?? "";
323
+ case "triggeredBy":
324
+ return "Trigger: " + triggerDescription(filters.triggeredBy ?? "");
325
+ case "exitCode":
326
+ return exitCodeChipLabel(filters.exitCode);
327
327
  case "retries":
328
328
  return "Retries only";
329
329
  }
330
330
  }
331
331
 
332
332
  function timeChipLabel(): string {
333
- const after = filters.created_after;
334
- const before = filters.created_before;
333
+ const after = filters.createdAfter;
334
+ const before = filters.createdBefore;
335
335
  if (isWholeDay(after, before) && after) return `On ${formatCalendarDate(after)}`;
336
336
  if (after && before) return `${formatDateTime(after)} – ${formatDateTime(before)}`;
337
337
  if (after) return `Since ${formatDateTime(after)}`;
@@ -350,9 +350,9 @@
350
350
  const sel = buildSelector();
351
351
  // Narrow the selector to the affected predicate when in explicit mode
352
352
  // so we don't ask the server to operate on rows the UI excluded.
353
- const narrowed: RunSelector = sel.match_all
353
+ const narrowed: RunSelector = sel.matchAll
354
354
  ? sel
355
- : { match_all: false, ids: affected.map((r) => r.id) };
355
+ : { matchAll: false, ids: affected.map((r) => r.id) };
356
356
  handler(narrowed, affected);
357
357
  clearSelection();
358
358
  }
@@ -368,7 +368,7 @@
368
368
  // reliably re-triggers its fetch effect.
369
369
  filters = {
370
370
  ...filters,
371
- sort_direction: filters.sort_direction === "asc" ? "desc" : "asc",
371
+ sortDirection: filters.sortDirection === "asc" ? "desc" : "asc",
372
372
  };
373
373
  }
374
374
 
@@ -480,7 +480,7 @@
480
480
  {#snippet icon()}
481
481
  <ArrowUpDown
482
482
  size={14}
483
- class="text-on-surface-muted {filters.sort_direction === 'asc'
483
+ class="text-on-surface-muted {filters.sortDirection === 'asc'
484
484
  ? 'rotate-180'
485
485
  : ''}"
486
486
  />
@@ -626,7 +626,7 @@
626
626
  checked={isRowSelected(run.id)}
627
627
  onchange={() => toggleRow(run.id)}
628
628
  onclick={(e) => e.stopPropagation()}
629
- aria-label={`Select run from ${formatDateTime(run.start_at ?? run.created_at)}`}
629
+ aria-label={`Select run from ${formatDateTime(run.startAt ?? run.createdAt)}`}
630
630
  class="size-3.5 cursor-pointer rounded border-outline accent-primary opacity-0 {selectionActive
631
631
  ? 'opacity-100'
632
632
  : 'group-hover/row:opacity-100'}"
@@ -639,9 +639,9 @@
639
639
  {@const config = getRunStatusConfig(dstatus)}
640
640
  {@const running = run.status === "running"}
641
641
  {@const spine = config.dot.replace(" animate-pulse", "")}
642
- {@const startedAt = run.start_at ?? run.created_at}
642
+ {@const startedAt = run.startAt ?? run.createdAt}
643
643
  {@const retry = runRetryLabel(run)}
644
- {@const suffix = instanceSuffix(run.instance_index, getInstanceCount(run.task_name))}
644
+ {@const suffix = instanceSuffix(run.instanceIndex, getInstanceCount(run.taskName))}
645
645
  <button
646
646
  class="btn-scale group relative w-full rounded-[3px] border text-left select-none {showTaskName
647
647
  ? 'p-3'
@@ -660,7 +660,7 @@
660
660
  <span class="flex min-w-0 flex-1 flex-col gap-0.5">
661
661
  <span class="flex items-center gap-1.5">
662
662
  <span class="truncate font-mono text-[13px] font-semibold text-on-surface">
663
- {run.task_name}{#if suffix}<span class="text-on-surface-muted"
663
+ {run.taskName}{#if suffix}<span class="text-on-surface-muted"
664
664
  >{suffix}</span
665
665
  >{/if}
666
666
  </span>
@@ -675,7 +675,7 @@
675
675
  title={formatFullDateTime(startedAt)}
676
676
  >
677
677
  <span class="truncate">{formatDateTime(startedAt)}</span>
678
- <span class="shrink-0">· {formatTriggeredByLabel(run.triggered_by)}</span>
678
+ <span class="shrink-0">· {formatTriggeredByLabel(run.triggeredBy)}</span>
679
679
  {#if retry}
680
680
  <span class="shrink-0 rounded bg-surface-sunken px-1 font-mono"
681
681
  >{retry}</span
@@ -47,7 +47,7 @@ describe("emptyRunFilters", () => {
47
47
  it("is a fully-open filter — no dimension active, newest-first", () => {
48
48
  const f = emptyRunFilters();
49
49
  expect(f.statuses).toEqual([]);
50
- expect(f.sort_direction).toBe("desc");
50
+ expect(f.sortDirection).toBe("desc");
51
51
  expect(activeFilterCount(f)).toBe(0);
52
52
  });
53
53
  });
@@ -75,56 +75,54 @@ describe("isNeedsAttention", () => {
75
75
  describe("dimensionActive / activeDimensions", () => {
76
76
  it("detects each dimension independently", () => {
77
77
  expect(dimensionActive(base({ statuses: ["failed"] }), "status")).toBe(true);
78
- expect(dimensionActive(base({ created_after: "2026-01-01T00:00:00Z" }), "time")).toBe(true);
79
- expect(dimensionActive(base({ created_before: "2026-01-01T00:00:00Z" }), "time")).toBe(
80
- true,
81
- );
82
- expect(dimensionActive(base({ task_name: "backup" }), "task")).toBe(true);
83
- expect(dimensionActive(base({ triggered_by: "cron" }), "triggered_by")).toBe(true);
84
- expect(dimensionActive(base({ exit_code: "137" }), "exit_code")).toBe(true);
85
- expect(dimensionActive(base({ exit_code: ">100" }), "exit_code")).toBe(true);
86
- expect(dimensionActive(base({ retries_only: true }), "retries")).toBe(true);
78
+ expect(dimensionActive(base({ createdAfter: "2026-01-01T00:00:00Z" }), "time")).toBe(true);
79
+ expect(dimensionActive(base({ createdBefore: "2026-01-01T00:00:00Z" }), "time")).toBe(true);
80
+ expect(dimensionActive(base({ taskName: "backup" }), "task")).toBe(true);
81
+ expect(dimensionActive(base({ triggeredBy: "cron" }), "triggeredBy")).toBe(true);
82
+ expect(dimensionActive(base({ exitCode: "137" }), "exitCode")).toBe(true);
83
+ expect(dimensionActive(base({ exitCode: ">100" }), "exitCode")).toBe(true);
84
+ expect(dimensionActive(base({ retriesOnly: true }), "retries")).toBe(true);
87
85
  });
88
86
 
89
87
  it("treats an exact exit code of 0 as active (distinct from absent)", () => {
90
- expect(dimensionActive(base({ exit_code: "0" }), "exit_code")).toBe(true);
88
+ expect(dimensionActive(base({ exitCode: "0" }), "exitCode")).toBe(true);
91
89
  });
92
90
 
93
91
  it("treats an empty or unparseable exit-code expression as inactive", () => {
94
- expect(dimensionActive(base({ exit_code: "" }), "exit_code")).toBe(false);
95
- expect(dimensionActive(base({ exit_code: "nonsense" }), "exit_code")).toBe(false);
92
+ expect(dimensionActive(base({ exitCode: "" }), "exitCode")).toBe(false);
93
+ expect(dimensionActive(base({ exitCode: "nonsense" }), "exitCode")).toBe(false);
96
94
  });
97
95
 
98
96
  it("lists active dimensions in display (most→least useful) order", () => {
99
97
  const f = base({
100
- retries_only: true,
98
+ retriesOnly: true,
101
99
  statuses: ["failed"],
102
- triggered_by: "cron",
103
- created_after: "2026-01-01T00:00:00Z",
100
+ triggeredBy: "cron",
101
+ createdAfter: "2026-01-01T00:00:00Z",
104
102
  });
105
- expect(activeDimensions(f)).toEqual(["status", "time", "triggered_by", "retries"]);
103
+ expect(activeDimensions(f)).toEqual(["status", "time", "triggeredBy", "retries"]);
106
104
  });
107
105
  });
108
106
 
109
107
  describe("clearDimension", () => {
110
108
  it("resets one dimension and leaves the rest untouched", () => {
111
- const f = base({ statuses: ["failed"], triggered_by: "cron", retries_only: true });
112
- const cleared = clearDimension(f, "triggered_by");
113
- expect(cleared.triggered_by).toBeUndefined();
109
+ const f = base({ statuses: ["failed"], triggeredBy: "cron", retriesOnly: true });
110
+ const cleared = clearDimension(f, "triggeredBy");
111
+ expect(cleared.triggeredBy).toBeUndefined();
114
112
  expect(cleared.statuses).toEqual(["failed"]);
115
- expect(cleared.retries_only).toBe(true);
113
+ expect(cleared.retriesOnly).toBe(true);
116
114
  });
117
115
 
118
116
  it("clears the exit-code expression", () => {
119
- const f = base({ exit_code: ">100 <150" });
120
- expect(clearDimension(f, "exit_code").exit_code).toBeUndefined();
117
+ const f = base({ exitCode: ">100 <150" });
118
+ expect(clearDimension(f, "exitCode").exitCode).toBeUndefined();
121
119
  });
122
120
 
123
121
  it("clears both time bounds together", () => {
124
- const f = base({ created_after: "2026-01-01T00:00:00Z", created_before: "2026-02-01" });
122
+ const f = base({ createdAfter: "2026-01-01T00:00:00Z", createdBefore: "2026-02-01" });
125
123
  const cleared = clearDimension(f, "time");
126
- expect(cleared.created_after).toBeUndefined();
127
- expect(cleared.created_before).toBeUndefined();
124
+ expect(cleared.createdAfter).toBeUndefined();
125
+ expect(cleared.createdBefore).toBeUndefined();
128
126
  });
129
127
  });
130
128
 
@@ -132,18 +130,18 @@ describe("clearPopoverFilters", () => {
132
130
  it("clears every popover dimension but keeps search and sort", () => {
133
131
  const f = base({
134
132
  search: "nightly",
135
- sort_direction: "asc",
133
+ sortDirection: "asc",
136
134
  statuses: ["failed"],
137
- task_name: "backup",
138
- triggered_by: "cron",
139
- exit_code: "1",
140
- retries_only: true,
141
- created_after: "2026-01-01T00:00:00Z",
135
+ taskName: "backup",
136
+ triggeredBy: "cron",
137
+ exitCode: "1",
138
+ retriesOnly: true,
139
+ createdAfter: "2026-01-01T00:00:00Z",
142
140
  });
143
141
  const cleared = clearPopoverFilters(f);
144
142
  expect(activeFilterCount(cleared)).toBe(0);
145
143
  expect(cleared.search).toBe("nightly");
146
- expect(cleared.sort_direction).toBe("asc");
144
+ expect(cleared.sortDirection).toBe("asc");
147
145
  });
148
146
  });
149
147
 
@@ -8,7 +8,7 @@ export type RunsListSortDirection = "asc" | "desc" | "";
8
8
  /**
9
9
  * The single filter shape shared by the runs list, the filter popover, the
10
10
  * SSE-merge source, and the bulk selector. Every field beyond `search` and
11
- * `sort_direction` is an optional dimension applied server-side; the zero
11
+ * `sortDirection` is an optional dimension applied server-side; the zero
12
12
  * value (`emptyRunFilters`) matches every run.
13
13
  *
14
14
  * `statuses` is a multi-select set joined to a comma-separated string only at
@@ -19,26 +19,26 @@ export type RunsListSortDirection = "asc" | "desc" | "";
19
19
  export interface RunsListFilters {
20
20
  search: string;
21
21
  statuses: string[];
22
- sort_direction: RunsListSortDirection;
22
+ sortDirection: RunsListSortDirection;
23
23
  // Optional dimensions explicitly admit `undefined` so a dimension can be
24
24
  // cleared by reassignment (`{ ...f, x: undefined }`) under the project's
25
25
  // exactOptionalPropertyTypes — a fresh object reference is what re-triggers
26
26
  // the parent's fetch effect through several levels of `bind:`.
27
- task_name?: string | undefined;
28
- created_after?: string | undefined;
29
- created_before?: string | undefined;
30
- triggered_by?: string | undefined;
27
+ taskName?: string | undefined;
28
+ createdAfter?: string | undefined;
29
+ createdBefore?: string | undefined;
30
+ triggeredBy?: string | undefined;
31
31
  // A human exit-code expression — a bare code (`137`), a comparison (`>100`,
32
32
  // `<=150`), or a space-separated combination (`>100 <150`). Normalized to an
33
33
  // inclusive [min, max] range (`exitCodeRange`) at the wire boundary; the
34
- // server only ever sees `exit_code_min` / `exit_code_max`.
35
- exit_code?: string | undefined;
36
- retries_only?: boolean | undefined;
34
+ // server only ever sees `exitCodeMin` / `exitCodeMax`.
35
+ exitCode?: string | undefined;
36
+ retriesOnly?: boolean | undefined;
37
37
  }
38
38
 
39
39
  /** A fresh default filter — no dimensions active, newest-first. */
40
40
  export function emptyRunFilters(): RunsListFilters {
41
- return { search: "", statuses: [], sort_direction: "desc" };
41
+ return { search: "", statuses: [], sortDirection: "desc" };
42
42
  }
43
43
 
44
44
  /**
@@ -131,14 +131,14 @@ function exactBucket(statuses: string[]): StatusBucket | undefined {
131
131
  * page the task name is the page scope (injected at fetch time), never a
132
132
  * popover-set filter, so it stays absent from those filters and is not counted.
133
133
  */
134
- export type FilterDimension = "status" | "time" | "task" | "triggered_by" | "exit_code" | "retries";
134
+ export type FilterDimension = "status" | "time" | "task" | "triggeredBy" | "exitCode" | "retries";
135
135
 
136
136
  const DIMENSION_ORDER: FilterDimension[] = [
137
137
  "status",
138
138
  "time",
139
139
  "task",
140
- "triggered_by",
141
- "exit_code",
140
+ "triggeredBy",
141
+ "exitCode",
142
142
  "retries",
143
143
  ];
144
144
 
@@ -148,15 +148,15 @@ export function dimensionActive(f: RunsListFilters, dim: FilterDimension): boole
148
148
  case "status":
149
149
  return f.statuses.length > 0;
150
150
  case "time":
151
- return Boolean(f.created_after) || Boolean(f.created_before);
151
+ return Boolean(f.createdAfter) || Boolean(f.createdBefore);
152
152
  case "task":
153
- return Boolean(f.task_name);
154
- case "triggered_by":
155
- return Boolean(f.triggered_by);
156
- case "exit_code":
157
- return exitCodeRangeActive(f.exit_code);
153
+ return Boolean(f.taskName);
154
+ case "triggeredBy":
155
+ return Boolean(f.triggeredBy);
156
+ case "exitCode":
157
+ return exitCodeRangeActive(f.exitCode);
158
158
  case "retries":
159
- return Boolean(f.retries_only);
159
+ return Boolean(f.retriesOnly);
160
160
  }
161
161
  }
162
162
 
@@ -176,15 +176,15 @@ export function clearDimension(f: RunsListFilters, dim: FilterDimension): RunsLi
176
176
  case "status":
177
177
  return { ...f, statuses: [] };
178
178
  case "time":
179
- return { ...f, created_after: undefined, created_before: undefined };
179
+ return { ...f, createdAfter: undefined, createdBefore: undefined };
180
180
  case "task":
181
- return { ...f, task_name: undefined };
182
- case "triggered_by":
183
- return { ...f, triggered_by: undefined };
184
- case "exit_code":
185
- return { ...f, exit_code: undefined };
181
+ return { ...f, taskName: undefined };
182
+ case "triggeredBy":
183
+ return { ...f, triggeredBy: undefined };
184
+ case "exitCode":
185
+ return { ...f, exitCode: undefined };
186
186
  case "retries":
187
- return { ...f, retries_only: undefined };
187
+ return { ...f, retriesOnly: undefined };
188
188
  }
189
189
  }
190
190
 
@@ -196,12 +196,12 @@ export function clearPopoverFilters(f: RunsListFilters): RunsListFilters {
196
196
  return {
197
197
  ...f,
198
198
  statuses: [],
199
- created_after: undefined,
200
- created_before: undefined,
201
- task_name: undefined,
202
- triggered_by: undefined,
203
- exit_code: undefined,
204
- retries_only: undefined,
199
+ createdAfter: undefined,
200
+ createdBefore: undefined,
201
+ taskName: undefined,
202
+ triggeredBy: undefined,
203
+ exitCode: undefined,
204
+ retriesOnly: undefined,
205
205
  };
206
206
  }
207
207
 
@@ -2,29 +2,54 @@
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
 
4
4
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+ import { RUN_STATUSES } from "@runwisp/common";
5
6
  import {
6
7
  runDuration,
7
8
  runStartDelay,
9
+ runVerdict,
8
10
  formatTriggeredByLabel,
9
11
  runRetryLabel,
10
12
  instanceSuffix,
11
13
  } from "./run-helpers.js";
12
14
 
15
+ describe("runVerdict", () => {
16
+ it("covers every run status", () => {
17
+ for (const status of RUN_STATUSES) {
18
+ expect(runVerdict(status).verb, status).toBeTruthy();
19
+ }
20
+ });
21
+
22
+ it("phrases a timed outcome so a duration reads after it", () => {
23
+ expect(runVerdict("success")).toEqual({ verb: "succeeded in", timed: true });
24
+ expect(runVerdict("failed")).toEqual({ verb: "failed after", timed: true });
25
+ });
26
+
27
+ it("marks statuses that never produced a duration as untimed", () => {
28
+ // These end without ever running, so the caller renders the verb alone
29
+ // rather than "skipped after —".
30
+ expect(runVerdict("missed").timed).toBe(false);
31
+ expect(runVerdict("skipped").timed).toBe(false);
32
+ expect(runVerdict("dst_skipped").timed).toBe(false);
33
+ expect(runVerdict("queue_full").timed).toBe(false);
34
+ expect(runVerdict("pending").timed).toBe(false);
35
+ });
36
+ });
37
+
13
38
  describe("runDuration", () => {
14
- it("returns undefined when start_at is not set", () => {
39
+ it("returns undefined when startAt is not set", () => {
15
40
  expect(runDuration({})).toBeUndefined();
16
41
  });
17
42
 
18
- it("returns formatted duration when both start_at and end_at are set", () => {
43
+ it("returns formatted duration when both startAt and endAt are set", () => {
19
44
  const start = "2024-06-15T12:00:00.000Z";
20
45
  const end = "2024-06-15T12:00:05.000Z";
21
- expect(runDuration({ start_at: start, end_at: end })).toBe("5s");
46
+ expect(runDuration({ startAt: start, endAt: end })).toBe("5s");
22
47
  });
23
48
 
24
49
  it("returns ms duration for sub-second runs", () => {
25
50
  const start = "2024-06-15T12:00:00.000Z";
26
51
  const end = "2024-06-15T12:00:00.500Z";
27
- expect(runDuration({ start_at: start, end_at: end })).toBe("500ms");
52
+ expect(runDuration({ startAt: start, endAt: end })).toBe("500ms");
28
53
  });
29
54
 
30
55
  describe("with fake clock", () => {
@@ -36,35 +61,35 @@ describe("runDuration", () => {
36
61
  vi.useRealTimers();
37
62
  });
38
63
 
39
- it("uses current time when end_at is not set (run still in progress)", () => {
40
- expect(runDuration({ start_at: "2024-06-15T12:00:00.000Z" })).toBe("2s");
64
+ it("uses current time when endAt is not set (run still in progress)", () => {
65
+ expect(runDuration({ startAt: "2024-06-15T12:00:00.000Z" })).toBe("2s");
41
66
  });
42
67
  });
43
68
 
44
69
  it("counts against an injected now for an in-progress run", () => {
45
70
  const start = "2024-06-15T12:00:00.000Z";
46
71
  const now = new Date("2024-06-15T12:00:07.000Z").getTime();
47
- expect(runDuration({ start_at: start }, now)).toBe("7s");
72
+ expect(runDuration({ startAt: start }, now)).toBe("7s");
48
73
  });
49
74
 
50
75
  it("ignores the injected now once the run has ended", () => {
51
76
  const start = "2024-06-15T12:00:00.000Z";
52
77
  const end = "2024-06-15T12:00:05.000Z";
53
78
  const now = new Date("2024-06-15T12:01:00.000Z").getTime();
54
- expect(runDuration({ start_at: start, end_at: end }, now)).toBe("5s");
79
+ expect(runDuration({ startAt: start, endAt: end }, now)).toBe("5s");
55
80
  });
56
81
  });
57
82
 
58
83
  describe("runStartDelay", () => {
59
- it("returns undefined when start_at is not set", () => {
60
- expect(runStartDelay({ created_at: "2024-06-15T12:00:00.000Z" })).toBeUndefined();
84
+ it("returns undefined when startAt is not set", () => {
85
+ expect(runStartDelay({ createdAt: "2024-06-15T12:00:00.000Z" })).toBeUndefined();
61
86
  });
62
87
 
63
88
  it("returns undefined when the run started within a second of its tick", () => {
64
89
  expect(
65
90
  runStartDelay({
66
- created_at: "2024-06-15T12:00:00.000Z",
67
- start_at: "2024-06-15T12:00:00.300Z",
91
+ createdAt: "2024-06-15T12:00:00.000Z",
92
+ startAt: "2024-06-15T12:00:00.300Z",
68
93
  }),
69
94
  ).toBeUndefined();
70
95
  });
@@ -72,8 +97,8 @@ describe("runStartDelay", () => {
72
97
  it("formats the jitter/queue gap when the run started meaningfully later", () => {
73
98
  expect(
74
99
  runStartDelay({
75
- created_at: "2024-06-15T03:00:00.000Z",
76
- start_at: "2024-06-15T03:07:12.000Z",
100
+ createdAt: "2024-06-15T03:00:00.000Z",
101
+ startAt: "2024-06-15T03:07:12.000Z",
77
102
  }),
78
103
  ).toBe("7m 12s");
79
104
  });
@@ -91,15 +116,15 @@ describe("formatTriggeredByLabel", () => {
91
116
 
92
117
  describe("runRetryLabel", () => {
93
118
  it("returns undefined for a first attempt that is not a retry", () => {
94
- expect(runRetryLabel({ retry_attempt: 0 })).toBeUndefined();
119
+ expect(runRetryLabel({ retryAttempt: 0 })).toBeUndefined();
95
120
  });
96
121
 
97
122
  it("labels a run with a positive attempt number", () => {
98
- expect(runRetryLabel({ retry_attempt: 2 })).toBe("retry #2");
123
+ expect(runRetryLabel({ retryAttempt: 2 })).toBe("retry #2");
99
124
  });
100
125
 
101
126
  it("labels a run that points back at the run it re-attempts", () => {
102
- expect(runRetryLabel({ retry_attempt: 1, retry_of_run_id: "01JABC" })).toBe("retry #1");
127
+ expect(runRetryLabel({ retryAttempt: 1, retryOfRunId: "01JABC" })).toBe("retry #1");
103
128
  });
104
129
  });
105
130
 
@@ -1,30 +1,68 @@
1
1
  // SPDX-FileCopyrightText: PoppyCake, s.r.o.
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
 
4
- import type { Run } from "@runwisp/common";
4
+ import type { Run, RunStatus } from "@runwisp/common";
5
5
  import { formatDuration } from "../../utils/format.js";
6
6
 
7
+ export interface RunVerdict {
8
+ /** Verb phrase for the outcome, ending in its preposition when `timed`. */
9
+ verb: string;
10
+ /**
11
+ * Whether the phrase expects a duration after it. False for statuses that
12
+ * never produced one (nothing ran, or nothing has yet), so the caller
13
+ * renders the verb alone rather than "skipped after —".
14
+ */
15
+ timed: boolean;
16
+ }
17
+
18
+ /**
19
+ * The run's outcome as a verb phrase, so a detail view can state it as one
20
+ * sentence ("succeeded in 933ms") instead of a status badge competing with a
21
+ * separate duration readout for the same glance.
22
+ */
23
+ const RUN_VERDICTS: Record<RunStatus, RunVerdict> = {
24
+ success: { verb: "succeeded in", timed: true },
25
+ failed: { verb: "failed after", timed: true },
26
+ crashed: { verb: "crashed after", timed: true },
27
+ timeout: { verb: "timed out after", timed: true },
28
+ stopped: { verb: "stopped after", timed: true },
29
+ daemon_stopped: { verb: "cut short after", timed: true },
30
+ log_overflow: { verb: "killed after", timed: true },
31
+ start_failed: { verb: "gave up after", timed: true },
32
+ ended: { verb: "ended after", timed: true },
33
+ running: { verb: "running for", timed: true },
34
+ pending: { verb: "queued", timed: false },
35
+ missed: { verb: "never ran", timed: false },
36
+ skipped: { verb: "skipped", timed: false },
37
+ dst_skipped: { verb: "skipped", timed: false },
38
+ queue_full: { verb: "skipped", timed: false },
39
+ };
40
+
41
+ export function runVerdict(status: RunStatus): RunVerdict {
42
+ return RUN_VERDICTS[status];
43
+ }
44
+
7
45
  export function runDuration(
8
- run: Pick<Run, "start_at" | "end_at">,
46
+ run: Pick<Run, "startAt" | "endAt">,
9
47
  now: number = Date.now(),
10
48
  ): string | undefined {
11
- if (!run.start_at) return undefined;
12
- const start = new Date(run.start_at).getTime();
13
- const end = run.end_at ? new Date(run.end_at).getTime() : now;
49
+ if (!run.startAt) return undefined;
50
+ const start = new Date(run.startAt).getTime();
51
+ const end = run.endAt ? new Date(run.endAt).getTime() : now;
14
52
  return formatDuration(end - start);
15
53
  }
16
54
 
17
55
  /**
18
- * Gap between when a run was scheduled (`created_at`, the cron tick) and when
19
- * it actually started (`start_at`), formatted — or undefined when the two are
56
+ * Gap between when a run was scheduled (`createdAt`, the cron tick) and when
57
+ * it actually started (`startAt`), formatted — or undefined when the two are
20
58
  * within a second of each other. This is the visible face of `jitter`: a
21
59
  * jittered run is created at its tick but starts later inside the window, and
22
60
  * this is by how much. It also surfaces queue-wait, since a queued run is
23
61
  * created when it joins the line and started when the line clears.
24
62
  */
25
- export function runStartDelay(run: Pick<Run, "created_at" | "start_at">): string | undefined {
26
- if (!run.start_at) return undefined;
27
- const delay = new Date(run.start_at).getTime() - new Date(run.created_at).getTime();
63
+ export function runStartDelay(run: Pick<Run, "createdAt" | "startAt">): string | undefined {
64
+ if (!run.startAt) return undefined;
65
+ const delay = new Date(run.startAt).getTime() - new Date(run.createdAt).getTime();
28
66
  if (delay < 1000) return undefined;
29
67
  return formatDuration(delay);
30
68
  }
@@ -44,8 +82,8 @@ export function instanceSuffix(instanceIndex: number, instanceCount: number): st
44
82
  return "";
45
83
  }
46
84
 
47
- /** Human label for why a run fired (the `triggered_by` source). */
48
- export function formatTriggeredByLabel(triggeredBy: Run["triggered_by"]): string {
85
+ /** Human label for why a run fired (the `triggeredBy` source). */
86
+ export function formatTriggeredByLabel(triggeredBy: Run["triggeredBy"]): string {
49
87
  if (triggeredBy === "api") return "API";
50
88
  if (triggeredBy === "cron") return "Cron";
51
89
  if (triggeredBy === "service") return "Service";
@@ -58,11 +96,9 @@ export function formatTriggeredByLabel(triggeredBy: Run["triggered_by"]): string
58
96
  * A run is a retry if it carries a positive attempt number or points back at
59
97
  * the run it re-attempts.
60
98
  */
61
- export function runRetryLabel(
62
- run: Pick<Run, "retry_attempt" | "retry_of_run_id">,
63
- ): string | undefined {
64
- if (run.retry_attempt > 0 || run.retry_of_run_id) {
65
- return `retry #${String(run.retry_attempt)}`;
99
+ export function runRetryLabel(run: Pick<Run, "retryAttempt" | "retryOfRunId">): string | undefined {
100
+ if (run.retryAttempt > 0 || run.retryOfRunId) {
101
+ return `retry #${String(run.retryAttempt)}`;
66
102
  }
67
103
  return undefined;
68
104
  }