@runwisp/ui 0.10.0 → 0.11.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/assets/runwisp-logo.svg +2 -2
- package/src/lib/components/LogConsole.svelte +518 -68
- package/src/lib/components/Popover.svelte +64 -31
- package/src/lib/components/dashboard/RunDetailPanel.svelte +753 -212
- package/src/lib/components/dashboard/RunFilterPopover.svelte +299 -0
- package/src/lib/components/dashboard/RunsList.svelte +418 -234
- package/src/lib/components/dashboard/run-filters.test.ts +299 -0
- package/src/lib/components/dashboard/run-filters.ts +384 -0
- package/src/lib/components/dashboard/run-helpers.test.ts +20 -0
- package/src/lib/components/dashboard/run-helpers.ts +15 -0
- package/src/lib/index.ts +20 -4
- package/src/lib/log-console/LogCache.svelte.ts +50 -1
- package/src/lib/log-console/types.ts +17 -0
- package/src/lib/theme-tokens.css +74 -62
- package/src/lib/theme.css +1 -0
- package/src/lib/utils/format.test.ts +38 -0
- package/src/lib/utils/format.ts +36 -0
- package/src/stories/assets/runwisp_c.svg +2 -2
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
<!-- SPDX-FileCopyrightText: PoppyCake, s.r.o. -->
|
|
2
|
+
<!-- SPDX-License-Identifier: Apache-2.0 -->
|
|
3
|
+
|
|
4
|
+
<!--
|
|
5
|
+
Runs filter, behind a single button + popover. On desktop it opens toward the
|
|
6
|
+
run *content* (right of the list rail) so the list it filters stays visible
|
|
7
|
+
and updates live; on phones it drops in as a full-width bottom sheet
|
|
8
|
+
(mobileSheet). The button shows a count badge of active dimensions; the parent
|
|
9
|
+
renders removable chips for each.
|
|
10
|
+
|
|
11
|
+
Status is a five-bucket pick (Running / Succeeded / Failed / Skipped /
|
|
12
|
+
Stopped) with an "Advanced" expander for the individual statuses — buckets are
|
|
13
|
+
pure UI groupings over the same `statuses` array. Time is an independent
|
|
14
|
+
From/To date pair; exit code is a free-form expression (137, >100, >100 <150)
|
|
15
|
+
normalized to an inclusive range at the wire.
|
|
16
|
+
|
|
17
|
+
All controls are NATIVE (<select>, <input>, checkbox, button) — a portalled
|
|
18
|
+
Select component must not nest inside the portalled Popover, since clicking an
|
|
19
|
+
option lands outside the popover's DOM subtree and triggers its outside-click
|
|
20
|
+
close. Every mutation reassigns the whole `filters` object (never an in-place
|
|
21
|
+
property write): the value reaches here through several levels of `bind:`, and
|
|
22
|
+
only a fresh reference reliably re-triggers the parent's fetch effect.
|
|
23
|
+
-->
|
|
24
|
+
<script lang="ts">
|
|
25
|
+
import { Funnel, ChevronRight } from "@lucide/svelte";
|
|
26
|
+
import { END_REASONS, type RunStatus } from "@runwisp/common";
|
|
27
|
+
import Popover from "../Popover.svelte";
|
|
28
|
+
import Badge from "../Badge.svelte";
|
|
29
|
+
import Toggle from "../Toggle.svelte";
|
|
30
|
+
import { RUN_STATUS_CONFIG } from "./status-config.js";
|
|
31
|
+
import {
|
|
32
|
+
activeFilterCount,
|
|
33
|
+
humanizeStatus,
|
|
34
|
+
triggerDescription,
|
|
35
|
+
FILTERABLE_TRIGGERS,
|
|
36
|
+
STATUS_BUCKETS,
|
|
37
|
+
bucketState,
|
|
38
|
+
toggleBucket,
|
|
39
|
+
isoToDayInput,
|
|
40
|
+
dayStartIso,
|
|
41
|
+
dayEndIso,
|
|
42
|
+
isExitCodeExprValid,
|
|
43
|
+
type RunsListFilters,
|
|
44
|
+
} from "./run-filters.js";
|
|
45
|
+
|
|
46
|
+
let {
|
|
47
|
+
filters = $bindable(),
|
|
48
|
+
showTask = false,
|
|
49
|
+
tasks = [],
|
|
50
|
+
}: {
|
|
51
|
+
filters: RunsListFilters;
|
|
52
|
+
showTask?: boolean;
|
|
53
|
+
tasks?: { name: string }[];
|
|
54
|
+
} = $props();
|
|
55
|
+
|
|
56
|
+
// The individual statuses behind the "Advanced" expander, grouped the way
|
|
57
|
+
// the buckets are summarized above.
|
|
58
|
+
const STATUS_GROUPS: { label: string; values: RunStatus[] }[] = [
|
|
59
|
+
{ label: "Active", values: ["pending", "running"] },
|
|
60
|
+
{ label: "Outcomes", values: [...END_REASONS] },
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
let advancedOpen = $state(false);
|
|
64
|
+
|
|
65
|
+
const count = $derived(activeFilterCount(filters) + (showTask && filters.task_name ? 1 : 0));
|
|
66
|
+
|
|
67
|
+
const headerClass = "text-xs font-semibold tracking-wide text-on-surface-muted uppercase";
|
|
68
|
+
const labelClass = "text-2xs text-on-surface-faint uppercase";
|
|
69
|
+
const selectClass =
|
|
70
|
+
"h-9 w-full appearance-none rounded-md border border-outline bg-surface-raised px-2.5 text-sm text-on-surface focus:border-ring focus:outline-none";
|
|
71
|
+
|
|
72
|
+
function statusDot(status: RunStatus): string {
|
|
73
|
+
return RUN_STATUS_CONFIG[status].dot.replace(" animate-pulse", "");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Reflect a tri-state bucket onto the native checkbox (no `indeterminate`
|
|
77
|
+
// HTML attribute exists — it must be set on the DOM node).
|
|
78
|
+
function indeterminate(node: HTMLInputElement, value: boolean) {
|
|
79
|
+
node.indeterminate = value;
|
|
80
|
+
return {
|
|
81
|
+
update(v: boolean) {
|
|
82
|
+
node.indeterminate = v;
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function toggleStatus(value: string) {
|
|
88
|
+
const statuses = filters.statuses.includes(value)
|
|
89
|
+
? filters.statuses.filter((s) => s !== value)
|
|
90
|
+
: [...filters.statuses, value];
|
|
91
|
+
filters = { ...filters, statuses };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// --- Time -------------------------------------------------------------
|
|
95
|
+
//
|
|
96
|
+
// Independent From/To date bounds. A bare date is that day's 00:00 (From) or
|
|
97
|
+
// its end (To), so either edge applies on its own — From alone means
|
|
98
|
+
// "everything since", To alone "everything before", and the same date in
|
|
99
|
+
// both captures the whole day.
|
|
100
|
+
|
|
101
|
+
function setFrom(value: string) {
|
|
102
|
+
filters = { ...filters, created_after: value ? dayStartIso(value) : undefined };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function setTo(value: string) {
|
|
106
|
+
filters = { ...filters, created_before: value ? dayEndIso(value) : undefined };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// --- Other dimensions -------------------------------------------------
|
|
110
|
+
|
|
111
|
+
function setTask(value: string) {
|
|
112
|
+
filters = { ...filters, task_name: value || undefined };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function setTrigger(value: string) {
|
|
116
|
+
filters = { ...filters, triggered_by: value || undefined };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Exit code: a free-form expression (`137`, `>100`, `>100 <150`) normalized
|
|
120
|
+
// to an inclusive range at the wire. Edited in a local buffer for smooth
|
|
121
|
+
// typing and committed on change; the popover content remounts on each open,
|
|
122
|
+
// so the buffer re-seeds from `filters` every time it opens.
|
|
123
|
+
let exitCodeInput = $state(filters.exit_code ?? "");
|
|
124
|
+
const exitCodeValid = $derived(isExitCodeExprValid(exitCodeInput));
|
|
125
|
+
|
|
126
|
+
function commitExitCode() {
|
|
127
|
+
const trimmed = exitCodeInput.trim();
|
|
128
|
+
filters = { ...filters, exit_code: trimmed || undefined };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function setRetriesOnly(checked: boolean) {
|
|
132
|
+
filters = { ...filters, retries_only: checked ? true : undefined };
|
|
133
|
+
}
|
|
134
|
+
</script>
|
|
135
|
+
|
|
136
|
+
<Popover placement="right-start" mobileSheet>
|
|
137
|
+
{#snippet trigger()}
|
|
138
|
+
<span
|
|
139
|
+
class="inline-flex h-7 cursor-pointer items-center gap-1.5 rounded-md border px-2 text-xs font-medium transition-colors {count >
|
|
140
|
+
0
|
|
141
|
+
? 'border-wisp-300 bg-primary-soft text-primary-soft-text'
|
|
142
|
+
: 'border-transparent text-on-surface-muted hover:bg-surface-sunken'}"
|
|
143
|
+
title="Filter runs"
|
|
144
|
+
>
|
|
145
|
+
<Funnel size={13} />
|
|
146
|
+
Filter
|
|
147
|
+
{#if count > 0}
|
|
148
|
+
<Badge variant="primary" size="sm" class="px-1.5 py-0">{count}</Badge>
|
|
149
|
+
{/if}
|
|
150
|
+
</span>
|
|
151
|
+
{/snippet}
|
|
152
|
+
|
|
153
|
+
<div class="flex w-full flex-col gap-5 md:w-80">
|
|
154
|
+
<!-- Status: five outcome buckets, with the individual statuses behind
|
|
155
|
+
an Advanced expander for surgical filters. -->
|
|
156
|
+
<section class="flex flex-col gap-2">
|
|
157
|
+
<span class={headerClass}>Status</span>
|
|
158
|
+
<div class="grid grid-cols-2 gap-x-3 gap-y-1.5">
|
|
159
|
+
{#each STATUS_BUCKETS as bucket (bucket.key)}
|
|
160
|
+
{@const state = bucketState(filters.statuses, bucket)}
|
|
161
|
+
<label class="flex cursor-pointer items-center gap-2">
|
|
162
|
+
<input
|
|
163
|
+
type="checkbox"
|
|
164
|
+
checked={state === "on"}
|
|
165
|
+
use:indeterminate={state === "partial"}
|
|
166
|
+
onchange={() => (filters = toggleBucket(filters, bucket))}
|
|
167
|
+
class="size-4 shrink-0 cursor-pointer rounded border-outline accent-primary"
|
|
168
|
+
/>
|
|
169
|
+
<span class="size-2.5 shrink-0 rounded-full {bucket.dot}"></span>
|
|
170
|
+
<span class="text-sm text-on-surface">{bucket.label}</span>
|
|
171
|
+
</label>
|
|
172
|
+
{/each}
|
|
173
|
+
</div>
|
|
174
|
+
|
|
175
|
+
<details class="mt-0.5" bind:open={advancedOpen}>
|
|
176
|
+
<summary
|
|
177
|
+
class="flex cursor-pointer items-center gap-1 text-2xs font-medium text-on-surface-muted transition-colors select-none marker:content-none hover:text-on-surface [&::-webkit-details-marker]:hidden"
|
|
178
|
+
>
|
|
179
|
+
<ChevronRight size={12} class={advancedOpen ? "rotate-90" : ""} />
|
|
180
|
+
Advanced — pick exact statuses
|
|
181
|
+
</summary>
|
|
182
|
+
<div class="mt-2 flex flex-col gap-1.5">
|
|
183
|
+
{#each STATUS_GROUPS as group (group.label)}
|
|
184
|
+
<span class={labelClass}>{group.label}</span>
|
|
185
|
+
<div class="grid grid-cols-2 gap-x-2 gap-y-1">
|
|
186
|
+
{#each group.values as status (status)}
|
|
187
|
+
<label
|
|
188
|
+
class="flex cursor-pointer items-center gap-1.5 truncate text-xs"
|
|
189
|
+
>
|
|
190
|
+
<input
|
|
191
|
+
type="checkbox"
|
|
192
|
+
checked={filters.statuses.includes(status)}
|
|
193
|
+
onchange={() => toggleStatus(status)}
|
|
194
|
+
class="size-3.5 shrink-0 cursor-pointer rounded border-outline accent-primary"
|
|
195
|
+
/>
|
|
196
|
+
<span class="size-2 shrink-0 rounded-full {statusDot(status)}"
|
|
197
|
+
></span>
|
|
198
|
+
<span class="truncate text-on-surface">
|
|
199
|
+
{humanizeStatus(status)}
|
|
200
|
+
</span>
|
|
201
|
+
</label>
|
|
202
|
+
{/each}
|
|
203
|
+
</div>
|
|
204
|
+
{/each}
|
|
205
|
+
</div>
|
|
206
|
+
</details>
|
|
207
|
+
</section>
|
|
208
|
+
|
|
209
|
+
<!-- Date range (filters on created_at). Independent From/To bounds: a
|
|
210
|
+
bare date is that day's 00:00 (From) or its end (To), so either edge
|
|
211
|
+
applies on its own and the same date in both captures the day. -->
|
|
212
|
+
<section class="flex flex-col gap-2">
|
|
213
|
+
<span class={headerClass}>Date range</span>
|
|
214
|
+
<div class="grid grid-cols-2 gap-2">
|
|
215
|
+
<label class="flex flex-col gap-1">
|
|
216
|
+
<span class={labelClass}>From</span>
|
|
217
|
+
<input
|
|
218
|
+
type="date"
|
|
219
|
+
value={filters.created_after ? isoToDayInput(filters.created_after) : ""}
|
|
220
|
+
onchange={(e) => setFrom(e.currentTarget.value)}
|
|
221
|
+
class={selectClass}
|
|
222
|
+
/>
|
|
223
|
+
</label>
|
|
224
|
+
<label class="flex flex-col gap-1">
|
|
225
|
+
<span class={labelClass}>To</span>
|
|
226
|
+
<input
|
|
227
|
+
type="date"
|
|
228
|
+
value={filters.created_before ? isoToDayInput(filters.created_before) : ""}
|
|
229
|
+
onchange={(e) => setTo(e.currentTarget.value)}
|
|
230
|
+
class={selectClass}
|
|
231
|
+
/>
|
|
232
|
+
</label>
|
|
233
|
+
</div>
|
|
234
|
+
</section>
|
|
235
|
+
|
|
236
|
+
<!-- Task (cross-task /runs view only). -->
|
|
237
|
+
{#if showTask}
|
|
238
|
+
<section class="flex flex-col gap-2">
|
|
239
|
+
<span class={headerClass}>Task</span>
|
|
240
|
+
<select
|
|
241
|
+
class={selectClass}
|
|
242
|
+
value={filters.task_name ?? ""}
|
|
243
|
+
onchange={(e) => setTask(e.currentTarget.value)}
|
|
244
|
+
>
|
|
245
|
+
<option value="">Any task</option>
|
|
246
|
+
{#each tasks as task (task.name)}
|
|
247
|
+
<option value={task.name}>{task.name}</option>
|
|
248
|
+
{/each}
|
|
249
|
+
</select>
|
|
250
|
+
</section>
|
|
251
|
+
{/if}
|
|
252
|
+
|
|
253
|
+
<!-- Triggered by. -->
|
|
254
|
+
<section class="flex flex-col gap-2">
|
|
255
|
+
<span class={headerClass}>Triggered by</span>
|
|
256
|
+
<select
|
|
257
|
+
class={selectClass}
|
|
258
|
+
value={filters.triggered_by ?? ""}
|
|
259
|
+
onchange={(e) => setTrigger(e.currentTarget.value)}
|
|
260
|
+
>
|
|
261
|
+
<option value="">Any trigger</option>
|
|
262
|
+
{#each FILTERABLE_TRIGGERS as trigger (trigger)}
|
|
263
|
+
<option value={trigger}>{triggerDescription(trigger)}</option>
|
|
264
|
+
{/each}
|
|
265
|
+
</select>
|
|
266
|
+
</section>
|
|
267
|
+
|
|
268
|
+
<!-- Exit code: a free-form expression parsed to an inclusive range.
|
|
269
|
+
Examples: 137 (exact), >100, <150, or >100 <150 (a window). -->
|
|
270
|
+
<section class="flex flex-col gap-2">
|
|
271
|
+
<span class={headerClass}>Exit code</span>
|
|
272
|
+
<input
|
|
273
|
+
type="text"
|
|
274
|
+
inputmode="text"
|
|
275
|
+
placeholder="e.g. 137, >100, >100 <150"
|
|
276
|
+
bind:value={exitCodeInput}
|
|
277
|
+
onchange={commitExitCode}
|
|
278
|
+
aria-invalid={!exitCodeValid}
|
|
279
|
+
class="{selectClass} {exitCodeValid ? '' : 'border-danger focus:border-danger'}"
|
|
280
|
+
/>
|
|
281
|
+
<span class="text-2xs {exitCodeValid ? 'text-on-surface-faint' : 'text-danger'}">
|
|
282
|
+
{exitCodeValid
|
|
283
|
+
? "Match an exact code, or a range with > >= < <="
|
|
284
|
+
: "Use a number, optionally with > >= < <= (e.g. >100 <150)"}
|
|
285
|
+
</span>
|
|
286
|
+
</section>
|
|
287
|
+
|
|
288
|
+
<!-- Retries. -->
|
|
289
|
+
<section class="flex flex-col gap-2">
|
|
290
|
+
<span class={headerClass}>Retries</span>
|
|
291
|
+
<Toggle
|
|
292
|
+
size="sm"
|
|
293
|
+
checked={Boolean(filters.retries_only)}
|
|
294
|
+
label="Only retried runs"
|
|
295
|
+
onchange={setRetriesOnly}
|
|
296
|
+
/>
|
|
297
|
+
</section>
|
|
298
|
+
</div>
|
|
299
|
+
</Popover>
|