@runwisp/ui 1.5.0 → 1.7.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.
- package/package.json +5 -7
- package/src/lib/actions/focusTrap.ts +3 -3
- package/src/lib/actions/fold-to-fit.test.ts +36 -0
- package/src/lib/actions/fold-to-fit.ts +89 -0
- package/src/lib/actions/modal-dialog.test.ts +47 -0
- package/src/lib/actions/modal-dialog.ts +48 -0
- package/src/lib/components/AccordionItem.svelte +16 -5
- package/src/lib/components/Badge.svelte +4 -25
- package/src/lib/components/Button.svelte +2 -9
- package/src/lib/components/CodeBlock.svelte +7 -6
- package/src/lib/components/DataGrid.svelte +5 -5
- package/src/lib/components/Drawer.svelte +22 -30
- package/src/lib/components/DurationInput.svelte +6 -25
- package/src/lib/components/FilterBar.svelte +3 -7
- package/src/lib/components/FilterPill.svelte +2 -2
- package/src/lib/components/FormField.svelte +1 -1
- package/src/lib/components/Link.svelte +9 -4
- package/src/lib/components/LogConsole.svelte +101 -424
- package/src/lib/components/Modal.svelte +7 -30
- package/src/lib/components/SearchInput.svelte +1 -1
- package/src/lib/components/Select.svelte +3 -2
- package/src/lib/components/Sparkline.svelte +2 -2
- package/src/lib/components/Spinner.svelte +10 -5
- package/src/lib/components/StatusDot.svelte +1 -1
- package/src/lib/components/StatusIndicator.svelte +37 -104
- package/src/lib/components/Tabs.svelte +15 -1
- package/src/lib/components/Tooltip.svelte +1 -1
- package/src/lib/components/dashboard/RunActions.svelte +156 -0
- package/src/lib/components/dashboard/RunConsoleFrame.svelte +319 -0
- package/src/lib/components/dashboard/RunDetailPanel.svelte +137 -725
- package/src/lib/components/dashboard/RunFacts.svelte +119 -0
- package/src/lib/components/dashboard/RunFilterChips.svelte +52 -0
- package/src/lib/components/dashboard/RunFilterPopover.svelte +9 -10
- package/src/lib/components/dashboard/RunHeaderLine.svelte +295 -0
- package/src/lib/components/dashboard/RunListSkeleton.svelte +43 -0
- package/src/lib/components/dashboard/RunRow.svelte +202 -0
- package/src/lib/components/dashboard/RunsList.svelte +267 -545
- package/src/lib/components/dashboard/bulk-selection.svelte.test.ts +42 -0
- package/src/lib/components/dashboard/bulk-selection.svelte.ts +50 -0
- package/src/lib/components/dashboard/cron-format.test.ts +10 -1
- package/src/lib/components/dashboard/cron-format.ts +7 -3
- package/src/lib/components/dashboard/run-filters.test.ts +62 -2
- package/src/lib/components/dashboard/run-filters.ts +104 -99
- package/src/lib/components/dashboard/run-helpers.test.ts +131 -12
- package/src/lib/components/dashboard/run-helpers.ts +150 -44
- package/src/lib/components/dashboard/status-config.ts +18 -0
- package/src/lib/components/dashboard/types.ts +6 -0
- package/src/lib/components/data-grid-selection.ts +2 -9
- package/src/lib/components/filter-spec.ts +3 -3
- package/src/lib/index.ts +13 -2
- package/src/lib/log-console/AnsiLine.svelte +12 -0
- package/src/lib/log-console/FrameHistoryBlock.svelte +57 -0
- package/src/lib/log-console/LogCache.svelte.ts +1 -1
- package/src/lib/log-console/LogConsoleFooter.svelte +88 -0
- package/src/lib/log-console/ansi.ts +1 -1
- package/src/lib/log-console/frame-history.svelte.test.ts +47 -0
- package/src/lib/log-console/frame-history.svelte.ts +71 -0
- package/src/lib/log-console/wrap-layout.svelte.test.ts +92 -0
- package/src/lib/log-console/wrap-layout.svelte.ts +188 -0
- package/src/lib/theme-tokens.css +21 -21
- package/src/lib/theme.css +13 -13
- package/src/lib/utils/format.test.ts +125 -3
- package/src/lib/utils/format.ts +101 -8
- package/src/lib/utils/toast.svelte.ts +2 -3
- package/src/lib/utils/typing-target.ts +10 -0
- package/src/stories/Accordion.stories.svelte +60 -1
- package/src/stories/CodeBlock.stories.svelte +13 -0
- package/src/stories/Link.stories.svelte +25 -1
- package/src/stories/LogConsole.stories.svelte +2 -2
- package/src/stories/Tabs.stories.svelte +17 -0
|
@@ -2,30 +2,57 @@
|
|
|
2
2
|
// SPDX-License-Identifier: Apache-2.0
|
|
3
3
|
|
|
4
4
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import type { Run } from "@runwisp/common";
|
|
5
6
|
import {
|
|
6
7
|
runDuration,
|
|
7
8
|
runStartDelay,
|
|
8
|
-
|
|
9
|
+
RUN_VERDICTS,
|
|
10
|
+
runEndMarker,
|
|
11
|
+
runRowReadout,
|
|
12
|
+
highlightParts,
|
|
9
13
|
formatTriggeredByLabel,
|
|
10
14
|
runRetryLabel,
|
|
11
15
|
runUsageLabel,
|
|
16
|
+
runUsageScale,
|
|
17
|
+
usageLevel,
|
|
12
18
|
instanceSuffix,
|
|
19
|
+
runLogDownloadUrl,
|
|
13
20
|
} from "./run-helpers.js";
|
|
14
21
|
|
|
15
|
-
describe("
|
|
22
|
+
describe("runLogDownloadUrl", () => {
|
|
23
|
+
it("points at the daemon's raw log route", () => {
|
|
24
|
+
expect(runLogDownloadUrl("01J0000000000000000000000A")).toBe(
|
|
25
|
+
"/api/runs/01J0000000000000000000000A/log/raw",
|
|
26
|
+
);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("runEndMarker", () => {
|
|
31
|
+
it("warns only for ends the operator or daemon caused", () => {
|
|
32
|
+
expect(runEndMarker("stopped")).toEqual({
|
|
33
|
+
label: "run stopped by operator",
|
|
34
|
+
tone: "warn",
|
|
35
|
+
});
|
|
36
|
+
expect(runEndMarker("daemon_stopped").tone).toBe("warn");
|
|
37
|
+
expect(runEndMarker("timeout").label).toBe("run timed out");
|
|
38
|
+
expect(runEndMarker("failed")).toEqual({ label: "end of output", tone: "muted" });
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe("RUN_VERDICTS", () => {
|
|
16
43
|
it("phrases a timed outcome so a duration reads after it", () => {
|
|
17
|
-
expect(
|
|
18
|
-
expect(
|
|
44
|
+
expect(RUN_VERDICTS.succeeded).toEqual({ verb: "succeeded in", timed: true });
|
|
45
|
+
expect(RUN_VERDICTS.failed).toEqual({ verb: "failed after", timed: true });
|
|
19
46
|
});
|
|
20
47
|
|
|
21
48
|
it("marks statuses that never produced a duration as untimed", () => {
|
|
22
49
|
// These end without ever running, so the caller renders the verb alone
|
|
23
50
|
// rather than "skipped after —".
|
|
24
|
-
expect(
|
|
25
|
-
expect(
|
|
26
|
-
expect(
|
|
27
|
-
expect(
|
|
28
|
-
expect(
|
|
51
|
+
expect(RUN_VERDICTS.missed.timed).toBe(false);
|
|
52
|
+
expect(RUN_VERDICTS.skipped.timed).toBe(false);
|
|
53
|
+
expect(RUN_VERDICTS.dst_skipped.timed).toBe(false);
|
|
54
|
+
expect(RUN_VERDICTS.queue_full.timed).toBe(false);
|
|
55
|
+
expect(RUN_VERDICTS.pending.timed).toBe(false);
|
|
29
56
|
});
|
|
30
57
|
});
|
|
31
58
|
|
|
@@ -115,9 +142,9 @@ describe("runUsageLabel", () => {
|
|
|
115
142
|
expect(runUsageLabel({})).toBeUndefined();
|
|
116
143
|
});
|
|
117
144
|
|
|
118
|
-
it("joins
|
|
119
|
-
expect(runUsageLabel({ peakMemoryBytes: 48 * 1024 * 1024, cpuTimeMs:
|
|
120
|
-
"
|
|
145
|
+
it("joins CPU time and memory, never saying peak", () => {
|
|
146
|
+
expect(runUsageLabel({ peakMemoryBytes: 48 * 1024 * 1024, cpuTimeMs: 112_000 })).toBe(
|
|
147
|
+
"CPU 1m 52s · RAM 48 MB",
|
|
121
148
|
);
|
|
122
149
|
});
|
|
123
150
|
|
|
@@ -158,3 +185,95 @@ describe("instanceSuffix", () => {
|
|
|
158
185
|
expect(instanceSuffix(2, 3)).toBe("#3");
|
|
159
186
|
});
|
|
160
187
|
});
|
|
188
|
+
|
|
189
|
+
describe("runRowReadout", () => {
|
|
190
|
+
const ended = { startedAt: "2026-01-01T00:00:00Z", endedAt: "2026-01-01T00:00:02Z" };
|
|
191
|
+
|
|
192
|
+
it("shows live, queued and the exit code of a failure", () => {
|
|
193
|
+
expect(runRowReadout({ ...ended, status: "running", exitCode: 0 }, "running")).toBe("live");
|
|
194
|
+
expect(runRowReadout({ ...ended, status: "pending", exitCode: 0 }, "pending")).toBe(
|
|
195
|
+
"queued",
|
|
196
|
+
);
|
|
197
|
+
expect(runRowReadout({ ...ended, status: "ended", exitCode: 3 }, "failed")).toBe("exit 3");
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("falls back to the duration, or a dash when it never started", () => {
|
|
201
|
+
expect(runRowReadout({ ...ended, status: "ended", exitCode: 0 }, "succeeded")).toBe("2s");
|
|
202
|
+
expect(runRowReadout({ status: "ended", exitCode: 0 }, "missed")).toBe("—");
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
describe("highlightParts", () => {
|
|
207
|
+
it("returns the whole line when nothing matches", () => {
|
|
208
|
+
expect(highlightParts("hello", "zzz")).toEqual({ before: "hello", match: "", after: "" });
|
|
209
|
+
expect(highlightParts("hello", " ")).toEqual({ before: "hello", match: "", after: "" });
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("splits around a case-insensitive match", () => {
|
|
213
|
+
expect(highlightParts("an ERROR here", "error")).toEqual({
|
|
214
|
+
before: "an ",
|
|
215
|
+
match: "ERROR",
|
|
216
|
+
after: " here",
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("windows a long lead-in with an ellipsis", () => {
|
|
221
|
+
const parts = highlightParts("x".repeat(40) + "needle", "needle");
|
|
222
|
+
expect(parts.before).toBe("…" + "x".repeat(14));
|
|
223
|
+
expect(parts.match).toBe("needle");
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
describe("runUsageScale", () => {
|
|
228
|
+
const run = (
|
|
229
|
+
cpuTimeMs?: number,
|
|
230
|
+
peakMemoryBytes?: number,
|
|
231
|
+
status: Run["status"] = "ended",
|
|
232
|
+
) => ({
|
|
233
|
+
status,
|
|
234
|
+
...(cpuTimeMs === undefined ? {} : { cpuTimeMs }),
|
|
235
|
+
...(peakMemoryBytes === undefined ? {} : { peakMemoryBytes }),
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("is undefined when no finished run was measured", () => {
|
|
239
|
+
expect(runUsageScale([])).toBeUndefined();
|
|
240
|
+
expect(runUsageScale([run(5, 10, "running"), run(undefined, 10)])).toBeUndefined();
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("scales to the largest run and takes the median as usual", () => {
|
|
244
|
+
expect(runUsageScale([run(100, 1400), run(110, 2900), run(30, 640), run(1, 1)])).toEqual({
|
|
245
|
+
max: { cpu: 110, ram: 2900 },
|
|
246
|
+
usual: { cpu: 100, ram: 1400 },
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it("leaves out live and unmeasured runs", () => {
|
|
251
|
+
expect(runUsageScale([run(10, 10), run(999, 999, "running"), run(undefined, 999)])).toEqual(
|
|
252
|
+
{
|
|
253
|
+
max: { cpu: 10, ram: 10 },
|
|
254
|
+
usual: { cpu: 10, ram: 10 },
|
|
255
|
+
},
|
|
256
|
+
);
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
describe("usageLevel", () => {
|
|
261
|
+
it("is 0 as usual, 1 from 1.5x, 2 from twice the usual run", () => {
|
|
262
|
+
expect(usageLevel(1400, 1400, "cpu")).toBe(0);
|
|
263
|
+
expect(usageLevel(2099, 1400, "cpu")).toBe(0);
|
|
264
|
+
expect(usageLevel(2100, 1400, "cpu")).toBe(1);
|
|
265
|
+
expect(usageLevel(2799, 1400, "cpu")).toBe(1);
|
|
266
|
+
expect(usageLevel(2800, 1400, "cpu")).toBe(2);
|
|
267
|
+
expect(usageLevel(10, 1400, "cpu")).toBe(0);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("judges a zero or tiny usual run against a floor", () => {
|
|
271
|
+
// A mostly idle task (median CPU 0) that burned 30s of CPU.
|
|
272
|
+
expect(usageLevel(30_000, 0, "cpu")).toBe(2);
|
|
273
|
+
expect(usageLevel(1, 0, "cpu")).toBe(0);
|
|
274
|
+
expect(usageLevel(3, 1, "cpu")).toBe(0);
|
|
275
|
+
// 64 MiB against an idle task's 1 MiB stands out; 8 MiB doesn't.
|
|
276
|
+
expect(usageLevel(64 * 1024 * 1024, 1024 * 1024, "ram")).toBe(2);
|
|
277
|
+
expect(usageLevel(8 * 1024 * 1024, 1024 * 1024, "ram")).toBe(0);
|
|
278
|
+
});
|
|
279
|
+
});
|
|
@@ -1,26 +1,18 @@
|
|
|
1
1
|
// SPDX-FileCopyrightText: PoppyCake, s.r.o.
|
|
2
2
|
// SPDX-License-Identifier: Apache-2.0
|
|
3
3
|
|
|
4
|
-
import type { Run, RunStatus } from "@runwisp/common";
|
|
4
|
+
import type { APIPaths, Run, RunStatus, Trigger } from "@runwisp/common";
|
|
5
5
|
import { formatBytes, formatDuration } from "../../utils/format.js";
|
|
6
6
|
|
|
7
7
|
export interface RunVerdict {
|
|
8
8
|
/** Verb phrase for the outcome, ending in its preposition when `timed`. */
|
|
9
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
|
-
*/
|
|
10
|
+
/** Whether a duration follows the verb; false when nothing ran yet. */
|
|
15
11
|
timed: boolean;
|
|
16
12
|
}
|
|
17
13
|
|
|
18
|
-
|
|
19
|
-
|
|
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> = {
|
|
14
|
+
// The run's outcome as a verb phrase, read as one sentence: "succeeded in 933ms".
|
|
15
|
+
export const RUN_VERDICTS: Record<RunStatus, RunVerdict> = {
|
|
24
16
|
succeeded: { verb: "succeeded in", timed: true },
|
|
25
17
|
failed: { verb: "failed after", timed: true },
|
|
26
18
|
crashed: { verb: "crashed after", timed: true },
|
|
@@ -39,8 +31,21 @@ const RUN_VERDICTS: Record<RunStatus, RunVerdict> = {
|
|
|
39
31
|
queue_full: { verb: "skipped", timed: false },
|
|
40
32
|
};
|
|
41
33
|
|
|
42
|
-
export
|
|
43
|
-
|
|
34
|
+
export interface RunEndMarker {
|
|
35
|
+
label: string;
|
|
36
|
+
tone: "muted" | "warn";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// The line closing a run's console output; only abnormal ends are worth a warning tone.
|
|
40
|
+
const RUN_END_MARKERS: Partial<Record<RunStatus, RunEndMarker>> = {
|
|
41
|
+
stopped: { label: "run stopped by operator", tone: "warn" },
|
|
42
|
+
daemon_stopped: { label: "daemon stopped mid-run", tone: "warn" },
|
|
43
|
+
timeout: { label: "run timed out", tone: "warn" },
|
|
44
|
+
};
|
|
45
|
+
const DEFAULT_END_MARKER: RunEndMarker = { label: "end of output", tone: "muted" };
|
|
46
|
+
|
|
47
|
+
export function runEndMarker(status: RunStatus): RunEndMarker {
|
|
48
|
+
return RUN_END_MARKERS[status] ?? DEFAULT_END_MARKER;
|
|
44
49
|
}
|
|
45
50
|
|
|
46
51
|
export function runDuration(
|
|
@@ -54,12 +59,8 @@ export function runDuration(
|
|
|
54
59
|
}
|
|
55
60
|
|
|
56
61
|
/**
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
* within a second of each other. This is the visible face of `jitter`: a
|
|
60
|
-
* jittered run is created at its tick but starts later inside the window, and
|
|
61
|
-
* this is by how much. It also surfaces queue-wait, since a queued run is
|
|
62
|
-
* created when it joins the line and started when the line clears.
|
|
62
|
+
* How long a run waited between `createdAt` and `startedAt` (jitter or queue
|
|
63
|
+
* wait), or undefined when it started within a second.
|
|
63
64
|
*/
|
|
64
65
|
export function runStartDelay(run: Pick<Run, "createdAt" | "startedAt">): string | undefined {
|
|
65
66
|
if (!run.startedAt) return undefined;
|
|
@@ -68,14 +69,7 @@ export function runStartDelay(run: Pick<Run, "createdAt" | "startedAt">): string
|
|
|
68
69
|
return formatDuration(delay);
|
|
69
70
|
}
|
|
70
71
|
|
|
71
|
-
/**
|
|
72
|
-
* Display suffix for a run's instance slot. A service configured with more than
|
|
73
|
-
* one instance gets a 1-based suffix (`#1`, `#2`, …) on every one of its runs;
|
|
74
|
-
* a single-instance task (or any non-service, where `instanceCount` is 1)
|
|
75
|
-
* returns an empty string so the bare task name is shown. `instanceIndex` is
|
|
76
|
-
* the stored 0-based slot; `instanceCount` is the task's currently configured
|
|
77
|
-
* instance count.
|
|
78
|
-
*/
|
|
72
|
+
/** 1-based `#N` suffix for a multi-instance service run, else "". */
|
|
79
73
|
export function instanceSuffix(instanceIndex: number, instanceCount: number): string {
|
|
80
74
|
if (instanceCount > 1) {
|
|
81
75
|
return `#${String(instanceIndex + 1)}`;
|
|
@@ -83,23 +77,32 @@ export function instanceSuffix(instanceIndex: number, instanceCount: number): st
|
|
|
83
77
|
return "";
|
|
84
78
|
}
|
|
85
79
|
|
|
80
|
+
// Typed against the generated paths so a route rename fails the build.
|
|
81
|
+
const RAW_LOG_PATH: keyof APIPaths = "/api/runs/{runId}/log/raw";
|
|
82
|
+
|
|
83
|
+
/** The full log of a run as one plain-text download. */
|
|
84
|
+
export function runLogDownloadUrl(runId: string): string {
|
|
85
|
+
return RAW_LOG_PATH.replace("{runId}", encodeURIComponent(runId));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Labels per trigger source: `short` for the row badge, `long` for descriptions. */
|
|
89
|
+
export const TRIGGER_LABELS: Record<Trigger, { short: string; long: string }> = {
|
|
90
|
+
cron: { short: "Cron", long: "Scheduled (cron)" },
|
|
91
|
+
api: { short: "API", long: "REST API" },
|
|
92
|
+
ui: { short: "UI", long: "UI" },
|
|
93
|
+
cli: { short: "CLI", long: "CLI" },
|
|
94
|
+
station: { short: "Station", long: "Control plane" },
|
|
95
|
+
service: { short: "Service", long: "Service auto-start" },
|
|
96
|
+
startup: { short: "Startup", long: "On daemon start" },
|
|
97
|
+
hook: { short: "Hook", long: "Hook" },
|
|
98
|
+
};
|
|
99
|
+
|
|
86
100
|
/** Human label for why a run fired (the `triggeredBy` source). */
|
|
87
101
|
export function formatTriggeredByLabel(triggeredBy: Run["triggeredBy"]): string {
|
|
88
|
-
|
|
89
|
-
if (triggeredBy === "ui") return "UI";
|
|
90
|
-
if (triggeredBy === "cli") return "CLI";
|
|
91
|
-
if (triggeredBy === "cron") return "Cron";
|
|
92
|
-
if (triggeredBy === "service") return "Service";
|
|
93
|
-
if (triggeredBy === "startup") return "Startup";
|
|
94
|
-
if (triggeredBy === "hook") return "Hook";
|
|
95
|
-
return "Station";
|
|
102
|
+
return TRIGGER_LABELS[triggeredBy].short;
|
|
96
103
|
}
|
|
97
104
|
|
|
98
|
-
/**
|
|
99
|
-
* "retry #N" label when a run is a retry of an earlier one, else undefined.
|
|
100
|
-
* A run is a retry if it carries a positive attempt number or points back at
|
|
101
|
-
* the run it re-attempts.
|
|
102
|
-
*/
|
|
105
|
+
/** "retry #N" when the run re-attempts an earlier one, else undefined. */
|
|
103
106
|
export function runRetryLabel(run: Pick<Run, "retryAttempt" | "retryOfRunId">): string | undefined {
|
|
104
107
|
if (run.retryAttempt > 0 || run.retryOfRunId) {
|
|
105
108
|
return `retry #${String(run.retryAttempt)}`;
|
|
@@ -108,16 +111,82 @@ export function runRetryLabel(run: Pick<Run, "retryAttempt" | "retryOfRunId">):
|
|
|
108
111
|
}
|
|
109
112
|
|
|
110
113
|
/**
|
|
111
|
-
* "
|
|
112
|
-
* only), else undefined.
|
|
114
|
+
* "CPU 1m 52s · RAM 48 MB" for a run whose resources were measured (shell
|
|
115
|
+
* runs only), else undefined. CPU is the CPU time the run used, RAM the most
|
|
116
|
+
* memory it held.
|
|
113
117
|
*/
|
|
114
118
|
export function runUsageLabel(run: Pick<Run, "peakMemoryBytes" | "cpuTimeMs">): string | undefined {
|
|
115
119
|
const parts: string[] = [];
|
|
116
|
-
if (run.peakMemoryBytes !== undefined) parts.push("peak " + formatBytes(run.peakMemoryBytes));
|
|
117
120
|
if (run.cpuTimeMs !== undefined) parts.push("CPU " + formatDuration(run.cpuTimeMs));
|
|
121
|
+
if (run.peakMemoryBytes !== undefined) parts.push("RAM " + formatBytes(run.peakMemoryBytes));
|
|
118
122
|
return parts.length > 0 ? parts.join(" · ") : undefined;
|
|
119
123
|
}
|
|
120
124
|
|
|
125
|
+
/** CPU time (ms) and peak memory (bytes) of one run, or of a task's typical run. */
|
|
126
|
+
export interface RunUsageAmounts {
|
|
127
|
+
cpu: number;
|
|
128
|
+
ram: number;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* How a task's runs compare: `max` is its largest run (the full length of a
|
|
133
|
+
* usage bar), `usual` the median run (what "normal for this task" means).
|
|
134
|
+
*/
|
|
135
|
+
export interface RunUsageScale {
|
|
136
|
+
max: RunUsageAmounts;
|
|
137
|
+
usual: RunUsageAmounts;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** A finished run's measured usage, or undefined when it wasn't measured. */
|
|
141
|
+
export function runUsageAmounts(
|
|
142
|
+
run: Pick<Run, "status" | "cpuTimeMs" | "peakMemoryBytes">,
|
|
143
|
+
): RunUsageAmounts | undefined {
|
|
144
|
+
if (run.status !== "ended") return undefined;
|
|
145
|
+
if (run.cpuTimeMs === undefined || run.peakMemoryBytes === undefined) return undefined;
|
|
146
|
+
return { cpu: run.cpuTimeMs, ram: run.peakMemoryBytes };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function median(values: number[]): number {
|
|
150
|
+
const sorted = values.toSorted((a, b) => a - b);
|
|
151
|
+
return sorted[Math.floor(sorted.length / 2)] ?? 0;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** The scale for a task's usage bars, over the measured runs given. */
|
|
155
|
+
export function runUsageScale(
|
|
156
|
+
runs: readonly Pick<Run, "status" | "cpuTimeMs" | "peakMemoryBytes">[],
|
|
157
|
+
): RunUsageScale | undefined {
|
|
158
|
+
const amounts = runs.flatMap((run) => runUsageAmounts(run) ?? []);
|
|
159
|
+
if (amounts.length === 0) return undefined;
|
|
160
|
+
const cpu = amounts.map((a) => a.cpu);
|
|
161
|
+
const ram = amounts.map((a) => a.ram);
|
|
162
|
+
return {
|
|
163
|
+
max: { cpu: Math.max(...cpu), ram: Math.max(...ram) },
|
|
164
|
+
usual: { cpu: median(cpu), ram: median(ram) },
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The least a usual run counts as using, so a tiny or zero baseline (a mostly
|
|
170
|
+
* idle task's median CPU time is often 0) doesn't hide a run that burns real
|
|
171
|
+
* CPU or memory, nor flag 3ms against 1ms. CPU time in ms, memory in bytes.
|
|
172
|
+
*/
|
|
173
|
+
const USAGE_FLOOR: RunUsageAmounts = { cpu: 1000, ram: 16 * 1024 * 1024 };
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* How unusual a run's amount is for its task: 0 about as usual, 1 at 1.5×
|
|
177
|
+
* the usual run, 2 at twice it or more. A task that always uses the same
|
|
178
|
+
* amount stays at 0, however large that amount is. The usual run counts as
|
|
179
|
+
* at least USAGE_FLOOR for its kind.
|
|
180
|
+
*/
|
|
181
|
+
export function usageLevel(value: number, usual: number, kind: keyof RunUsageAmounts): 0 | 1 | 2 {
|
|
182
|
+
const baseline = Math.max(usual, USAGE_FLOOR[kind]);
|
|
183
|
+
if (baseline <= 0) return 0;
|
|
184
|
+
const ratio = value / baseline;
|
|
185
|
+
if (ratio >= 2) return 2;
|
|
186
|
+
if (ratio >= 1.5) return 1;
|
|
187
|
+
return 0;
|
|
188
|
+
}
|
|
189
|
+
|
|
121
190
|
/**
|
|
122
191
|
* Monotonic position of a run's status in its lifecycle. Used to reject stale
|
|
123
192
|
* updates (e.g. a `pending` HTTP response arriving after an SSE already
|
|
@@ -128,3 +197,40 @@ export function runPhaseOrder(status: string): number {
|
|
|
128
197
|
if (status === "running") return 1;
|
|
129
198
|
return 2;
|
|
130
199
|
}
|
|
200
|
+
|
|
201
|
+
/** Right-hand mono readout of a run row: live / queued / exit N / duration. */
|
|
202
|
+
export function runRowReadout(
|
|
203
|
+
run: Pick<Run, "status" | "exitCode" | "startedAt" | "endedAt">,
|
|
204
|
+
displayed: RunStatus,
|
|
205
|
+
): string {
|
|
206
|
+
if (run.status === "running") return "live";
|
|
207
|
+
if (run.status === "pending") return "queued";
|
|
208
|
+
if (displayed === "failed" || displayed === "crashed") return "exit " + String(run.exitCode);
|
|
209
|
+
return runDuration(run) ?? "—";
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export interface HighlightParts {
|
|
213
|
+
before: string;
|
|
214
|
+
match: string;
|
|
215
|
+
after: string;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Split an output line into [before, match, after] around the first
|
|
220
|
+
* occurrence of the query, windowed to keep the match in view. Plain strings,
|
|
221
|
+
* so rendering them as text never lets untrusted HTML reach the DOM.
|
|
222
|
+
*/
|
|
223
|
+
export function highlightParts(text: string, query: string): HighlightParts {
|
|
224
|
+
const q = query.trim();
|
|
225
|
+
const idx = q ? text.toLowerCase().indexOf(q.toLowerCase()) : -1;
|
|
226
|
+
if (idx === -1) return { before: text, match: "", after: "" };
|
|
227
|
+
const start = Math.max(0, idx - 14);
|
|
228
|
+
const lead = start > 0 ? "…" : "";
|
|
229
|
+
const windowed = text.slice(start);
|
|
230
|
+
const fi = windowed.toLowerCase().indexOf(q.toLowerCase());
|
|
231
|
+
return {
|
|
232
|
+
before: lead + windowed.slice(0, fi),
|
|
233
|
+
match: windowed.slice(fi, fi + q.length),
|
|
234
|
+
after: windowed.slice(fi + q.length),
|
|
235
|
+
};
|
|
236
|
+
}
|
|
@@ -23,7 +23,14 @@ interface RunStatusConfig {
|
|
|
23
23
|
color: string;
|
|
24
24
|
bg: string;
|
|
25
25
|
dot: string;
|
|
26
|
+
/** `dot` without the running pulse, for static markers (spines, filter swatches). */
|
|
27
|
+
solidDot: string;
|
|
26
28
|
badge: string;
|
|
29
|
+
/**
|
|
30
|
+
* Accent colour for statuses that mean "something to triage", or undefined.
|
|
31
|
+
* A deliberate stop or a skip is not an alarm, and success least of all.
|
|
32
|
+
*/
|
|
33
|
+
alarm: string | undefined;
|
|
27
34
|
/** One-sentence explanation of what this status means, for tooltips. */
|
|
28
35
|
description: string;
|
|
29
36
|
}
|
|
@@ -34,19 +41,25 @@ const DANGER = {
|
|
|
34
41
|
color: "text-danger-surface",
|
|
35
42
|
bg: "bg-danger-soft",
|
|
36
43
|
dot: "bg-danger-surface",
|
|
44
|
+
solidDot: "bg-danger-surface",
|
|
37
45
|
badge: "bg-danger-soft text-danger-soft-text",
|
|
46
|
+
alarm: "var(--color-danger-surface)",
|
|
38
47
|
};
|
|
39
48
|
const WARNING = {
|
|
40
49
|
color: "text-warning-surface",
|
|
41
50
|
bg: "bg-warning-soft",
|
|
42
51
|
dot: "bg-warning-surface",
|
|
52
|
+
solidDot: "bg-warning-surface",
|
|
43
53
|
badge: "bg-warning-soft text-warning-soft-text",
|
|
54
|
+
alarm: "var(--color-warning-surface)",
|
|
44
55
|
};
|
|
45
56
|
const NEUTRAL = {
|
|
46
57
|
color: "text-on-surface-muted",
|
|
47
58
|
bg: "bg-surface-sunken",
|
|
48
59
|
dot: "bg-on-surface-faint",
|
|
60
|
+
solidDot: "bg-on-surface-faint",
|
|
49
61
|
badge: "bg-surface-sunken text-on-surface",
|
|
62
|
+
alarm: undefined,
|
|
50
63
|
};
|
|
51
64
|
|
|
52
65
|
export const RUN_STATUS_CONFIG: Record<RunStatus, RunStatusConfig> = {
|
|
@@ -55,7 +68,9 @@ export const RUN_STATUS_CONFIG: Record<RunStatus, RunStatusConfig> = {
|
|
|
55
68
|
color: "text-info-surface",
|
|
56
69
|
bg: "bg-info-soft",
|
|
57
70
|
dot: "bg-info-surface animate-pulse",
|
|
71
|
+
solidDot: "bg-info-surface",
|
|
58
72
|
badge: "bg-info-soft text-info-soft-text",
|
|
73
|
+
alarm: undefined,
|
|
59
74
|
description: "This run is executing right now.",
|
|
60
75
|
},
|
|
61
76
|
succeeded: {
|
|
@@ -63,7 +78,9 @@ export const RUN_STATUS_CONFIG: Record<RunStatus, RunStatusConfig> = {
|
|
|
63
78
|
color: "text-success-surface",
|
|
64
79
|
bg: "bg-success-soft",
|
|
65
80
|
dot: "bg-success-surface",
|
|
81
|
+
solidDot: "bg-success-surface",
|
|
66
82
|
badge: "bg-success-soft text-success-soft-text",
|
|
83
|
+
alarm: undefined,
|
|
67
84
|
description: "The run finished with exit code 0 - everything OK.",
|
|
68
85
|
},
|
|
69
86
|
failed: {
|
|
@@ -79,6 +96,7 @@ export const RUN_STATUS_CONFIG: Record<RunStatus, RunStatusConfig> = {
|
|
|
79
96
|
},
|
|
80
97
|
stopped: {
|
|
81
98
|
...WARNING,
|
|
99
|
+
alarm: undefined,
|
|
82
100
|
icon: CircleStop,
|
|
83
101
|
description: "A human or an external script manually stopped this run before it finished.",
|
|
84
102
|
},
|
|
@@ -1,14 +1,6 @@
|
|
|
1
1
|
// SPDX-FileCopyrightText: PoppyCake, s.r.o.
|
|
2
2
|
// SPDX-License-Identifier: Apache-2.0
|
|
3
3
|
|
|
4
|
-
// DataGrid's header checkbox (checked / indeterminate / unchecked) must
|
|
5
|
-
// reflect which rows on the current page are ACTUALLY selected, not merely
|
|
6
|
-
// how many rows are selected. A bare count comparison is wrong the moment
|
|
7
|
-
// `selectedRows` holds a different set of rows than `pagedRows` but happens
|
|
8
|
-
// to be the same size — e.g. rows selected on a page the grid has since
|
|
9
|
-
// paged/filtered away from. Extracted so the membership check is unit
|
|
10
|
-
// testable without a component-render harness.
|
|
11
|
-
|
|
12
4
|
export interface SelectionState {
|
|
13
5
|
allSelected: boolean;
|
|
14
6
|
someSelected: boolean;
|
|
@@ -17,7 +9,8 @@ export interface SelectionState {
|
|
|
17
9
|
/**
|
|
18
10
|
* Header checkbox state for `pagedRows`: fully checked only when every row
|
|
19
11
|
* currently on the page is selected, indeterminate when some (but not all)
|
|
20
|
-
* of them are. Matches rows by `rowKey`, not by
|
|
12
|
+
* of them are. Matches rows by `rowKey`, not by count: the selection can
|
|
13
|
+
* hold rows from a page the grid has since moved away from.
|
|
21
14
|
*/
|
|
22
15
|
export function selectionState<T>(
|
|
23
16
|
pagedRows: readonly T[],
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
// Declarative filter spec. A `FilterField[]` + one bound `FilterValues` object
|
|
5
5
|
// drives <FilterBar>, the client engine (`applyFilters`), and the URL sync
|
|
6
|
-
// helper
|
|
6
|
+
// helper, the same way DataGrid's `Column[]` drives the table. Add a field
|
|
7
7
|
// type here, and every table that adopts it renders identically.
|
|
8
8
|
|
|
9
9
|
import type { SelectOption } from "./Select.svelte";
|
|
@@ -44,7 +44,7 @@ export type FilterField =
|
|
|
44
44
|
primary?: boolean;
|
|
45
45
|
};
|
|
46
46
|
|
|
47
|
-
// All values are strings
|
|
47
|
+
// All values are strings, DOM inputs are string-valued and URL params are
|
|
48
48
|
// strings; the client engine coerces (e.g. Number()) where a field needs it.
|
|
49
49
|
export type FilterValues = Record<string, string>;
|
|
50
50
|
|
|
@@ -65,7 +65,7 @@ export function isBlank(v: string | undefined): boolean {
|
|
|
65
65
|
return !v || v === "all";
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
-
// Which value keys a field owns
|
|
68
|
+
// Which value keys a field owns, daterange owns two, everything else one.
|
|
69
69
|
// Used by chips, active-state detection, clear, and URL (de)serialization so
|
|
70
70
|
// none of them has to special-case the range type.
|
|
71
71
|
export function fieldKeys(field: FilterField): string[] {
|
package/src/lib/index.ts
CHANGED
|
@@ -24,7 +24,7 @@ export { default as FormField } from "./components/FormField.svelte";
|
|
|
24
24
|
export { default as Input } from "./components/Input.svelte";
|
|
25
25
|
export { default as Radio } from "./components/Radio.svelte";
|
|
26
26
|
export { default as RadioGroup } from "./components/RadioGroup.svelte";
|
|
27
|
-
export { default as Select } from "./components/Select.svelte";
|
|
27
|
+
export { default as Select, type SelectOption } from "./components/Select.svelte";
|
|
28
28
|
export { default as Textarea } from "./components/Textarea.svelte";
|
|
29
29
|
export { default as TimezoneSelect } from "./components/TimezoneSelect.svelte";
|
|
30
30
|
export { default as Toggle } from "./components/Toggle.svelte";
|
|
@@ -86,14 +86,16 @@ export type { FetchLogsFn, LogEvent, LogSlice } from "./log-console/types.js";
|
|
|
86
86
|
export { default as RunDetailPanel } from "./components/dashboard/RunDetailPanel.svelte";
|
|
87
87
|
export { default as RunsList } from "./components/dashboard/RunsList.svelte";
|
|
88
88
|
export { default as RunFilterPopover } from "./components/dashboard/RunFilterPopover.svelte";
|
|
89
|
-
export type { RunOutputMatch } from "./components/dashboard/
|
|
89
|
+
export type { RunOutputMatch } from "./components/dashboard/types.js";
|
|
90
90
|
export {
|
|
91
91
|
activeFilterCount,
|
|
92
92
|
emptyRunFilters,
|
|
93
93
|
FAILURE_STATUS_TOKEN,
|
|
94
94
|
humanizeStatus,
|
|
95
95
|
exitCodeRange,
|
|
96
|
+
runFilterParams,
|
|
96
97
|
type ExitCodeRange,
|
|
98
|
+
type RunFilterParams,
|
|
97
99
|
type RunsListFilters,
|
|
98
100
|
} from "./components/dashboard/run-filters.js";
|
|
99
101
|
export { RUN_STATUS_CONFIG } from "./components/dashboard/status-config.js";
|
|
@@ -102,6 +104,10 @@ export {
|
|
|
102
104
|
runPhaseOrder,
|
|
103
105
|
formatTriggeredByLabel,
|
|
104
106
|
instanceSuffix,
|
|
107
|
+
runUsageLabel,
|
|
108
|
+
runUsageScale,
|
|
109
|
+
usageLevel,
|
|
110
|
+
type RunUsageScale,
|
|
105
111
|
} from "./components/dashboard/run-helpers.js";
|
|
106
112
|
export { humanizeCron } from "./components/dashboard/cron-format.js";
|
|
107
113
|
export type { DaemonStats } from "./components/dashboard/types.js";
|
|
@@ -110,14 +116,19 @@ export { toast } from "./utils/toast.svelte.js";
|
|
|
110
116
|
export { extractErrorMessage } from "./utils/error.js";
|
|
111
117
|
export {
|
|
112
118
|
formatBytes,
|
|
119
|
+
formatDayMonth,
|
|
120
|
+
formatRelativeCompact,
|
|
113
121
|
formatRelativeTime,
|
|
114
122
|
formatRelativeTimeWithAbsolute,
|
|
123
|
+
type RelativeTimeOptions,
|
|
115
124
|
formatDuration,
|
|
116
125
|
formatFullDateTime,
|
|
117
126
|
} from "./utils/format.js";
|
|
118
127
|
export { formatShortId } from "./utils/id.js";
|
|
119
128
|
export { debounce } from "./utils/debounce.js";
|
|
129
|
+
export { isTypingTarget } from "./utils/typing-target.js";
|
|
120
130
|
export { TickingNow } from "./utils/ticking-now.svelte.js";
|
|
121
131
|
export { CopyFeedback, copyText } from "./utils/clipboard.svelte.js";
|
|
122
132
|
export { RunMotion } from "./utils/run-motion.js";
|
|
123
133
|
export { arrival, leave, shift } from "./actions/row-motion.js";
|
|
134
|
+
export { foldToFit, foldUntilFits, type FoldToFitOptions } from "./actions/fold-to-fit.js";
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<!-- SPDX-FileCopyrightText: PoppyCake, s.r.o. -->
|
|
2
|
+
<!-- SPDX-License-Identifier: Apache-2.0 -->
|
|
3
|
+
|
|
4
|
+
<script lang="ts">
|
|
5
|
+
import { ansiLineToHtml } from "./ansi.js";
|
|
6
|
+
|
|
7
|
+
let { text }: { text: string } = $props();
|
|
8
|
+
</script>
|
|
9
|
+
|
|
10
|
+
<!-- ansiLineToHtml sanitises its input, which is what makes @html safe here. -->
|
|
11
|
+
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
|
12
|
+
<span class="whitespace-pre">{@html ansiLineToHtml(text)}</span>
|