@runwisp/ui 1.7.1 → 1.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 -4
- package/src/lib/actions/focusTrap.ts +13 -5
- package/src/lib/components/DataGrid.svelte +11 -4
- package/src/lib/components/Dropdown.svelte +1 -1
- package/src/lib/components/LogConsole.svelte +26 -10
- package/src/lib/components/dashboard/RunConsoleFrame.svelte +2 -1
- package/src/lib/components/dashboard/RunRow.svelte +91 -17
- package/src/lib/components/dashboard/RunsList.svelte +36 -6
- package/src/lib/components/dashboard/cron-format.test.ts +3 -3
- package/src/lib/components/dashboard/cron-format.ts +2 -1
- package/src/lib/components/dashboard/run-helpers.test.ts +69 -0
- package/src/lib/components/dashboard/run-helpers.ts +121 -3
- package/src/lib/log-console/LogConsoleFooter.svelte +1 -1
- package/src/lib/utils/format.test.ts +18 -0
- package/src/lib/utils/format.ts +28 -4
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": "1.
|
|
4
|
+
"version": "1.8.0",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
@@ -49,8 +49,8 @@
|
|
|
49
49
|
"devDependencies": {
|
|
50
50
|
"@chromatic-com/storybook": "^5.2.1",
|
|
51
51
|
"@eslint/js": "^10.0.1",
|
|
52
|
-
"@runwisp/eslint-config": "1.
|
|
53
|
-
"@runwisp/typescript-config": "1.
|
|
52
|
+
"@runwisp/eslint-config": "1.8.0",
|
|
53
|
+
"@runwisp/typescript-config": "1.8.0",
|
|
54
54
|
"@storybook/addon-a11y": "^10.5.5",
|
|
55
55
|
"@storybook/addon-docs": "^10.5.5",
|
|
56
56
|
"@storybook/addon-svelte-csf": "^5.1.2",
|
|
@@ -92,7 +92,7 @@
|
|
|
92
92
|
"@fontsource-variable/geist-mono": "^5.3.0",
|
|
93
93
|
"@fontsource/tasa-orbiter": "^5.3.0",
|
|
94
94
|
"@lucide/svelte": "^1.52.0",
|
|
95
|
-
"@runwisp/common": "1.
|
|
95
|
+
"@runwisp/common": "1.8.0",
|
|
96
96
|
"@tanstack/svelte-virtual": "^3.13.35",
|
|
97
97
|
"ansi-to-html": "^0.7.2",
|
|
98
98
|
"cron-parser": "^5.7.0",
|
|
@@ -53,6 +53,11 @@ export interface TrapFocusOptions {
|
|
|
53
53
|
* that focuses its own search input).
|
|
54
54
|
*/
|
|
55
55
|
autoFocus?: boolean;
|
|
56
|
+
/**
|
|
57
|
+
* When true, focusing elements inside the trap (and restoring focus upon
|
|
58
|
+
* teardown) will not cause the browser to scroll the viewport. Defaults to `false`.
|
|
59
|
+
*/
|
|
60
|
+
preventScroll?: boolean;
|
|
56
61
|
}
|
|
57
62
|
|
|
58
63
|
/**
|
|
@@ -64,10 +69,10 @@ export interface TrapFocusOptions {
|
|
|
64
69
|
* alone.
|
|
65
70
|
*/
|
|
66
71
|
export function trapFocus(node: HTMLElement, options: TrapFocusOptions = {}) {
|
|
67
|
-
|
|
72
|
+
let currentOptions = options;
|
|
68
73
|
const previouslyFocused = isHTMLElement(document.activeElement) ? document.activeElement : null;
|
|
69
74
|
|
|
70
|
-
if (autoFocus) {
|
|
75
|
+
if (currentOptions.autoFocus ?? true) {
|
|
71
76
|
// Deferred a tick: `node` may still be moved by a sibling `use:portal`
|
|
72
77
|
// action (appendChild-ing it to <body>) after this action runs, and
|
|
73
78
|
// relocating a focused element resets focus to <body>. Waiting a
|
|
@@ -78,7 +83,7 @@ export function trapFocus(node: HTMLElement, options: TrapFocusOptions = {}) {
|
|
|
78
83
|
if (target === node && !node.hasAttribute("tabindex")) {
|
|
79
84
|
node.tabIndex = -1;
|
|
80
85
|
}
|
|
81
|
-
target.focus();
|
|
86
|
+
target.focus({ preventScroll: currentOptions.preventScroll ?? false });
|
|
82
87
|
});
|
|
83
88
|
}
|
|
84
89
|
|
|
@@ -87,13 +92,16 @@ export function trapFocus(node: HTMLElement, options: TrapFocusOptions = {}) {
|
|
|
87
92
|
const target = resolveTrapFocusTarget(e, getFocusableElements(node), active);
|
|
88
93
|
if (target) {
|
|
89
94
|
e.preventDefault();
|
|
90
|
-
target.focus();
|
|
95
|
+
target.focus({ preventScroll: currentOptions.preventScroll ?? false });
|
|
91
96
|
}
|
|
92
97
|
}
|
|
93
98
|
|
|
94
99
|
node.addEventListener("keydown", handleKeydown);
|
|
95
100
|
|
|
96
101
|
return {
|
|
102
|
+
update(newOptions: TrapFocusOptions = {}) {
|
|
103
|
+
currentOptions = newOptions;
|
|
104
|
+
},
|
|
97
105
|
destroy() {
|
|
98
106
|
node.removeEventListener("keydown", handleKeydown);
|
|
99
107
|
// By the time an action's destroy() runs, `node` (and whatever
|
|
@@ -108,7 +116,7 @@ export function trapFocus(node: HTMLElement, options: TrapFocusOptions = {}) {
|
|
|
108
116
|
const active = document.activeElement;
|
|
109
117
|
const focusWasReset = !isHTMLElement(active) || active === document.body;
|
|
110
118
|
if (focusWasReset && previouslyFocused && document.contains(previouslyFocused)) {
|
|
111
|
-
previouslyFocused.focus();
|
|
119
|
+
previouslyFocused.focus({ preventScroll: currentOptions.preventScroll ?? false });
|
|
112
120
|
}
|
|
113
121
|
},
|
|
114
122
|
};
|
|
@@ -229,7 +229,10 @@
|
|
|
229
229
|
: ""}
|
|
230
230
|
>
|
|
231
231
|
{#if selectable}
|
|
232
|
-
|
|
232
|
+
<!-- w-px: in an auto-layout table a width is only a minimum, so a
|
|
233
|
+
fixed width still soaks up spare space. 1px shrinks the
|
|
234
|
+
column to the checkbox; the next cell's padding is the gap. -->
|
|
235
|
+
<TableCell header {density} class="w-px pr-0">
|
|
233
236
|
<Checkbox
|
|
234
237
|
checked={allSelected}
|
|
235
238
|
indeterminate={someSelected}
|
|
@@ -285,7 +288,9 @@
|
|
|
285
288
|
</TableHead>
|
|
286
289
|
{/if}
|
|
287
290
|
<TableBody>
|
|
288
|
-
{#if pagedData.length === 0
|
|
291
|
+
{#if pagedData.length === 0}
|
|
292
|
+
<!-- Kept while loading (blank) so the loading overlay has a body to
|
|
293
|
+
cover instead of squeezing into the header row. -->
|
|
289
294
|
<TableRow border="none">
|
|
290
295
|
<TableCell
|
|
291
296
|
colspan={columns.length + (selectable ? 1 : 0) + (rowAction ? 1 : 0)}
|
|
@@ -293,7 +298,9 @@
|
|
|
293
298
|
align="center"
|
|
294
299
|
class="px-4 py-12 text-on-surface-muted"
|
|
295
300
|
>
|
|
296
|
-
{#if
|
|
301
|
+
{#if loading}
|
|
302
|
+
|
|
303
|
+
{:else if emptyState}
|
|
297
304
|
{@render emptyState()}
|
|
298
305
|
{:else}
|
|
299
306
|
{emptyMessage}
|
|
@@ -319,7 +326,7 @@
|
|
|
319
326
|
fire onRowClick (which usually navigates away). -->
|
|
320
327
|
<TableCell
|
|
321
328
|
{density}
|
|
322
|
-
class="cursor-pointer {hoverable ? railHover : ''}"
|
|
329
|
+
class="cursor-pointer pr-0 {hoverable ? railHover : ''}"
|
|
323
330
|
onclick={(e) => {
|
|
324
331
|
e.stopPropagation();
|
|
325
332
|
if (!(e.target instanceof HTMLInputElement)) toggleRow(row);
|
|
@@ -495,18 +495,25 @@
|
|
|
495
495
|
{/if}
|
|
496
496
|
|
|
497
497
|
{#if cache.finished && cache.totalLines > 0}
|
|
498
|
+
<!-- Pinned to the visible width, not the scroll surface: one very
|
|
499
|
+
long line would otherwise centre the marker far off-screen. -->
|
|
498
500
|
<div
|
|
499
|
-
class="sentinel absolute right-
|
|
501
|
+
class="sentinel absolute right-0 left-0 select-none"
|
|
500
502
|
style="top: {lineTop(cache.totalLines)}px; height: {lineHeight * 2}px;"
|
|
501
503
|
>
|
|
502
|
-
<
|
|
503
|
-
class="
|
|
504
|
-
|
|
505
|
-
? 'sentinel--warn'
|
|
506
|
-
: 'sentinel--muted'}"
|
|
504
|
+
<div
|
|
505
|
+
class="sticky left-0 flex justify-center px-4"
|
|
506
|
+
style:width={containerWidth ? `${String(containerWidth)}px` : "100%"}
|
|
507
507
|
>
|
|
508
|
-
|
|
509
|
-
|
|
508
|
+
<span
|
|
509
|
+
class="mt-[14px] border-t border-dashed pt-3 text-center text-[11px] tracking-wide {endTone ===
|
|
510
|
+
'warn'
|
|
511
|
+
? 'sentinel--warn'
|
|
512
|
+
: 'sentinel--muted'}"
|
|
513
|
+
>
|
|
514
|
+
── {endLabel} ──
|
|
515
|
+
</span>
|
|
516
|
+
</div>
|
|
510
517
|
</div>
|
|
511
518
|
{/if}
|
|
512
519
|
|
|
@@ -538,8 +545,17 @@
|
|
|
538
545
|
class="absolute inset-0 flex items-center justify-center text-[var(--rw-con-dim)]"
|
|
539
546
|
>
|
|
540
547
|
<div class="text-center">
|
|
541
|
-
|
|
542
|
-
|
|
548
|
+
{#if cache.finished}
|
|
549
|
+
<div class="mb-1 text-lg">No output</div>
|
|
550
|
+
<div class="text-sm text-[var(--rw-con-gutter)]">
|
|
551
|
+
The run ended without printing anything.
|
|
552
|
+
</div>
|
|
553
|
+
{:else}
|
|
554
|
+
<div class="mb-1 text-lg">No output yet</div>
|
|
555
|
+
<div class="text-sm text-[var(--rw-con-gutter)]">
|
|
556
|
+
Waiting for logs...
|
|
557
|
+
</div>
|
|
558
|
+
{/if}
|
|
543
559
|
</div>
|
|
544
560
|
</div>
|
|
545
561
|
{/if}
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import { isTypingTarget } from "../../utils/typing-target.js";
|
|
13
13
|
import { displayStatus, type Run } from "@runwisp/common";
|
|
14
14
|
import { RUN_STATUS_CONFIG } from "./status-config.js";
|
|
15
|
+
import { humanizeStatus } from "./run-filters.js";
|
|
15
16
|
import { runEndMarker } from "./run-helpers.js";
|
|
16
17
|
|
|
17
18
|
let {
|
|
@@ -220,7 +221,7 @@
|
|
|
220
221
|
{run.taskName}{suffix}
|
|
221
222
|
</span>
|
|
222
223
|
<span class="text-[var(--rw-con-gutter)]">·</span>
|
|
223
|
-
<span class="font-semibold
|
|
224
|
+
<span class="font-semibold {config.color}">{humanizeStatus(status)}</span>
|
|
224
225
|
</span>
|
|
225
226
|
{:else}
|
|
226
227
|
<span
|
|
@@ -4,15 +4,21 @@
|
|
|
4
4
|
<script lang="ts">
|
|
5
5
|
import { displayStatus, type Run } from "@runwisp/common";
|
|
6
6
|
import { RUN_STATUS_CONFIG } from "./status-config.js";
|
|
7
|
+
import { humanizeStatus } from "./run-filters.js";
|
|
7
8
|
import {
|
|
8
9
|
formatTriggeredByLabel,
|
|
9
10
|
runRetryLabel,
|
|
10
11
|
runRowReadout,
|
|
11
12
|
runUsageAmounts,
|
|
12
13
|
runUsageLabel,
|
|
14
|
+
runDurationMs,
|
|
13
15
|
usageLevel,
|
|
14
16
|
highlightParts,
|
|
15
|
-
|
|
17
|
+
formatLines,
|
|
18
|
+
outputUnusual,
|
|
19
|
+
runOutputLabel,
|
|
20
|
+
runOutputLines,
|
|
21
|
+
type OutputScale,
|
|
16
22
|
type RunUsageScale,
|
|
17
23
|
} from "./run-helpers.js";
|
|
18
24
|
import type { RunOutputMatch } from "./types.js";
|
|
@@ -35,6 +41,8 @@
|
|
|
35
41
|
bulkActions,
|
|
36
42
|
selectionActive,
|
|
37
43
|
usage,
|
|
44
|
+
output,
|
|
45
|
+
durationUsual,
|
|
38
46
|
onselect,
|
|
39
47
|
}: {
|
|
40
48
|
run: Run;
|
|
@@ -52,6 +60,10 @@
|
|
|
52
60
|
* no run has usage yet (rows keep the bars' room), undefined for none.
|
|
53
61
|
*/
|
|
54
62
|
usage: RunUsageScale | null | undefined;
|
|
63
|
+
/** The task's output scale for the rail's third bar, undefined for none. */
|
|
64
|
+
output: OutputScale | undefined;
|
|
65
|
+
/** The task's usual run duration in ms, undefined for none. */
|
|
66
|
+
durationUsual: number | undefined;
|
|
55
67
|
onselect: (runId: string) => void;
|
|
56
68
|
} = $props();
|
|
57
69
|
|
|
@@ -61,6 +73,49 @@
|
|
|
61
73
|
const startedAt = $derived(run.startedAt ?? run.createdAt);
|
|
62
74
|
const retry = $derived(runRetryLabel(run));
|
|
63
75
|
const amounts = $derived(usage ? runUsageAmounts(run) : undefined);
|
|
76
|
+
const lines = $derived(output ? runOutputLines(run) : undefined);
|
|
77
|
+
const barsTitle = $derived(
|
|
78
|
+
[
|
|
79
|
+
[runUsageLabel(run), runOutputLabel(run)].filter(Boolean).join(" · "),
|
|
80
|
+
[
|
|
81
|
+
usage &&
|
|
82
|
+
`CPU ${formatDuration(usage.usual.cpu)} · RAM ${formatBytes(usage.usual.ram)}`,
|
|
83
|
+
output?.band &&
|
|
84
|
+
`${formatLines(output.band.p10)} to ${formatLines(output.band.p90)}`,
|
|
85
|
+
]
|
|
86
|
+
.filter(Boolean)
|
|
87
|
+
.join(" · "),
|
|
88
|
+
]
|
|
89
|
+
.filter(Boolean)
|
|
90
|
+
.join(". Usual for this task: "),
|
|
91
|
+
);
|
|
92
|
+
// The readout shows the duration only for runs that didn't fail; a failed
|
|
93
|
+
// one shows its exit code, which stays plain.
|
|
94
|
+
const durationMs = $derived(
|
|
95
|
+
durationUsual === undefined || dstatus === "failed" || dstatus === "crashed"
|
|
96
|
+
? undefined
|
|
97
|
+
: runDurationMs(run),
|
|
98
|
+
);
|
|
99
|
+
const durationLevel = $derived(
|
|
100
|
+
durationMs === undefined || durationUsual === undefined
|
|
101
|
+
? 0
|
|
102
|
+
: usageLevel(durationMs, durationUsual, "duration"),
|
|
103
|
+
);
|
|
104
|
+
const readoutTitle = $derived(
|
|
105
|
+
[
|
|
106
|
+
retry,
|
|
107
|
+
durationLevel > 0 &&
|
|
108
|
+
durationUsual !== undefined &&
|
|
109
|
+
`Usual for this task: ${formatDuration(durationUsual)}`,
|
|
110
|
+
]
|
|
111
|
+
.filter(Boolean)
|
|
112
|
+
.join(". ") || undefined,
|
|
113
|
+
);
|
|
114
|
+
const READOUT_COLOR = [
|
|
115
|
+
"text-on-surface-faint",
|
|
116
|
+
"text-warning-soft-text",
|
|
117
|
+
"text-danger-soft-text",
|
|
118
|
+
];
|
|
64
119
|
const tight = $derived(usage === undefined ? "" : "@max-[19rem]:hidden");
|
|
65
120
|
const LEVEL_COLOR = ["bg-success-surface", "bg-warning-surface", "bg-danger-surface"];
|
|
66
121
|
// With bulk actions on, the status dot fades out on row hover, or whenever a
|
|
@@ -91,9 +146,8 @@
|
|
|
91
146
|
>{/if}
|
|
92
147
|
</span>
|
|
93
148
|
<span class="shrink-0 text-on-surface-faint">·</span>
|
|
94
|
-
<span
|
|
95
|
-
|
|
96
|
-
>{dstatus}</span
|
|
149
|
+
<span class="shrink-0 font-mono text-[12px] font-semibold {config.color}"
|
|
150
|
+
>{humanizeStatus(dstatus)}</span
|
|
97
151
|
>
|
|
98
152
|
</span>
|
|
99
153
|
<span
|
|
@@ -134,31 +188,51 @@
|
|
|
134
188
|
<!-- With usage bars a tight rail drops the status word; the dot
|
|
135
189
|
keeps its colour. -->
|
|
136
190
|
<span class="text-on-surface-faint {tight}">·</span>
|
|
137
|
-
<span
|
|
138
|
-
|
|
139
|
-
>{dstatus}</span
|
|
191
|
+
<span class="font-mono text-[12.5px] font-semibold {config.color} {tight}"
|
|
192
|
+
>{humanizeStatus(dstatus)}</span
|
|
140
193
|
>
|
|
141
194
|
{#if suffix}
|
|
142
195
|
<span class="font-mono text-2xs text-on-surface-faint">{suffix}</span>
|
|
143
196
|
{/if}
|
|
144
197
|
</span>
|
|
145
|
-
{#if usage && amounts}
|
|
198
|
+
{#if usage !== undefined && (amounts || lines !== undefined)}
|
|
199
|
+
<!-- Fixed slots, CPU over RAM over lines printed, so a run
|
|
200
|
+
missing one (a non-shell backend has no CPU/RAM) keeps the
|
|
201
|
+
others aligned with its neighbours. -->
|
|
146
202
|
<span
|
|
147
203
|
class="flex w-6 shrink-0 cursor-help flex-col gap-0.5"
|
|
148
|
-
title=
|
|
149
|
-
usage.usual.cpu,
|
|
150
|
-
)} · RAM {formatBytes(usage.usual.ram)}"
|
|
204
|
+
title={barsTitle}
|
|
151
205
|
data-testid="run-usage-bars"
|
|
152
206
|
>
|
|
153
|
-
{
|
|
154
|
-
|
|
207
|
+
{#if usage && amounts}
|
|
208
|
+
{@render bar(
|
|
209
|
+
amounts.cpu,
|
|
210
|
+
usage.max.cpu,
|
|
211
|
+
usageLevel(amounts.cpu, usage.usual.cpu, "cpu"),
|
|
212
|
+
)}
|
|
213
|
+
{@render bar(
|
|
214
|
+
amounts.ram,
|
|
215
|
+
usage.max.ram,
|
|
216
|
+
usageLevel(amounts.ram, usage.usual.ram, "ram"),
|
|
217
|
+
)}
|
|
218
|
+
{:else}
|
|
219
|
+
<span class="block h-[3px]"></span>
|
|
220
|
+
<span class="block h-[3px]"></span>
|
|
221
|
+
{/if}
|
|
222
|
+
{#if output && lines !== undefined}
|
|
223
|
+
{@render bar(
|
|
224
|
+
lines,
|
|
225
|
+
output.max,
|
|
226
|
+
output.band && outputUnusual(lines, output.band) ? 1 : 0,
|
|
227
|
+
)}
|
|
228
|
+
{/if}
|
|
155
229
|
</span>
|
|
156
230
|
{:else if usage !== undefined}
|
|
157
231
|
<span class="w-6 shrink-0" aria-hidden="true"></span>
|
|
158
232
|
{/if}
|
|
159
233
|
<span
|
|
160
|
-
class="shrink-0 font-mono text-[11.5px]
|
|
161
|
-
title={
|
|
234
|
+
class="shrink-0 font-mono text-[11.5px] {READOUT_COLOR[durationLevel]} tabular-nums"
|
|
235
|
+
title={readoutTitle}
|
|
162
236
|
>
|
|
163
237
|
{runRowReadout(run, dstatus)}
|
|
164
238
|
</span>
|
|
@@ -183,10 +257,10 @@
|
|
|
183
257
|
></div>
|
|
184
258
|
</button>
|
|
185
259
|
|
|
186
|
-
{#snippet bar(value: number, max: number,
|
|
260
|
+
{#snippet bar(value: number, max: number, level: 0 | 1 | 2)}
|
|
187
261
|
<span class="block h-[3px] overflow-hidden rounded-[1px] bg-outline-faint">
|
|
188
262
|
<span
|
|
189
|
-
class="block h-full {LEVEL_COLOR[
|
|
263
|
+
class="block h-full {LEVEL_COLOR[level]}"
|
|
190
264
|
style="width: {String(max > 0 ? Math.max(4, Math.round((100 * value) / max)) : 4)}%"
|
|
191
265
|
></span>
|
|
192
266
|
</span>
|
|
@@ -24,8 +24,13 @@
|
|
|
24
24
|
import RunRow from "./RunRow.svelte";
|
|
25
25
|
import type { RunOutputMatch } from "./types.js";
|
|
26
26
|
import type { Run, RunSelector } from "@runwisp/common";
|
|
27
|
-
import {
|
|
28
|
-
|
|
27
|
+
import {
|
|
28
|
+
activeFilterCount,
|
|
29
|
+
emptyRunFilters,
|
|
30
|
+
runFilterParams,
|
|
31
|
+
type RunsListFilters,
|
|
32
|
+
} from "./run-filters.js";
|
|
33
|
+
import { instanceSuffix, runOutputScale, runUsageScale, usualDuration } from "./run-helpers.js";
|
|
29
34
|
import { formatDateTime } from "../../utils/format.js";
|
|
30
35
|
|
|
31
36
|
type BulkHandler = (selector: RunSelector, affected: Run[]) => void;
|
|
@@ -75,7 +80,7 @@
|
|
|
75
80
|
showTaskName?: boolean;
|
|
76
81
|
taskNameFilter?: string;
|
|
77
82
|
emptyText?: string;
|
|
78
|
-
emptyDescription?: string;
|
|
83
|
+
emptyDescription?: string | undefined;
|
|
79
84
|
bulkActions?: boolean;
|
|
80
85
|
onBulkCancel?: BulkHandler;
|
|
81
86
|
onBulkDelete?: BulkHandler;
|
|
@@ -99,8 +104,9 @@
|
|
|
99
104
|
// True while a query is typed but its results aren't in yet (debounce
|
|
100
105
|
// window or request in flight), the rail shows its searching shimmer.
|
|
101
106
|
outputSearchPending?: boolean;
|
|
102
|
-
// Task rail only:
|
|
103
|
-
// scaled to the largest loaded run and colored against
|
|
107
|
+
// Task rail only: three small bars per finished run, CPU time, RAM and
|
|
108
|
+
// lines printed, scaled to the largest loaded run and colored against
|
|
109
|
+
// the usual one. The duration readout is colored against the usual run too.
|
|
104
110
|
usageBars?: boolean;
|
|
105
111
|
// Folds the list to a thin strip that still counts the runs, giving the
|
|
106
112
|
// run detail the width. Omitted where the screen keeps the list open.
|
|
@@ -109,6 +115,8 @@
|
|
|
109
115
|
} = $props();
|
|
110
116
|
|
|
111
117
|
const usageScale = $derived(usageBars ? runUsageScale(items) : undefined);
|
|
118
|
+
const outputScale = $derived(usageBars ? runOutputScale(items) : undefined);
|
|
119
|
+
const durationUsual = $derived(usageBars ? usualDuration(items) : undefined);
|
|
112
120
|
|
|
113
121
|
// Task-rail rows are one dense line (44px); the cross-task /runs view adds
|
|
114
122
|
// the task name on a second line (64px).
|
|
@@ -246,6 +254,12 @@
|
|
|
246
254
|
};
|
|
247
255
|
}
|
|
248
256
|
|
|
257
|
+
// An empty list under filters says so, rather than claiming no runs exist.
|
|
258
|
+
const filtered = $derived(activeFilterCount(filters) > 0 || filters.search !== "");
|
|
259
|
+
function clearFilters() {
|
|
260
|
+
filters = { ...emptyRunFilters(), sortDirection: filters.sortDirection };
|
|
261
|
+
}
|
|
262
|
+
|
|
249
263
|
// The count stands in for a title: "12 runs", or matches while searching.
|
|
250
264
|
// Empty until the first page lands, when the count isn't known yet.
|
|
251
265
|
const countLabel = $derived.by(() => {
|
|
@@ -420,7 +434,21 @@
|
|
|
420
434
|
{/if}
|
|
421
435
|
{:else if items.length === 0 && loading}
|
|
422
436
|
<RunListSkeleton label="Loading runs" {showTaskName} />
|
|
423
|
-
{:else if items.length === 0}
|
|
437
|
+
{:else if items.length === 0 && filtered}
|
|
438
|
+
<EmptyState
|
|
439
|
+
title="No matching runs"
|
|
440
|
+
description="No run matches the current filters."
|
|
441
|
+
icon={Clock}
|
|
442
|
+
iconSize={32}
|
|
443
|
+
class="py-8"
|
|
444
|
+
>
|
|
445
|
+
{#snippet actions()}
|
|
446
|
+
<Button variant="secondary" size="sm" onclick={clearFilters}>
|
|
447
|
+
Clear filters
|
|
448
|
+
</Button>
|
|
449
|
+
{/snippet}
|
|
450
|
+
</EmptyState>
|
|
451
|
+
{:else if items.length === 0 && emptyText}
|
|
424
452
|
<EmptyState
|
|
425
453
|
title={emptyText}
|
|
426
454
|
description={emptyDescription}
|
|
@@ -504,6 +532,8 @@
|
|
|
504
532
|
{bulkActions}
|
|
505
533
|
{selectionActive}
|
|
506
534
|
usage={usageBars ? (usageScale ?? null) : undefined}
|
|
535
|
+
output={outputScale}
|
|
536
|
+
{durationUsual}
|
|
507
537
|
onselect={selectRun}
|
|
508
538
|
/>
|
|
509
539
|
{/snippet}
|
|
@@ -13,16 +13,16 @@ describe("humanizeCron", () => {
|
|
|
13
13
|
|
|
14
14
|
it("humanizes a daily expression", () => {
|
|
15
15
|
const result = humanizeCron("0 3 * * *");
|
|
16
|
-
expect(result.humanized).toBe("At 03:00
|
|
16
|
+
expect(result.humanized).toBe("At 03:00");
|
|
17
17
|
expect(result.isHumanized).toBe(true);
|
|
18
18
|
});
|
|
19
19
|
|
|
20
20
|
it("describes the nth and last weekday of the month", () => {
|
|
21
21
|
expect(humanizeCron("0 3 * * FRI#3").humanized).toBe(
|
|
22
|
-
"At 03:00
|
|
22
|
+
"At 03:00, on the third Friday of the month",
|
|
23
23
|
);
|
|
24
24
|
const last = humanizeCron("0 3 * * FRI#L");
|
|
25
|
-
expect(last.humanized).toBe("At 03:00
|
|
25
|
+
expect(last.humanized).toBe("At 03:00, on the last Friday of the month");
|
|
26
26
|
expect(last.raw).toBe("0 3 * * FRI#L");
|
|
27
27
|
});
|
|
28
28
|
|
|
@@ -67,9 +67,10 @@ export function humanizeCron(cron: string): HumanizedCron {
|
|
|
67
67
|
|
|
68
68
|
try {
|
|
69
69
|
// cronstrue reads a last weekday as Quartz's "5L"; "5#L" would come out as
|
|
70
|
-
// "on the null Friday of the month".
|
|
70
|
+
// "on the null Friday of the month". 24-hour, like every other clock in the UI.
|
|
71
71
|
const humanized = cronstrue.toString(raw.replace(/#L/gi, "L"), {
|
|
72
72
|
throwExceptionOnParseError: true,
|
|
73
|
+
use24HourTimeFormat: true,
|
|
73
74
|
});
|
|
74
75
|
return { humanized, raw, isHumanized: true };
|
|
75
76
|
} catch {
|
|
@@ -15,6 +15,12 @@ import {
|
|
|
15
15
|
runUsageLabel,
|
|
16
16
|
runUsageScale,
|
|
17
17
|
usageLevel,
|
|
18
|
+
usualDuration,
|
|
19
|
+
outputBand,
|
|
20
|
+
runOutputScale,
|
|
21
|
+
outputUnusual,
|
|
22
|
+
formatLines,
|
|
23
|
+
runOutputLabel,
|
|
18
24
|
instanceSuffix,
|
|
19
25
|
runLogDownloadUrl,
|
|
20
26
|
} from "./run-helpers.js";
|
|
@@ -277,3 +283,66 @@ describe("usageLevel", () => {
|
|
|
277
283
|
expect(usageLevel(8 * 1024 * 1024, 1024 * 1024, "ram")).toBe(0);
|
|
278
284
|
});
|
|
279
285
|
});
|
|
286
|
+
|
|
287
|
+
describe("usualDuration", () => {
|
|
288
|
+
const ran = (seconds: number, status: Run["status"] = "ended") => ({
|
|
289
|
+
status,
|
|
290
|
+
startedAt: "2026-01-01T00:00:00Z",
|
|
291
|
+
endedAt: new Date(Date.parse("2026-01-01T00:00:00Z") + seconds * 1000).toISOString(),
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it("takes the median over finished runs only", () => {
|
|
295
|
+
expect(usualDuration([ran(20), ran(240), ran(3600, "running"), ran(18)])).toBe(20_000);
|
|
296
|
+
expect(usualDuration([{ status: "ended" }, ran(5, "running")])).toBeUndefined();
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it("flags a run far slower than usual, with a 5s floor", () => {
|
|
300
|
+
expect(usageLevel(240_000, 20_000, "duration")).toBe(2);
|
|
301
|
+
expect(usageLevel(31_000, 20_000, "duration")).toBe(1);
|
|
302
|
+
expect(usageLevel(22_000, 20_000, "duration")).toBe(0);
|
|
303
|
+
// 3s against a usual 50ms is jitter, not an outlier.
|
|
304
|
+
expect(usageLevel(3_000, 50, "duration")).toBe(0);
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
describe("output band", () => {
|
|
309
|
+
const printed = (
|
|
310
|
+
stdoutLines: number,
|
|
311
|
+
stderrLines = 0,
|
|
312
|
+
): Pick<Run, "status" | "stdoutLines" | "stderrLines"> => ({
|
|
313
|
+
status: "ended",
|
|
314
|
+
stdoutLines,
|
|
315
|
+
stderrLines,
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it("takes the nearest-rank p10 and p90 once ten runs have counts", () => {
|
|
319
|
+
const runs = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((n) => printed(n));
|
|
320
|
+
expect(outputBand(runs)).toEqual({ p10: 1, p90: 9 });
|
|
321
|
+
expect(outputBand(runs.slice(1))).toBeUndefined();
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it("scales the output bars to the chattiest run", () => {
|
|
325
|
+
expect(runOutputScale([printed(2), printed(40, 2)])).toEqual({ max: 42, band: undefined });
|
|
326
|
+
expect(runOutputScale([{ status: "ended" }])).toBeUndefined();
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it("flags clear outliers only", () => {
|
|
330
|
+
expect(outputUnusual(3, { p10: 0, p90: 0 })).toBe(true);
|
|
331
|
+
expect(outputUnusual(0, { p10: 0, p90: 0 })).toBe(false);
|
|
332
|
+
expect(outputUnusual(3, { p10: 480, p90: 520 })).toBe(true);
|
|
333
|
+
expect(outputUnusual(5000, { p10: 480, p90: 520 })).toBe(true);
|
|
334
|
+
expect(outputUnusual(300, { p10: 480, p90: 520 })).toBe(false);
|
|
335
|
+
expect(outputUnusual(3, { p10: 0, p90: 1 })).toBe(false);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it("labels what a run printed", () => {
|
|
339
|
+
expect(formatLines(1)).toBe("1 line");
|
|
340
|
+
expect(formatLines(1234)).toBe("1.2K lines");
|
|
341
|
+
expect(runOutputLabel(printed(0))).toBe("no output");
|
|
342
|
+
expect(runOutputLabel(printed(9, 3))).toBe("12 lines · 3 on stderr");
|
|
343
|
+
expect(runOutputLabel(printed(0, 2))).toBe("2 lines · all on stderr");
|
|
344
|
+
expect(
|
|
345
|
+
runOutputLabel({ status: "running", stdoutLines: 1, stderrLines: 0 }),
|
|
346
|
+
).toBeUndefined();
|
|
347
|
+
});
|
|
348
|
+
});
|
|
@@ -165,12 +165,30 @@ export function runUsageScale(
|
|
|
165
165
|
};
|
|
166
166
|
}
|
|
167
167
|
|
|
168
|
+
/** A finished run's wall time in ms, or undefined while it is still going. */
|
|
169
|
+
export function runDurationMs(
|
|
170
|
+
run: Pick<Run, "status" | "startedAt" | "endedAt">,
|
|
171
|
+
): number | undefined {
|
|
172
|
+
if (run.status !== "ended" || !run.startedAt || !run.endedAt) return undefined;
|
|
173
|
+
return new Date(run.endedAt).getTime() - new Date(run.startedAt).getTime();
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** The median wall time in ms over the finished runs given, the baseline a slow run is judged against. */
|
|
177
|
+
export function usualDuration(
|
|
178
|
+
runs: readonly Pick<Run, "status" | "startedAt" | "endedAt">[],
|
|
179
|
+
): number | undefined {
|
|
180
|
+
const durations = runs.flatMap((run) => runDurationMs(run) ?? []);
|
|
181
|
+
return durations.length > 0 ? median(durations) : undefined;
|
|
182
|
+
}
|
|
183
|
+
|
|
168
184
|
/**
|
|
169
185
|
* The least a usual run counts as using, so a tiny or zero baseline (a mostly
|
|
170
186
|
* idle task's median CPU time is often 0) doesn't hide a run that burns real
|
|
171
|
-
* CPU or memory, nor flag 3ms against 1ms. CPU time in ms, memory
|
|
187
|
+
* CPU or memory, nor flag 3ms against 1ms. CPU time and duration in ms, memory
|
|
188
|
+
* in bytes. Duration's floor is higher since wall time also carries network
|
|
189
|
+
* and scheduling jitter. Mirrors the TUI's uikit.UsageFloor*.
|
|
172
190
|
*/
|
|
173
|
-
const USAGE_FLOOR
|
|
191
|
+
const USAGE_FLOOR = { cpu: 1000, ram: 16 * 1024 * 1024, duration: 5000 };
|
|
174
192
|
|
|
175
193
|
/**
|
|
176
194
|
* How unusual a run's amount is for its task: 0 about as usual, 1 at 1.5×
|
|
@@ -178,7 +196,11 @@ const USAGE_FLOOR: RunUsageAmounts = { cpu: 1000, ram: 16 * 1024 * 1024 };
|
|
|
178
196
|
* amount stays at 0, however large that amount is. The usual run counts as
|
|
179
197
|
* at least USAGE_FLOOR for its kind.
|
|
180
198
|
*/
|
|
181
|
-
export function usageLevel(
|
|
199
|
+
export function usageLevel(
|
|
200
|
+
value: number,
|
|
201
|
+
usual: number,
|
|
202
|
+
kind: keyof typeof USAGE_FLOOR,
|
|
203
|
+
): 0 | 1 | 2 {
|
|
182
204
|
const baseline = Math.max(usual, USAGE_FLOOR[kind]);
|
|
183
205
|
if (baseline <= 0) return 0;
|
|
184
206
|
const ratio = value / baseline;
|
|
@@ -187,6 +209,102 @@ export function usageLevel(value: number, usual: number, kind: keyof RunUsageAmo
|
|
|
187
209
|
return 0;
|
|
188
210
|
}
|
|
189
211
|
|
|
212
|
+
/** Lines a finished run printed on stdout and stderr together, or undefined without counts. */
|
|
213
|
+
export function runOutputLines(
|
|
214
|
+
run: Pick<Run, "status" | "stdoutLines" | "stderrLines">,
|
|
215
|
+
): number | undefined {
|
|
216
|
+
if (run.status !== "ended") return undefined;
|
|
217
|
+
if (run.stdoutLines === undefined || run.stderrLines === undefined) return undefined;
|
|
218
|
+
return run.stdoutLines + run.stderrLines;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** The range of output a task's runs usually print, in lines: its 10th and 90th percentile. */
|
|
222
|
+
export interface OutputBand {
|
|
223
|
+
p10: number;
|
|
224
|
+
p90: number;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** How many finished runs a task needs before a run is judged against its band. */
|
|
228
|
+
const OUTPUT_BAND_MIN_RUNS = 10;
|
|
229
|
+
/** The fewest lines a run must be past the band's edge, so 3 against a usual 1 isn't flagged. */
|
|
230
|
+
const OUTPUT_BAND_SLACK = 5;
|
|
231
|
+
|
|
232
|
+
/** The p-th percentile of sorted values, by the nearest-rank method. */
|
|
233
|
+
function nearestRank(sorted: number[], p: number): number {
|
|
234
|
+
const rank = Math.max(Math.ceil((p * sorted.length) / 100), 1);
|
|
235
|
+
return sorted[rank - 1] ?? 0;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* The output band over the finished runs given, or undefined while fewer than
|
|
240
|
+
* OUTPUT_BAND_MIN_RUNS have line counts. Mirrors the TUI's uikit.UsualOutput.
|
|
241
|
+
*/
|
|
242
|
+
export function outputBand(
|
|
243
|
+
runs: readonly Pick<Run, "status" | "stdoutLines" | "stderrLines">[],
|
|
244
|
+
): OutputBand | undefined {
|
|
245
|
+
const lines = runs.flatMap((run) => runOutputLines(run) ?? []);
|
|
246
|
+
if (lines.length < OUTPUT_BAND_MIN_RUNS) return undefined;
|
|
247
|
+
const sorted = lines.toSorted((a, b) => a - b);
|
|
248
|
+
return { p10: nearestRank(sorted, 10), p90: nearestRank(sorted, 90) };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Whether a line count is clearly outside the band: more than twice its 90th
|
|
253
|
+
* percentile or under half its 10th, and at least OUTPUT_BAND_SLACK lines past
|
|
254
|
+
* it. A task that is silent nearly every time is flagged by any output at all.
|
|
255
|
+
*/
|
|
256
|
+
export function outputUnusual(lines: number, band: OutputBand): boolean {
|
|
257
|
+
if (band.p90 === 0) return lines > 0;
|
|
258
|
+
const high = lines > 2 * band.p90 && lines - band.p90 >= OUTPUT_BAND_SLACK;
|
|
259
|
+
const low = 2 * lines < band.p10 && band.p10 - lines >= OUTPUT_BAND_SLACK;
|
|
260
|
+
return high || low;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* How a task's runs compare on output: `max` is its chattiest run (the full
|
|
265
|
+
* length of an output bar), `band` its usual range, undefined until enough
|
|
266
|
+
* runs have counts. Undefined when no run has counts yet.
|
|
267
|
+
*/
|
|
268
|
+
export interface OutputScale {
|
|
269
|
+
max: number;
|
|
270
|
+
band: OutputBand | undefined;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** The scale for a task's output bars, over the finished runs given. */
|
|
274
|
+
export function runOutputScale(
|
|
275
|
+
runs: readonly Pick<Run, "status" | "stdoutLines" | "stderrLines">[],
|
|
276
|
+
): OutputScale | undefined {
|
|
277
|
+
const lines = runs.flatMap((run) => runOutputLines(run) ?? []);
|
|
278
|
+
if (lines.length === 0) return undefined;
|
|
279
|
+
return { max: Math.max(...lines), band: outputBand(runs) };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const compactNumber = new Intl.NumberFormat("en", {
|
|
283
|
+
notation: "compact",
|
|
284
|
+
maximumFractionDigits: 1,
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
/** "1 line", "37 lines", "1.2K lines". */
|
|
288
|
+
export function formatLines(lines: number): string {
|
|
289
|
+
return lines === 1 ? "1 line" : `${compactNumber.format(lines)} lines`;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* How much a run printed: "no output", "12 lines", "12 lines · 3 on stderr",
|
|
294
|
+
* or undefined for a run without counts.
|
|
295
|
+
*/
|
|
296
|
+
export function runOutputLabel(
|
|
297
|
+
run: Pick<Run, "status" | "stdoutLines" | "stderrLines">,
|
|
298
|
+
): string | undefined {
|
|
299
|
+
const lines = runOutputLines(run);
|
|
300
|
+
if (lines === undefined) return undefined;
|
|
301
|
+
if (lines === 0) return "no output";
|
|
302
|
+
const stderr = run.stderrLines ?? 0;
|
|
303
|
+
if (stderr === 0) return formatLines(lines);
|
|
304
|
+
if (stderr === lines) return `${formatLines(lines)} · all on stderr`;
|
|
305
|
+
return `${formatLines(lines)} · ${String(stderr)} on stderr`;
|
|
306
|
+
}
|
|
307
|
+
|
|
190
308
|
/**
|
|
191
309
|
* Monotonic position of a run's status in its lifecycle. Used to reject stale
|
|
192
310
|
* updates (e.g. a `pending` HTTP response arriving after an SSE already
|
|
@@ -63,7 +63,7 @@
|
|
|
63
63
|
{/if}
|
|
64
64
|
</div>
|
|
65
65
|
<div class="flex items-center gap-4">
|
|
66
|
-
<span>{totalLines.toLocaleString()} lines</span>
|
|
66
|
+
<span>{totalLines.toLocaleString()} {totalLines === 1 ? "line" : "lines"}</span>
|
|
67
67
|
{#if totalBytes > 0}
|
|
68
68
|
<span>{formatBytes(totalBytes)}</span>
|
|
69
69
|
{/if}
|
|
@@ -103,6 +103,24 @@ describe("formatRelativeTimeWithAbsolute", () => {
|
|
|
103
103
|
expect(r).toMatch(/\([^)]*\d[^)]*\)$/);
|
|
104
104
|
expect(r).not.toMatch(/\(\d{1,2}:\d{2}\)$/);
|
|
105
105
|
});
|
|
106
|
+
it("adds the year when the date falls in another year", () => {
|
|
107
|
+
const now = new Date(2026, 9, 20, 12);
|
|
108
|
+
expect(formatRelativeTimeWithAbsolute(new Date(2028, 1, 29), now)).toMatch(/2028\)$/);
|
|
109
|
+
expect(formatRelativeTimeWithAbsolute(new Date(2026, 11, 1), now)).not.toMatch(/2026/);
|
|
110
|
+
});
|
|
111
|
+
it("shows the clock time in the given zone, not the browser's", () => {
|
|
112
|
+
// Kathmandu is UTC+05:45, so no browser zone renders 02:30Z as 08:15.
|
|
113
|
+
const now = new Date("2026-10-21T00:00:00Z");
|
|
114
|
+
expect(
|
|
115
|
+
formatRelativeTimeWithAbsolute("2026-10-21T02:30:00Z", now, "Asia/Kathmandu"),
|
|
116
|
+
).toMatch(/\(0?8:15\)$/);
|
|
117
|
+
});
|
|
118
|
+
it("falls back to the browser zone for a zone Intl doesn't know", () => {
|
|
119
|
+
const now = new Date("2026-10-21T00:00:00Z");
|
|
120
|
+
expect(formatRelativeTimeWithAbsolute("2026-10-21T02:30:00Z", now, "Mars/Olympus")).toBe(
|
|
121
|
+
formatRelativeTimeWithAbsolute("2026-10-21T02:30:00Z", now),
|
|
122
|
+
);
|
|
123
|
+
});
|
|
106
124
|
it("computes the relative part against the supplied now", () => {
|
|
107
125
|
const date = new Date("2024-01-01T12:00:00Z");
|
|
108
126
|
const twoHoursBefore = formatRelativeTimeWithAbsolute(
|
package/src/lib/utils/format.ts
CHANGED
|
@@ -63,12 +63,14 @@ function dateFormatter(options: Intl.DateTimeFormatOptions): (date: string | Dat
|
|
|
63
63
|
return (date) => format.format(new Date(date));
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
/** Date and time, 24-hour like every other clock in the UI, e.g. "Oct 20, 2026, 21:05". */
|
|
66
67
|
export const formatDateTime = dateFormatter({
|
|
67
68
|
year: "numeric",
|
|
68
69
|
month: "short",
|
|
69
70
|
day: "numeric",
|
|
70
|
-
hour: "
|
|
71
|
+
hour: "2-digit",
|
|
71
72
|
minute: "2-digit",
|
|
73
|
+
hour12: false,
|
|
72
74
|
});
|
|
73
75
|
|
|
74
76
|
/** Wall-clock time of day, seconds included, 24-hour, e.g. "17:15:02". */
|
|
@@ -102,16 +104,38 @@ export const formatFullDateTime = dateFormatter({
|
|
|
102
104
|
hour12: false,
|
|
103
105
|
});
|
|
104
106
|
|
|
105
|
-
|
|
107
|
+
/** Formats date with options in timeZone; the browser's zone when unset or unknown to Intl. */
|
|
108
|
+
function formatInZone(date: Date, options: Intl.DateTimeFormatOptions, timeZone?: string): string {
|
|
109
|
+
if (timeZone) {
|
|
110
|
+
try {
|
|
111
|
+
return new Intl.DateTimeFormat(undefined, { ...options, timeZone }).format(date);
|
|
112
|
+
} catch {
|
|
113
|
+
// A zone the daemon knows but this browser's Intl doesn't.
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return new Intl.DateTimeFormat(undefined, options).format(date);
|
|
117
|
+
}
|
|
106
118
|
|
|
119
|
+
/**
|
|
120
|
+
* "in 7 hours (02:30)". Pass timeZone for a scheduled time, so the clock time
|
|
121
|
+
* agrees with the cron description ("At 02:30") written in the schedule's zone.
|
|
122
|
+
*/
|
|
107
123
|
export function formatRelativeTimeWithAbsolute(
|
|
108
124
|
dateStr: string | Date,
|
|
109
125
|
now: Date = new Date(),
|
|
126
|
+
timeZone?: string,
|
|
110
127
|
): string {
|
|
111
128
|
const date = new Date(dateStr);
|
|
112
129
|
const diffDays = Math.abs(now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24);
|
|
113
|
-
const
|
|
114
|
-
|
|
130
|
+
const year = (d: Date): string => formatInZone(d, { year: "numeric" }, timeZone);
|
|
131
|
+
// The year only when it differs, so "in 1 year (Feb 29)" can't hide which Feb 29.
|
|
132
|
+
const options: Intl.DateTimeFormatOptions =
|
|
133
|
+
diffDays < 1
|
|
134
|
+
? { hour: "numeric", minute: "2-digit", hour12: false }
|
|
135
|
+
: year(date) === year(now)
|
|
136
|
+
? { month: "short", day: "numeric" }
|
|
137
|
+
: { year: "numeric", month: "short", day: "numeric" };
|
|
138
|
+
return `${formatRelativeTime(date, now)} (${formatInZone(date, options, timeZone)})`;
|
|
115
139
|
}
|
|
116
140
|
|
|
117
141
|
export function formatBytes(bytes: number): string {
|