@runwisp/ui 1.5.1 → 1.7.1
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 +5 -7
- package/src/lib/actions/fold-to-fit.test.ts +36 -0
- package/src/lib/actions/fold-to-fit.ts +89 -0
- package/src/lib/actions/modal-dialog.test.ts +47 -0
- package/src/lib/actions/modal-dialog.ts +48 -0
- package/src/lib/components/AccordionItem.svelte +16 -5
- package/src/lib/components/Badge.svelte +3 -24
- package/src/lib/components/Button.svelte +2 -9
- package/src/lib/components/CodeBlock.svelte +7 -6
- package/src/lib/components/Drawer.svelte +22 -30
- package/src/lib/components/DurationInput.svelte +6 -25
- package/src/lib/components/FilterBar.svelte +2 -6
- package/src/lib/components/Link.svelte +9 -4
- package/src/lib/components/LogConsole.svelte +95 -418
- package/src/lib/components/Modal.svelte +7 -30
- package/src/lib/components/Spinner.svelte +10 -5
- package/src/lib/components/StatusIndicator.svelte +37 -104
- package/src/lib/components/Tabs.svelte +15 -1
- package/src/lib/components/dashboard/RunActions.svelte +156 -0
- package/src/lib/components/dashboard/RunConsoleFrame.svelte +319 -0
- package/src/lib/components/dashboard/RunDetailPanel.svelte +130 -717
- package/src/lib/components/dashboard/RunFacts.svelte +119 -0
- package/src/lib/components/dashboard/RunFilterChips.svelte +52 -0
- package/src/lib/components/dashboard/RunFilterPopover.svelte +4 -5
- package/src/lib/components/dashboard/RunHeaderLine.svelte +295 -0
- package/src/lib/components/dashboard/RunListSkeleton.svelte +43 -0
- package/src/lib/components/dashboard/RunRow.svelte +202 -0
- package/src/lib/components/dashboard/RunsList.svelte +265 -543
- package/src/lib/components/dashboard/bulk-selection.svelte.test.ts +42 -0
- package/src/lib/components/dashboard/bulk-selection.svelte.ts +50 -0
- package/src/lib/components/dashboard/cron-format.test.ts +9 -0
- package/src/lib/components/dashboard/cron-format.ts +5 -1
- package/src/lib/components/dashboard/run-filters.test.ts +60 -0
- package/src/lib/components/dashboard/run-filters.ts +78 -6
- package/src/lib/components/dashboard/run-helpers.test.ts +122 -12
- package/src/lib/components/dashboard/run-helpers.ts +122 -6
- package/src/lib/components/dashboard/status-config.ts +18 -0
- package/src/lib/components/dashboard/types.ts +6 -0
- package/src/lib/index.ts +13 -2
- package/src/lib/log-console/AnsiLine.svelte +12 -0
- package/src/lib/log-console/FrameHistoryBlock.svelte +57 -0
- package/src/lib/log-console/LogCache.svelte.test.ts +135 -0
- package/src/lib/log-console/LogConsoleFooter.svelte +88 -0
- package/src/lib/log-console/LogFetcher.svelte.test.ts +159 -0
- package/src/lib/log-console/frame-history.svelte.test.ts +47 -0
- package/src/lib/log-console/frame-history.svelte.ts +71 -0
- package/src/lib/log-console/wrap-layout.svelte.test.ts +92 -0
- package/src/lib/log-console/wrap-layout.svelte.ts +188 -0
- package/src/lib/utils/format.test.ts +122 -0
- package/src/lib/utils/format.ts +97 -4
- package/src/lib/utils/toast.svelte.test.ts +46 -0
- package/src/lib/utils/typing-target.ts +10 -0
- package/src/stories/Accordion.stories.svelte +60 -1
- package/src/stories/CodeBlock.stories.svelte +13 -0
- package/src/stories/Link.stories.svelte +25 -1
- package/src/stories/Tabs.stories.svelte +17 -0
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
<!-- SPDX-FileCopyrightText: PoppyCake, s.r.o. -->
|
|
2
|
+
<!-- SPDX-License-Identifier: Apache-2.0 -->
|
|
3
|
+
|
|
4
|
+
<script lang="ts">
|
|
5
|
+
import { untrack } from "svelte";
|
|
6
|
+
import { Maximize2, Minimize2, Terminal as TerminalIcon, TextWrap } from "@lucide/svelte";
|
|
7
|
+
import Kbd from "../Kbd.svelte";
|
|
8
|
+
import LogConsole from "../LogConsole.svelte";
|
|
9
|
+
import { portal } from "../../actions/portal.js";
|
|
10
|
+
import { isLogEvent, type LogEvent, type LogSlice } from "../../log-console/types.js";
|
|
11
|
+
import { extractErrorMessage } from "../../utils/error.js";
|
|
12
|
+
import { isTypingTarget } from "../../utils/typing-target.js";
|
|
13
|
+
import { displayStatus, type Run } from "@runwisp/common";
|
|
14
|
+
import { RUN_STATUS_CONFIG } from "./status-config.js";
|
|
15
|
+
import { runEndMarker } from "./run-helpers.js";
|
|
16
|
+
|
|
17
|
+
let {
|
|
18
|
+
run,
|
|
19
|
+
suffix,
|
|
20
|
+
fetchLogs,
|
|
21
|
+
streamLogs,
|
|
22
|
+
fetchLineHistory,
|
|
23
|
+
highlightLine,
|
|
24
|
+
}: {
|
|
25
|
+
run: Run;
|
|
26
|
+
/** Instance suffix of a multi-instance service, shown in focus mode. */
|
|
27
|
+
suffix: string;
|
|
28
|
+
fetchLogs: (
|
|
29
|
+
runId: string,
|
|
30
|
+
from: number,
|
|
31
|
+
to: number,
|
|
32
|
+
) => Promise<LogSlice | LogEvent | undefined> | LogSlice | LogEvent | undefined;
|
|
33
|
+
streamLogs:
|
|
34
|
+
| ((
|
|
35
|
+
runId: string,
|
|
36
|
+
onEvent: (event: LogEvent) => void,
|
|
37
|
+
initialState?: { fromLine: number },
|
|
38
|
+
) => () => void)
|
|
39
|
+
| undefined;
|
|
40
|
+
fetchLineHistory: ((runId: string, lineNum: number) => Promise<string[][]>) | undefined;
|
|
41
|
+
highlightLine: number | null;
|
|
42
|
+
} = $props();
|
|
43
|
+
|
|
44
|
+
const TAIL_LINES = 1000;
|
|
45
|
+
|
|
46
|
+
let status = $derived(displayStatus(run.status, run.endReason));
|
|
47
|
+
let config = $derived(RUN_STATUS_CONFIG[status]);
|
|
48
|
+
let endMarker = $derived(runEndMarker(status));
|
|
49
|
+
|
|
50
|
+
let logConsole = $state<{ onStream: (event: LogEvent) => void } | null>(null);
|
|
51
|
+
|
|
52
|
+
// Message from the most recent seed-fetch failure (session expiry, daemon
|
|
53
|
+
// restart mid-request, ...), surfaced to LogConsole instead of letting a
|
|
54
|
+
// failed fetch render as an indistinguishable blank "no output" pane.
|
|
55
|
+
let logFetchError = $state<string | null>(null);
|
|
56
|
+
|
|
57
|
+
// Derive a stable scalar so the effect only re-runs when the id actually
|
|
58
|
+
// changes, not on every run object reference swap from SSE array updates.
|
|
59
|
+
let runId = $derived(run.id);
|
|
60
|
+
|
|
61
|
+
// The run whose seed fetch has settled. Until it matches the shown run the
|
|
62
|
+
// console says it's loading, not "No output yet".
|
|
63
|
+
let seededRunId = $state<string | null>(null);
|
|
64
|
+
|
|
65
|
+
// Fetches the batched tail page a run's console opens on (see the effect
|
|
66
|
+
// below) and paints it into the console. Resolves the line the live
|
|
67
|
+
// stream should resume from, or null when there's nothing left to stream
|
|
68
|
+
// (the run already ended, or the caller cancelled while we were waiting).
|
|
69
|
+
// A failed fetch still falls back to an SSE tail backfill from scratch,
|
|
70
|
+
// it does not stop the console from live-tailing, but records the
|
|
71
|
+
// failure so LogConsole can show it instead of looking like a quiet run.
|
|
72
|
+
async function seedConsoleTail(
|
|
73
|
+
id: string,
|
|
74
|
+
isCancelled: () => boolean,
|
|
75
|
+
): Promise<{ fromLine: number } | null> {
|
|
76
|
+
try {
|
|
77
|
+
const seed = await fetchLogs(id, -TAIL_LINES, -1);
|
|
78
|
+
if (isCancelled()) return null;
|
|
79
|
+
if (!isLogEvent(seed)) return { fromLine: -TAIL_LINES };
|
|
80
|
+
if (logConsole) logConsole.onStream(seed);
|
|
81
|
+
return seed.finished // ended run: nothing live to follow
|
|
82
|
+
? null
|
|
83
|
+
: { fromLine: seed.sizeLines > 0 ? seed.sizeLines : -TAIL_LINES };
|
|
84
|
+
} catch (err) {
|
|
85
|
+
if (isCancelled()) return null;
|
|
86
|
+
logFetchError = extractErrorMessage(err, "Failed to load logs");
|
|
87
|
+
return { fromLine: -TAIL_LINES };
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
$effect(() => {
|
|
92
|
+
const id = runId;
|
|
93
|
+
|
|
94
|
+
const stream = streamLogs;
|
|
95
|
+
if (!stream) {
|
|
96
|
+
seededRunId = id;
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
logFetchError = null;
|
|
101
|
+
|
|
102
|
+
let cleanup: (() => void) | undefined;
|
|
103
|
+
let cancelled = false;
|
|
104
|
+
|
|
105
|
+
untrack(() => {
|
|
106
|
+
void (async () => {
|
|
107
|
+
// Paint the tail in one batched page so the console opens
|
|
108
|
+
// already at the newest output, instead of replaying 1000
|
|
109
|
+
// backfill lines over SSE and visibly scrolling down to reach
|
|
110
|
+
// the end. The live stream then picks up after the seeded tail
|
|
111
|
+
// (its disk backfill from that anchor closes the fetch↔stream
|
|
112
|
+
// race, and the daemon dedupes anything already shown).
|
|
113
|
+
const seeded = await seedConsoleTail(id, () => cancelled);
|
|
114
|
+
if (cancelled) return;
|
|
115
|
+
seededRunId = id;
|
|
116
|
+
if (!seeded) return;
|
|
117
|
+
cleanup = stream(
|
|
118
|
+
id,
|
|
119
|
+
(event: LogEvent) => {
|
|
120
|
+
if (logConsole) logConsole.onStream(event);
|
|
121
|
+
},
|
|
122
|
+
{ fromLine: seeded.fromLine },
|
|
123
|
+
);
|
|
124
|
+
})();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
return () => {
|
|
128
|
+
cancelled = true;
|
|
129
|
+
if (cleanup) cleanup();
|
|
130
|
+
};
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// Maximize the console to a full-bleed overlay. The console wrapper is
|
|
134
|
+
// never remounted, `maximizePortal` relocates the *live* node to <body>
|
|
135
|
+
// (escaping any transformed/blurred ancestor that would otherwise trap a
|
|
136
|
+
// position:fixed child) and moves it back on restore, so the SSE stream,
|
|
137
|
+
// scroll position, and the {#key run.id} mount all survive the toggle.
|
|
138
|
+
let consoleMaximized = $state(false);
|
|
139
|
+
// Wrap long console lines instead of horizontally scrolling them. Owned
|
|
140
|
+
// here so the toolbar toggle and the <LogConsole> share one source of truth.
|
|
141
|
+
let consoleWrap = $state(false);
|
|
142
|
+
|
|
143
|
+
function handleKeydown(event: KeyboardEvent) {
|
|
144
|
+
if (event.key === "Escape" && consoleMaximized) {
|
|
145
|
+
consoleMaximized = false;
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
// `F` toggles focus mode, the way a media player does, but never while
|
|
149
|
+
// the operator is typing into a field (search box, etc.).
|
|
150
|
+
if (
|
|
151
|
+
(event.key === "f" || event.key === "F") &&
|
|
152
|
+
!event.metaKey &&
|
|
153
|
+
!event.ctrlKey &&
|
|
154
|
+
!event.altKey
|
|
155
|
+
) {
|
|
156
|
+
if (isTypingTarget(event.target)) return;
|
|
157
|
+
event.preventDefault();
|
|
158
|
+
consoleMaximized = !consoleMaximized;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function maximizePortal(node: HTMLElement, active: boolean) {
|
|
163
|
+
const anchor = document.createComment("maximized-console");
|
|
164
|
+
function moveOut() {
|
|
165
|
+
const parent = node.parentNode;
|
|
166
|
+
if (parent && parent !== document.body) {
|
|
167
|
+
parent.insertBefore(anchor, node);
|
|
168
|
+
document.body.appendChild(node);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
function moveBack() {
|
|
172
|
+
const parent = anchor.parentNode;
|
|
173
|
+
if (parent) {
|
|
174
|
+
parent.insertBefore(node, anchor);
|
|
175
|
+
anchor.remove();
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
if (active) moveOut();
|
|
179
|
+
return {
|
|
180
|
+
update(next: boolean) {
|
|
181
|
+
if (next) moveOut();
|
|
182
|
+
else moveBack();
|
|
183
|
+
},
|
|
184
|
+
destroy() {
|
|
185
|
+
// Mirror the `portal` action: if still parked on <body>, remove
|
|
186
|
+
// the node so Svelte's own teardown doesn't leak it there.
|
|
187
|
+
if (node.parentNode === document.body) node.remove();
|
|
188
|
+
anchor.remove();
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
</script>
|
|
193
|
+
|
|
194
|
+
<svelte:window onkeydown={handleKeydown} />
|
|
195
|
+
|
|
196
|
+
<!-- Console hero, maximizes to a full-bleed overlay via portal -->
|
|
197
|
+
{#if consoleMaximized}
|
|
198
|
+
<div
|
|
199
|
+
use:portal
|
|
200
|
+
class="console-backdrop fixed inset-0 z-40 bg-backdrop backdrop-blur-sm"
|
|
201
|
+
onclick={() => (consoleMaximized = false)}
|
|
202
|
+
role="presentation"
|
|
203
|
+
></div>
|
|
204
|
+
{/if}
|
|
205
|
+
<div
|
|
206
|
+
use:maximizePortal={consoleMaximized}
|
|
207
|
+
class={consoleMaximized
|
|
208
|
+
? "console-zoom fixed inset-3 z-50 flex flex-col overflow-hidden rounded-[4px] bg-[var(--rw-con-bg)] shadow-2xl ring-1 ring-[var(--rw-con-gutter)] sm:inset-6"
|
|
209
|
+
: "ml-1 flex min-h-[300px] flex-1 flex-col overflow-hidden border-t border-[var(--rw-con-gutter)] bg-[var(--rw-con-bg)]"}
|
|
210
|
+
>
|
|
211
|
+
<div
|
|
212
|
+
class="@container flex shrink-0 items-center justify-between gap-3 border-b border-[var(--rw-con-gutter)] bg-[var(--rw-con-panel)] px-3.5 py-[9px] font-mono text-[11.5px] text-[var(--rw-con-dim)]"
|
|
213
|
+
>
|
|
214
|
+
<div class="flex min-w-0 items-center gap-3">
|
|
215
|
+
{#if consoleMaximized}
|
|
216
|
+
<!-- Focus-mode identity: which run is filling the screen -->
|
|
217
|
+
<span class="flex min-w-0 items-center gap-2">
|
|
218
|
+
<span class="h-2 w-2 shrink-0 rounded-full {config.solidDot}"></span>
|
|
219
|
+
<span class="truncate font-medium text-[var(--rw-con-text)]">
|
|
220
|
+
{run.taskName}{suffix}
|
|
221
|
+
</span>
|
|
222
|
+
<span class="text-[var(--rw-con-gutter)]">·</span>
|
|
223
|
+
<span class="font-semibold capitalize {config.color}">{status}</span>
|
|
224
|
+
</span>
|
|
225
|
+
{:else}
|
|
226
|
+
<span
|
|
227
|
+
class="flex items-center gap-2 font-semibold whitespace-nowrap text-[var(--rw-con-text)]"
|
|
228
|
+
>
|
|
229
|
+
<TerminalIcon size={14} class="opacity-70" />
|
|
230
|
+
Console output
|
|
231
|
+
</span>
|
|
232
|
+
<span class="hidden text-[var(--rw-con-gutter)] @lg:inline">stdout + stderr</span>
|
|
233
|
+
{/if}
|
|
234
|
+
</div>
|
|
235
|
+
<div class="flex shrink-0 items-center gap-3">
|
|
236
|
+
<button
|
|
237
|
+
type="button"
|
|
238
|
+
onclick={() => (consoleWrap = !consoleWrap)}
|
|
239
|
+
class="flex cursor-pointer items-center gap-1.5 rounded-[3px] px-2 py-1 hover:bg-[var(--rw-con-gutter)]/30 hover:text-[var(--rw-con-text)] {consoleWrap
|
|
240
|
+
? 'text-[var(--rw-con-text)]'
|
|
241
|
+
: 'text-[var(--rw-con-dim)]'}"
|
|
242
|
+
title={consoleWrap ? "Unwrap lines" : "Wrap long lines"}
|
|
243
|
+
aria-pressed={consoleWrap}
|
|
244
|
+
aria-label="Toggle line wrapping"
|
|
245
|
+
>
|
|
246
|
+
<TextWrap size={13} />
|
|
247
|
+
<span class="hidden @sm:inline">Wrap</span>
|
|
248
|
+
</button>
|
|
249
|
+
<button
|
|
250
|
+
type="button"
|
|
251
|
+
onclick={() => (consoleMaximized = !consoleMaximized)}
|
|
252
|
+
class="flex cursor-pointer items-center gap-1.5 rounded-[3px] px-2 py-1 text-[var(--rw-con-dim)] hover:bg-[var(--rw-con-gutter)]/30 hover:text-[var(--rw-con-text)]"
|
|
253
|
+
title={consoleMaximized ? "Restore console (Esc)" : "Maximize console · F"}
|
|
254
|
+
aria-label={consoleMaximized ? "Restore console" : "Maximize console"}
|
|
255
|
+
>
|
|
256
|
+
{#if consoleMaximized}
|
|
257
|
+
<Minimize2 size={13} />
|
|
258
|
+
<Kbd keys="Esc" size="xs" tone="console" />
|
|
259
|
+
{:else}
|
|
260
|
+
<Maximize2 size={13} />
|
|
261
|
+
Expand
|
|
262
|
+
{/if}
|
|
263
|
+
</button>
|
|
264
|
+
</div>
|
|
265
|
+
</div>
|
|
266
|
+
{#key run.id}
|
|
267
|
+
<LogConsole
|
|
268
|
+
bind:this={logConsole}
|
|
269
|
+
fetchLogs={(f: number, t: number) => fetchLogs(run.id, f, t)}
|
|
270
|
+
fetchLineHistory={fetchLineHistory
|
|
271
|
+
? (n: number) => fetchLineHistory(run.id, n)
|
|
272
|
+
: undefined}
|
|
273
|
+
bind:wrap={consoleWrap}
|
|
274
|
+
class="min-h-0 flex-1"
|
|
275
|
+
{highlightLine}
|
|
276
|
+
endLabel={endMarker.label}
|
|
277
|
+
endTone={endMarker.tone}
|
|
278
|
+
error={logFetchError}
|
|
279
|
+
loading={seededRunId !== run.id}
|
|
280
|
+
/>
|
|
281
|
+
{/key}
|
|
282
|
+
</div>
|
|
283
|
+
|
|
284
|
+
<style>
|
|
285
|
+
/* Gentle fade for the maximize backdrop; disabled under reduced motion. */
|
|
286
|
+
.console-backdrop {
|
|
287
|
+
animation: console-backdrop-in 150ms;
|
|
288
|
+
}
|
|
289
|
+
@keyframes console-backdrop-in {
|
|
290
|
+
from {
|
|
291
|
+
opacity: 0;
|
|
292
|
+
}
|
|
293
|
+
to {
|
|
294
|
+
opacity: 1;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/* The console zooms up as it takes over the viewport. */
|
|
299
|
+
.console-zoom {
|
|
300
|
+
animation: console-zoom-in 260ms cubic-bezier(0.33, 0.05, 0.2, 1);
|
|
301
|
+
}
|
|
302
|
+
@keyframes console-zoom-in {
|
|
303
|
+
from {
|
|
304
|
+
opacity: 0.4;
|
|
305
|
+
transform: scale(0.96);
|
|
306
|
+
}
|
|
307
|
+
to {
|
|
308
|
+
opacity: 1;
|
|
309
|
+
transform: none;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
@media (prefers-reduced-motion: reduce) {
|
|
314
|
+
.console-backdrop,
|
|
315
|
+
.console-zoom {
|
|
316
|
+
animation: none;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
</style>
|