@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
|
@@ -23,7 +23,14 @@ interface RunStatusConfig {
|
|
|
23
23
|
color: string;
|
|
24
24
|
bg: string;
|
|
25
25
|
dot: string;
|
|
26
|
+
/** `dot` without the running pulse, for static markers (spines, filter swatches). */
|
|
27
|
+
solidDot: string;
|
|
26
28
|
badge: string;
|
|
29
|
+
/**
|
|
30
|
+
* Accent colour for statuses that mean "something to triage", or undefined.
|
|
31
|
+
* A deliberate stop or a skip is not an alarm, and success least of all.
|
|
32
|
+
*/
|
|
33
|
+
alarm: string | undefined;
|
|
27
34
|
/** One-sentence explanation of what this status means, for tooltips. */
|
|
28
35
|
description: string;
|
|
29
36
|
}
|
|
@@ -34,19 +41,25 @@ const DANGER = {
|
|
|
34
41
|
color: "text-danger-surface",
|
|
35
42
|
bg: "bg-danger-soft",
|
|
36
43
|
dot: "bg-danger-surface",
|
|
44
|
+
solidDot: "bg-danger-surface",
|
|
37
45
|
badge: "bg-danger-soft text-danger-soft-text",
|
|
46
|
+
alarm: "var(--color-danger-surface)",
|
|
38
47
|
};
|
|
39
48
|
const WARNING = {
|
|
40
49
|
color: "text-warning-surface",
|
|
41
50
|
bg: "bg-warning-soft",
|
|
42
51
|
dot: "bg-warning-surface",
|
|
52
|
+
solidDot: "bg-warning-surface",
|
|
43
53
|
badge: "bg-warning-soft text-warning-soft-text",
|
|
54
|
+
alarm: "var(--color-warning-surface)",
|
|
44
55
|
};
|
|
45
56
|
const NEUTRAL = {
|
|
46
57
|
color: "text-on-surface-muted",
|
|
47
58
|
bg: "bg-surface-sunken",
|
|
48
59
|
dot: "bg-on-surface-faint",
|
|
60
|
+
solidDot: "bg-on-surface-faint",
|
|
49
61
|
badge: "bg-surface-sunken text-on-surface",
|
|
62
|
+
alarm: undefined,
|
|
50
63
|
};
|
|
51
64
|
|
|
52
65
|
export const RUN_STATUS_CONFIG: Record<RunStatus, RunStatusConfig> = {
|
|
@@ -55,7 +68,9 @@ export const RUN_STATUS_CONFIG: Record<RunStatus, RunStatusConfig> = {
|
|
|
55
68
|
color: "text-info-surface",
|
|
56
69
|
bg: "bg-info-soft",
|
|
57
70
|
dot: "bg-info-surface animate-pulse",
|
|
71
|
+
solidDot: "bg-info-surface",
|
|
58
72
|
badge: "bg-info-soft text-info-soft-text",
|
|
73
|
+
alarm: undefined,
|
|
59
74
|
description: "This run is executing right now.",
|
|
60
75
|
},
|
|
61
76
|
succeeded: {
|
|
@@ -63,7 +78,9 @@ export const RUN_STATUS_CONFIG: Record<RunStatus, RunStatusConfig> = {
|
|
|
63
78
|
color: "text-success-surface",
|
|
64
79
|
bg: "bg-success-soft",
|
|
65
80
|
dot: "bg-success-surface",
|
|
81
|
+
solidDot: "bg-success-surface",
|
|
66
82
|
badge: "bg-success-soft text-success-soft-text",
|
|
83
|
+
alarm: undefined,
|
|
67
84
|
description: "The run finished with exit code 0 - everything OK.",
|
|
68
85
|
},
|
|
69
86
|
failed: {
|
|
@@ -79,6 +96,7 @@ export const RUN_STATUS_CONFIG: Record<RunStatus, RunStatusConfig> = {
|
|
|
79
96
|
},
|
|
80
97
|
stopped: {
|
|
81
98
|
...WARNING,
|
|
99
|
+
alarm: undefined,
|
|
82
100
|
icon: CircleStop,
|
|
83
101
|
description: "A human or an external script manually stopped this run before it finished.",
|
|
84
102
|
},
|
package/src/lib/index.ts
CHANGED
|
@@ -24,7 +24,7 @@ export { default as FormField } from "./components/FormField.svelte";
|
|
|
24
24
|
export { default as Input } from "./components/Input.svelte";
|
|
25
25
|
export { default as Radio } from "./components/Radio.svelte";
|
|
26
26
|
export { default as RadioGroup } from "./components/RadioGroup.svelte";
|
|
27
|
-
export { default as Select } from "./components/Select.svelte";
|
|
27
|
+
export { default as Select, type SelectOption } from "./components/Select.svelte";
|
|
28
28
|
export { default as Textarea } from "./components/Textarea.svelte";
|
|
29
29
|
export { default as TimezoneSelect } from "./components/TimezoneSelect.svelte";
|
|
30
30
|
export { default as Toggle } from "./components/Toggle.svelte";
|
|
@@ -86,14 +86,16 @@ export type { FetchLogsFn, LogEvent, LogSlice } from "./log-console/types.js";
|
|
|
86
86
|
export { default as RunDetailPanel } from "./components/dashboard/RunDetailPanel.svelte";
|
|
87
87
|
export { default as RunsList } from "./components/dashboard/RunsList.svelte";
|
|
88
88
|
export { default as RunFilterPopover } from "./components/dashboard/RunFilterPopover.svelte";
|
|
89
|
-
export type { RunOutputMatch } from "./components/dashboard/
|
|
89
|
+
export type { RunOutputMatch } from "./components/dashboard/types.js";
|
|
90
90
|
export {
|
|
91
91
|
activeFilterCount,
|
|
92
92
|
emptyRunFilters,
|
|
93
93
|
FAILURE_STATUS_TOKEN,
|
|
94
94
|
humanizeStatus,
|
|
95
95
|
exitCodeRange,
|
|
96
|
+
runFilterParams,
|
|
96
97
|
type ExitCodeRange,
|
|
98
|
+
type RunFilterParams,
|
|
97
99
|
type RunsListFilters,
|
|
98
100
|
} from "./components/dashboard/run-filters.js";
|
|
99
101
|
export { RUN_STATUS_CONFIG } from "./components/dashboard/status-config.js";
|
|
@@ -102,6 +104,10 @@ export {
|
|
|
102
104
|
runPhaseOrder,
|
|
103
105
|
formatTriggeredByLabel,
|
|
104
106
|
instanceSuffix,
|
|
107
|
+
runUsageLabel,
|
|
108
|
+
runUsageScale,
|
|
109
|
+
usageLevel,
|
|
110
|
+
type RunUsageScale,
|
|
105
111
|
} from "./components/dashboard/run-helpers.js";
|
|
106
112
|
export { humanizeCron } from "./components/dashboard/cron-format.js";
|
|
107
113
|
export type { DaemonStats } from "./components/dashboard/types.js";
|
|
@@ -110,14 +116,19 @@ export { toast } from "./utils/toast.svelte.js";
|
|
|
110
116
|
export { extractErrorMessage } from "./utils/error.js";
|
|
111
117
|
export {
|
|
112
118
|
formatBytes,
|
|
119
|
+
formatDayMonth,
|
|
120
|
+
formatRelativeCompact,
|
|
113
121
|
formatRelativeTime,
|
|
114
122
|
formatRelativeTimeWithAbsolute,
|
|
123
|
+
type RelativeTimeOptions,
|
|
115
124
|
formatDuration,
|
|
116
125
|
formatFullDateTime,
|
|
117
126
|
} from "./utils/format.js";
|
|
118
127
|
export { formatShortId } from "./utils/id.js";
|
|
119
128
|
export { debounce } from "./utils/debounce.js";
|
|
129
|
+
export { isTypingTarget } from "./utils/typing-target.js";
|
|
120
130
|
export { TickingNow } from "./utils/ticking-now.svelte.js";
|
|
121
131
|
export { CopyFeedback, copyText } from "./utils/clipboard.svelte.js";
|
|
122
132
|
export { RunMotion } from "./utils/run-motion.js";
|
|
123
133
|
export { arrival, leave, shift } from "./actions/row-motion.js";
|
|
134
|
+
export { foldToFit, foldUntilFits, type FoldToFitOptions } from "./actions/fold-to-fit.js";
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<!-- SPDX-FileCopyrightText: PoppyCake, s.r.o. -->
|
|
2
|
+
<!-- SPDX-License-Identifier: Apache-2.0 -->
|
|
3
|
+
|
|
4
|
+
<script lang="ts">
|
|
5
|
+
import { ansiLineToHtml } from "./ansi.js";
|
|
6
|
+
|
|
7
|
+
let { text }: { text: string } = $props();
|
|
8
|
+
</script>
|
|
9
|
+
|
|
10
|
+
<!-- ansiLineToHtml sanitises its input, which is what makes @html safe here. -->
|
|
11
|
+
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
|
12
|
+
<span class="whitespace-pre">{@html ansiLineToHtml(text)}</span>
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
<!-- SPDX-FileCopyrightText: PoppyCake, s.r.o. -->
|
|
2
|
+
<!-- SPDX-License-Identifier: Apache-2.0 -->
|
|
3
|
+
|
|
4
|
+
<script lang="ts">
|
|
5
|
+
import AnsiLine from "./AnsiLine.svelte";
|
|
6
|
+
import { FRAME_BLOCK_PAD, FRAME_GAP, type FrameHistory } from "./frame-history.svelte.js";
|
|
7
|
+
import { LINE_HEIGHT } from "./wrap-layout.svelte.js";
|
|
8
|
+
|
|
9
|
+
let {
|
|
10
|
+
history,
|
|
11
|
+
top,
|
|
12
|
+
gutterWidth,
|
|
13
|
+
}: {
|
|
14
|
+
history: FrameHistory;
|
|
15
|
+
/** Distance from the top of the virtual surface, px. */
|
|
16
|
+
top: number;
|
|
17
|
+
gutterWidth: number;
|
|
18
|
+
} = $props();
|
|
19
|
+
</script>
|
|
20
|
+
|
|
21
|
+
<div
|
|
22
|
+
class="frame-history absolute right-0 left-0 overflow-y-auto border-y border-[var(--rw-con-gutter)] bg-[var(--rw-con-panel)]"
|
|
23
|
+
style="top: {top}px; height: {history.blockHeight}px;"
|
|
24
|
+
>
|
|
25
|
+
<div style="padding: {FRAME_BLOCK_PAD}px 0;">
|
|
26
|
+
{#if history.frames}
|
|
27
|
+
{#each history.frames as frame, fi (fi)}
|
|
28
|
+
<div style="margin-top: {fi > 0 ? FRAME_GAP : 0}px;">
|
|
29
|
+
<div
|
|
30
|
+
class="px-3 text-xs text-[var(--rw-con-dim)] select-none"
|
|
31
|
+
style="height: {LINE_HEIGHT}px; line-height: {LINE_HEIGHT}px;"
|
|
32
|
+
>
|
|
33
|
+
Frame {fi + 1} of {history.frames.length}
|
|
34
|
+
</div>
|
|
35
|
+
{#each frame as row, ri (ri)}
|
|
36
|
+
<div class="flex items-center" style="height: {LINE_HEIGHT}px;">
|
|
37
|
+
<div
|
|
38
|
+
class="flex-shrink-0 pr-3 text-right text-[var(--rw-con-gutter)] select-none"
|
|
39
|
+
style="width: {gutterWidth}px;"
|
|
40
|
+
></div>
|
|
41
|
+
<div class="flex-1 pr-4 text-[var(--rw-con-text)]">
|
|
42
|
+
<AnsiLine text={row} />
|
|
43
|
+
</div>
|
|
44
|
+
</div>
|
|
45
|
+
{/each}
|
|
46
|
+
</div>
|
|
47
|
+
{/each}
|
|
48
|
+
{:else}
|
|
49
|
+
<div
|
|
50
|
+
class="px-3 text-xs text-[var(--rw-con-dim)] italic"
|
|
51
|
+
style="line-height: {LINE_HEIGHT}px; padding-left: {gutterWidth}px;"
|
|
52
|
+
>
|
|
53
|
+
{history.failed ? "Failed to load frame history" : "Loading frames…"}
|
|
54
|
+
</div>
|
|
55
|
+
{/if}
|
|
56
|
+
</div>
|
|
57
|
+
</div>
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: PoppyCake, s.r.o.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
import { LogCache } from "./LogCache.svelte";
|
|
6
|
+
|
|
7
|
+
describe("LogCache", () => {
|
|
8
|
+
it("merges a slice, skipping bad keys and reporting what changed", () => {
|
|
9
|
+
const cache = new LogCache();
|
|
10
|
+
expect(cache.mergeSlice({ 2: "two", 5: "five", [-1]: "neg" })).toEqual({
|
|
11
|
+
touched: true,
|
|
12
|
+
min: 2,
|
|
13
|
+
max: 5,
|
|
14
|
+
});
|
|
15
|
+
expect(cache.size).toBe(2);
|
|
16
|
+
expect(cache.mergeSlice({ 2: "two" }).touched).toBe(false);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("keeps the widest line width even after that line is gone", () => {
|
|
20
|
+
const cache = new LogCache();
|
|
21
|
+
cache.mergeSlice({ 0: "x".repeat(40) });
|
|
22
|
+
cache.mergeSlice({ 0: "short" });
|
|
23
|
+
expect(cache.maxLineColumns).toBe(40);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("only grows totals and the first available line from events", () => {
|
|
27
|
+
const cache = new LogCache();
|
|
28
|
+
cache.applyEvent({
|
|
29
|
+
lines: {},
|
|
30
|
+
sizeLines: 10,
|
|
31
|
+
sizeBytes: 300,
|
|
32
|
+
finished: false,
|
|
33
|
+
firstAvailableLine: 4,
|
|
34
|
+
});
|
|
35
|
+
cache.applyEvent({
|
|
36
|
+
lines: {},
|
|
37
|
+
sizeLines: 8,
|
|
38
|
+
sizeBytes: 100,
|
|
39
|
+
finished: false,
|
|
40
|
+
firstAvailableLine: 2,
|
|
41
|
+
});
|
|
42
|
+
expect([cache.totalLines, cache.totalBytes, cache.firstAvailableLine]).toEqual([
|
|
43
|
+
10, 300, 4,
|
|
44
|
+
]);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("records frame history only for lines that have some", () => {
|
|
48
|
+
const cache = new LogCache();
|
|
49
|
+
cache.applyEvent({
|
|
50
|
+
lines: { 3: "100%" },
|
|
51
|
+
sizeLines: 4,
|
|
52
|
+
finished: false,
|
|
53
|
+
frameCounts: { 3: 12, 1: 0 },
|
|
54
|
+
});
|
|
55
|
+
expect([...cache.frameCounts]).toEqual([[3, 12]]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("orders the live overlay stdout, stderr, then other streams by name", () => {
|
|
59
|
+
const cache = new LogCache();
|
|
60
|
+
cache.applyRegion({ stream: "zeta", epoch: 1, rows: ["z"] });
|
|
61
|
+
cache.applyRegion({ stream: "stderr", epoch: 1, rows: ["e"] });
|
|
62
|
+
cache.applyRegion({ stream: "alpha", epoch: 1, rows: ["a"] });
|
|
63
|
+
cache.applyRegion({ stream: "stdout", epoch: 1, rows: ["o1", "o2"] });
|
|
64
|
+
expect(cache.overlayRows).toEqual(["o1", "o2", "e", "a", "z"]);
|
|
65
|
+
|
|
66
|
+
cache.applyRegion({ stream: "zeta", epoch: 2, rows: [] });
|
|
67
|
+
expect(cache.overlayRows).toEqual(["o1", "o2", "e", "a"]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("keeps region frames out of the committed lines and drops them once finished", () => {
|
|
71
|
+
const cache = new LogCache();
|
|
72
|
+
const res = cache.applyEvent({
|
|
73
|
+
lines: {},
|
|
74
|
+
sizeLines: 0,
|
|
75
|
+
finished: false,
|
|
76
|
+
region: { stream: "stdout", epoch: 1, rows: ["[##..] 50%"] },
|
|
77
|
+
});
|
|
78
|
+
expect(res.touched).toBe(false);
|
|
79
|
+
expect(cache.size).toBe(0);
|
|
80
|
+
expect(cache.overlayRows).toEqual(["[##..] 50%"]);
|
|
81
|
+
|
|
82
|
+
cache.applyEvent({ lines: { 0: "done" }, sizeLines: 1, finished: true });
|
|
83
|
+
expect(cache.overlayRows).toEqual([]);
|
|
84
|
+
expect(cache.finished).toBe(true);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("prunes lines far from the viewport once the cache is full", () => {
|
|
88
|
+
const cache = new LogCache();
|
|
89
|
+
const slice: Record<number, string> = {};
|
|
90
|
+
for (let n = 0; n < cache.MAX_CACHE_SIZE; n++) slice[n] = "l";
|
|
91
|
+
cache.mergeSlice(slice);
|
|
92
|
+
|
|
93
|
+
cache.prune(40_000, 40_100);
|
|
94
|
+
expect(cache.lines.has(40_050)).toBe(true);
|
|
95
|
+
expect(cache.lines.has(0)).toBe(false);
|
|
96
|
+
expect(cache.size).toBeLessThan(cache.MAX_CACHE_SIZE);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("leaves a cache under the limit alone", () => {
|
|
100
|
+
const cache = new LogCache();
|
|
101
|
+
cache.mergeSlice({ 0: "a", 99_999: "b" });
|
|
102
|
+
cache.prune(50_000, 50_010);
|
|
103
|
+
expect(cache.size).toBe(2);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("judges a range complete within what the server still holds", () => {
|
|
107
|
+
const cache = new LogCache();
|
|
108
|
+
cache.applyEvent({
|
|
109
|
+
lines: { 5: "a", 6: "b" },
|
|
110
|
+
sizeLines: 7,
|
|
111
|
+
finished: false,
|
|
112
|
+
firstAvailableLine: 5,
|
|
113
|
+
});
|
|
114
|
+
expect(cache.isRangeComplete(0, 100)).toBe(true);
|
|
115
|
+
cache.applyEvent({ lines: {}, sizeLines: 8, finished: false });
|
|
116
|
+
expect(cache.isRangeComplete(0, 100)).toBe(false);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("resets to empty", () => {
|
|
120
|
+
const cache = new LogCache();
|
|
121
|
+
cache.applyEvent({
|
|
122
|
+
lines: { 0: "a" },
|
|
123
|
+
sizeLines: 1,
|
|
124
|
+
finished: true,
|
|
125
|
+
firstAvailableLine: 1,
|
|
126
|
+
});
|
|
127
|
+
cache.reset();
|
|
128
|
+
expect([cache.size, cache.totalLines, cache.finished, cache.firstAvailableLine]).toEqual([
|
|
129
|
+
0,
|
|
130
|
+
0,
|
|
131
|
+
false,
|
|
132
|
+
0,
|
|
133
|
+
]);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
<!-- SPDX-FileCopyrightText: PoppyCake, s.r.o. -->
|
|
2
|
+
<!-- SPDX-License-Identifier: Apache-2.0 -->
|
|
3
|
+
|
|
4
|
+
<script lang="ts">
|
|
5
|
+
import { formatBytes } from "../utils/format.js";
|
|
6
|
+
|
|
7
|
+
let {
|
|
8
|
+
followTail,
|
|
9
|
+
streaming,
|
|
10
|
+
finished,
|
|
11
|
+
fetching,
|
|
12
|
+
totalLines,
|
|
13
|
+
totalBytes,
|
|
14
|
+
onFollow,
|
|
15
|
+
}: {
|
|
16
|
+
followTail: boolean;
|
|
17
|
+
streaming: boolean;
|
|
18
|
+
finished: boolean;
|
|
19
|
+
fetching: boolean;
|
|
20
|
+
totalLines: number;
|
|
21
|
+
totalBytes: number;
|
|
22
|
+
onFollow: () => void;
|
|
23
|
+
} = $props();
|
|
24
|
+
</script>
|
|
25
|
+
|
|
26
|
+
{#if !followTail && totalLines > 0}
|
|
27
|
+
<button
|
|
28
|
+
class="absolute right-4 bottom-12 z-10 flex items-center gap-2 rounded-[3px] border
|
|
29
|
+
border-[var(--rw-con-gutter)] bg-[var(--rw-con-panel)]
|
|
30
|
+
px-3 py-1.5 text-xs
|
|
31
|
+
text-[var(--rw-con-text)]
|
|
32
|
+
hover:border-term-teal hover:text-term-teal active:translate-y-[1px]"
|
|
33
|
+
onclick={onFollow}
|
|
34
|
+
>
|
|
35
|
+
<svg
|
|
36
|
+
class="h-3.5 w-3.5"
|
|
37
|
+
viewBox="0 0 24 24"
|
|
38
|
+
fill="none"
|
|
39
|
+
stroke="currentColor"
|
|
40
|
+
stroke-width="2"
|
|
41
|
+
>
|
|
42
|
+
<path d="M12 5v14M19 12l-7 7-7-7" />
|
|
43
|
+
</svg>
|
|
44
|
+
Scroll to bottom
|
|
45
|
+
</button>
|
|
46
|
+
{/if}
|
|
47
|
+
|
|
48
|
+
<div
|
|
49
|
+
class="flex flex-shrink-0 items-center justify-between border-t border-[var(--rw-con-gutter)]
|
|
50
|
+
bg-[var(--rw-con-panel)] px-3.5 py-2 text-[11px] tracking-wide text-[var(--rw-con-dim)]"
|
|
51
|
+
>
|
|
52
|
+
<div class="flex items-center gap-3">
|
|
53
|
+
{#if streaming}
|
|
54
|
+
<div class="flex items-center gap-1.5 text-aurora-400">
|
|
55
|
+
<div class="h-1.5 w-1.5 animate-pulse rounded-full bg-aurora-400"></div>
|
|
56
|
+
Streaming
|
|
57
|
+
</div>
|
|
58
|
+
{:else if finished}
|
|
59
|
+
<span>Stream ended</span>
|
|
60
|
+
{/if}
|
|
61
|
+
{#if fetching}
|
|
62
|
+
<span>Fetching...</span>
|
|
63
|
+
{/if}
|
|
64
|
+
</div>
|
|
65
|
+
<div class="flex items-center gap-4">
|
|
66
|
+
<span>{totalLines.toLocaleString()} lines</span>
|
|
67
|
+
{#if totalBytes > 0}
|
|
68
|
+
<span>{formatBytes(totalBytes)}</span>
|
|
69
|
+
{/if}
|
|
70
|
+
{#if followTail}
|
|
71
|
+
<span class="inline-flex items-center gap-1.5 text-term-ok">
|
|
72
|
+
<svg
|
|
73
|
+
class="h-3 w-3"
|
|
74
|
+
viewBox="0 0 24 24"
|
|
75
|
+
fill="none"
|
|
76
|
+
stroke="currentColor"
|
|
77
|
+
stroke-width="2.2"
|
|
78
|
+
stroke-linecap="round"
|
|
79
|
+
stroke-linejoin="round"
|
|
80
|
+
>
|
|
81
|
+
<line x1="12" y1="5" x2="12" y2="19" />
|
|
82
|
+
<polyline points="6 13 12 19 18 13" />
|
|
83
|
+
</svg>
|
|
84
|
+
Auto-scroll
|
|
85
|
+
</span>
|
|
86
|
+
{/if}
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: PoppyCake, s.r.o.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { LogCache } from "./LogCache.svelte";
|
|
6
|
+
import { LogFetcher } from "./LogFetcher.svelte";
|
|
7
|
+
import type { LogEvent, LogSlice } from "./types";
|
|
8
|
+
|
|
9
|
+
const CHUNK = 4096;
|
|
10
|
+
|
|
11
|
+
/** A cache that knows the log is `total` lines long but holds none of them. */
|
|
12
|
+
function emptyCache(total: number): LogCache {
|
|
13
|
+
const cache = new LogCache();
|
|
14
|
+
cache.applyEvent({ lines: {}, sizeLines: total, finished: false });
|
|
15
|
+
return cache;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** A fetch whose calls stay pending until the test settles them. */
|
|
19
|
+
function deferredFetch() {
|
|
20
|
+
const pending: { from: number; to: number; settle: (v: LogSlice | LogEvent) => void }[] = [];
|
|
21
|
+
const fn = vi.fn(
|
|
22
|
+
(from: number, to: number) =>
|
|
23
|
+
new Promise<LogSlice | LogEvent>((resolve) => {
|
|
24
|
+
pending.push({ from, to, settle: resolve });
|
|
25
|
+
}),
|
|
26
|
+
);
|
|
27
|
+
return { fn, pending };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const live: LogFetcher[] = [];
|
|
31
|
+
|
|
32
|
+
function start(...args: ConstructorParameters<typeof LogFetcher>): LogFetcher {
|
|
33
|
+
const fetcher = new LogFetcher(...args);
|
|
34
|
+
live.push(fetcher);
|
|
35
|
+
return fetcher;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
vi.useFakeTimers();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
afterEach(() => {
|
|
43
|
+
for (const fetcher of live.splice(0)) fetcher.destroy();
|
|
44
|
+
vi.useRealTimers();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("LogFetcher", () => {
|
|
48
|
+
it("fetches a missing chunk once, merges it, and reports the loaded range", async () => {
|
|
49
|
+
const cache = emptyCache(10);
|
|
50
|
+
const onLoaded = vi.fn();
|
|
51
|
+
const fetchLogs = vi.fn(() => ({ 0: "a", 9: "j" }));
|
|
52
|
+
const fetcher = start(cache, fetchLogs, onLoaded);
|
|
53
|
+
|
|
54
|
+
fetcher.enqueue(0, 9);
|
|
55
|
+
fetcher.enqueue(2, 5); // same chunk, already queued
|
|
56
|
+
await vi.runAllTimersAsync();
|
|
57
|
+
|
|
58
|
+
expect(fetchLogs).toHaveBeenCalledExactlyOnceWith(0, 9);
|
|
59
|
+
expect(cache.lines.get(9)).toBe("j");
|
|
60
|
+
expect(onLoaded).toHaveBeenCalledWith(0, 9);
|
|
61
|
+
expect(fetcher.isFetching).toBe(false);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("applies an event response, not just a slice", async () => {
|
|
65
|
+
const cache = emptyCache(1);
|
|
66
|
+
const fetcher = start(cache, () => ({ lines: { 0: "x" }, sizeLines: 3, finished: true }));
|
|
67
|
+
fetcher.enqueue(0, 0);
|
|
68
|
+
await vi.runAllTimersAsync();
|
|
69
|
+
expect([cache.totalLines, cache.finished]).toEqual([3, true]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("skips a range the cache already holds", async () => {
|
|
73
|
+
const cache = emptyCache(2);
|
|
74
|
+
cache.mergeSlice({ 0: "a", 1: "b" });
|
|
75
|
+
const fetchLogs = vi.fn(() => ({}));
|
|
76
|
+
const fetcher = start(cache, fetchLogs);
|
|
77
|
+
fetcher.enqueue(0, 1);
|
|
78
|
+
await vi.runAllTimersAsync();
|
|
79
|
+
expect(fetchLogs).not.toHaveBeenCalled();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("never asks for lines rotated away", async () => {
|
|
83
|
+
const cache = emptyCache(CHUNK * 2);
|
|
84
|
+
cache.applyEvent({
|
|
85
|
+
lines: {},
|
|
86
|
+
sizeLines: CHUNK * 2,
|
|
87
|
+
finished: false,
|
|
88
|
+
firstAvailableLine: CHUNK + 5,
|
|
89
|
+
});
|
|
90
|
+
const fetchLogs = vi.fn(() => ({}));
|
|
91
|
+
const fetcher = start(cache, fetchLogs);
|
|
92
|
+
fetcher.enqueue(0, CHUNK * 2 - 1);
|
|
93
|
+
await vi.runAllTimersAsync();
|
|
94
|
+
expect(fetchLogs.mock.calls).toEqual([[CHUNK, CHUNK * 2 - 1]]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("keeps at most two chunks in flight", async () => {
|
|
98
|
+
const { fn, pending } = deferredFetch();
|
|
99
|
+
const fetcher = start(emptyCache(CHUNK * 3), fn);
|
|
100
|
+
|
|
101
|
+
fetcher.enqueue(0, 0);
|
|
102
|
+
await vi.advanceTimersByTimeAsync(fetcher.MIN_REQUEST_INTERVAL_MS);
|
|
103
|
+
fetcher.enqueue(CHUNK, CHUNK * 3 - 1);
|
|
104
|
+
await vi.advanceTimersByTimeAsync(fetcher.MIN_REQUEST_INTERVAL_MS * 5);
|
|
105
|
+
expect(fn).toHaveBeenCalledTimes(2);
|
|
106
|
+
expect(fetcher.isFetching).toBe(true);
|
|
107
|
+
|
|
108
|
+
pending[0]?.settle({});
|
|
109
|
+
await vi.advanceTimersByTimeAsync(fetcher.MIN_REQUEST_INTERVAL_MS * 5);
|
|
110
|
+
expect(fn).toHaveBeenCalledTimes(3);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("drops queued chunks the viewport has left behind", async () => {
|
|
114
|
+
const fetchLogs = vi.fn((_from: number, _to: number) => ({}));
|
|
115
|
+
const fetcher = start(emptyCache(CHUNK * 10), fetchLogs);
|
|
116
|
+
fetcher.enqueue(0, CHUNK * 10 - 1);
|
|
117
|
+
fetcher.pruneQueue(CHUNK * 9, CHUNK * 10 - 1);
|
|
118
|
+
await vi.runAllTimersAsync();
|
|
119
|
+
expect(fetchLogs.mock.calls.map(([from]) => from / CHUNK)).toEqual([7, 8, 9]);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("recovers from a failed fetch so the chunk can be asked for again", async () => {
|
|
123
|
+
const fetchLogs = vi
|
|
124
|
+
.fn()
|
|
125
|
+
.mockRejectedValueOnce(new Error("503"))
|
|
126
|
+
.mockResolvedValue({ 0: "a" });
|
|
127
|
+
const cache = emptyCache(1);
|
|
128
|
+
const fetcher = start(cache, fetchLogs);
|
|
129
|
+
fetcher.enqueue(0, 0);
|
|
130
|
+
await vi.runAllTimersAsync();
|
|
131
|
+
expect(fetcher.isFetching).toBe(false);
|
|
132
|
+
fetcher.enqueue(0, 0);
|
|
133
|
+
await vi.runAllTimersAsync();
|
|
134
|
+
expect(cache.lines.get(0)).toBe("a");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("requests only the incomplete chunks in view, up to the cap", async () => {
|
|
138
|
+
const cache = emptyCache(CHUNK * 4);
|
|
139
|
+
const filled: Record<number, string> = {};
|
|
140
|
+
for (let n = CHUNK; n < CHUNK * 2; n++) filled[n] = "x";
|
|
141
|
+
cache.mergeSlice(filled);
|
|
142
|
+
const fetchLogs = vi.fn((_from: number, _to: number) => ({}));
|
|
143
|
+
const fetcher = start(cache, fetchLogs);
|
|
144
|
+
|
|
145
|
+
fetcher.maybeRequestMissing(0, CHUNK * 4 - 1, 3);
|
|
146
|
+
await vi.runAllTimersAsync();
|
|
147
|
+
expect(fetchLogs.mock.calls.map(([from]) => from / CHUNK)).toEqual([0, 2]);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("does nothing without a fetch function or after destroy", async () => {
|
|
151
|
+
const fetchLogs = vi.fn(() => ({}));
|
|
152
|
+
new LogFetcher(emptyCache(5), undefined).enqueue(0, 4);
|
|
153
|
+
const fetcher = start(emptyCache(5), fetchLogs);
|
|
154
|
+
fetcher.enqueue(0, 4);
|
|
155
|
+
fetcher.destroy();
|
|
156
|
+
await vi.runAllTimersAsync();
|
|
157
|
+
expect(fetchLogs).not.toHaveBeenCalled();
|
|
158
|
+
});
|
|
159
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: PoppyCake, s.r.o.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
import { FrameHistory } from "./frame-history.svelte.js";
|
|
6
|
+
import { LINE_HEIGHT } from "./wrap-layout.svelte.js";
|
|
7
|
+
|
|
8
|
+
describe("FrameHistory", () => {
|
|
9
|
+
it("takes no space while closed", () => {
|
|
10
|
+
const history = new FrameHistory(() => undefined);
|
|
11
|
+
expect(history.blockHeight).toBe(0);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("reserves a placeholder while frames load, then sizes to them", async () => {
|
|
15
|
+
const history = new FrameHistory(() => () => Promise.resolve([["a", "b"], ["c"]]));
|
|
16
|
+
const done = history.toggle(4);
|
|
17
|
+
expect(history.line).toBe(4);
|
|
18
|
+
expect(history.blockHeight).toBe(LINE_HEIGHT * 2);
|
|
19
|
+
await done;
|
|
20
|
+
// padding 16 + (label + 2 rows) + (label + 1 row) + one 6px gap.
|
|
21
|
+
expect(history.blockHeight).toBe(16 + LINE_HEIGHT * 5 + 6);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("collapses when the open line is toggled again", async () => {
|
|
25
|
+
const history = new FrameHistory(() => () => Promise.resolve([["a"]]));
|
|
26
|
+
await history.toggle(1);
|
|
27
|
+
await history.toggle(1);
|
|
28
|
+
expect(history.line).toBeNull();
|
|
29
|
+
expect(history.frames).toBeNull();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("flags a failed fetch and ignores a superseded one", async () => {
|
|
33
|
+
const failing = new FrameHistory(() => () => Promise.reject(new Error("boom")));
|
|
34
|
+
await failing.toggle(2);
|
|
35
|
+
expect(failing.failed).toBe(true);
|
|
36
|
+
|
|
37
|
+
let release: (frames: string[][]) => void = () => {};
|
|
38
|
+
const slow = new FrameHistory(
|
|
39
|
+
() => () => new Promise<string[][]>((resolve) => (release = resolve)),
|
|
40
|
+
);
|
|
41
|
+
const first = slow.toggle(1);
|
|
42
|
+
slow.collapse();
|
|
43
|
+
release([["late"]]);
|
|
44
|
+
await first;
|
|
45
|
+
expect(slow.frames).toBeNull();
|
|
46
|
+
});
|
|
47
|
+
});
|