@runwisp/ui 0.8.0 → 0.10.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 -4
- package/src/lib/components/LogConsole.svelte +53 -8
- package/src/lib/components/Modal.svelte +3 -1
- package/src/lib/components/dashboard/RunDetailPanel.svelte +78 -5
- package/src/lib/components/dashboard/RunsList.svelte +39 -24
- package/src/lib/components/dashboard/run-helpers.test.ts +54 -1
- package/src/lib/components/dashboard/run-helpers.ts +38 -0
- package/src/lib/components/dashboard/status-config.ts +22 -0
- package/src/lib/index.ts +7 -1
- package/src/lib/log-console/LogCache.svelte.ts +6 -0
- package/src/lib/log-console/ansi.test.ts +18 -1
- package/src/lib/log-console/ansi.ts +17 -0
- package/src/lib/utils/error.test.ts +34 -0
- package/src/lib/utils/id.test.ts +23 -0
- package/src/stories/LogConsole.stories.svelte +13 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@runwisp/ui",
|
|
3
3
|
"description": "Svelte 5 UI component library for RunWisp dashboards and admin interfaces.",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.10.0",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
@@ -50,8 +50,8 @@
|
|
|
50
50
|
"@chromatic-com/storybook": "^4.1.3",
|
|
51
51
|
"@eslint/compat": "^1.4.0",
|
|
52
52
|
"@eslint/js": "^9.39.1",
|
|
53
|
-
"@runwisp/eslint-config": "0.
|
|
54
|
-
"@runwisp/typescript-config": "0.
|
|
53
|
+
"@runwisp/eslint-config": "0.10.0",
|
|
54
|
+
"@runwisp/typescript-config": "0.10.0",
|
|
55
55
|
"@storybook/addon-a11y": "^10.1.9",
|
|
56
56
|
"@storybook/addon-docs": "^10.1.9",
|
|
57
57
|
"@storybook/addon-svelte-csf": "^5.0.10",
|
|
@@ -99,7 +99,7 @@
|
|
|
99
99
|
"@floating-ui/dom": "^1.7.4",
|
|
100
100
|
"@fontsource/tasa-orbiter": "^5.2.2",
|
|
101
101
|
"@lucide/svelte": "^0.561.0",
|
|
102
|
-
"@runwisp/common": "0.
|
|
102
|
+
"@runwisp/common": "0.10.0",
|
|
103
103
|
"@tanstack/svelte-virtual": "^3.13.25",
|
|
104
104
|
"ansi-to-html": "^0.7.2",
|
|
105
105
|
"cron-parser": "^5.4.0",
|
|
@@ -35,14 +35,23 @@
|
|
|
35
35
|
const cache = new LogCache();
|
|
36
36
|
let fetcher: LogFetcher | null = $state(null);
|
|
37
37
|
let containerEl: HTMLDivElement | null = $state(null);
|
|
38
|
+
let rulerEl: HTMLSpanElement | null = $state(null);
|
|
38
39
|
let scrollTop = $state(0);
|
|
39
40
|
let containerHeight = $state(0);
|
|
41
|
+
let containerWidth = $state(0);
|
|
42
|
+
// Monospace character width in px, measured from a hidden ruler so the
|
|
43
|
+
// horizontal scroll surface is sized without per-line DOM reflow.
|
|
44
|
+
let charWidth = $state(8);
|
|
40
45
|
let isAutoScroll = $state(true);
|
|
41
46
|
let userScrolledUp = $state(false);
|
|
42
47
|
let isStreaming = $derived(!cache.finished && cache.totalLines > 0);
|
|
43
48
|
|
|
44
49
|
const OVERSCAN = 50;
|
|
45
50
|
const BLANK_LINES_AT_END = 3;
|
|
51
|
+
const RULER_SAMPLE = "0".repeat(50);
|
|
52
|
+
// Trailing slack past the last column so the widest line never sits flush
|
|
53
|
+
// against the scroll edge.
|
|
54
|
+
const SURFACE_PADDING = 16;
|
|
46
55
|
|
|
47
56
|
let truncationBannerHeight = $derived(cache.firstAvailableLine > 0 ? lineHeight : 0);
|
|
48
57
|
|
|
@@ -85,6 +94,13 @@
|
|
|
85
94
|
|
|
86
95
|
let gutterWidth = $derived(Math.max(4, String(cache.totalLines || 1).length) * 10 + 16);
|
|
87
96
|
|
|
97
|
+
// Width of the virtual scroll surface: wide enough for the longest line
|
|
98
|
+
// seen so far, never narrower than the viewport (so short logs show no
|
|
99
|
+
// horizontal scrollbar).
|
|
100
|
+
let surfaceWidth = $derived(
|
|
101
|
+
Math.max(containerWidth, gutterWidth + cache.maxLineColumns * charWidth + SURFACE_PADDING),
|
|
102
|
+
);
|
|
103
|
+
|
|
88
104
|
let renderedLines = $derived.by(() => {
|
|
89
105
|
const lines: Array<{ num: number; text: string | undefined }> = [];
|
|
90
106
|
const start = Math.max(cache.firstAvailableLine, visibleStart);
|
|
@@ -186,6 +202,17 @@
|
|
|
186
202
|
function onResize() {
|
|
187
203
|
if (containerEl) {
|
|
188
204
|
containerHeight = containerEl.clientHeight;
|
|
205
|
+
containerWidth = containerEl.clientWidth;
|
|
206
|
+
}
|
|
207
|
+
measureCharWidth();
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Measure one monospace column from the hidden ruler. Font, zoom, and
|
|
211
|
+
// DPI changes all surface through the ResizeObserver, so this stays
|
|
212
|
+
// accurate without polling.
|
|
213
|
+
function measureCharWidth() {
|
|
214
|
+
if (rulerEl && rulerEl.offsetWidth > 0) {
|
|
215
|
+
charWidth = rulerEl.offsetWidth / RULER_SAMPLE.length;
|
|
189
216
|
}
|
|
190
217
|
}
|
|
191
218
|
|
|
@@ -236,6 +263,8 @@
|
|
|
236
263
|
$effect(() => {
|
|
237
264
|
if (!containerEl) return;
|
|
238
265
|
containerHeight = containerEl.clientHeight;
|
|
266
|
+
containerWidth = containerEl.clientWidth;
|
|
267
|
+
measureCharWidth();
|
|
239
268
|
|
|
240
269
|
const resizeObserver = new ResizeObserver(() => {
|
|
241
270
|
onResize();
|
|
@@ -251,18 +280,30 @@
|
|
|
251
280
|
<div
|
|
252
281
|
class="log-console relative flex h-full w-full flex-col overflow-hidden bg-mist-950 font-mono text-sm {className}"
|
|
253
282
|
>
|
|
283
|
+
<!-- Hidden ruler: one monospace column is measured from this off-screen
|
|
284
|
+
sample to size the horizontal scroll surface without per-line reflow. -->
|
|
285
|
+
<span
|
|
286
|
+
bind:this={rulerEl}
|
|
287
|
+
aria-hidden="true"
|
|
288
|
+
class="pointer-events-none absolute -top-[9999px] left-0 whitespace-pre select-none"
|
|
289
|
+
>{RULER_SAMPLE}</span
|
|
290
|
+
>
|
|
291
|
+
|
|
254
292
|
<div bind:this={containerEl} class="flex-1 overflow-auto" onscroll={onScroll}>
|
|
255
|
-
<div
|
|
293
|
+
<div
|
|
294
|
+
class="relative"
|
|
295
|
+
style="height: {totalHeight}px; min-height: 100%; width: {surfaceWidth}px; min-width: 100%;"
|
|
296
|
+
>
|
|
256
297
|
{#if cache.firstAvailableLine > 0}
|
|
257
298
|
<div
|
|
258
299
|
class="absolute right-0 left-0 flex items-center bg-warning-700/20 text-warning-400"
|
|
259
300
|
style="top: 0px; height: {lineHeight}px;"
|
|
260
301
|
>
|
|
261
302
|
<div
|
|
262
|
-
class="flex-shrink-0 pr-3 text-right select-none"
|
|
303
|
+
class="sticky left-0 z-10 flex-shrink-0 pr-3 text-right select-none"
|
|
263
304
|
style="width: {gutterWidth}px;"
|
|
264
305
|
></div>
|
|
265
|
-
<div class="flex-
|
|
306
|
+
<div class="sticky flex-shrink-0 pr-4 text-xs" style="left: {gutterWidth}px;">
|
|
266
307
|
Log truncated: {cache.firstAvailableLine.toLocaleString()} earlier line{cache.firstAvailableLine ===
|
|
267
308
|
1
|
|
268
309
|
? ""
|
|
@@ -280,12 +321,12 @@
|
|
|
280
321
|
style="top: {lineTop(line.num)}px; height: {lineHeight}px;"
|
|
281
322
|
>
|
|
282
323
|
<div
|
|
283
|
-
class="flex-shrink-0 pr-3 text-right text-mist-500 select-none"
|
|
324
|
+
class="sticky left-0 z-10 flex-shrink-0 bg-mist-950 pr-3 text-right text-mist-500 select-none"
|
|
284
325
|
style="width: {gutterWidth}px;"
|
|
285
326
|
>
|
|
286
327
|
{line.num + 1}
|
|
287
328
|
</div>
|
|
288
|
-
<div class="flex-1
|
|
329
|
+
<div class="flex-1 pr-4 text-mist-200">
|
|
289
330
|
{#if line.text !== undefined}
|
|
290
331
|
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
|
291
332
|
<span class="whitespace-pre">{@html ansiLineToHtml(line.text)}</span>
|
|
@@ -302,10 +343,13 @@
|
|
|
302
343
|
style="top: {lineTop(cache.totalLines)}px; height: {lineHeight}px;"
|
|
303
344
|
>
|
|
304
345
|
<div
|
|
305
|
-
class="flex-shrink-0 pr-3 text-right select-none"
|
|
346
|
+
class="sticky left-0 z-10 flex-shrink-0 bg-mist-950 pr-3 text-right select-none"
|
|
306
347
|
style="width: {gutterWidth}px;"
|
|
307
348
|
></div>
|
|
308
|
-
<div
|
|
349
|
+
<div
|
|
350
|
+
class="sticky flex items-center gap-1 text-mist-400"
|
|
351
|
+
style="left: {gutterWidth}px;"
|
|
352
|
+
>
|
|
309
353
|
<span class="dot" style="animation-delay: 0ms;"></span>
|
|
310
354
|
<span class="dot" style="animation-delay: 150ms;"></span>
|
|
311
355
|
<span class="dot" style="animation-delay: 300ms;"></span>
|
|
@@ -322,7 +366,7 @@
|
|
|
322
366
|
)}px; height: {lineHeight}px;"
|
|
323
367
|
>
|
|
324
368
|
<div
|
|
325
|
-
class="flex-shrink-0 pr-3 text-right text-mist-700 opacity-0 select-none"
|
|
369
|
+
class="sticky left-0 z-10 flex-shrink-0 bg-mist-950 pr-3 text-right text-mist-700 opacity-0 select-none"
|
|
326
370
|
style="width: {gutterWidth}px;"
|
|
327
371
|
>
|
|
328
372
|
~
|
|
@@ -408,6 +452,7 @@
|
|
|
408
452
|
<style>
|
|
409
453
|
.log-console {
|
|
410
454
|
--log-line-height: 20px;
|
|
455
|
+
tab-size: 8;
|
|
411
456
|
}
|
|
412
457
|
|
|
413
458
|
.log-line--flash {
|
|
@@ -14,7 +14,9 @@
|
|
|
14
14
|
onClose?: () => void;
|
|
15
15
|
header?: Snippet;
|
|
16
16
|
footer?: Snippet;
|
|
17
|
-
children
|
|
17
|
+
// Explicitly nullable so callers can pass `children={cond ? snip : undefined}`
|
|
18
|
+
// to suppress the padded body band entirely when there's nothing to show.
|
|
19
|
+
children?: Snippet | undefined;
|
|
18
20
|
class?: string;
|
|
19
21
|
}
|
|
20
22
|
|
|
@@ -10,18 +10,26 @@
|
|
|
10
10
|
Download,
|
|
11
11
|
Trash2,
|
|
12
12
|
MousePointerClick,
|
|
13
|
+
RotateCw,
|
|
14
|
+
SlidersHorizontal,
|
|
13
15
|
} from "@lucide/svelte";
|
|
14
16
|
import Badge from "../Badge.svelte";
|
|
15
17
|
import Button from "../Button.svelte";
|
|
16
18
|
import EmptyState from "../EmptyState.svelte";
|
|
17
19
|
import LogConsole from "../LogConsole.svelte";
|
|
20
|
+
import Popover from "../Popover.svelte";
|
|
18
21
|
import Tooltip from "../Tooltip.svelte";
|
|
19
22
|
import type { Run } from "./types.js";
|
|
20
23
|
import type { LogEvent, LogSlice } from "../../log-console/types.js";
|
|
21
|
-
import { formatDateTime } from "../../utils/format.js";
|
|
24
|
+
import { formatDateTime, formatFullDateTime } from "../../utils/format.js";
|
|
22
25
|
import { formatShortId } from "../../utils/id.js";
|
|
23
26
|
import { getRunStatusConfig, runDisplayStatus } from "./status-config.js";
|
|
24
|
-
import {
|
|
27
|
+
import {
|
|
28
|
+
runDuration,
|
|
29
|
+
runStartDelay,
|
|
30
|
+
formatTriggeredByLabel,
|
|
31
|
+
runRetryLabel,
|
|
32
|
+
} from "./run-helpers.js";
|
|
25
33
|
|
|
26
34
|
let {
|
|
27
35
|
run,
|
|
@@ -94,6 +102,10 @@
|
|
|
94
102
|
{@const config = getRunStatusConfig(runDisplayStatus(run))}
|
|
95
103
|
{@const DetailIcon = config.icon}
|
|
96
104
|
{@const duration = runDuration(run)}
|
|
105
|
+
{@const startDelay = runStartDelay(run)}
|
|
106
|
+
{@const startedAt = run.start_at ?? run.created_at}
|
|
107
|
+
{@const retry = runRetryLabel(run)}
|
|
108
|
+
{@const paramEntries = run.params ? Object.entries(run.params) : []}
|
|
97
109
|
<!-- Detailed Header -->
|
|
98
110
|
<div class="@container shrink-0 border-b border-outline-faint bg-surface-raised p-6">
|
|
99
111
|
<div class="flex flex-col justify-between gap-6 @4xl:flex-row @4xl:items-start">
|
|
@@ -113,7 +125,9 @@
|
|
|
113
125
|
class="text-on-surface-muted">#{run.instance_index}</span
|
|
114
126
|
>{/if}
|
|
115
127
|
{:else}
|
|
116
|
-
|
|
128
|
+
<span title={formatFullDateTime(startedAt)}
|
|
129
|
+
>Run · {formatDateTime(startedAt)}</span
|
|
130
|
+
>{#if run.instance_index > 0}
|
|
117
131
|
<span class="text-on-surface-muted"
|
|
118
132
|
>· instance #{run.instance_index}</span
|
|
119
133
|
>
|
|
@@ -161,7 +175,7 @@
|
|
|
161
175
|
<Server size={14} />
|
|
162
176
|
<span
|
|
163
177
|
>Triggered by <span class="font-medium text-on-surface"
|
|
164
|
-
>{run.triggered_by}</span
|
|
178
|
+
>{formatTriggeredByLabel(run.triggered_by)}</span
|
|
165
179
|
></span
|
|
166
180
|
>
|
|
167
181
|
</div>
|
|
@@ -170,7 +184,7 @@
|
|
|
170
184
|
<Server size={14} />
|
|
171
185
|
<span
|
|
172
186
|
>Triggered by <span class="font-medium text-on-surface"
|
|
173
|
-
>{run.triggered_by}</span
|
|
187
|
+
>{formatTriggeredByLabel(run.triggered_by)}</span
|
|
174
188
|
></span
|
|
175
189
|
>
|
|
176
190
|
</div>
|
|
@@ -184,6 +198,57 @@
|
|
|
184
198
|
{run.id}
|
|
185
199
|
</div>
|
|
186
200
|
{/if}
|
|
201
|
+
{#if retry}
|
|
202
|
+
<div
|
|
203
|
+
class="hidden h-1 w-1 rounded-full bg-outline-faint sm:block"
|
|
204
|
+
></div>
|
|
205
|
+
<div class="flex items-center gap-1.5">
|
|
206
|
+
<RotateCw size={14} />
|
|
207
|
+
<span
|
|
208
|
+
>Retry #{run.retry_attempt}{#if run.retry_of_run_id}
|
|
209
|
+
<span class="text-on-surface-faint"
|
|
210
|
+
>of run {formatShortId(run.retry_of_run_id)}</span
|
|
211
|
+
>{/if}</span
|
|
212
|
+
>
|
|
213
|
+
</div>
|
|
214
|
+
{/if}
|
|
215
|
+
{#if paramEntries.length > 0}
|
|
216
|
+
<div
|
|
217
|
+
class="hidden h-1 w-1 rounded-full bg-outline-faint sm:block"
|
|
218
|
+
></div>
|
|
219
|
+
<Popover placement="bottom-start">
|
|
220
|
+
{#snippet trigger()}
|
|
221
|
+
<span
|
|
222
|
+
class="inline-flex cursor-pointer items-center gap-1.5 rounded-full border border-outline-faint bg-surface-sunken px-2.5 py-0.5 text-xs font-medium text-on-surface-muted transition-colors hover:border-outline hover:text-on-surface"
|
|
223
|
+
title="View run parameters"
|
|
224
|
+
>
|
|
225
|
+
<SlidersHorizontal size={12} />
|
|
226
|
+
{paramEntries.length}
|
|
227
|
+
{paramEntries.length === 1 ? "parameter" : "parameters"}
|
|
228
|
+
</span>
|
|
229
|
+
{/snippet}
|
|
230
|
+
<div class="max-w-md min-w-48">
|
|
231
|
+
<span
|
|
232
|
+
class="text-xs font-semibold tracking-wide text-on-surface-faint uppercase"
|
|
233
|
+
>Parameters</span
|
|
234
|
+
>
|
|
235
|
+
<dl
|
|
236
|
+
class="mt-2 grid max-h-72 grid-cols-[max-content_1fr] gap-x-4 gap-y-1.5 overflow-y-auto"
|
|
237
|
+
>
|
|
238
|
+
{#each paramEntries as [key, value] (key)}
|
|
239
|
+
<dt class="font-mono text-xs text-on-surface-muted">
|
|
240
|
+
{key}
|
|
241
|
+
</dt>
|
|
242
|
+
<dd
|
|
243
|
+
class="font-mono text-xs font-medium break-all text-on-surface"
|
|
244
|
+
>
|
|
245
|
+
{value}
|
|
246
|
+
</dd>
|
|
247
|
+
{/each}
|
|
248
|
+
</dl>
|
|
249
|
+
</div>
|
|
250
|
+
</Popover>
|
|
251
|
+
{/if}
|
|
187
252
|
</div>
|
|
188
253
|
</div>
|
|
189
254
|
</div>
|
|
@@ -200,6 +265,14 @@
|
|
|
200
265
|
<span class="font-mono text-sm font-medium whitespace-nowrap text-on-surface">
|
|
201
266
|
{run.start_at ? formatDateTime(run.start_at) : "--:--"}
|
|
202
267
|
</span>
|
|
268
|
+
{#if startDelay}
|
|
269
|
+
<span
|
|
270
|
+
class="font-mono text-xs whitespace-nowrap text-on-surface-faint"
|
|
271
|
+
title="Scheduled at {formatDateTime(run.created_at)}"
|
|
272
|
+
>
|
|
273
|
+
+{startDelay} after scheduled
|
|
274
|
+
</span>
|
|
275
|
+
{/if}
|
|
203
276
|
</div>
|
|
204
277
|
|
|
205
278
|
<div
|
|
@@ -28,13 +28,11 @@
|
|
|
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";
|
|
32
31
|
import type { Run } from "./types.js";
|
|
33
32
|
import type { RunSelector } from "@runwisp/common";
|
|
34
33
|
import { getRunStatusConfig, runDisplayStatus } from "./status-config.js";
|
|
35
|
-
import { runDuration } from "./run-helpers.js";
|
|
36
|
-
import { formatRelativeTime } from "../../utils/format.js";
|
|
37
|
-
import { formatShortId } from "../../utils/id.js";
|
|
34
|
+
import { runDuration, formatTriggeredByLabel, runRetryLabel } from "./run-helpers.js";
|
|
35
|
+
import { formatRelativeTime, formatDateTime, formatFullDateTime } from "../../utils/format.js";
|
|
38
36
|
|
|
39
37
|
type BulkHandler = (selector: RunSelector, affected: Run[]) => void;
|
|
40
38
|
|
|
@@ -365,6 +363,8 @@
|
|
|
365
363
|
{@const config = getRunStatusConfig(runDisplayStatus(run))}
|
|
366
364
|
{@const Icon = config.icon}
|
|
367
365
|
{@const duration = runDuration(run)}
|
|
366
|
+
{@const retry = runRetryLabel(run)}
|
|
367
|
+
{@const startedAt = run.start_at ?? run.created_at}
|
|
368
368
|
<div
|
|
369
369
|
class="flex items-stretch gap-1"
|
|
370
370
|
style:position="absolute"
|
|
@@ -377,7 +377,7 @@
|
|
|
377
377
|
{#if bulkActions}
|
|
378
378
|
<label
|
|
379
379
|
class="flex shrink-0 cursor-pointer items-center px-1.5"
|
|
380
|
-
aria-label={`Select run ${
|
|
380
|
+
aria-label={`Select run from ${formatDateTime(run.start_at ?? run.created_at)}`}
|
|
381
381
|
>
|
|
382
382
|
<input
|
|
383
383
|
type="checkbox"
|
|
@@ -421,16 +421,27 @@
|
|
|
421
421
|
class="shrink-0 text-2xs {isActive
|
|
422
422
|
? 'font-medium text-primary'
|
|
423
423
|
: 'text-on-surface-faint'}"
|
|
424
|
+
title={formatFullDateTime(startedAt)}
|
|
424
425
|
>
|
|
425
|
-
{formatRelativeTime(
|
|
426
|
+
{formatRelativeTime(startedAt)}
|
|
426
427
|
</span>
|
|
427
428
|
</div>
|
|
428
429
|
|
|
429
430
|
<div class="flex items-center justify-between pl-6 text-xs">
|
|
430
431
|
<div
|
|
431
|
-
class="flex items-center gap-1
|
|
432
|
+
class="flex min-w-0 items-center gap-1.5 text-2xs text-on-surface-muted"
|
|
432
433
|
>
|
|
433
|
-
|
|
434
|
+
<span class="truncate">{formatDateTime(startedAt)}</span
|
|
435
|
+
>
|
|
436
|
+
<span class="text-on-surface-faint"
|
|
437
|
+
>· {formatTriggeredByLabel(run.triggered_by)}</span
|
|
438
|
+
>
|
|
439
|
+
{#if retry}
|
|
440
|
+
<span
|
|
441
|
+
class="shrink-0 rounded bg-surface-sunken px-1 font-mono text-2xs text-on-surface-faint"
|
|
442
|
+
>{retry}</span
|
|
443
|
+
>
|
|
444
|
+
{/if}
|
|
434
445
|
</div>
|
|
435
446
|
{#if duration}
|
|
436
447
|
<div
|
|
@@ -444,10 +455,10 @@
|
|
|
444
455
|
{/if}
|
|
445
456
|
</div>
|
|
446
457
|
{:else}
|
|
447
|
-
<!-- Task variant:
|
|
458
|
+
<!-- Task variant: when it ran as primary, outcome secondary -->
|
|
448
459
|
<div class="mb-1.5 flex items-center justify-between">
|
|
449
|
-
<div class="flex items-center gap-2">
|
|
450
|
-
<div class={config.color}>
|
|
460
|
+
<div class="flex min-w-0 items-center gap-2">
|
|
461
|
+
<div class="{config.color} shrink-0">
|
|
451
462
|
<Icon
|
|
452
463
|
size={16}
|
|
453
464
|
class={run.status === "running"
|
|
@@ -456,31 +467,35 @@
|
|
|
456
467
|
/>
|
|
457
468
|
</div>
|
|
458
469
|
<span
|
|
459
|
-
class="
|
|
470
|
+
class="truncate text-sm font-semibold text-on-surface"
|
|
460
471
|
>
|
|
461
|
-
|
|
472
|
+
{formatDateTime(startedAt)}
|
|
462
473
|
</span>
|
|
463
474
|
</div>
|
|
464
475
|
<span
|
|
465
|
-
class="text-2xs {isActive
|
|
476
|
+
class="shrink-0 text-2xs {isActive
|
|
466
477
|
? 'font-medium text-primary'
|
|
467
478
|
: 'text-on-surface-faint'}"
|
|
479
|
+
title={formatFullDateTime(startedAt)}
|
|
468
480
|
>
|
|
469
|
-
{formatRelativeTime(
|
|
481
|
+
{formatRelativeTime(startedAt)}
|
|
470
482
|
</span>
|
|
471
483
|
</div>
|
|
472
484
|
|
|
473
|
-
<div class="flex items-center justify-between text-xs">
|
|
474
|
-
<div
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
485
|
+
<div class="flex items-center justify-between pl-6 text-xs">
|
|
486
|
+
<div
|
|
487
|
+
class="flex min-w-0 items-center gap-2 text-on-surface-muted"
|
|
488
|
+
>
|
|
489
|
+
<span class="capitalize">{runDisplayStatus(run)}</span>
|
|
490
|
+
<span class="text-on-surface-faint"
|
|
491
|
+
>· {formatTriggeredByLabel(run.triggered_by)}</span
|
|
479
492
|
>
|
|
480
|
-
|
|
481
|
-
|
|
493
|
+
{#if retry}
|
|
494
|
+
<span
|
|
495
|
+
class="shrink-0 rounded bg-surface-sunken px-1 font-mono text-2xs text-on-surface-faint"
|
|
496
|
+
>{retry}</span
|
|
482
497
|
>
|
|
483
|
-
|
|
498
|
+
{/if}
|
|
484
499
|
{#if run.instance_index > 0}
|
|
485
500
|
<span
|
|
486
501
|
class="font-mono text-2xs text-on-surface-faint"
|
|
@@ -2,7 +2,12 @@
|
|
|
2
2
|
// SPDX-License-Identifier: Apache-2.0
|
|
3
3
|
|
|
4
4
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
runDuration,
|
|
7
|
+
runStartDelay,
|
|
8
|
+
formatTriggeredByLabel,
|
|
9
|
+
runRetryLabel,
|
|
10
|
+
} from "./run-helpers.js";
|
|
6
11
|
|
|
7
12
|
describe("runDuration", () => {
|
|
8
13
|
it("returns undefined when start_at is not set", () => {
|
|
@@ -35,3 +40,51 @@ describe("runDuration", () => {
|
|
|
35
40
|
});
|
|
36
41
|
});
|
|
37
42
|
});
|
|
43
|
+
|
|
44
|
+
describe("runStartDelay", () => {
|
|
45
|
+
it("returns undefined when start_at is not set", () => {
|
|
46
|
+
expect(runStartDelay({ created_at: "2024-06-15T12:00:00.000Z" })).toBeUndefined();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("returns undefined when the run started within a second of its tick", () => {
|
|
50
|
+
expect(
|
|
51
|
+
runStartDelay({
|
|
52
|
+
created_at: "2024-06-15T12:00:00.000Z",
|
|
53
|
+
start_at: "2024-06-15T12:00:00.300Z",
|
|
54
|
+
}),
|
|
55
|
+
).toBeUndefined();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("formats the jitter/queue gap when the run started meaningfully later", () => {
|
|
59
|
+
expect(
|
|
60
|
+
runStartDelay({
|
|
61
|
+
created_at: "2024-06-15T03:00:00.000Z",
|
|
62
|
+
start_at: "2024-06-15T03:07:12.000Z",
|
|
63
|
+
}),
|
|
64
|
+
).toBe("7m 12s");
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe("formatTriggeredByLabel", () => {
|
|
69
|
+
it("humanizes each trigger source", () => {
|
|
70
|
+
expect(formatTriggeredByLabel("api")).toBe("API");
|
|
71
|
+
expect(formatTriggeredByLabel("cron")).toBe("Cron");
|
|
72
|
+
expect(formatTriggeredByLabel("service")).toBe("Service");
|
|
73
|
+
expect(formatTriggeredByLabel("startup")).toBe("Startup");
|
|
74
|
+
expect(formatTriggeredByLabel("cloud")).toBe("Cloud");
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("runRetryLabel", () => {
|
|
79
|
+
it("returns undefined for a first attempt that is not a retry", () => {
|
|
80
|
+
expect(runRetryLabel({ retry_attempt: 0 })).toBeUndefined();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("labels a run with a positive attempt number", () => {
|
|
84
|
+
expect(runRetryLabel({ retry_attempt: 2 })).toBe("retry #2");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("labels a run that points back at the run it re-attempts", () => {
|
|
88
|
+
expect(runRetryLabel({ retry_attempt: 1, retry_of_run_id: "01JABC" })).toBe("retry #1");
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -11,6 +11,44 @@ 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
|
+
|
|
29
|
+
/** Human label for why a run fired (the `triggered_by` source). */
|
|
30
|
+
export function formatTriggeredByLabel(triggeredBy: Run["triggered_by"]): string {
|
|
31
|
+
if (triggeredBy === "api") return "API";
|
|
32
|
+
if (triggeredBy === "cron") return "Cron";
|
|
33
|
+
if (triggeredBy === "service") return "Service";
|
|
34
|
+
if (triggeredBy === "startup") return "Startup";
|
|
35
|
+
return "Cloud";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* "retry #N" label when a run is a retry of an earlier one, else undefined.
|
|
40
|
+
* A run is a retry if it carries a positive attempt number or points back at
|
|
41
|
+
* the run it re-attempts.
|
|
42
|
+
*/
|
|
43
|
+
export function runRetryLabel(
|
|
44
|
+
run: Pick<Run, "retry_attempt" | "retry_of_run_id">,
|
|
45
|
+
): string | undefined {
|
|
46
|
+
if (run.retry_attempt > 0 || run.retry_of_run_id) {
|
|
47
|
+
return `retry #${String(run.retry_attempt)}`;
|
|
48
|
+
}
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
14
52
|
/**
|
|
15
53
|
* Monotonic position of a run's status in its lifecycle. Used to reject stale
|
|
16
54
|
* updates (e.g. a `pending` HTTP response arriving after an SSE already
|
|
@@ -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";
|
|
@@ -130,6 +132,26 @@ export const RUN_STATUS_CONFIG: Record<RunStatus, RunStatusConfig> = {
|
|
|
130
132
|
description:
|
|
131
133
|
"The daemon shut down while this run was in flight and it exceeded shutdown_timeout; it was not resumed.",
|
|
132
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.",
|
|
154
|
+
},
|
|
133
155
|
pending: {
|
|
134
156
|
icon: Clock,
|
|
135
157
|
color: "text-on-surface-muted",
|
package/src/lib/index.ts
CHANGED
|
@@ -90,7 +90,13 @@ export {
|
|
|
90
90
|
RUN_STATUS_CONFIG,
|
|
91
91
|
type RunStatusConfig,
|
|
92
92
|
} from "./components/dashboard/status-config.js";
|
|
93
|
-
export {
|
|
93
|
+
export {
|
|
94
|
+
runDuration,
|
|
95
|
+
runStartDelay,
|
|
96
|
+
runPhaseOrder,
|
|
97
|
+
formatTriggeredByLabel,
|
|
98
|
+
runRetryLabel,
|
|
99
|
+
} from "./components/dashboard/run-helpers.js";
|
|
94
100
|
export { humanizeCron, type HumanizedCron } from "./components/dashboard/cron-format.js";
|
|
95
101
|
export type { DaemonState, DaemonStats } from "./components/dashboard/types.js";
|
|
96
102
|
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
import type { LogSlice, LogEvent } from "./types.js";
|
|
5
5
|
import { SvelteMap } from "svelte/reactivity";
|
|
6
|
+
import { visibleColumns } from "./ansi.js";
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* LogCache provides high-performance storage and pruning for log data.
|
|
@@ -17,6 +18,9 @@ export class LogCache {
|
|
|
17
18
|
totalBytes = $state(0);
|
|
18
19
|
finished = $state(false);
|
|
19
20
|
firstAvailableLine = $state(0);
|
|
21
|
+
// Widest visible line seen so far, in monospace columns. Monotonic so the
|
|
22
|
+
// horizontal scroll surface never shrinks as windowed lines are pruned.
|
|
23
|
+
maxLineColumns = $state(0);
|
|
20
24
|
|
|
21
25
|
get size() {
|
|
22
26
|
return this.lines.size;
|
|
@@ -38,6 +42,7 @@ export class LogCache {
|
|
|
38
42
|
const prev = this.lines.get(line);
|
|
39
43
|
if (prev !== v) {
|
|
40
44
|
this.lines.set(line, v);
|
|
45
|
+
this.maxLineColumns = Math.max(this.maxLineColumns, visibleColumns(v));
|
|
41
46
|
touched = true;
|
|
42
47
|
}
|
|
43
48
|
}
|
|
@@ -77,6 +82,7 @@ export class LogCache {
|
|
|
77
82
|
this.totalBytes = 0;
|
|
78
83
|
this.finished = false;
|
|
79
84
|
this.firstAvailableLine = 0;
|
|
85
|
+
this.maxLineColumns = 0;
|
|
80
86
|
}
|
|
81
87
|
|
|
82
88
|
isRangeComplete(start: number, end: number) {
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// SPDX-License-Identifier: Apache-2.0
|
|
3
3
|
|
|
4
4
|
import { describe, expect, it } from "vitest";
|
|
5
|
-
import { ansiLineToHtml } from "./ansi.js";
|
|
5
|
+
import { ansiLineToHtml, visibleColumns } from "./ansi.js";
|
|
6
6
|
|
|
7
7
|
describe("ansiLineToHtml", () => {
|
|
8
8
|
it("passes plain text through unchanged", () => {
|
|
@@ -48,3 +48,20 @@ describe("ansiLineToHtml", () => {
|
|
|
48
48
|
expect(ansiLineToHtml("plain")).toBe("plain");
|
|
49
49
|
});
|
|
50
50
|
});
|
|
51
|
+
|
|
52
|
+
describe("visibleColumns", () => {
|
|
53
|
+
it("counts plain characters", () => {
|
|
54
|
+
expect(visibleColumns("hello")).toBe(5);
|
|
55
|
+
expect(visibleColumns("")).toBe(0);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("ignores ANSI escape sequences", () => {
|
|
59
|
+
expect(visibleColumns("\x1b[31merror\x1b[0m")).toBe(5);
|
|
60
|
+
expect(visibleColumns("\x1b[1;36minfo\x1b[0m done")).toBe(9);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("charges tabs the full tab width", () => {
|
|
64
|
+
expect(visibleColumns("\t")).toBe(8);
|
|
65
|
+
expect(visibleColumns("ab\tcd")).toBe(12);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -37,3 +37,20 @@ export function ansiLineToHtml(text: string): string {
|
|
|
37
37
|
colors: ANSI_PALETTE,
|
|
38
38
|
}).toHtml(text);
|
|
39
39
|
}
|
|
40
|
+
|
|
41
|
+
// Matches CSI escape sequences (colour codes, cursor moves, etc.), which
|
|
42
|
+
// render to zero visible width. The ESC control char is intentional here.
|
|
43
|
+
// eslint-disable-next-line no-control-regex
|
|
44
|
+
const ANSI_ESCAPE = /\x1b\[[0-9;?]*[A-Za-z]/g;
|
|
45
|
+
const TAB_WIDTH = 8;
|
|
46
|
+
|
|
47
|
+
// Count the visible columns a log line occupies once ANSI escape sequences
|
|
48
|
+
// are stripped. Tabs are charged the full tab width — an over-estimate that
|
|
49
|
+
// only adds trailing slack to the horizontal scroll surface, never clips.
|
|
50
|
+
export function visibleColumns(text: string): number {
|
|
51
|
+
let columns = 0;
|
|
52
|
+
for (const ch of text.replace(ANSI_ESCAPE, "")) {
|
|
53
|
+
columns += ch === "\t" ? TAB_WIDTH : 1;
|
|
54
|
+
}
|
|
55
|
+
return columns;
|
|
56
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: PoppyCake, s.r.o.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
import { extractErrorMessage } from "./error.js";
|
|
6
|
+
|
|
7
|
+
describe("extractErrorMessage", () => {
|
|
8
|
+
it("returns Error.message when given an Error", () => {
|
|
9
|
+
expect(extractErrorMessage(new Error("boom"))).toBe("boom");
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("returns the fallback when an Error has an empty message", () => {
|
|
13
|
+
expect(extractErrorMessage(new Error(""))).toBe("An unexpected error occurred");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("returns a string error as-is", () => {
|
|
17
|
+
expect(extractErrorMessage("plain text error")).toBe("plain text error");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("returns the fallback for an empty string", () => {
|
|
21
|
+
expect(extractErrorMessage("")).toBe("An unexpected error occurred");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("honours a custom fallback", () => {
|
|
25
|
+
expect(extractErrorMessage(undefined, "custom fallback")).toBe("custom fallback");
|
|
26
|
+
expect(extractErrorMessage({ unrelated: 1 }, "custom fallback")).toBe("custom fallback");
|
|
27
|
+
expect(extractErrorMessage(null, "custom fallback")).toBe("custom fallback");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("returns the default fallback for non-Error non-string values", () => {
|
|
31
|
+
expect(extractErrorMessage(42)).toBe("An unexpected error occurred");
|
|
32
|
+
expect(extractErrorMessage({ code: 500 })).toBe("An unexpected error occurred");
|
|
33
|
+
});
|
|
34
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: PoppyCake, s.r.o.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
import { formatShortId } from "./id.js";
|
|
6
|
+
|
|
7
|
+
describe("formatShortId", () => {
|
|
8
|
+
it("returns the last 8 characters by default", () => {
|
|
9
|
+
expect(formatShortId("01HVZ4XYZ123456ABCDEF")).toBe("56ABCDEF");
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("honours a custom length", () => {
|
|
13
|
+
expect(formatShortId("01HVZ4XYZ123456ABCDEF", 4)).toBe("CDEF");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("returns the whole string when shorter than the requested length", () => {
|
|
17
|
+
expect(formatShortId("abc", 8)).toBe("abc");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("returns an empty string for an empty input", () => {
|
|
21
|
+
expect(formatShortId("")).toBe("");
|
|
22
|
+
});
|
|
23
|
+
});
|
|
@@ -50,6 +50,15 @@
|
|
|
50
50
|
`${ESC}4;36munderlined cyan link${ESC}0m and ${ESC}4;33munderlined yellow${ESC}0m`,
|
|
51
51
|
];
|
|
52
52
|
|
|
53
|
+
const LONG_LINES = [
|
|
54
|
+
`${ESC}36m[info]${ESC}0m starting request trace`,
|
|
55
|
+
`${ESC}90m2026-06-05T03:00:00Z${ESC}0m GET /api/v1/tasks?include=runs,logs,schedule&limit=100&offset=0 → ${ESC}32m200${ESC}0m ${ESC}90m{"trace":"a1b2c3d4-e5f6-7890-abcd-ef1234567890","upstream":"https://internal.example.com/very/long/path/segment/that/keeps/going/and/going","latency_ms":1287,"bytes":204812}${ESC}0m`,
|
|
56
|
+
`${ESC}31m[error]${ESC}0m panic: runtime error: index out of range [12] with length 8 — goroutine 42 [running]: main.process(0xc0000b4000, 0x1f4) /home/runner/work/app/app/internal/pipeline/process.go:317 +0x4a5`,
|
|
57
|
+
" short line in between",
|
|
58
|
+
`tab-separated\tcolumns\there\tshould\tstay\taligned\tand\textend\tfar\tpast\tthe\tviewport\tedge\tto\tprove\thorizontal\tscroll`,
|
|
59
|
+
`${ESC}32m[done]${ESC}0m ${"=".repeat(400)}|END`,
|
|
60
|
+
];
|
|
61
|
+
|
|
53
62
|
const STREAM_TICKS = [
|
|
54
63
|
`${ESC}36m[info]${ESC}0m processed batch ${ESC}1m#42${ESC}0m ${ESC}90m(1,024 records)${ESC}0m`,
|
|
55
64
|
`${ESC}32m✓${ESC}0m health check passed`,
|
|
@@ -73,3 +82,7 @@
|
|
|
73
82
|
<Story name="Truncated" asChild>
|
|
74
83
|
<LogConsoleDemo lines={SAMPLE_LOG.slice(6)} firstAvailableLine={1500} />
|
|
75
84
|
</Story>
|
|
85
|
+
|
|
86
|
+
<Story name="Long Lines" asChild>
|
|
87
|
+
<LogConsoleDemo lines={LONG_LINES} />
|
|
88
|
+
</Story>
|