@runwisp/ui 0.7.0 → 0.9.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 +4 -12
- package/src/lib/components/Alert.svelte +4 -4
- package/src/lib/components/Avatar.svelte +8 -8
- package/src/lib/components/Badge.svelte +23 -23
- package/src/lib/components/CronInput.svelte +1 -1
- package/src/lib/components/Dropdown.svelte +16 -3
- package/src/lib/components/DurationInput.svelte +4 -2
- package/src/lib/components/EmptyState.svelte +1 -1
- package/src/lib/components/FormField.svelte +4 -2
- package/src/lib/components/Input.svelte +4 -2
- package/src/lib/components/LogConsole.svelte +22 -29
- package/src/lib/components/Progress.svelte +4 -4
- package/src/lib/components/RadioGroup.svelte +3 -1
- package/src/lib/components/Select.svelte +1 -1
- package/src/lib/components/Sparkline.svelte +11 -3
- package/src/lib/components/TaskCard.svelte +4 -4
- package/src/lib/components/Textarea.svelte +2 -2
- package/src/lib/components/ToastContainer.svelte +4 -4
- package/src/lib/components/Tooltip.svelte +11 -2
- package/src/lib/components/dashboard/RunDetailPanel.svelte +34 -18
- package/src/lib/components/dashboard/RunsList.svelte +19 -2
- package/src/lib/components/dashboard/cron-format.test.ts +67 -0
- package/src/lib/components/dashboard/cron-format.ts +71 -0
- package/src/lib/components/dashboard/run-helpers.test.ts +25 -1
- package/src/lib/components/dashboard/run-helpers.ts +15 -0
- package/src/lib/components/dashboard/status-config.test.ts +18 -0
- package/src/lib/components/dashboard/status-config.ts +41 -0
- package/src/lib/index.ts +2 -5
- package/src/lib/log-console/ansi.test.ts +50 -0
- package/src/lib/log-console/ansi.ts +39 -0
- package/src/lib/theme-tokens.css +33 -0
- package/src/lib/theme.css +6 -0
- package/src/lib/utils/error.test.ts +34 -0
- package/src/lib/utils/format.test.ts +37 -1
- package/src/lib/utils/format.ts +8 -6
- package/src/lib/utils/id.test.ts +23 -0
- package/src/stories/Avatar.stories.svelte +1 -1
- package/src/stories/Breadcrumb.stories.svelte +3 -3
- package/src/stories/Card.stories.svelte +21 -19
- package/src/stories/Checkbox.stories.svelte +2 -2
- package/src/stories/DataGrid.stories.svelte +1 -1
- package/src/stories/Dropdown.stories.svelte +3 -3
- package/src/stories/EmptyState.stories.svelte +1 -1
- package/src/stories/LogConsole.stories.svelte +75 -0
- package/src/stories/LogConsoleDemo.svelte +60 -0
- package/src/stories/Logo.stories.svelte +9 -9
- package/src/stories/Modal.stories.svelte +12 -10
- package/src/stories/Progress.stories.svelte +6 -6
- package/src/stories/Tabs.stories.svelte +16 -12
- package/src/stories/Toggle.stories.svelte +2 -2
- package/src/stories/Tooltip.stories.svelte +5 -5
- package/src/lib/utils/toast.ts +0 -60
|
@@ -15,12 +15,13 @@
|
|
|
15
15
|
import Button from "../Button.svelte";
|
|
16
16
|
import EmptyState from "../EmptyState.svelte";
|
|
17
17
|
import LogConsole from "../LogConsole.svelte";
|
|
18
|
+
import Tooltip from "../Tooltip.svelte";
|
|
18
19
|
import type { Run } from "./types.js";
|
|
19
20
|
import type { LogEvent, LogSlice } from "../../log-console/types.js";
|
|
20
21
|
import { formatDateTime } from "../../utils/format.js";
|
|
21
22
|
import { formatShortId } from "../../utils/id.js";
|
|
22
23
|
import { getRunStatusConfig, runDisplayStatus } from "./status-config.js";
|
|
23
|
-
import { runDuration } from "./run-helpers.js";
|
|
24
|
+
import { runDuration, runStartDelay } from "./run-helpers.js";
|
|
24
25
|
|
|
25
26
|
let {
|
|
26
27
|
run,
|
|
@@ -93,9 +94,10 @@
|
|
|
93
94
|
{@const config = getRunStatusConfig(runDisplayStatus(run))}
|
|
94
95
|
{@const DetailIcon = config.icon}
|
|
95
96
|
{@const duration = runDuration(run)}
|
|
97
|
+
{@const startDelay = runStartDelay(run)}
|
|
96
98
|
<!-- Detailed Header -->
|
|
97
|
-
<div class="shrink-0 border-b border-outline-faint bg-surface-raised p-6">
|
|
98
|
-
<div class="flex flex-col justify-between gap-6
|
|
99
|
+
<div class="@container shrink-0 border-b border-outline-faint bg-surface-raised p-6">
|
|
100
|
+
<div class="flex flex-col justify-between gap-6 @4xl:flex-row @4xl:items-start">
|
|
99
101
|
<div class="flex items-start gap-4">
|
|
100
102
|
<!-- Large Status Icon -->
|
|
101
103
|
<div
|
|
@@ -119,15 +121,17 @@
|
|
|
119
121
|
{/if}
|
|
120
122
|
{/if}
|
|
121
123
|
</h2>
|
|
122
|
-
<
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
124
|
+
<Tooltip content={config.description} position="bottom" wide>
|
|
125
|
+
<Badge
|
|
126
|
+
variant={runDisplayStatus(run) === "success"
|
|
127
|
+
? "success"
|
|
128
|
+
: run.status === "running"
|
|
129
|
+
? "info"
|
|
130
|
+
: "danger"}
|
|
131
|
+
>
|
|
132
|
+
{runDisplayStatus(run).toUpperCase()}
|
|
133
|
+
</Badge>
|
|
134
|
+
</Tooltip>
|
|
131
135
|
{#if canDelete}
|
|
132
136
|
<Button
|
|
133
137
|
variant="ghost"
|
|
@@ -187,7 +191,7 @@
|
|
|
187
191
|
|
|
188
192
|
<!-- Stats Grid -->
|
|
189
193
|
<div
|
|
190
|
-
class="grid grid-cols-
|
|
194
|
+
class="grid grid-cols-1 gap-3 rounded-xl border border-outline-faint bg-surface-sunken px-6 py-3 @lg:grid-cols-3 @lg:gap-6 @4xl:shrink-0"
|
|
191
195
|
>
|
|
192
196
|
<div class="flex flex-col gap-1">
|
|
193
197
|
<span
|
|
@@ -197,9 +201,19 @@
|
|
|
197
201
|
<span class="font-mono text-sm font-medium whitespace-nowrap text-on-surface">
|
|
198
202
|
{run.start_at ? formatDateTime(run.start_at) : "--:--"}
|
|
199
203
|
</span>
|
|
204
|
+
{#if startDelay}
|
|
205
|
+
<span
|
|
206
|
+
class="font-mono text-xs whitespace-nowrap text-on-surface-faint"
|
|
207
|
+
title="Scheduled at {formatDateTime(run.created_at)}"
|
|
208
|
+
>
|
|
209
|
+
+{startDelay} after scheduled
|
|
210
|
+
</span>
|
|
211
|
+
{/if}
|
|
200
212
|
</div>
|
|
201
213
|
|
|
202
|
-
<div
|
|
214
|
+
<div
|
|
215
|
+
class="flex flex-col gap-1 border-t border-outline pt-3 @lg:border-t-0 @lg:border-l @lg:pt-0 @lg:pl-6"
|
|
216
|
+
>
|
|
203
217
|
<span
|
|
204
218
|
class="text-xs font-semibold tracking-wide text-on-surface-faint uppercase"
|
|
205
219
|
>Duration</span
|
|
@@ -209,7 +223,9 @@
|
|
|
209
223
|
</span>
|
|
210
224
|
</div>
|
|
211
225
|
|
|
212
|
-
<div
|
|
226
|
+
<div
|
|
227
|
+
class="flex flex-col gap-1 border-t border-outline pt-3 @lg:border-t-0 @lg:border-l @lg:pt-0 @lg:pl-6"
|
|
228
|
+
>
|
|
213
229
|
<span
|
|
214
230
|
class="text-xs font-semibold tracking-wide text-on-surface-faint uppercase"
|
|
215
231
|
>Exited</span
|
|
@@ -227,9 +243,9 @@
|
|
|
227
243
|
</div>
|
|
228
244
|
|
|
229
245
|
<!-- Console View -->
|
|
230
|
-
<div class="flex min-h-0 flex-1 flex-col bg-
|
|
246
|
+
<div class="flex min-h-0 flex-1 flex-col bg-mist-950">
|
|
231
247
|
<div
|
|
232
|
-
class="flex shrink-0 items-center justify-between border-b border-
|
|
248
|
+
class="flex shrink-0 items-center justify-between border-b border-mist-800 bg-mist-900/50 px-4 py-2 font-mono text-xs text-mist-400"
|
|
233
249
|
>
|
|
234
250
|
<div class="flex items-center gap-2">
|
|
235
251
|
<TerminalIcon size={14} />
|
|
@@ -254,7 +270,7 @@
|
|
|
254
270
|
run.id,
|
|
255
271
|
)}/log/raw"
|
|
256
272
|
download="{run.task_name}-{run.id}.log"
|
|
257
|
-
class="flex items-center gap-1.5 rounded border border-
|
|
273
|
+
class="flex items-center gap-1.5 rounded border border-mist-700 px-2 py-1 text-mist-200 transition-colors hover:border-mist-500 hover:bg-mist-800 hover:text-mist-100"
|
|
258
274
|
title="Download the full log (rotated and current parts as one file)"
|
|
259
275
|
>
|
|
260
276
|
<Download size={12} />
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
import Button from "../Button.svelte";
|
|
29
29
|
import Badge from "../Badge.svelte";
|
|
30
30
|
import EmptyState from "../EmptyState.svelte";
|
|
31
|
+
import Tooltip from "../Tooltip.svelte";
|
|
31
32
|
import type { Run } from "./types.js";
|
|
32
33
|
import type { RunSelector } from "@runwisp/common";
|
|
33
34
|
import { getRunStatusConfig, runDisplayStatus } from "./status-config.js";
|
|
@@ -50,6 +51,7 @@
|
|
|
50
51
|
taskNameFilter,
|
|
51
52
|
headerLabel = "Run History",
|
|
52
53
|
emptyText = "No runs yet",
|
|
54
|
+
emptyDescription,
|
|
53
55
|
bulkActions = false,
|
|
54
56
|
onBulkCancel,
|
|
55
57
|
onBulkDelete,
|
|
@@ -67,6 +69,7 @@
|
|
|
67
69
|
taskNameFilter?: string;
|
|
68
70
|
headerLabel?: string;
|
|
69
71
|
emptyText?: string;
|
|
72
|
+
emptyDescription?: string;
|
|
70
73
|
bulkActions?: boolean;
|
|
71
74
|
onBulkCancel?: BulkHandler;
|
|
72
75
|
onBulkDelete?: BulkHandler;
|
|
@@ -341,7 +344,13 @@
|
|
|
341
344
|
|
|
342
345
|
<div bind:this={scrollElement} class="min-h-0 flex-1 overflow-y-auto p-2">
|
|
343
346
|
{#if items.length === 0 && !loading}
|
|
344
|
-
<EmptyState
|
|
347
|
+
<EmptyState
|
|
348
|
+
title={emptyText}
|
|
349
|
+
description={emptyDescription}
|
|
350
|
+
icon={Clock}
|
|
351
|
+
iconSize={32}
|
|
352
|
+
class="py-8"
|
|
353
|
+
/>
|
|
345
354
|
{:else}
|
|
346
355
|
<div
|
|
347
356
|
style:height="{$virtualizer.getTotalSize()}px"
|
|
@@ -463,7 +472,15 @@
|
|
|
463
472
|
|
|
464
473
|
<div class="flex items-center justify-between text-xs">
|
|
465
474
|
<div class="flex items-center gap-2 text-on-surface-muted">
|
|
466
|
-
<
|
|
475
|
+
<Tooltip
|
|
476
|
+
content={config.description}
|
|
477
|
+
position="right"
|
|
478
|
+
wide
|
|
479
|
+
>
|
|
480
|
+
<span class="capitalize"
|
|
481
|
+
>{runDisplayStatus(run)}</span
|
|
482
|
+
>
|
|
483
|
+
</Tooltip>
|
|
467
484
|
{#if run.instance_index > 0}
|
|
468
485
|
<span
|
|
469
486
|
class="font-mono text-2xs text-on-surface-faint"
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Copyright (c) PoppyCake, s.r.o. SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import { humanizeCron } from "./cron-format.js";
|
|
5
|
+
|
|
6
|
+
describe("humanizeCron", () => {
|
|
7
|
+
it("humanizes a standard 5-field expression", () => {
|
|
8
|
+
const result = humanizeCron("*/5 * * * *");
|
|
9
|
+
expect(result.humanized).toBe("Every 5 minutes");
|
|
10
|
+
expect(result.raw).toBe("*/5 * * * *");
|
|
11
|
+
expect(result.isHumanized).toBe(true);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("humanizes a daily expression", () => {
|
|
15
|
+
const result = humanizeCron("0 3 * * *");
|
|
16
|
+
expect(result.humanized).toBe("At 03:00 AM");
|
|
17
|
+
expect(result.isHumanized).toBe(true);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("humanizes @hourly via cronstrue", () => {
|
|
21
|
+
const result = humanizeCron("@hourly");
|
|
22
|
+
expect(result.humanized).toBe("Every hour");
|
|
23
|
+
expect(result.isHumanized).toBe(true);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("handles robfig's @every with a compound duration", () => {
|
|
27
|
+
const result = humanizeCron("@every 1h30m");
|
|
28
|
+
expect(result.humanized).toBe("Every 1 hour 30 minutes");
|
|
29
|
+
expect(result.raw).toBe("@every 1h30m");
|
|
30
|
+
expect(result.isHumanized).toBe(true);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("handles @every with a single unit", () => {
|
|
34
|
+
expect(humanizeCron("@every 10s").humanized).toBe("Every 10 seconds");
|
|
35
|
+
expect(humanizeCron("@every 1m").humanized).toBe("Every 1 minute");
|
|
36
|
+
expect(humanizeCron("@every 2h").humanized).toBe("Every 2 hours");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("skips zero-valued duration segments", () => {
|
|
40
|
+
expect(humanizeCron("@every 1h0m").humanized).toBe("Every 1 hour");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("falls back to raw for @every with sub-second units", () => {
|
|
44
|
+
const result = humanizeCron("@every 500ms");
|
|
45
|
+
expect(result.humanized).toBe("@every 500ms");
|
|
46
|
+
expect(result.isHumanized).toBe(false);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("falls back to raw for @every with a garbage duration", () => {
|
|
50
|
+
const result = humanizeCron("@every banana");
|
|
51
|
+
expect(result.humanized).toBe("@every banana");
|
|
52
|
+
expect(result.isHumanized).toBe(false);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("falls back to raw for unparseable expressions — never 'Invalid'", () => {
|
|
56
|
+
const result = humanizeCron("not a cron");
|
|
57
|
+
expect(result.humanized).toBe("not a cron");
|
|
58
|
+
expect(result.isHumanized).toBe(false);
|
|
59
|
+
expect(result.humanized).not.toMatch(/invalid/i);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("trims surrounding whitespace", () => {
|
|
63
|
+
const result = humanizeCron(" */5 * * * * ");
|
|
64
|
+
expect(result.raw).toBe("*/5 * * * *");
|
|
65
|
+
expect(result.isHumanized).toBe(true);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Copyright (c) PoppyCake, s.r.o. SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
|
|
3
|
+
import cronstrue from "cronstrue";
|
|
4
|
+
|
|
5
|
+
export interface HumanizedCron {
|
|
6
|
+
/** Display text — humanized when possible, otherwise the raw expression. */
|
|
7
|
+
humanized: string;
|
|
8
|
+
/** The original cron expression, for tooltips. */
|
|
9
|
+
raw: string;
|
|
10
|
+
/** False when humanizing failed and `humanized` is just `raw`. */
|
|
11
|
+
isHumanized: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const EVERY_RE = /^@every\s+(\S+)$/;
|
|
15
|
+
|
|
16
|
+
const UNIT_NAMES: Record<string, [string, string]> = {
|
|
17
|
+
h: ["hour", "hours"],
|
|
18
|
+
m: ["minute", "minutes"],
|
|
19
|
+
s: ["second", "seconds"],
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
// humanizeEvery turns robfig's `@every 1h30m` (Go duration syntax) into
|
|
23
|
+
// "Every 1 hour 30 minutes". Returns null when the duration doesn't parse.
|
|
24
|
+
function humanizeEvery(duration: string): string | null {
|
|
25
|
+
const parts = [...duration.matchAll(/(\d+(?:\.\d+)?)(h|m|s|ms|us|µs|ns)/g)];
|
|
26
|
+
if (parts.length === 0 || parts.map((p) => p[0]).join("") !== duration) {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const words: string[] = [];
|
|
31
|
+
for (const [, amount, unit] of parts) {
|
|
32
|
+
if (!amount || !unit) return null;
|
|
33
|
+
const value = Number(amount);
|
|
34
|
+
if (value === 0) continue;
|
|
35
|
+
const names = UNIT_NAMES[unit];
|
|
36
|
+
if (!names) {
|
|
37
|
+
// Sub-second units are valid Go durations but absurd as schedules;
|
|
38
|
+
// show the raw expression rather than "Every 500 milliseconds".
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
words.push(`${amount} ${value === 1 ? names[0] : names[1]}`);
|
|
42
|
+
}
|
|
43
|
+
if (words.length === 0) return null;
|
|
44
|
+
|
|
45
|
+
return `Every ${words.join(" ")}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* humanizeCron renders a cron expression as plain English ("Every 5 minutes").
|
|
50
|
+
* Handles robfig's `@every <duration>` extension (cronstrue throws on it) and
|
|
51
|
+
* falls back to the raw expression on anything unparseable — never "Invalid".
|
|
52
|
+
*/
|
|
53
|
+
export function humanizeCron(cron: string): HumanizedCron {
|
|
54
|
+
const raw = cron.trim();
|
|
55
|
+
|
|
56
|
+
const every = EVERY_RE.exec(raw);
|
|
57
|
+
if (every?.[1]) {
|
|
58
|
+
const humanized = humanizeEvery(every[1]);
|
|
59
|
+
if (humanized) {
|
|
60
|
+
return { humanized, raw, isHumanized: true };
|
|
61
|
+
}
|
|
62
|
+
return { humanized: raw, raw, isHumanized: false };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
const humanized = cronstrue.toString(raw, { throwExceptionOnParseError: true });
|
|
67
|
+
return { humanized, raw, isHumanized: true };
|
|
68
|
+
} catch {
|
|
69
|
+
return { humanized: raw, raw, isHumanized: false };
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// SPDX-License-Identifier: Apache-2.0
|
|
3
3
|
|
|
4
4
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
-
import { runDuration } from "./run-helpers.js";
|
|
5
|
+
import { runDuration, runStartDelay } from "./run-helpers.js";
|
|
6
6
|
|
|
7
7
|
describe("runDuration", () => {
|
|
8
8
|
it("returns undefined when start_at is not set", () => {
|
|
@@ -35,3 +35,27 @@ describe("runDuration", () => {
|
|
|
35
35
|
});
|
|
36
36
|
});
|
|
37
37
|
});
|
|
38
|
+
|
|
39
|
+
describe("runStartDelay", () => {
|
|
40
|
+
it("returns undefined when start_at is not set", () => {
|
|
41
|
+
expect(runStartDelay({ created_at: "2024-06-15T12:00:00.000Z" })).toBeUndefined();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("returns undefined when the run started within a second of its tick", () => {
|
|
45
|
+
expect(
|
|
46
|
+
runStartDelay({
|
|
47
|
+
created_at: "2024-06-15T12:00:00.000Z",
|
|
48
|
+
start_at: "2024-06-15T12:00:00.300Z",
|
|
49
|
+
}),
|
|
50
|
+
).toBeUndefined();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("formats the jitter/queue gap when the run started meaningfully later", () => {
|
|
54
|
+
expect(
|
|
55
|
+
runStartDelay({
|
|
56
|
+
created_at: "2024-06-15T03:00:00.000Z",
|
|
57
|
+
start_at: "2024-06-15T03:07:12.000Z",
|
|
58
|
+
}),
|
|
59
|
+
).toBe("7m 12s");
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -11,6 +11,21 @@ export function runDuration(run: Pick<Run, "start_at" | "end_at">): string | und
|
|
|
11
11
|
return formatDuration(end - start);
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Gap between when a run was scheduled (`created_at`, the cron tick) and when
|
|
16
|
+
* it actually started (`start_at`), formatted — or undefined when the two are
|
|
17
|
+
* within a second of each other. This is the visible face of `jitter`: a
|
|
18
|
+
* jittered run is created at its tick but starts later inside the window, and
|
|
19
|
+
* this is by how much. It also surfaces queue-wait, since a queued run is
|
|
20
|
+
* created when it joins the line and started when the line clears.
|
|
21
|
+
*/
|
|
22
|
+
export function runStartDelay(run: Pick<Run, "created_at" | "start_at">): string | undefined {
|
|
23
|
+
if (!run.start_at) return undefined;
|
|
24
|
+
const delay = new Date(run.start_at).getTime() - new Date(run.created_at).getTime();
|
|
25
|
+
if (delay < 1000) return undefined;
|
|
26
|
+
return formatDuration(delay);
|
|
27
|
+
}
|
|
28
|
+
|
|
14
29
|
/**
|
|
15
30
|
* Monotonic position of a run's status in its lifecycle. Used to reject stale
|
|
16
31
|
* updates (e.g. a `pending` HTTP response arriving after an SSE already
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Copyright (c) PoppyCake, s.r.o. SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import { RUN_STATUSES } from "@runwisp/common";
|
|
5
|
+
import { RUN_STATUS_CONFIG } from "./status-config.js";
|
|
6
|
+
|
|
7
|
+
describe("RUN_STATUS_CONFIG", () => {
|
|
8
|
+
it("has a non-empty description for every run status", () => {
|
|
9
|
+
for (const status of RUN_STATUSES) {
|
|
10
|
+
const config = RUN_STATUS_CONFIG[status];
|
|
11
|
+
expect(config, `missing config for status "${status}"`).toBeDefined();
|
|
12
|
+
expect(
|
|
13
|
+
config.description.trim().length,
|
|
14
|
+
`empty description for status "${status}"`,
|
|
15
|
+
).toBeGreaterThan(0);
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
});
|
|
@@ -11,6 +11,8 @@ import {
|
|
|
11
11
|
CircleDashed,
|
|
12
12
|
SkipForward,
|
|
13
13
|
FileExclamationPoint,
|
|
14
|
+
CalendarX,
|
|
15
|
+
OctagonX,
|
|
14
16
|
} from "@lucide/svelte";
|
|
15
17
|
import type { Component } from "svelte";
|
|
16
18
|
import { displayStatus, type RunStatus, type Run } from "@runwisp/common";
|
|
@@ -22,6 +24,8 @@ export interface RunStatusConfig {
|
|
|
22
24
|
border: string;
|
|
23
25
|
dot: string;
|
|
24
26
|
badge: string;
|
|
27
|
+
/** One-sentence explanation of what this status means, for tooltips. */
|
|
28
|
+
description: string;
|
|
25
29
|
}
|
|
26
30
|
|
|
27
31
|
export const RUN_STATUS_CONFIG: Record<RunStatus, RunStatusConfig> = {
|
|
@@ -32,6 +36,7 @@ export const RUN_STATUS_CONFIG: Record<RunStatus, RunStatusConfig> = {
|
|
|
32
36
|
border: "border-info-soft",
|
|
33
37
|
dot: "bg-info-surface animate-pulse",
|
|
34
38
|
badge: "bg-info-soft text-info-soft-text",
|
|
39
|
+
description: "This run is executing right now.",
|
|
35
40
|
},
|
|
36
41
|
success: {
|
|
37
42
|
icon: CircleCheck,
|
|
@@ -40,6 +45,7 @@ export const RUN_STATUS_CONFIG: Record<RunStatus, RunStatusConfig> = {
|
|
|
40
45
|
border: "border-success-soft",
|
|
41
46
|
dot: "bg-success-surface",
|
|
42
47
|
badge: "bg-success-soft text-success-soft-text",
|
|
48
|
+
description: "The run finished with exit code 0.",
|
|
43
49
|
},
|
|
44
50
|
failed: {
|
|
45
51
|
icon: CircleX,
|
|
@@ -48,6 +54,7 @@ export const RUN_STATUS_CONFIG: Record<RunStatus, RunStatusConfig> = {
|
|
|
48
54
|
border: "border-danger-soft",
|
|
49
55
|
dot: "bg-danger-surface",
|
|
50
56
|
badge: "bg-danger-soft text-danger-soft-text",
|
|
57
|
+
description: "The run exited with a non-zero code.",
|
|
51
58
|
},
|
|
52
59
|
crashed: {
|
|
53
60
|
icon: CircleAlert,
|
|
@@ -56,6 +63,8 @@ export const RUN_STATUS_CONFIG: Record<RunStatus, RunStatusConfig> = {
|
|
|
56
63
|
border: "border-danger-soft",
|
|
57
64
|
dot: "bg-danger-surface",
|
|
58
65
|
badge: "bg-danger-soft text-danger-soft-text",
|
|
66
|
+
description:
|
|
67
|
+
"The process was killed, or the daemon found it still 'running' after a hard crash and marked it crashed (exit -2). It was not resumed.",
|
|
59
68
|
},
|
|
60
69
|
stopped: {
|
|
61
70
|
icon: CircleStop,
|
|
@@ -64,6 +73,7 @@ export const RUN_STATUS_CONFIG: Record<RunStatus, RunStatusConfig> = {
|
|
|
64
73
|
border: "border-warning-soft",
|
|
65
74
|
dot: "bg-warning-surface",
|
|
66
75
|
badge: "bg-warning-soft text-warning-soft-text",
|
|
76
|
+
description: "An operator stopped this run before it finished.",
|
|
67
77
|
},
|
|
68
78
|
timeout: {
|
|
69
79
|
icon: TimerOff,
|
|
@@ -72,6 +82,7 @@ export const RUN_STATUS_CONFIG: Record<RunStatus, RunStatusConfig> = {
|
|
|
72
82
|
border: "border-warning-soft",
|
|
73
83
|
dot: "bg-warning-surface",
|
|
74
84
|
badge: "bg-warning-soft text-warning-soft-text",
|
|
85
|
+
description: "The run exceeded its configured timeout and was terminated.",
|
|
75
86
|
},
|
|
76
87
|
skipped: {
|
|
77
88
|
icon: SkipForward,
|
|
@@ -80,6 +91,8 @@ export const RUN_STATUS_CONFIG: Record<RunStatus, RunStatusConfig> = {
|
|
|
80
91
|
border: "border-outline-faint",
|
|
81
92
|
dot: "bg-on-surface-faint",
|
|
82
93
|
badge: "bg-surface-sunken text-on-surface",
|
|
94
|
+
description:
|
|
95
|
+
'Skipped by the concurrency policy (on_overlap = "skip") because a previous run was still going.',
|
|
83
96
|
},
|
|
84
97
|
log_overflow: {
|
|
85
98
|
icon: FileExclamationPoint,
|
|
@@ -88,6 +101,7 @@ export const RUN_STATUS_CONFIG: Record<RunStatus, RunStatusConfig> = {
|
|
|
88
101
|
border: "border-danger-soft",
|
|
89
102
|
dot: "bg-danger-surface",
|
|
90
103
|
badge: "bg-danger-soft text-danger-soft-text",
|
|
104
|
+
description: "The run hit log_max_size and was handled per log_on_full.",
|
|
91
105
|
},
|
|
92
106
|
queue_full: {
|
|
93
107
|
icon: SkipForward,
|
|
@@ -96,6 +110,7 @@ export const RUN_STATUS_CONFIG: Record<RunStatus, RunStatusConfig> = {
|
|
|
96
110
|
border: "border-warning-soft",
|
|
97
111
|
dot: "bg-warning-surface",
|
|
98
112
|
badge: "bg-warning-soft text-warning-soft-text",
|
|
113
|
+
description: "Skipped because the task's queue was already at queue_max.",
|
|
99
114
|
},
|
|
100
115
|
dst_skipped: {
|
|
101
116
|
icon: SkipForward,
|
|
@@ -104,6 +119,8 @@ export const RUN_STATUS_CONFIG: Record<RunStatus, RunStatusConfig> = {
|
|
|
104
119
|
border: "border-outline-faint",
|
|
105
120
|
dot: "bg-on-surface-faint",
|
|
106
121
|
badge: "bg-surface-sunken text-on-surface",
|
|
122
|
+
description:
|
|
123
|
+
"Skipped: this cron tick was the duplicate half of a DST fall-back, so it was recorded but not run.",
|
|
107
124
|
},
|
|
108
125
|
daemon_stopped: {
|
|
109
126
|
icon: CircleStop,
|
|
@@ -112,6 +129,28 @@ export const RUN_STATUS_CONFIG: Record<RunStatus, RunStatusConfig> = {
|
|
|
112
129
|
border: "border-warning-soft",
|
|
113
130
|
dot: "bg-warning-surface",
|
|
114
131
|
badge: "bg-warning-soft text-warning-soft-text",
|
|
132
|
+
description:
|
|
133
|
+
"The daemon shut down while this run was in flight and it exceeded shutdown_timeout; it was not resumed.",
|
|
134
|
+
},
|
|
135
|
+
missed: {
|
|
136
|
+
icon: CalendarX,
|
|
137
|
+
color: "text-danger-surface",
|
|
138
|
+
bg: "bg-danger-soft",
|
|
139
|
+
border: "border-danger-soft",
|
|
140
|
+
dot: "bg-danger-surface",
|
|
141
|
+
badge: "bg-danger-soft text-danger-soft-text",
|
|
142
|
+
description:
|
|
143
|
+
"A scheduled run never happened because the daemon was down. Detected and recorded on restart.",
|
|
144
|
+
},
|
|
145
|
+
start_failed: {
|
|
146
|
+
icon: OctagonX,
|
|
147
|
+
color: "text-danger-surface",
|
|
148
|
+
bg: "bg-danger-soft",
|
|
149
|
+
border: "border-danger-soft",
|
|
150
|
+
dot: "bg-danger-surface",
|
|
151
|
+
badge: "bg-danger-soft text-danger-soft-text",
|
|
152
|
+
description:
|
|
153
|
+
"The service kept failing to stay up for healthy_after; after start_retries the supervisor gave up (FATAL) and stopped restarting it.",
|
|
115
154
|
},
|
|
116
155
|
pending: {
|
|
117
156
|
icon: Clock,
|
|
@@ -120,6 +159,7 @@ export const RUN_STATUS_CONFIG: Record<RunStatus, RunStatusConfig> = {
|
|
|
120
159
|
border: "border-outline",
|
|
121
160
|
dot: "bg-on-surface-faint",
|
|
122
161
|
badge: "bg-surface-sunken text-on-surface",
|
|
162
|
+
description: "Queued and waiting to start.",
|
|
123
163
|
},
|
|
124
164
|
ended: {
|
|
125
165
|
icon: CircleDashed,
|
|
@@ -128,6 +168,7 @@ export const RUN_STATUS_CONFIG: Record<RunStatus, RunStatusConfig> = {
|
|
|
128
168
|
border: "border-outline-faint",
|
|
129
169
|
dot: "bg-on-surface-faint",
|
|
130
170
|
badge: "bg-surface-sunken text-on-surface",
|
|
171
|
+
description: "The run finished. No specific end reason was recorded.",
|
|
131
172
|
},
|
|
132
173
|
};
|
|
133
174
|
|
package/src/lib/index.ts
CHANGED
|
@@ -43,8 +43,6 @@ export { default as AccordionItem } from "./components/AccordionItem.svelte";
|
|
|
43
43
|
export { default as Breadcrumb } from "./components/Breadcrumb.svelte";
|
|
44
44
|
export { default as Pagination } from "./components/Pagination.svelte";
|
|
45
45
|
export { default as Tabs } from "./components/Tabs.svelte";
|
|
46
|
-
export { default as ThemeToggle } from "./components/ThemeToggle.svelte";
|
|
47
|
-
|
|
48
46
|
export { default as Avatar } from "./components/Avatar.svelte";
|
|
49
47
|
export { default as Badge } from "./components/Badge.svelte";
|
|
50
48
|
export { default as Card } from "./components/Card.svelte";
|
|
@@ -71,8 +69,6 @@ export { default as ToastContainer } from "./components/ToastContainer.svelte";
|
|
|
71
69
|
export { default as Tooltip } from "./components/Tooltip.svelte";
|
|
72
70
|
|
|
73
71
|
export { default as CodeBlock } from "./components/CodeBlock.svelte";
|
|
74
|
-
export { default as FeatureCard } from "./components/FeatureCard.svelte";
|
|
75
|
-
export { default as FeatureGrid } from "./components/FeatureGrid.svelte";
|
|
76
72
|
export { default as TaskCard } from "./components/TaskCard.svelte";
|
|
77
73
|
export type { TaskCardAccent } from "./components/TaskCard.svelte";
|
|
78
74
|
|
|
@@ -94,7 +90,8 @@ export {
|
|
|
94
90
|
RUN_STATUS_CONFIG,
|
|
95
91
|
type RunStatusConfig,
|
|
96
92
|
} from "./components/dashboard/status-config.js";
|
|
97
|
-
export { runDuration, runPhaseOrder } from "./components/dashboard/run-helpers.js";
|
|
93
|
+
export { runDuration, runStartDelay, runPhaseOrder } from "./components/dashboard/run-helpers.js";
|
|
94
|
+
export { humanizeCron, type HumanizedCron } from "./components/dashboard/cron-format.js";
|
|
98
95
|
export type { DaemonState, DaemonStats } from "./components/dashboard/types.js";
|
|
99
96
|
|
|
100
97
|
export { toast, type Toast, type ToastType } from "./utils/toast.svelte.js";
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: PoppyCake, s.r.o.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
import { ansiLineToHtml } from "./ansi.js";
|
|
6
|
+
|
|
7
|
+
describe("ansiLineToHtml", () => {
|
|
8
|
+
it("passes plain text through unchanged", () => {
|
|
9
|
+
expect(ansiLineToHtml("hello world")).toBe("hello world");
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("maps standard foreground codes to theme tokens", () => {
|
|
13
|
+
expect(ansiLineToHtml("\x1b[31merror\x1b[0m")).toBe(
|
|
14
|
+
'<span style="color:var(--rw-ansi-red)">error</span>',
|
|
15
|
+
);
|
|
16
|
+
expect(ansiLineToHtml("\x1b[32mok\x1b[0m")).toBe(
|
|
17
|
+
'<span style="color:var(--rw-ansi-green)">ok</span>',
|
|
18
|
+
);
|
|
19
|
+
expect(ansiLineToHtml("\x1b[36minfo\x1b[0m")).toBe(
|
|
20
|
+
'<span style="color:var(--rw-ansi-cyan)">info</span>',
|
|
21
|
+
);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("maps bright foreground codes to bright theme tokens", () => {
|
|
25
|
+
expect(ansiLineToHtml("\x1b[91mwarn\x1b[0m")).toBe(
|
|
26
|
+
'<span style="color:var(--rw-ansi-bright-red)">warn</span>',
|
|
27
|
+
);
|
|
28
|
+
expect(ansiLineToHtml("\x1b[97mtext\x1b[0m")).toBe(
|
|
29
|
+
'<span style="color:var(--rw-ansi-bright-white)">text</span>',
|
|
30
|
+
);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("maps background codes to theme tokens", () => {
|
|
34
|
+
expect(ansiLineToHtml("\x1b[41mfail\x1b[0m")).toBe(
|
|
35
|
+
'<span style="background-color:var(--rw-ansi-red)">fail</span>',
|
|
36
|
+
);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("escapes HTML in log content", () => {
|
|
40
|
+
expect(ansiLineToHtml("<script>alert(1)</script>")).toBe(
|
|
41
|
+
"<script>alert(1)</script>",
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("does not leak colour state between calls", () => {
|
|
46
|
+
// An unterminated colour in one line must not affect the next.
|
|
47
|
+
ansiLineToHtml("\x1b[31munclosed red");
|
|
48
|
+
expect(ansiLineToHtml("plain")).toBe("plain");
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: PoppyCake, s.r.o.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import Convert from "ansi-to-html";
|
|
5
|
+
|
|
6
|
+
// Theme-derived 16-color ANSI palette. ansi-to-html inlines these values
|
|
7
|
+
// verbatim into style attributes, so CSS var() references resolve against
|
|
8
|
+
// the --rw-ansi-* tokens in theme-tokens.css. Codes 16-255 keep the
|
|
9
|
+
// library's computed RGB defaults.
|
|
10
|
+
const ANSI_PALETTE: Record<number, string> = Object.freeze({
|
|
11
|
+
0: "var(--rw-ansi-black)",
|
|
12
|
+
1: "var(--rw-ansi-red)",
|
|
13
|
+
2: "var(--rw-ansi-green)",
|
|
14
|
+
3: "var(--rw-ansi-yellow)",
|
|
15
|
+
4: "var(--rw-ansi-blue)",
|
|
16
|
+
5: "var(--rw-ansi-magenta)",
|
|
17
|
+
6: "var(--rw-ansi-cyan)",
|
|
18
|
+
7: "var(--rw-ansi-white)",
|
|
19
|
+
8: "var(--rw-ansi-bright-black)",
|
|
20
|
+
9: "var(--rw-ansi-bright-red)",
|
|
21
|
+
10: "var(--rw-ansi-bright-green)",
|
|
22
|
+
11: "var(--rw-ansi-bright-yellow)",
|
|
23
|
+
12: "var(--rw-ansi-bright-blue)",
|
|
24
|
+
13: "var(--rw-ansi-bright-magenta)",
|
|
25
|
+
14: "var(--rw-ansi-bright-cyan)",
|
|
26
|
+
15: "var(--rw-ansi-bright-white)",
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// Convert ANSI escape sequences in a single log line to safe HTML spans.
|
|
30
|
+
// A fresh converter per call ensures no colour state leaks between
|
|
31
|
+
// independently virtualised lines.
|
|
32
|
+
export function ansiLineToHtml(text: string): string {
|
|
33
|
+
return new Convert({
|
|
34
|
+
escapeXML: true,
|
|
35
|
+
fg: "var(--rw-color-mist-200)",
|
|
36
|
+
bg: "var(--rw-color-mist-950)",
|
|
37
|
+
colors: ANSI_PALETTE,
|
|
38
|
+
}).toHtml(text);
|
|
39
|
+
}
|