@runwisp/ui 0.6.0 → 0.8.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 +76 -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 +27 -17
- 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/status-config.test.ts +18 -0
- package/src/lib/components/dashboard/status-config.ts +19 -0
- package/src/lib/index.ts +1 -4
- 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/format.test.ts +37 -1
- package/src/lib/utils/format.ts +8 -6
- 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
|
@@ -67,14 +67,14 @@
|
|
|
67
67
|
></textarea>
|
|
68
68
|
|
|
69
69
|
{#if error}
|
|
70
|
-
<div class="pointer-events-none absolute top-3 right-3 text-danger-
|
|
70
|
+
<div class="pointer-events-none absolute top-3 right-3 text-danger-soft-text">
|
|
71
71
|
<CircleAlert size={18} />
|
|
72
72
|
</div>
|
|
73
73
|
{/if}
|
|
74
74
|
</div>
|
|
75
75
|
|
|
76
76
|
{#if error}
|
|
77
|
-
<p class="text-sm text-danger-
|
|
77
|
+
<p class="text-sm text-danger-soft-text">{error}</p>
|
|
78
78
|
{:else if hint}
|
|
79
79
|
<p class="text-sm text-on-surface-muted">{hint}</p>
|
|
80
80
|
{/if}
|
|
@@ -26,13 +26,13 @@
|
|
|
26
26
|
function getColorClasses(type: Toast["type"]) {
|
|
27
27
|
switch (type) {
|
|
28
28
|
case "success":
|
|
29
|
-
return "border-success-
|
|
29
|
+
return "border-success-soft-border text-success-soft-text";
|
|
30
30
|
case "error":
|
|
31
|
-
return "border-danger-
|
|
31
|
+
return "border-danger-soft-border text-danger-soft-text";
|
|
32
32
|
case "warning":
|
|
33
|
-
return "border-warning-
|
|
33
|
+
return "border-warning-soft-border text-warning-soft-text";
|
|
34
34
|
case "info":
|
|
35
|
-
return "border-
|
|
35
|
+
return "border-primary-soft-border text-primary-soft-text";
|
|
36
36
|
}
|
|
37
37
|
}
|
|
38
38
|
|
|
@@ -11,9 +11,17 @@
|
|
|
11
11
|
position?: TooltipPosition;
|
|
12
12
|
children: Snippet;
|
|
13
13
|
class?: string;
|
|
14
|
+
/** Wrap long content across lines instead of one nowrap line. */
|
|
15
|
+
wide?: boolean;
|
|
14
16
|
}
|
|
15
17
|
|
|
16
|
-
let {
|
|
18
|
+
let {
|
|
19
|
+
content,
|
|
20
|
+
position = "top",
|
|
21
|
+
children,
|
|
22
|
+
class: className = "",
|
|
23
|
+
wide = false,
|
|
24
|
+
}: Props = $props();
|
|
17
25
|
|
|
18
26
|
const positionClasses: Record<TooltipPosition, string> = {
|
|
19
27
|
top: "bottom-full left-1/2 -translate-x-1/2 mb-2",
|
|
@@ -39,7 +47,8 @@
|
|
|
39
47
|
pointer-events-none invisible rounded-lg
|
|
40
48
|
bg-mist-800 px-2.5 py-1.5 text-xs
|
|
41
49
|
font-medium
|
|
42
|
-
|
|
50
|
+
{wide ? 'w-max max-w-xs whitespace-normal' : 'whitespace-nowrap'}
|
|
51
|
+
text-white opacity-0 transition-opacity
|
|
43
52
|
duration-150 group-hover:visible
|
|
44
53
|
group-hover:opacity-100
|
|
45
54
|
"
|
|
@@ -15,6 +15,7 @@
|
|
|
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";
|
|
@@ -28,6 +29,7 @@
|
|
|
28
29
|
streamLogs,
|
|
29
30
|
showTaskName = false,
|
|
30
31
|
onDelete,
|
|
32
|
+
highlightLine = null,
|
|
31
33
|
}: {
|
|
32
34
|
run: Run | undefined;
|
|
33
35
|
fetchLogs: (
|
|
@@ -42,6 +44,7 @@
|
|
|
42
44
|
) => () => void;
|
|
43
45
|
showTaskName?: boolean;
|
|
44
46
|
onDelete?: (runId: string) => void;
|
|
47
|
+
highlightLine?: number | null;
|
|
45
48
|
} = $props();
|
|
46
49
|
|
|
47
50
|
let canDelete = $derived.by(() => {
|
|
@@ -92,8 +95,8 @@
|
|
|
92
95
|
{@const DetailIcon = config.icon}
|
|
93
96
|
{@const duration = runDuration(run)}
|
|
94
97
|
<!-- Detailed Header -->
|
|
95
|
-
<div class="shrink-0 border-b border-outline-faint bg-surface-raised p-6">
|
|
96
|
-
<div class="flex flex-col justify-between gap-6
|
|
98
|
+
<div class="@container shrink-0 border-b border-outline-faint bg-surface-raised p-6">
|
|
99
|
+
<div class="flex flex-col justify-between gap-6 @4xl:flex-row @4xl:items-start">
|
|
97
100
|
<div class="flex items-start gap-4">
|
|
98
101
|
<!-- Large Status Icon -->
|
|
99
102
|
<div
|
|
@@ -117,15 +120,17 @@
|
|
|
117
120
|
{/if}
|
|
118
121
|
{/if}
|
|
119
122
|
</h2>
|
|
120
|
-
<
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
123
|
+
<Tooltip content={config.description} position="bottom" wide>
|
|
124
|
+
<Badge
|
|
125
|
+
variant={runDisplayStatus(run) === "success"
|
|
126
|
+
? "success"
|
|
127
|
+
: run.status === "running"
|
|
128
|
+
? "info"
|
|
129
|
+
: "danger"}
|
|
130
|
+
>
|
|
131
|
+
{runDisplayStatus(run).toUpperCase()}
|
|
132
|
+
</Badge>
|
|
133
|
+
</Tooltip>
|
|
129
134
|
{#if canDelete}
|
|
130
135
|
<Button
|
|
131
136
|
variant="ghost"
|
|
@@ -185,7 +190,7 @@
|
|
|
185
190
|
|
|
186
191
|
<!-- Stats Grid -->
|
|
187
192
|
<div
|
|
188
|
-
class="grid grid-cols-
|
|
193
|
+
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"
|
|
189
194
|
>
|
|
190
195
|
<div class="flex flex-col gap-1">
|
|
191
196
|
<span
|
|
@@ -197,7 +202,9 @@
|
|
|
197
202
|
</span>
|
|
198
203
|
</div>
|
|
199
204
|
|
|
200
|
-
<div
|
|
205
|
+
<div
|
|
206
|
+
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"
|
|
207
|
+
>
|
|
201
208
|
<span
|
|
202
209
|
class="text-xs font-semibold tracking-wide text-on-surface-faint uppercase"
|
|
203
210
|
>Duration</span
|
|
@@ -207,7 +214,9 @@
|
|
|
207
214
|
</span>
|
|
208
215
|
</div>
|
|
209
216
|
|
|
210
|
-
<div
|
|
217
|
+
<div
|
|
218
|
+
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"
|
|
219
|
+
>
|
|
211
220
|
<span
|
|
212
221
|
class="text-xs font-semibold tracking-wide text-on-surface-faint uppercase"
|
|
213
222
|
>Exited</span
|
|
@@ -225,9 +234,9 @@
|
|
|
225
234
|
</div>
|
|
226
235
|
|
|
227
236
|
<!-- Console View -->
|
|
228
|
-
<div class="flex min-h-0 flex-1 flex-col bg-
|
|
237
|
+
<div class="flex min-h-0 flex-1 flex-col bg-mist-950">
|
|
229
238
|
<div
|
|
230
|
-
class="flex shrink-0 items-center justify-between border-b border-
|
|
239
|
+
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"
|
|
231
240
|
>
|
|
232
241
|
<div class="flex items-center gap-2">
|
|
233
242
|
<TerminalIcon size={14} />
|
|
@@ -252,7 +261,7 @@
|
|
|
252
261
|
run.id,
|
|
253
262
|
)}/log/raw"
|
|
254
263
|
download="{run.task_name}-{run.id}.log"
|
|
255
|
-
class="flex items-center gap-1.5 rounded border border-
|
|
264
|
+
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"
|
|
256
265
|
title="Download the full log (rotated and current parts as one file)"
|
|
257
266
|
>
|
|
258
267
|
<Download size={12} />
|
|
@@ -265,6 +274,7 @@
|
|
|
265
274
|
bind:this={logConsole}
|
|
266
275
|
fetchLogs={(f: number, t: number) => fetchLogs(run.id, f, t)}
|
|
267
276
|
class="min-h-0 flex-1"
|
|
277
|
+
{highlightLine}
|
|
268
278
|
/>
|
|
269
279
|
{/key}
|
|
270
280
|
</div>
|
|
@@ -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
|
+
}
|
|
@@ -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
|
+
});
|
|
@@ -22,6 +22,8 @@ export interface RunStatusConfig {
|
|
|
22
22
|
border: string;
|
|
23
23
|
dot: string;
|
|
24
24
|
badge: string;
|
|
25
|
+
/** One-sentence explanation of what this status means, for tooltips. */
|
|
26
|
+
description: string;
|
|
25
27
|
}
|
|
26
28
|
|
|
27
29
|
export const RUN_STATUS_CONFIG: Record<RunStatus, RunStatusConfig> = {
|
|
@@ -32,6 +34,7 @@ export const RUN_STATUS_CONFIG: Record<RunStatus, RunStatusConfig> = {
|
|
|
32
34
|
border: "border-info-soft",
|
|
33
35
|
dot: "bg-info-surface animate-pulse",
|
|
34
36
|
badge: "bg-info-soft text-info-soft-text",
|
|
37
|
+
description: "This run is executing right now.",
|
|
35
38
|
},
|
|
36
39
|
success: {
|
|
37
40
|
icon: CircleCheck,
|
|
@@ -40,6 +43,7 @@ export const RUN_STATUS_CONFIG: Record<RunStatus, RunStatusConfig> = {
|
|
|
40
43
|
border: "border-success-soft",
|
|
41
44
|
dot: "bg-success-surface",
|
|
42
45
|
badge: "bg-success-soft text-success-soft-text",
|
|
46
|
+
description: "The run finished with exit code 0.",
|
|
43
47
|
},
|
|
44
48
|
failed: {
|
|
45
49
|
icon: CircleX,
|
|
@@ -48,6 +52,7 @@ export const RUN_STATUS_CONFIG: Record<RunStatus, RunStatusConfig> = {
|
|
|
48
52
|
border: "border-danger-soft",
|
|
49
53
|
dot: "bg-danger-surface",
|
|
50
54
|
badge: "bg-danger-soft text-danger-soft-text",
|
|
55
|
+
description: "The run exited with a non-zero code.",
|
|
51
56
|
},
|
|
52
57
|
crashed: {
|
|
53
58
|
icon: CircleAlert,
|
|
@@ -56,6 +61,8 @@ export const RUN_STATUS_CONFIG: Record<RunStatus, RunStatusConfig> = {
|
|
|
56
61
|
border: "border-danger-soft",
|
|
57
62
|
dot: "bg-danger-surface",
|
|
58
63
|
badge: "bg-danger-soft text-danger-soft-text",
|
|
64
|
+
description:
|
|
65
|
+
"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
66
|
},
|
|
60
67
|
stopped: {
|
|
61
68
|
icon: CircleStop,
|
|
@@ -64,6 +71,7 @@ export const RUN_STATUS_CONFIG: Record<RunStatus, RunStatusConfig> = {
|
|
|
64
71
|
border: "border-warning-soft",
|
|
65
72
|
dot: "bg-warning-surface",
|
|
66
73
|
badge: "bg-warning-soft text-warning-soft-text",
|
|
74
|
+
description: "An operator stopped this run before it finished.",
|
|
67
75
|
},
|
|
68
76
|
timeout: {
|
|
69
77
|
icon: TimerOff,
|
|
@@ -72,6 +80,7 @@ export const RUN_STATUS_CONFIG: Record<RunStatus, RunStatusConfig> = {
|
|
|
72
80
|
border: "border-warning-soft",
|
|
73
81
|
dot: "bg-warning-surface",
|
|
74
82
|
badge: "bg-warning-soft text-warning-soft-text",
|
|
83
|
+
description: "The run exceeded its configured timeout and was terminated.",
|
|
75
84
|
},
|
|
76
85
|
skipped: {
|
|
77
86
|
icon: SkipForward,
|
|
@@ -80,6 +89,8 @@ export const RUN_STATUS_CONFIG: Record<RunStatus, RunStatusConfig> = {
|
|
|
80
89
|
border: "border-outline-faint",
|
|
81
90
|
dot: "bg-on-surface-faint",
|
|
82
91
|
badge: "bg-surface-sunken text-on-surface",
|
|
92
|
+
description:
|
|
93
|
+
'Skipped by the concurrency policy (on_overlap = "skip") because a previous run was still going.',
|
|
83
94
|
},
|
|
84
95
|
log_overflow: {
|
|
85
96
|
icon: FileExclamationPoint,
|
|
@@ -88,6 +99,7 @@ export const RUN_STATUS_CONFIG: Record<RunStatus, RunStatusConfig> = {
|
|
|
88
99
|
border: "border-danger-soft",
|
|
89
100
|
dot: "bg-danger-surface",
|
|
90
101
|
badge: "bg-danger-soft text-danger-soft-text",
|
|
102
|
+
description: "The run hit log_max_size and was handled per log_on_full.",
|
|
91
103
|
},
|
|
92
104
|
queue_full: {
|
|
93
105
|
icon: SkipForward,
|
|
@@ -96,6 +108,7 @@ export const RUN_STATUS_CONFIG: Record<RunStatus, RunStatusConfig> = {
|
|
|
96
108
|
border: "border-warning-soft",
|
|
97
109
|
dot: "bg-warning-surface",
|
|
98
110
|
badge: "bg-warning-soft text-warning-soft-text",
|
|
111
|
+
description: "Skipped because the task's queue was already at queue_max.",
|
|
99
112
|
},
|
|
100
113
|
dst_skipped: {
|
|
101
114
|
icon: SkipForward,
|
|
@@ -104,6 +117,8 @@ export const RUN_STATUS_CONFIG: Record<RunStatus, RunStatusConfig> = {
|
|
|
104
117
|
border: "border-outline-faint",
|
|
105
118
|
dot: "bg-on-surface-faint",
|
|
106
119
|
badge: "bg-surface-sunken text-on-surface",
|
|
120
|
+
description:
|
|
121
|
+
"Skipped: this cron tick was the duplicate half of a DST fall-back, so it was recorded but not run.",
|
|
107
122
|
},
|
|
108
123
|
daemon_stopped: {
|
|
109
124
|
icon: CircleStop,
|
|
@@ -112,6 +127,8 @@ export const RUN_STATUS_CONFIG: Record<RunStatus, RunStatusConfig> = {
|
|
|
112
127
|
border: "border-warning-soft",
|
|
113
128
|
dot: "bg-warning-surface",
|
|
114
129
|
badge: "bg-warning-soft text-warning-soft-text",
|
|
130
|
+
description:
|
|
131
|
+
"The daemon shut down while this run was in flight and it exceeded shutdown_timeout; it was not resumed.",
|
|
115
132
|
},
|
|
116
133
|
pending: {
|
|
117
134
|
icon: Clock,
|
|
@@ -120,6 +137,7 @@ export const RUN_STATUS_CONFIG: Record<RunStatus, RunStatusConfig> = {
|
|
|
120
137
|
border: "border-outline",
|
|
121
138
|
dot: "bg-on-surface-faint",
|
|
122
139
|
badge: "bg-surface-sunken text-on-surface",
|
|
140
|
+
description: "Queued and waiting to start.",
|
|
123
141
|
},
|
|
124
142
|
ended: {
|
|
125
143
|
icon: CircleDashed,
|
|
@@ -128,6 +146,7 @@ export const RUN_STATUS_CONFIG: Record<RunStatus, RunStatusConfig> = {
|
|
|
128
146
|
border: "border-outline-faint",
|
|
129
147
|
dot: "bg-on-surface-faint",
|
|
130
148
|
badge: "bg-surface-sunken text-on-surface",
|
|
149
|
+
description: "The run finished. No specific end reason was recorded.",
|
|
131
150
|
},
|
|
132
151
|
};
|
|
133
152
|
|
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
|
|
|
@@ -95,6 +91,7 @@ export {
|
|
|
95
91
|
type RunStatusConfig,
|
|
96
92
|
} from "./components/dashboard/status-config.js";
|
|
97
93
|
export { runDuration, 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
|
+
}
|