@runwisp/ui 1.1.0 → 1.3.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/row-motion.test.ts +115 -0
- package/src/lib/actions/row-motion.ts +75 -0
- package/src/lib/components/dashboard/RunDetailPanel.svelte +33 -2
- package/src/lib/components/dashboard/RunsList.svelte +28 -4
- package/src/lib/components/dashboard/run-filters.test.ts +11 -1
- package/src/lib/components/dashboard/run-filters.ts +4 -2
- package/src/lib/components/dashboard/run-helpers.ts +2 -0
- package/src/lib/components/dashboard/status-config.ts +11 -0
- package/src/lib/index.ts +2 -0
- package/src/lib/theme.css +50 -1
- package/src/lib/utils/run-motion.test.ts +67 -0
- package/src/lib/utils/run-motion.ts +56 -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": "1.
|
|
4
|
+
"version": "1.3.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.3.0",
|
|
53
|
+
"@runwisp/typescript-config": "1.3.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",
|
|
@@ -93,7 +93,7 @@
|
|
|
93
93
|
"@fontsource-variable/geist-mono": "^5.3.0",
|
|
94
94
|
"@fontsource/tasa-orbiter": "^5.3.0",
|
|
95
95
|
"@lucide/svelte": "^1.28.0",
|
|
96
|
-
"@runwisp/common": "1.
|
|
96
|
+
"@runwisp/common": "1.3.0",
|
|
97
97
|
"@tanstack/svelte-virtual": "^3.13.35",
|
|
98
98
|
"ansi-to-html": "^0.7.2",
|
|
99
99
|
"cron-parser": "^5.7.0",
|
|
@@ -0,0 +1,115 @@
|
|
|
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 { arrival, leave, prefersReducedMotion } from "./row-motion.js";
|
|
6
|
+
|
|
7
|
+
const LEAVE_MS = 160;
|
|
8
|
+
const CLOSE_GAP_DELAY_MS = 110;
|
|
9
|
+
const SHIFT_MS = 200;
|
|
10
|
+
const SHIFT_DELAY = "--rw-shift-delay";
|
|
11
|
+
|
|
12
|
+
// The helpers read the reduced-motion preference off `matchMedia` and write to
|
|
13
|
+
// the narrow element slices they declare, so a stubbed global and plain objects
|
|
14
|
+
// stand in for the browser (this repo's vitest env has no jsdom).
|
|
15
|
+
function setReducedMotion(reduce: boolean): void {
|
|
16
|
+
vi.stubGlobal("matchMedia", (media: string) => ({ media, matches: reduce }));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** A list element recording the custom properties a leaving row sets on it. */
|
|
20
|
+
function listNode() {
|
|
21
|
+
const props = new Map<string, string>();
|
|
22
|
+
return {
|
|
23
|
+
props,
|
|
24
|
+
style: {
|
|
25
|
+
setProperty: (name: string, value: string | null) => {
|
|
26
|
+
props.set(name, value ?? "");
|
|
27
|
+
},
|
|
28
|
+
removeProperty: (name: string) => {
|
|
29
|
+
const previous = props.get(name) ?? "";
|
|
30
|
+
props.delete(name);
|
|
31
|
+
return previous;
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe("prefersReducedMotion", () => {
|
|
38
|
+
afterEach(() => vi.unstubAllGlobals());
|
|
39
|
+
|
|
40
|
+
it("reports what the reduced-motion media query matches", () => {
|
|
41
|
+
setReducedMotion(true);
|
|
42
|
+
expect(prefersReducedMotion()).toBe(true);
|
|
43
|
+
setReducedMotion(false);
|
|
44
|
+
expect(prefersReducedMotion()).toBe(false);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("arrival", () => {
|
|
49
|
+
it("plays the arrival cue on a row mounting for a fresh run", () => {
|
|
50
|
+
const added: string[] = [];
|
|
51
|
+
arrival({ classList: { add: (token: string) => added.push(token) } }, true);
|
|
52
|
+
expect(added).toEqual(["run-arrive"]);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("leaves a row that mounted for an already-known run alone", () => {
|
|
56
|
+
const added: string[] = [];
|
|
57
|
+
arrival({ classList: { add: (token: string) => added.push(token) } }, false);
|
|
58
|
+
expect(added).toEqual([]);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe("leave", () => {
|
|
63
|
+
beforeEach(() => {
|
|
64
|
+
vi.useFakeTimers();
|
|
65
|
+
setReducedMotion(false);
|
|
66
|
+
});
|
|
67
|
+
afterEach(() => {
|
|
68
|
+
vi.useRealTimers();
|
|
69
|
+
vi.unstubAllGlobals();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("fades out a run removed live and delays the rows closing its gap", () => {
|
|
73
|
+
const list = listNode();
|
|
74
|
+
const config = leave({ dataset: { runId: "run-1" }, parentElement: list }, () => true);
|
|
75
|
+
|
|
76
|
+
expect(config.duration).toBe(LEAVE_MS);
|
|
77
|
+
expect(list.props.get(SHIFT_DELAY)).toBe("110ms");
|
|
78
|
+
|
|
79
|
+
// Halfway through: half faded, halfway back to its resting offset.
|
|
80
|
+
expect(config.css?.(0.5, 0.5)).toBe("opacity: 0.5; translate: -6px 0");
|
|
81
|
+
|
|
82
|
+
// The delay is a one-shot for this removal, not a lasting list style.
|
|
83
|
+
vi.advanceTimersByTime(CLOSE_GAP_DELAY_MS + SHIFT_MS);
|
|
84
|
+
expect(list.props.has(SHIFT_DELAY)).toBe(false);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("still animates a row with no list element around it", () => {
|
|
88
|
+
const config = leave({ dataset: { runId: "run-1" }, parentElement: null }, () => true);
|
|
89
|
+
expect(config.duration).toBe(LEAVE_MS);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("goes instantly when the run left for any reason other than a live removal", () => {
|
|
93
|
+
const list = listNode();
|
|
94
|
+
const scrolledOut = leave(
|
|
95
|
+
{ dataset: { runId: "run-1" }, parentElement: list },
|
|
96
|
+
() => false,
|
|
97
|
+
);
|
|
98
|
+
const noTracker = leave({ dataset: { runId: "run-1" }, parentElement: list }, undefined);
|
|
99
|
+
const noRunId = leave({ dataset: {}, parentElement: list }, () => true);
|
|
100
|
+
|
|
101
|
+
expect(scrolledOut.duration).toBe(0);
|
|
102
|
+
expect(noTracker.duration).toBe(0);
|
|
103
|
+
expect(noRunId.duration).toBe(0);
|
|
104
|
+
expect(list.props.has(SHIFT_DELAY)).toBe(false);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("goes instantly when the operator prefers reduced motion", () => {
|
|
108
|
+
setReducedMotion(true);
|
|
109
|
+
const list = listNode();
|
|
110
|
+
const config = leave({ dataset: { runId: "run-1" }, parentElement: list }, () => true);
|
|
111
|
+
|
|
112
|
+
expect(config.duration).toBe(0);
|
|
113
|
+
expect(list.props.has(SHIFT_DELAY)).toBe(false);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: PoppyCake, s.r.o.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import { cubicOut } from "svelte/easing";
|
|
5
|
+
import { flip, type AnimationConfig } from "svelte/animate";
|
|
6
|
+
import type { TransitionConfig } from "svelte/transition";
|
|
7
|
+
|
|
8
|
+
const LEAVE_MS = 160;
|
|
9
|
+
// Rows closing the gap wait for most of the fade, so they don't slide over a
|
|
10
|
+
// row that is still visible.
|
|
11
|
+
const CLOSE_GAP_DELAY_MS = 110;
|
|
12
|
+
const SHIFT_MS = 200;
|
|
13
|
+
|
|
14
|
+
// The slices of a row (and of the list it sits in) these helpers touch. Every
|
|
15
|
+
// real row element satisfies them; naming just the members used keeps the
|
|
16
|
+
// helpers unit-testable without a browser DOM.
|
|
17
|
+
type ArrivingRow = { readonly classList: Pick<DOMTokenList, "add"> };
|
|
18
|
+
type LeavingRow = {
|
|
19
|
+
readonly dataset: DOMStringMap;
|
|
20
|
+
readonly parentElement: { readonly style: ListStyle } | null;
|
|
21
|
+
};
|
|
22
|
+
type ListStyle = Pick<CSSStyleDeclaration, "setProperty" | "removeProperty">;
|
|
23
|
+
|
|
24
|
+
export function prefersReducedMotion(): boolean {
|
|
25
|
+
return matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Plays the live-arrival cue (`.run-arrive` in theme.css) on a list row that
|
|
30
|
+
* mounts for a freshly arrived run. Mount-only, so the run's next re-render
|
|
31
|
+
* (pending → running) can't cut the animation short.
|
|
32
|
+
*/
|
|
33
|
+
export function arrival(node: ArrivingRow, fresh: boolean) {
|
|
34
|
+
if (fresh) node.classList.add("run-arrive");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Outro for a run row (`out:leave={motion?.removed}`): fades and sweeps the
|
|
39
|
+
* row out when `removed` says its run (read from `data-run-id`) left live.
|
|
40
|
+
* Rows leaving for any other reason (scrolled out of the virtual window, a
|
|
41
|
+
* filter change) go instantly. Also sets `--rw-shift-delay` on the list so
|
|
42
|
+
* rows sliding up with a CSS transition start after the fade.
|
|
43
|
+
*/
|
|
44
|
+
export function leave(
|
|
45
|
+
node: LeavingRow,
|
|
46
|
+
removed: ((runId: string) => boolean) | undefined,
|
|
47
|
+
): TransitionConfig {
|
|
48
|
+
const runId = node.dataset.runId;
|
|
49
|
+
if (!runId || removed?.(runId) !== true || prefersReducedMotion()) return { duration: 0 };
|
|
50
|
+
const list = node.parentElement;
|
|
51
|
+
if (list) {
|
|
52
|
+
list.style.setProperty("--rw-shift-delay", `${String(CLOSE_GAP_DELAY_MS)}ms`);
|
|
53
|
+
setTimeout(
|
|
54
|
+
() => list.style.removeProperty("--rw-shift-delay"),
|
|
55
|
+
CLOSE_GAP_DELAY_MS + SHIFT_MS,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
duration: LEAVE_MS,
|
|
60
|
+
easing: cubicOut,
|
|
61
|
+
css: (t) => `opacity: ${String(t)}; translate: ${String((1 - t) * -12)}px 0`,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* `animate:` for plain (non-virtual) run lists: flip, except rows moving up to
|
|
67
|
+
* close a removed row's gap wait for its fade.
|
|
68
|
+
*/
|
|
69
|
+
export function shift(node: Element, rects: { from: DOMRect; to: DOMRect }): AnimationConfig {
|
|
70
|
+
if (prefersReducedMotion()) return { duration: 0 };
|
|
71
|
+
return flip(node, rects, {
|
|
72
|
+
duration: SHIFT_MS,
|
|
73
|
+
delay: rects.to.top < rects.from.top ? CLOSE_GAP_DELAY_MS : 0,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
@@ -29,6 +29,8 @@
|
|
|
29
29
|
import Popover from "../Popover.svelte";
|
|
30
30
|
import Tooltip from "../Tooltip.svelte";
|
|
31
31
|
import { portal } from "../../actions/portal.js";
|
|
32
|
+
import { prefersReducedMotion } from "../../actions/row-motion.js";
|
|
33
|
+
import type { RunMotion } from "../../utils/run-motion.js";
|
|
32
34
|
import type { Run } from "./types.js";
|
|
33
35
|
import { isLogEvent, type LogEvent, type LogSlice } from "../../log-console/types.js";
|
|
34
36
|
import { formatClockTime, formatCalendarDate } from "../../utils/format.js";
|
|
@@ -63,6 +65,7 @@
|
|
|
63
65
|
historyVisible = false,
|
|
64
66
|
highlightLine = null,
|
|
65
67
|
getInstanceCount = () => 1,
|
|
68
|
+
motion,
|
|
66
69
|
notFound = false,
|
|
67
70
|
}: {
|
|
68
71
|
run: Run | undefined;
|
|
@@ -109,12 +112,36 @@
|
|
|
109
112
|
// Resolves a task's currently configured instance count so multi-instance
|
|
110
113
|
// services render a 1-based #N suffix. Defaults to single-instance.
|
|
111
114
|
getInstanceCount?: (taskName: string) => number;
|
|
115
|
+
// Which runs arrived or were removed live moments ago. The panel eases in
|
|
116
|
+
// when a new run takes it (triggered, or a scheduled run auto-selected)
|
|
117
|
+
// or when its run was deleted and another takes its place; picking a
|
|
118
|
+
// run by hand swaps instantly.
|
|
119
|
+
motion?: RunMotion;
|
|
112
120
|
// True when a deep-linked run id resolved to no run (deleted by retention,
|
|
113
121
|
// or never existed). The empty state then says so plainly instead of the
|
|
114
122
|
// generic "Select a run" — the caller must not silently substitute another.
|
|
115
123
|
notFound?: boolean;
|
|
116
124
|
} = $props();
|
|
117
125
|
|
|
126
|
+
function easeInFresh(node: HTMLElement, runId: string) {
|
|
127
|
+
let shown = runId;
|
|
128
|
+
const play = (id: string) => {
|
|
129
|
+
const previous = shown;
|
|
130
|
+
shown = id;
|
|
131
|
+
if (!motion?.arrived(id) && !motion?.removed(previous)) return;
|
|
132
|
+
if (prefersReducedMotion()) return;
|
|
133
|
+
node.animate(
|
|
134
|
+
[
|
|
135
|
+
{ opacity: 0.35, translate: "0 6px" },
|
|
136
|
+
{ opacity: 1, translate: "0 0" },
|
|
137
|
+
],
|
|
138
|
+
{ duration: 200, easing: "cubic-bezier(0.2, 0.8, 0.2, 1)" },
|
|
139
|
+
);
|
|
140
|
+
};
|
|
141
|
+
play(runId);
|
|
142
|
+
return { update: play };
|
|
143
|
+
}
|
|
144
|
+
|
|
118
145
|
let canDelete = $derived.by(() => {
|
|
119
146
|
if (!run || !onDelete) return false;
|
|
120
147
|
const status = runDisplayStatus(run);
|
|
@@ -251,7 +278,8 @@
|
|
|
251
278
|
displayed === "crashed" ||
|
|
252
279
|
displayed === "log_overflow" ||
|
|
253
280
|
displayed === "missed" ||
|
|
254
|
-
displayed === "start_failed"
|
|
281
|
+
displayed === "start_failed" ||
|
|
282
|
+
displayed === "unhealthy"
|
|
255
283
|
)
|
|
256
284
|
return "var(--color-danger-surface)";
|
|
257
285
|
if (displayed === "timeout" || displayed === "daemon_stopped" || displayed === "queue_full")
|
|
@@ -355,7 +383,10 @@
|
|
|
355
383
|
<!-- The panel: a status spine runs the full left edge across both the header
|
|
356
384
|
readout and the console below, hugging the rail divider (artifact
|
|
357
385
|
".detail .spine"). -->
|
|
358
|
-
<div
|
|
386
|
+
<div
|
|
387
|
+
use:easeInFresh={run.id}
|
|
388
|
+
class="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden"
|
|
389
|
+
>
|
|
359
390
|
<!-- Status spine: a vivid edge coloring the panel by outcome; it streams
|
|
360
391
|
a light sweep while a run is live. -->
|
|
361
392
|
<div
|
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
import { SvelteSet } from "svelte/reactivity";
|
|
16
16
|
import { createVirtualizer } from "@tanstack/svelte-virtual";
|
|
17
17
|
import Button from "../Button.svelte";
|
|
18
|
+
import { arrival, leave, prefersReducedMotion } from "../../actions/row-motion.js";
|
|
19
|
+
import type { RunMotion } from "../../utils/run-motion.js";
|
|
18
20
|
import EmptyState from "../EmptyState.svelte";
|
|
19
21
|
import RunFilterPopover from "./RunFilterPopover.svelte";
|
|
20
22
|
import type { Run } from "./types.js";
|
|
@@ -68,6 +70,7 @@
|
|
|
68
70
|
onBulkDelete,
|
|
69
71
|
onBulkRerun,
|
|
70
72
|
getInstanceCount = () => 1,
|
|
73
|
+
motion,
|
|
71
74
|
flush = false,
|
|
72
75
|
outputSearch = false,
|
|
73
76
|
outputQuery = "",
|
|
@@ -99,6 +102,9 @@
|
|
|
99
102
|
// Resolves a task's currently configured instance count so multi-instance
|
|
100
103
|
// services render a 1-based #N suffix. Defaults to single-instance.
|
|
101
104
|
getInstanceCount?: (taskName: string) => number;
|
|
105
|
+
// Which runs arrived or were removed live moments ago: their rows animate
|
|
106
|
+
// in or out. Page loads, scrolling and filtering never do.
|
|
107
|
+
motion?: RunMotion;
|
|
102
108
|
// Flush rail mode (task detail page): render as a borderless rail that
|
|
103
109
|
// fills its column and is divided from the detail panel by a single right
|
|
104
110
|
// border — no card chrome of its own. Default renders the standalone card
|
|
@@ -189,8 +195,13 @@
|
|
|
189
195
|
|
|
190
196
|
// `setOptions` always notifies the store (see @tanstack/svelte-virtual src),
|
|
191
197
|
// so reading $virtualizer here would loop. Untrack the store read.
|
|
192
|
-
|
|
198
|
+
// Pre-effect so the count lands in the same render as `items`: otherwise
|
|
199
|
+
// the row pushed past the old count unmounts for a frame and remounts
|
|
200
|
+
// without its slide. A pre-effect first runs before `bind:this`, so it also
|
|
201
|
+
// re-runs once the scroll element exists or the virtualizer never attaches.
|
|
202
|
+
$effect.pre(() => {
|
|
193
203
|
const count = items.length;
|
|
204
|
+
if (!scrollElement) return;
|
|
194
205
|
untrack(() => $virtualizer.setOptions({ count }));
|
|
195
206
|
});
|
|
196
207
|
|
|
@@ -222,8 +233,10 @@
|
|
|
222
233
|
if (!scrollElement) return;
|
|
223
234
|
lastScrolled = id;
|
|
224
235
|
// "auto" only scrolls when the row is off-screen, so visible selections
|
|
225
|
-
// (and manual clicks) don't jump.
|
|
226
|
-
|
|
236
|
+
// (and manual clicks) don't jump. A live arrival taking focus glides
|
|
237
|
+
// there so the operator sees where it went; deep links jump.
|
|
238
|
+
const behavior = motion?.arrived(id) && !prefersReducedMotion() ? "smooth" : "auto";
|
|
239
|
+
untrack(() => $virtualizer.scrollToIndex(index, { align: "auto", behavior }));
|
|
227
240
|
});
|
|
228
241
|
|
|
229
242
|
function isRowSelected(id: string): boolean {
|
|
@@ -575,8 +588,19 @@
|
|
|
575
588
|
{#each $virtualizer.getVirtualItems() as row (items[row.index]?.id ?? row.index)}
|
|
576
589
|
{@const run = items[row.index]}
|
|
577
590
|
{#if run}
|
|
591
|
+
<!-- The transform transition slides rows aside when a live
|
|
592
|
+
run is inserted or removed. Navigation never animates:
|
|
593
|
+
filter/sort/task changes remount rows, pagination
|
|
594
|
+
appends, and scrolling doesn't move a row. `leave`
|
|
595
|
+
is global because this {#if}, not the row's each
|
|
596
|
+
item, is the block it would otherwise wait for; it
|
|
597
|
+
only plays for runs removed live. -->
|
|
578
598
|
<div
|
|
579
|
-
|
|
599
|
+
data-run-id={run.id}
|
|
600
|
+
use:arrival={motion?.arrived(run.id) ?? false}
|
|
601
|
+
out:leave|global={motion?.removed}
|
|
602
|
+
class="group/row flex items-center gap-1 transition-transform duration-200 ease-out motion-reduce:transition-none"
|
|
603
|
+
style:transition-delay="var(--rw-shift-delay, 0ms)"
|
|
580
604
|
style:position="absolute"
|
|
581
605
|
style:top="0"
|
|
582
606
|
style:left="0"
|
|
@@ -179,6 +179,7 @@ describe("STATUS_BUCKETS", () => {
|
|
|
179
179
|
"timeout",
|
|
180
180
|
"log_overflow",
|
|
181
181
|
"start_failed",
|
|
182
|
+
"unhealthy",
|
|
182
183
|
"missed",
|
|
183
184
|
];
|
|
184
185
|
|
|
@@ -266,6 +267,7 @@ describe("triggerDescription", () => {
|
|
|
266
267
|
expect(triggerDescription("cli")).toBe("CLI");
|
|
267
268
|
expect(triggerDescription("service")).toBe("Service auto-start");
|
|
268
269
|
expect(triggerDescription("startup")).toBe("On daemon start");
|
|
270
|
+
expect(triggerDescription("hook")).toBe("Hook");
|
|
269
271
|
});
|
|
270
272
|
|
|
271
273
|
it("falls back to a humanized token for anything unknown", () => {
|
|
@@ -275,7 +277,15 @@ describe("triggerDescription", () => {
|
|
|
275
277
|
|
|
276
278
|
describe("FILTERABLE_TRIGGERS", () => {
|
|
277
279
|
it("offers every trigger except station", () => {
|
|
278
|
-
expect(FILTERABLE_TRIGGERS).toEqual([
|
|
280
|
+
expect(FILTERABLE_TRIGGERS).toEqual([
|
|
281
|
+
"cron",
|
|
282
|
+
"api",
|
|
283
|
+
"ui",
|
|
284
|
+
"cli",
|
|
285
|
+
"service",
|
|
286
|
+
"startup",
|
|
287
|
+
"hook",
|
|
288
|
+
]);
|
|
279
289
|
expect(FILTERABLE_TRIGGERS).not.toContain("station");
|
|
280
290
|
});
|
|
281
291
|
});
|
|
@@ -227,8 +227,8 @@ export function humanizeStatus(status: string): string {
|
|
|
227
227
|
* Descriptive label for a run's trigger source — fuller than the one-word row
|
|
228
228
|
* badge (`formatTriggeredByLabel`) for the "cron"/"station"/"service"/"startup"
|
|
229
229
|
* sources, where the extra words disambiguate (e.g. "REST API" vs a bare
|
|
230
|
-
* "API"). "ui" and "
|
|
231
|
-
*
|
|
230
|
+
* "API"). "ui", "cli" and "hook" already read fine as their bare badge word,
|
|
231
|
+
* so those three match `formatTriggeredByLabel` exactly.
|
|
232
232
|
*
|
|
233
233
|
* Note `cron` covers both on-time schedule firings and catch-up for missed
|
|
234
234
|
* runs, and `startup` is specifically `run_on_start` (not catch-up).
|
|
@@ -249,6 +249,8 @@ export function triggerDescription(trigger: string): string {
|
|
|
249
249
|
return "Service auto-start";
|
|
250
250
|
case "startup":
|
|
251
251
|
return "On daemon start";
|
|
252
|
+
case "hook":
|
|
253
|
+
return "Hook";
|
|
252
254
|
default:
|
|
253
255
|
return humanizeStatus(trigger);
|
|
254
256
|
}
|
|
@@ -29,6 +29,7 @@ const RUN_VERDICTS: Record<RunStatus, RunVerdict> = {
|
|
|
29
29
|
daemon_stopped: { verb: "cut short after", timed: true },
|
|
30
30
|
log_overflow: { verb: "killed after", timed: true },
|
|
31
31
|
start_failed: { verb: "gave up after", timed: true },
|
|
32
|
+
unhealthy: { verb: "killed as unhealthy after", timed: true },
|
|
32
33
|
ended: { verb: "ended after", timed: true },
|
|
33
34
|
running: { verb: "running for", timed: true },
|
|
34
35
|
pending: { verb: "queued", timed: false },
|
|
@@ -90,6 +91,7 @@ export function formatTriggeredByLabel(triggeredBy: Run["triggeredBy"]): string
|
|
|
90
91
|
if (triggeredBy === "cron") return "Cron";
|
|
91
92
|
if (triggeredBy === "service") return "Service";
|
|
92
93
|
if (triggeredBy === "startup") return "Startup";
|
|
94
|
+
if (triggeredBy === "hook") return "Hook";
|
|
93
95
|
return "Station";
|
|
94
96
|
}
|
|
95
97
|
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
FileExclamationPoint,
|
|
14
14
|
CalendarX,
|
|
15
15
|
OctagonX,
|
|
16
|
+
HeartCrack,
|
|
16
17
|
} from "@lucide/svelte";
|
|
17
18
|
import type { Component } from "svelte";
|
|
18
19
|
import { displayStatus, type RunStatus, type Run } from "@runwisp/common";
|
|
@@ -153,6 +154,16 @@ export const RUN_STATUS_CONFIG: Record<RunStatus, RunStatusConfig> = {
|
|
|
153
154
|
description:
|
|
154
155
|
"The run (or service instance) kept failing; after restart_attempts consecutive failures, RunWisp gave up restarting it automatically.",
|
|
155
156
|
},
|
|
157
|
+
unhealthy: {
|
|
158
|
+
icon: HeartCrack,
|
|
159
|
+
color: "text-danger-surface",
|
|
160
|
+
bg: "bg-danger-soft",
|
|
161
|
+
border: "border-danger-soft",
|
|
162
|
+
dot: "bg-danger-surface",
|
|
163
|
+
badge: "bg-danger-soft text-danger-soft-text",
|
|
164
|
+
description:
|
|
165
|
+
"The service instance kept failing its health_check, so RunWisp stopped it. Its restart policy decides what happens next.",
|
|
166
|
+
},
|
|
156
167
|
pending: {
|
|
157
168
|
icon: Clock,
|
|
158
169
|
color: "text-on-surface-muted",
|
package/src/lib/index.ts
CHANGED
|
@@ -154,3 +154,5 @@ export {
|
|
|
154
154
|
export { formatShortId } from "./utils/id.js";
|
|
155
155
|
export { debounce } from "./utils/debounce.js";
|
|
156
156
|
export { TickingNow } from "./utils/ticking-now.svelte.js";
|
|
157
|
+
export { RunMotion } from "./utils/run-motion.js";
|
|
158
|
+
export { arrival, leave, shift, prefersReducedMotion } from "./actions/row-motion.js";
|
package/src/lib/theme.css
CHANGED
|
@@ -259,7 +259,17 @@ h3 {
|
|
|
259
259
|
font-weight: 800;
|
|
260
260
|
line-height: 1.1;
|
|
261
261
|
letter-spacing: -0.02em;
|
|
262
|
-
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/* Layered, unlike the rule above: text-wrap sets the same property as
|
|
265
|
+
white-space, so unlayered it would beat the `truncate` utility and wrap a
|
|
266
|
+
heading that asked to be cut to one line. */
|
|
267
|
+
@layer base {
|
|
268
|
+
h1,
|
|
269
|
+
h2,
|
|
270
|
+
h3 {
|
|
271
|
+
text-wrap: balance;
|
|
272
|
+
}
|
|
263
273
|
}
|
|
264
274
|
|
|
265
275
|
/* Utility classes for the design system */
|
|
@@ -298,3 +308,42 @@ h3 {
|
|
|
298
308
|
.btn-scale:active {
|
|
299
309
|
transform: scale(0.97);
|
|
300
310
|
}
|
|
311
|
+
|
|
312
|
+
/* Live arrival: the row drops in, then a primary ring glows and fades so the
|
|
313
|
+
eye finds the new run. Added by the `arrival` action; `translate` composes
|
|
314
|
+
with any inline `transform`. */
|
|
315
|
+
.run-arrive {
|
|
316
|
+
border-radius: 3px;
|
|
317
|
+
animation: run-arrive 1.2s ease-out;
|
|
318
|
+
}
|
|
319
|
+
@keyframes run-arrive {
|
|
320
|
+
0% {
|
|
321
|
+
opacity: 0;
|
|
322
|
+
translate: 0 -6px;
|
|
323
|
+
box-shadow: 0 0 0 1px var(--color-primary);
|
|
324
|
+
}
|
|
325
|
+
18% {
|
|
326
|
+
opacity: 1;
|
|
327
|
+
translate: 0 0;
|
|
328
|
+
box-shadow:
|
|
329
|
+
0 0 0 1px var(--color-primary),
|
|
330
|
+
0 0 12px color-mix(in srgb, var(--color-primary) 35%, transparent);
|
|
331
|
+
}
|
|
332
|
+
100% {
|
|
333
|
+
box-shadow: 0 0 0 1px transparent;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
@keyframes run-arrive-still {
|
|
337
|
+
0%,
|
|
338
|
+
18% {
|
|
339
|
+
box-shadow: 0 0 0 1px var(--color-primary);
|
|
340
|
+
}
|
|
341
|
+
100% {
|
|
342
|
+
box-shadow: 0 0 0 1px transparent;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
@media (prefers-reduced-motion: reduce) {
|
|
346
|
+
.run-arrive {
|
|
347
|
+
animation-name: run-arrive-still;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
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 { RunMotion } from "./run-motion.js";
|
|
6
|
+
|
|
7
|
+
const WINDOW_MS = 2000;
|
|
8
|
+
|
|
9
|
+
describe("RunMotion", () => {
|
|
10
|
+
beforeEach(() => vi.useFakeTimers());
|
|
11
|
+
afterEach(() => vi.useRealTimers());
|
|
12
|
+
|
|
13
|
+
it("reports nothing for a run it was never told about", () => {
|
|
14
|
+
const motion = new RunMotion();
|
|
15
|
+
expect(motion.arrived("run-1")).toBe(false);
|
|
16
|
+
expect(motion.removed("run-1")).toBe(false);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("keeps arrivals and removals apart", () => {
|
|
20
|
+
const motion = new RunMotion();
|
|
21
|
+
motion.markArrived("arrived-run");
|
|
22
|
+
motion.markRemoved("removed-run");
|
|
23
|
+
expect(motion.arrived("arrived-run")).toBe(true);
|
|
24
|
+
expect(motion.removed("arrived-run")).toBe(false);
|
|
25
|
+
expect(motion.removed("removed-run")).toBe(true);
|
|
26
|
+
expect(motion.arrived("removed-run")).toBe(false);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("forgets a run once the window passes, so a later click doesn't animate it", () => {
|
|
30
|
+
const motion = new RunMotion();
|
|
31
|
+
motion.markArrived("run-1");
|
|
32
|
+
motion.markRemoved("run-2");
|
|
33
|
+
vi.advanceTimersByTime(WINDOW_MS - 1);
|
|
34
|
+
expect(motion.arrived("run-1")).toBe(true);
|
|
35
|
+
expect(motion.removed("run-2")).toBe(true);
|
|
36
|
+
vi.advanceTimersByTime(1);
|
|
37
|
+
expect(motion.arrived("run-1")).toBe(false);
|
|
38
|
+
expect(motion.removed("run-2")).toBe(false);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("prunes expired runs when a later one is marked", () => {
|
|
42
|
+
const motion = new RunMotion();
|
|
43
|
+
motion.markArrived("old-run");
|
|
44
|
+
vi.advanceTimersByTime(WINDOW_MS);
|
|
45
|
+
motion.markArrived("new-run");
|
|
46
|
+
expect(motion.arrived("old-run")).toBe(false);
|
|
47
|
+
expect(motion.arrived("new-run")).toBe(true);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("re-marking a run restarts its window", () => {
|
|
51
|
+
const motion = new RunMotion();
|
|
52
|
+
motion.markArrived("run-1");
|
|
53
|
+
vi.advanceTimersByTime(WINDOW_MS - 1);
|
|
54
|
+
motion.markArrived("run-1");
|
|
55
|
+
vi.advanceTimersByTime(WINDOW_MS - 1);
|
|
56
|
+
expect(motion.arrived("run-1")).toBe(true);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("clear forgets everything", () => {
|
|
60
|
+
const motion = new RunMotion();
|
|
61
|
+
motion.markArrived("arrived-run");
|
|
62
|
+
motion.markRemoved("removed-run");
|
|
63
|
+
motion.clear();
|
|
64
|
+
expect(motion.arrived("arrived-run")).toBe(false);
|
|
65
|
+
expect(motion.removed("removed-run")).toBe(false);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: PoppyCake, s.r.o.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
// Long enough to span an SSE insert and the trigger's HTTP response that
|
|
5
|
+
// selects the new run; short enough that a later click never re-animates it.
|
|
6
|
+
const WINDOW_MS = 2000;
|
|
7
|
+
|
|
8
|
+
class RecentIds {
|
|
9
|
+
readonly #at = new Map<string, number>();
|
|
10
|
+
|
|
11
|
+
mark(id: string): void {
|
|
12
|
+
const now = Date.now();
|
|
13
|
+
for (const [known, at] of this.#at) {
|
|
14
|
+
if (now - at >= WINDOW_MS) this.#at.delete(known);
|
|
15
|
+
}
|
|
16
|
+
this.#at.set(id, now);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
has(id: string): boolean {
|
|
20
|
+
const at = this.#at.get(id);
|
|
21
|
+
return at !== undefined && Date.now() - at < WINDOW_MS;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
clear(): void {
|
|
25
|
+
this.#at.clear();
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Remembers which runs arrived or were removed live moments ago (a trigger, a
|
|
31
|
+
* scheduled firing, a run finishing, a delete), as opposed to rows that a page
|
|
32
|
+
* fetch, scroll or filter change loaded or dropped, so only genuine changes
|
|
33
|
+
* animate. Plain (non-reactive): it is only read at mount, unmount or
|
|
34
|
+
* selection time.
|
|
35
|
+
*/
|
|
36
|
+
export class RunMotion {
|
|
37
|
+
readonly #arrived = new RecentIds();
|
|
38
|
+
readonly #removed = new RecentIds();
|
|
39
|
+
|
|
40
|
+
markArrived(runId: string): void {
|
|
41
|
+
this.#arrived.mark(runId);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
markRemoved(runId: string): void {
|
|
45
|
+
this.#removed.mark(runId);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Arrows so they can be handed around unbound.
|
|
49
|
+
arrived = (runId: string): boolean => this.#arrived.has(runId);
|
|
50
|
+
removed = (runId: string): boolean => this.#removed.has(runId);
|
|
51
|
+
|
|
52
|
+
clear(): void {
|
|
53
|
+
this.#arrived.clear();
|
|
54
|
+
this.#removed.clear();
|
|
55
|
+
}
|
|
56
|
+
}
|