@runwisp/ui 0.9.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 +69 -5
- package/src/lib/components/dashboard/RunsList.svelte +39 -24
- package/src/lib/components/dashboard/run-helpers.test.ts +30 -1
- package/src/lib/components/dashboard/run-helpers.ts +23 -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/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,
|
|
@@ -95,6 +103,9 @@
|
|
|
95
103
|
{@const DetailIcon = config.icon}
|
|
96
104
|
{@const duration = runDuration(run)}
|
|
97
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) : []}
|
|
98
109
|
<!-- Detailed Header -->
|
|
99
110
|
<div class="@container shrink-0 border-b border-outline-faint bg-surface-raised p-6">
|
|
100
111
|
<div class="flex flex-col justify-between gap-6 @4xl:flex-row @4xl:items-start">
|
|
@@ -114,7 +125,9 @@
|
|
|
114
125
|
class="text-on-surface-muted">#{run.instance_index}</span
|
|
115
126
|
>{/if}
|
|
116
127
|
{:else}
|
|
117
|
-
|
|
128
|
+
<span title={formatFullDateTime(startedAt)}
|
|
129
|
+
>Run · {formatDateTime(startedAt)}</span
|
|
130
|
+
>{#if run.instance_index > 0}
|
|
118
131
|
<span class="text-on-surface-muted"
|
|
119
132
|
>· instance #{run.instance_index}</span
|
|
120
133
|
>
|
|
@@ -162,7 +175,7 @@
|
|
|
162
175
|
<Server size={14} />
|
|
163
176
|
<span
|
|
164
177
|
>Triggered by <span class="font-medium text-on-surface"
|
|
165
|
-
>{run.triggered_by}</span
|
|
178
|
+
>{formatTriggeredByLabel(run.triggered_by)}</span
|
|
166
179
|
></span
|
|
167
180
|
>
|
|
168
181
|
</div>
|
|
@@ -171,7 +184,7 @@
|
|
|
171
184
|
<Server size={14} />
|
|
172
185
|
<span
|
|
173
186
|
>Triggered by <span class="font-medium text-on-surface"
|
|
174
|
-
>{run.triggered_by}</span
|
|
187
|
+
>{formatTriggeredByLabel(run.triggered_by)}</span
|
|
175
188
|
></span
|
|
176
189
|
>
|
|
177
190
|
</div>
|
|
@@ -185,6 +198,57 @@
|
|
|
185
198
|
{run.id}
|
|
186
199
|
</div>
|
|
187
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}
|
|
188
252
|
</div>
|
|
189
253
|
</div>
|
|
190
254
|
</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", () => {
|
|
@@ -59,3 +64,27 @@ describe("runStartDelay", () => {
|
|
|
59
64
|
).toBe("7m 12s");
|
|
60
65
|
});
|
|
61
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
|
+
});
|
|
@@ -26,6 +26,29 @@ export function runStartDelay(run: Pick<Run, "created_at" | "start_at">): string
|
|
|
26
26
|
return formatDuration(delay);
|
|
27
27
|
}
|
|
28
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
|
+
|
|
29
52
|
/**
|
|
30
53
|
* Monotonic position of a run's status in its lifecycle. Used to reject stale
|
|
31
54
|
* updates (e.g. a `pending` HTTP response arriving after an SSE already
|
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
|
+
}
|
|
@@ -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>
|