@voyantjs/workflows-ui 0.37.1 → 0.38.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.
Files changed (48) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +2 -3
  3. package/dist/client.d.ts +1 -1
  4. package/dist/client.d.ts.map +1 -1
  5. package/dist/client.js +1 -1
  6. package/dist/components/common.d.ts +30 -0
  7. package/dist/components/common.d.ts.map +1 -0
  8. package/dist/components/common.js +108 -0
  9. package/dist/components/workflow-run-actions.d.ts +7 -0
  10. package/dist/components/workflow-run-actions.d.ts.map +1 -0
  11. package/dist/components/workflow-run-actions.js +72 -0
  12. package/dist/components/workflow-run-detail-page.d.ts +9 -1
  13. package/dist/components/workflow-run-detail-page.d.ts.map +1 -1
  14. package/dist/components/workflow-run-detail-page.js +96 -1
  15. package/dist/components/workflow-runs-filters.d.ts +32 -0
  16. package/dist/components/workflow-runs-filters.d.ts.map +1 -0
  17. package/dist/components/workflow-runs-filters.js +97 -0
  18. package/dist/components/workflow-runs-page.d.ts +11 -1
  19. package/dist/components/workflow-runs-page.d.ts.map +1 -1
  20. package/dist/components/workflow-runs-page.js +132 -1
  21. package/dist/i18n/en.d.ts +3 -2
  22. package/dist/i18n/en.d.ts.map +1 -1
  23. package/dist/i18n/en.js +96 -2
  24. package/dist/i18n/index.d.ts +4 -2
  25. package/dist/i18n/index.d.ts.map +1 -1
  26. package/dist/i18n/index.js +3 -2
  27. package/dist/i18n/messages.d.ts +86 -0
  28. package/dist/i18n/messages.d.ts.map +1 -0
  29. package/dist/i18n/messages.js +1 -0
  30. package/dist/i18n/provider.d.ts +26 -0
  31. package/dist/i18n/provider.d.ts.map +1 -0
  32. package/dist/i18n/provider.js +44 -0
  33. package/dist/i18n/ro.d.ts +3 -2
  34. package/dist/i18n/ro.d.ts.map +1 -1
  35. package/dist/i18n/ro.js +98 -2
  36. package/dist/index.d.ts +6 -2
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +4 -2
  39. package/dist/types.d.ts +2 -0
  40. package/dist/types.d.ts.map +1 -0
  41. package/dist/types.js +1 -0
  42. package/package.json +55 -68
  43. package/src/components/common.tsx +182 -0
  44. package/src/components/workflow-run-actions.tsx +160 -0
  45. package/src/components/workflow-run-detail-page.tsx +393 -0
  46. package/src/components/workflow-runs-filters.tsx +349 -0
  47. package/src/components/workflow-runs-page.tsx +357 -0
  48. package/src/styles.css +5 -3
@@ -0,0 +1,349 @@
1
+ "use client"
2
+
3
+ import { Button } from "@voyantjs/ui/components/button"
4
+ import { Card, CardContent, CardHeader, CardTitle } from "@voyantjs/ui/components/card"
5
+ import {
6
+ Combobox,
7
+ ComboboxCollection,
8
+ ComboboxContent,
9
+ ComboboxEmpty,
10
+ ComboboxInput,
11
+ ComboboxItem,
12
+ ComboboxList,
13
+ } from "@voyantjs/ui/components/combobox"
14
+ import { Input } from "@voyantjs/ui/components/input"
15
+ import { CheckCircle2, Clock, Search, X, XCircle } from "lucide-react"
16
+ import { useEffect, useMemo, useRef, useState } from "react"
17
+
18
+ import { useWorkflowRunsUiMessagesOrDefault } from "../i18n/index.js"
19
+ import type { ListWorkflowRunsQuery, WorkflowRun, WorkflowRunStatus } from "../types.js"
20
+ import { TagChip } from "./common.js"
21
+
22
+ export const STATUS_OPTIONS: WorkflowRunStatus[] = ["running", "failed", "succeeded", "cancelled"]
23
+ export const TIME_RANGES = ["15m", "1h", "24h", "7d", "all"] as const
24
+
25
+ export type TimeRange = (typeof TIME_RANGES)[number]
26
+
27
+ export type WorkflowOption = {
28
+ name: string
29
+ count: number
30
+ }
31
+
32
+ export function WorkflowRunsFilters({
33
+ filters,
34
+ workflowOptions,
35
+ tagOptions,
36
+ statusFilters,
37
+ tagFilters,
38
+ searchQuery,
39
+ timeRange,
40
+ onChange,
41
+ onToggleStatus,
42
+ onAddTagFilter,
43
+ onRemoveTagFilter,
44
+ onSearchChange,
45
+ onTimeRangeChange,
46
+ onClear,
47
+ }: {
48
+ filters: ListWorkflowRunsQuery
49
+ workflowOptions: WorkflowOption[]
50
+ tagOptions: string[]
51
+ statusFilters: WorkflowRunStatus[]
52
+ tagFilters: string[]
53
+ searchQuery: string
54
+ timeRange: TimeRange
55
+ onChange: (next: ListWorkflowRunsQuery) => void
56
+ onToggleStatus: (status: WorkflowRunStatus) => void
57
+ onAddTagFilter: (tag: string) => void
58
+ onRemoveTagFilter: (tag: string) => void
59
+ onSearchChange: (value: string) => void
60
+ onTimeRangeChange: (value: TimeRange) => void
61
+ onClear: () => void
62
+ }) {
63
+ const messages = useWorkflowRunsUiMessagesOrDefault()
64
+ const searchInputRef = useRef<HTMLInputElement>(null)
65
+
66
+ useEffect(() => {
67
+ const onKeyDown = (event: KeyboardEvent) => {
68
+ if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== "k") return
69
+ event.preventDefault()
70
+ searchInputRef.current?.focus()
71
+ }
72
+ globalThis.addEventListener("keydown", onKeyDown)
73
+ return () => globalThis.removeEventListener("keydown", onKeyDown)
74
+ }, [])
75
+
76
+ return (
77
+ <Card>
78
+ <CardHeader className="pb-3">
79
+ <div className="flex items-center justify-between gap-2">
80
+ <CardTitle className="text-sm">{messages.page.filterTitle}</CardTitle>
81
+ <Button type="button" variant="ghost" size="sm" onClick={onClear}>
82
+ {messages.page.clearFilters}
83
+ </Button>
84
+ </div>
85
+ </CardHeader>
86
+ <CardContent className="space-y-4">
87
+ <Field label={messages.page.searchLabel}>
88
+ <div className="relative">
89
+ <Search className="absolute top-1/2 left-2.5 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
90
+ <Input
91
+ ref={searchInputRef}
92
+ className="pl-8"
93
+ placeholder={messages.page.searchPlaceholder}
94
+ value={searchQuery}
95
+ onChange={(event) => onSearchChange(event.target.value)}
96
+ />
97
+ </div>
98
+ </Field>
99
+ <Field label={messages.page.workflowLabel}>
100
+ <WorkflowCombobox
101
+ value={filters.workflowName ?? null}
102
+ options={workflowOptions}
103
+ placeholder={messages.page.workflowPlaceholder}
104
+ emptyLabel={messages.page.workflowEmpty}
105
+ onChange={(workflowName) =>
106
+ onChange({ ...filters, workflowName: workflowName ?? undefined })
107
+ }
108
+ />
109
+ </Field>
110
+ <Field label={messages.page.statusLabel}>
111
+ <div className="flex flex-wrap gap-1.5">
112
+ <Button
113
+ type="button"
114
+ variant={statusFilters.length === 0 ? "default" : "outline"}
115
+ size="sm"
116
+ onClick={() => {
117
+ for (const status of statusFilters) onToggleStatus(status)
118
+ }}
119
+ >
120
+ {messages.page.anyStatus}
121
+ </Button>
122
+ {STATUS_OPTIONS.map((status) => (
123
+ <Button
124
+ key={status}
125
+ type="button"
126
+ variant={statusFilters.includes(status) ? "default" : "outline"}
127
+ size="sm"
128
+ onClick={() => onToggleStatus(status)}
129
+ aria-pressed={statusFilters.includes(status)}
130
+ >
131
+ <StatusGlyph status={status} />
132
+ {messages.status[status]}
133
+ </Button>
134
+ ))}
135
+ </div>
136
+ </Field>
137
+ <Field label={messages.page.timeRangeLabel}>
138
+ <div className="flex flex-wrap gap-1.5">
139
+ {TIME_RANGES.map((range) => (
140
+ <Button
141
+ key={range}
142
+ type="button"
143
+ variant={timeRange === range ? "default" : "outline"}
144
+ size="sm"
145
+ onClick={() => onTimeRangeChange(range)}
146
+ aria-pressed={timeRange === range}
147
+ >
148
+ {messages.page.timeRanges[range]}
149
+ </Button>
150
+ ))}
151
+ </div>
152
+ </Field>
153
+ <Field label={messages.page.tagLabel}>
154
+ <TagFilterBuilder
155
+ tagOptions={tagOptions}
156
+ tagFilters={tagFilters}
157
+ placeholder={messages.page.tagPlaceholder}
158
+ emptyLabel={messages.page.tagEmpty}
159
+ addLabel={messages.page.addTag}
160
+ removeLabel={messages.page.removeTag}
161
+ onAdd={onAddTagFilter}
162
+ onRemove={onRemoveTagFilter}
163
+ />
164
+ </Field>
165
+ </CardContent>
166
+ </Card>
167
+ )
168
+ }
169
+
170
+ export function buildFilterOptions(runs: WorkflowRun[], selectedWorkflow?: string) {
171
+ const workflowCounts = new Map<string, number>()
172
+ const tags = new Set<string>()
173
+
174
+ for (const run of runs) {
175
+ workflowCounts.set(run.workflowName, (workflowCounts.get(run.workflowName) ?? 0) + 1)
176
+ for (const tag of run.tags) tags.add(tag)
177
+ }
178
+ if (selectedWorkflow && !workflowCounts.has(selectedWorkflow)) {
179
+ workflowCounts.set(selectedWorkflow, 0)
180
+ }
181
+
182
+ return {
183
+ workflows: Array.from(workflowCounts.entries())
184
+ .map(([name, count]) => ({ name, count }))
185
+ .sort((a, b) => b.count - a.count || a.name.localeCompare(b.name)),
186
+ tags: Array.from(tags).sort((a, b) => a.localeCompare(b)),
187
+ }
188
+ }
189
+
190
+ function WorkflowCombobox({
191
+ value,
192
+ options,
193
+ placeholder,
194
+ emptyLabel,
195
+ onChange,
196
+ }: {
197
+ value: string | null
198
+ options: WorkflowOption[]
199
+ placeholder: string
200
+ emptyLabel: string
201
+ onChange: (value: string | null) => void
202
+ }) {
203
+ const itemMap = useMemo(() => new Map(options.map((item) => [item.name, item])), [options])
204
+ const selectedLabel = value ?? ""
205
+ const [inputValue, setInputValue] = useState(selectedLabel)
206
+
207
+ useEffect(() => {
208
+ setInputValue(selectedLabel)
209
+ }, [selectedLabel])
210
+
211
+ return (
212
+ <Combobox
213
+ items={options.map((item) => item.name)}
214
+ value={value}
215
+ inputValue={inputValue}
216
+ autoHighlight
217
+ itemToStringValue={(item) => String(item)}
218
+ onInputValueChange={(next) => {
219
+ setInputValue(next)
220
+ if (!next) onChange(null)
221
+ }}
222
+ onValueChange={(next) => {
223
+ const nextValue = (next as string | null) ?? null
224
+ onChange(nextValue)
225
+ setInputValue(nextValue ?? "")
226
+ }}
227
+ >
228
+ <ComboboxInput placeholder={placeholder} showClear={!!value} className="w-full" />
229
+ <ComboboxContent>
230
+ <ComboboxEmpty>{emptyLabel}</ComboboxEmpty>
231
+ <ComboboxList>
232
+ <ComboboxCollection>
233
+ {(name) => {
234
+ const option = itemMap.get(String(name))
235
+ if (!option) return null
236
+ return (
237
+ <ComboboxItem key={option.name} value={option.name}>
238
+ <div className="flex min-w-0 flex-1 items-center justify-between gap-3">
239
+ <span className="truncate font-medium">{option.name}</span>
240
+ <span className="text-muted-foreground text-xs">{option.count}</span>
241
+ </div>
242
+ </ComboboxItem>
243
+ )
244
+ }}
245
+ </ComboboxCollection>
246
+ </ComboboxList>
247
+ </ComboboxContent>
248
+ </Combobox>
249
+ )
250
+ }
251
+
252
+ function TagFilterBuilder({
253
+ tagOptions,
254
+ tagFilters,
255
+ placeholder,
256
+ emptyLabel,
257
+ addLabel,
258
+ removeLabel,
259
+ onAdd,
260
+ onRemove,
261
+ }: {
262
+ tagOptions: string[]
263
+ tagFilters: string[]
264
+ placeholder: string
265
+ emptyLabel: string
266
+ addLabel: string
267
+ removeLabel: string
268
+ onAdd: (tag: string) => void
269
+ onRemove: (tag: string) => void
270
+ }) {
271
+ const [inputValue, setInputValue] = useState("")
272
+
273
+ const submit = () => {
274
+ const next = inputValue.trim()
275
+ if (!next) return
276
+ onAdd(next)
277
+ setInputValue("")
278
+ }
279
+
280
+ return (
281
+ <div className="space-y-2">
282
+ <div className="grid grid-cols-[1fr_auto] gap-2">
283
+ <Combobox
284
+ items={tagOptions}
285
+ value={null}
286
+ inputValue={inputValue}
287
+ autoHighlight
288
+ itemToStringValue={(item) => String(item)}
289
+ onInputValueChange={setInputValue}
290
+ onValueChange={(next) => {
291
+ const tag = (next as string | null) ?? ""
292
+ if (tag) {
293
+ onAdd(tag)
294
+ setInputValue("")
295
+ }
296
+ }}
297
+ >
298
+ <ComboboxInput placeholder={placeholder} className="w-full" />
299
+ <ComboboxContent>
300
+ <ComboboxEmpty>{emptyLabel}</ComboboxEmpty>
301
+ <ComboboxList>
302
+ <ComboboxCollection>
303
+ {(tag) => (
304
+ <ComboboxItem key={String(tag)} value={String(tag)}>
305
+ <TagChip tag={String(tag)} />
306
+ </ComboboxItem>
307
+ )}
308
+ </ComboboxCollection>
309
+ </ComboboxList>
310
+ </ComboboxContent>
311
+ </Combobox>
312
+ <Button type="button" variant="outline" size="sm" onClick={submit}>
313
+ {addLabel}
314
+ </Button>
315
+ </div>
316
+ {tagFilters.length > 0 ? (
317
+ <div className="flex flex-wrap gap-1.5">
318
+ {tagFilters.map((tag) => (
319
+ <button
320
+ key={tag}
321
+ type="button"
322
+ className="inline-flex items-center gap-1 rounded-full border px-2 py-0.5 font-mono text-[10px] hover:bg-muted"
323
+ onClick={() => onRemove(tag)}
324
+ aria-label={`${removeLabel}: ${tag}`}
325
+ >
326
+ <TagChip tag={tag} />
327
+ <X className="h-3 w-3" aria-hidden="true" />
328
+ </button>
329
+ ))}
330
+ </div>
331
+ ) : null}
332
+ </div>
333
+ )
334
+ }
335
+
336
+ function Field({ label, children }: { label: string; children: React.ReactNode }) {
337
+ return (
338
+ <div className="space-y-1">
339
+ <span className="text-muted-foreground text-xs">{label}</span>
340
+ {children}
341
+ </div>
342
+ )
343
+ }
344
+
345
+ function StatusGlyph({ status }: { status: WorkflowRunStatus }) {
346
+ if (status === "succeeded") return <CheckCircle2 data-icon="inline-start" aria-hidden="true" />
347
+ if (status === "failed") return <XCircle data-icon="inline-start" aria-hidden="true" />
348
+ return <Clock data-icon="inline-start" aria-hidden="true" />
349
+ }
@@ -0,0 +1,357 @@
1
+ "use client"
2
+
3
+ import { Button } from "@voyantjs/ui/components/button"
4
+ import { Card, CardContent } from "@voyantjs/ui/components/card"
5
+ import { Clock, Workflow } from "lucide-react"
6
+ import { useEffect, useMemo, useState } from "react"
7
+
8
+ import { useWorkflowRunsUiMessagesOrDefault } from "../i18n/index.js"
9
+ import type {
10
+ ListWorkflowRunsQuery,
11
+ WorkflowRun,
12
+ WorkflowRunStatus,
13
+ WorkflowRunsApi,
14
+ } from "../types.js"
15
+ import { formatDuration, formatRelative, StatusBadge, StatusIcon, TagChip } from "./common.js"
16
+ import { WorkflowRunDetailPage } from "./workflow-run-detail-page.js"
17
+ import { buildFilterOptions, type TimeRange, WorkflowRunsFilters } from "./workflow-runs-filters.js"
18
+
19
+ export interface WorkflowRunsPageProps {
20
+ api: WorkflowRunsApi
21
+ selectedRunId?: string | null
22
+ onOpenRun?: (id: string) => void
23
+ initialFilters?: ListWorkflowRunsQuery
24
+ pollIntervalMs?: number
25
+ className?: string
26
+ }
27
+
28
+ export function WorkflowRunsPage({
29
+ api,
30
+ selectedRunId,
31
+ onOpenRun,
32
+ initialFilters,
33
+ pollIntervalMs = 5000,
34
+ className,
35
+ }: WorkflowRunsPageProps) {
36
+ const messages = useWorkflowRunsUiMessagesOrDefault()
37
+ const [filters, setFilters] = useState<ListWorkflowRunsQuery>(initialFilters ?? { limit: 50 })
38
+ const [statusFilters, setStatusFilters] = useState<WorkflowRunStatus[]>(
39
+ initialFilters?.status ? [initialFilters.status] : [],
40
+ )
41
+ const [tagFilters, setTagFilters] = useState<string[]>(
42
+ initialFilters?.tag ? [initialFilters.tag] : [],
43
+ )
44
+ const [searchQuery, setSearchQuery] = useState("")
45
+ const [timeRange, setTimeRange] = useState<TimeRange>("24h")
46
+ const [live, setLive] = useState(false)
47
+ const [runs, setRuns] = useState<WorkflowRun[]>([])
48
+ const [error, setError] = useState<string | null>(null)
49
+ const [loading, setLoading] = useState(false)
50
+ const [localSelectedRunId, setLocalSelectedRunId] = useState<string | null>(null)
51
+ const activeRunId = selectedRunId !== undefined ? selectedRunId : localSelectedRunId
52
+
53
+ const serverFilters = useMemo(
54
+ () => ({
55
+ ...filters,
56
+ status: statusFilters.length === 1 ? statusFilters[0] : undefined,
57
+ tag: tagFilters.length === 1 ? tagFilters[0] : undefined,
58
+ }),
59
+ [filters, statusFilters, tagFilters],
60
+ )
61
+
62
+ useEffect(() => {
63
+ let cancelled = false
64
+ const refresh = async () => {
65
+ if (typeof document !== "undefined" && document.hidden) return
66
+ setLoading(true)
67
+ try {
68
+ const res = await api.listRuns(serverFilters)
69
+ if (!cancelled) {
70
+ setRuns(res.data)
71
+ setError(null)
72
+ }
73
+ } catch (err) {
74
+ if (!cancelled) setError(err instanceof Error ? err.message : messages.page.loadError)
75
+ } finally {
76
+ if (!cancelled) setLoading(false)
77
+ }
78
+ }
79
+ void refresh()
80
+ const interval = setInterval(() => void refresh(), live ? 1000 : pollIntervalMs)
81
+ return () => {
82
+ cancelled = true
83
+ clearInterval(interval)
84
+ }
85
+ }, [api, live, messages.page.loadError, pollIntervalMs, serverFilters])
86
+
87
+ const filterOptions = useMemo(
88
+ () => buildFilterOptions(runs, filters.workflowName),
89
+ [filters.workflowName, runs],
90
+ )
91
+ const filteredRuns = useMemo(
92
+ () => filterRuns({ runs, statusFilters, tagFilters, searchQuery, timeRange }),
93
+ [runs, searchQuery, statusFilters, tagFilters, timeRange],
94
+ )
95
+
96
+ const openRun = (id: string) => {
97
+ setLocalSelectedRunId(id)
98
+ onOpenRun?.(id)
99
+ }
100
+ const toggleStatus = (status: WorkflowRunStatus) => {
101
+ setStatusFilters((current) =>
102
+ current.includes(status) ? current.filter((item) => item !== status) : [...current, status],
103
+ )
104
+ }
105
+ const addTagFilter = (tag: string) => {
106
+ const trimmed = tag.trim()
107
+ if (!trimmed) return
108
+ setTagFilters((current) => (current.includes(trimmed) ? current : [...current, trimmed]))
109
+ }
110
+ const removeTagFilter = (tag: string) =>
111
+ setTagFilters((current) => current.filter((item) => item !== tag))
112
+
113
+ return (
114
+ <div className={`flex min-h-screen flex-col bg-background ${className ?? ""}`}>
115
+ <header className="sticky top-0 z-10 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
116
+ <div className="container mx-auto flex items-center gap-3 px-4 py-3">
117
+ <Workflow className="h-5 w-5" />
118
+ <div className="min-w-0">
119
+ <h1 className="font-semibold text-base">{messages.page.title}</h1>
120
+ <p className="text-muted-foreground text-xs">{messages.page.subtitle}</p>
121
+ </div>
122
+ <div className="ml-auto flex items-center gap-2">
123
+ <Button
124
+ type="button"
125
+ variant={live ? "default" : "outline"}
126
+ size="sm"
127
+ onClick={() => setLive((value) => !value)}
128
+ aria-pressed={live}
129
+ >
130
+ <Clock data-icon="inline-start" aria-hidden="true" />
131
+ {messages.page.live}
132
+ </Button>
133
+ <span className="text-muted-foreground text-xs">
134
+ {messages.page.filteredRunCount(filteredRuns.length, runs.length)}
135
+ </span>
136
+ </div>
137
+ </div>
138
+ </header>
139
+ <main className="container mx-auto flex flex-1 flex-col gap-6 px-4 py-6 md:flex-row">
140
+ <aside className="space-y-3 md:w-96 md:shrink-0">
141
+ <WorkflowRunsFilters
142
+ filters={filters}
143
+ workflowOptions={filterOptions.workflows}
144
+ tagOptions={filterOptions.tags}
145
+ statusFilters={statusFilters}
146
+ tagFilters={tagFilters}
147
+ searchQuery={searchQuery}
148
+ timeRange={timeRange}
149
+ onChange={setFilters}
150
+ onToggleStatus={toggleStatus}
151
+ onAddTagFilter={addTagFilter}
152
+ onRemoveTagFilter={removeTagFilter}
153
+ onSearchChange={setSearchQuery}
154
+ onTimeRangeChange={setTimeRange}
155
+ onClear={() => {
156
+ setFilters({ limit: filters.limit ?? 50 })
157
+ setStatusFilters([])
158
+ setTagFilters([])
159
+ setSearchQuery("")
160
+ setTimeRange("24h")
161
+ }}
162
+ />
163
+ {error ? (
164
+ <Card className="border-destructive/40">
165
+ <CardContent className="pt-4 text-destructive text-sm">{error}</CardContent>
166
+ </Card>
167
+ ) : null}
168
+ <div className="space-y-2">
169
+ {filteredRuns.length === 0 && !loading ? (
170
+ <Card>
171
+ <CardContent className="pt-4 text-muted-foreground text-sm">
172
+ {messages.page.empty}
173
+ </CardContent>
174
+ </Card>
175
+ ) : null}
176
+ {filteredRuns.map((run) => (
177
+ <RunListItem
178
+ key={run.id}
179
+ run={run}
180
+ selected={activeRunId === run.id}
181
+ activeTagFilters={tagFilters}
182
+ activeStatusFilters={statusFilters}
183
+ onSelect={() => openRun(run.id)}
184
+ onToggleStatus={toggleStatus}
185
+ onToggleTag={(tag) =>
186
+ tagFilters.includes(tag) ? removeTagFilter(tag) : addTagFilter(tag)
187
+ }
188
+ />
189
+ ))}
190
+ </div>
191
+ </aside>
192
+ <section className="min-w-0 flex-1">
193
+ {activeRunId ? (
194
+ <WorkflowRunDetailPage api={api} runId={activeRunId} onOpenRun={openRun} />
195
+ ) : (
196
+ <SelectPrompt />
197
+ )}
198
+ </section>
199
+ </main>
200
+ </div>
201
+ )
202
+ }
203
+
204
+ export function WorkflowRunsPageSkeleton() {
205
+ const messages = useWorkflowRunsUiMessagesOrDefault()
206
+ return (
207
+ <div className="flex min-h-screen flex-col bg-background">
208
+ <header className="border-b px-4 py-3">
209
+ <div
210
+ className="h-5 w-40 rounded bg-muted"
211
+ role="status"
212
+ aria-label={messages.page.loading}
213
+ />
214
+ </header>
215
+ <main className="container mx-auto flex flex-1 flex-col gap-6 px-4 py-6 md:flex-row">
216
+ <aside className="space-y-3 md:w-96 md:shrink-0">
217
+ <div className="h-56 rounded-md bg-muted" />
218
+ <div className="h-20 rounded-md bg-muted" />
219
+ <div className="h-20 rounded-md bg-muted" />
220
+ </aside>
221
+ <section className="min-h-[24rem] flex-1 rounded-md bg-muted" />
222
+ </main>
223
+ </div>
224
+ )
225
+ }
226
+
227
+ function RunListItem({
228
+ run,
229
+ selected,
230
+ activeTagFilters,
231
+ activeStatusFilters,
232
+ onSelect,
233
+ onToggleStatus,
234
+ onToggleTag,
235
+ }: {
236
+ run: WorkflowRun
237
+ selected: boolean
238
+ activeTagFilters: string[]
239
+ activeStatusFilters: WorkflowRunStatus[]
240
+ onSelect: () => void
241
+ onToggleStatus: (status: WorkflowRunStatus) => void
242
+ onToggleTag: (tag: string) => void
243
+ }) {
244
+ const messages = useWorkflowRunsUiMessagesOrDefault()
245
+ return (
246
+ <div
247
+ className={`rounded-md border bg-card p-3 text-sm transition-colors hover:bg-muted/50 ${
248
+ selected ? "border-primary bg-primary/5" : "" // i18n-literal-ok: CSS classes
249
+ }`}
250
+ >
251
+ <div className="flex items-center gap-2">
252
+ <button
253
+ type="button"
254
+ onClick={onSelect}
255
+ className="flex min-w-0 flex-1 items-center gap-2 text-left"
256
+ >
257
+ <StatusIcon status={run.status} />
258
+ <span className="truncate font-medium">{run.workflowName}</span>
259
+ <span className="ml-auto whitespace-nowrap text-muted-foreground text-xs">
260
+ {formatRelative(run.startedAt, messages)}
261
+ </span>
262
+ </button>
263
+ <button
264
+ type="button"
265
+ onClick={() => onToggleStatus(run.status)}
266
+ aria-pressed={activeStatusFilters.includes(run.status)}
267
+ >
268
+ <StatusBadge status={run.status} messages={messages} />
269
+ </button>
270
+ </div>
271
+ {run.durationMs != null ? (
272
+ <div className="mt-1 text-muted-foreground text-xs">{formatDuration(run.durationMs)}</div>
273
+ ) : null}
274
+ {run.tags.length > 0 ? (
275
+ <div className="mt-2 flex flex-wrap gap-1">
276
+ {run.tags.slice(0, 3).map((tag) => (
277
+ <button
278
+ key={tag}
279
+ type="button"
280
+ onClick={() => onToggleTag(tag)}
281
+ aria-pressed={activeTagFilters.includes(tag)}
282
+ className={
283
+ activeTagFilters.includes(tag) ? "rounded-full ring-2 ring-primary/40" : undefined
284
+ }
285
+ >
286
+ <TagChip tag={tag} />
287
+ </button>
288
+ ))}
289
+ {run.tags.length > 3 ? (
290
+ <span className="text-muted-foreground text-xs">{`+${run.tags.length - 3}`}</span>
291
+ ) : null}
292
+ </div>
293
+ ) : null}
294
+ </div>
295
+ )
296
+ }
297
+
298
+ function SelectPrompt() {
299
+ const messages = useWorkflowRunsUiMessagesOrDefault()
300
+ return (
301
+ <Card>
302
+ <CardContent className="flex min-h-[24rem] items-center justify-center text-muted-foreground text-sm">
303
+ {messages.page.selectPrompt}
304
+ </CardContent>
305
+ </Card>
306
+ )
307
+ }
308
+
309
+ function filterRuns({
310
+ runs,
311
+ statusFilters,
312
+ tagFilters,
313
+ searchQuery,
314
+ timeRange,
315
+ }: {
316
+ runs: WorkflowRun[]
317
+ statusFilters: WorkflowRunStatus[]
318
+ tagFilters: string[]
319
+ searchQuery: string
320
+ timeRange: TimeRange
321
+ }) {
322
+ const search = searchQuery.trim().toLowerCase()
323
+ const cutoff = rangeCutoff(timeRange)
324
+
325
+ return runs.filter((run) => {
326
+ if (statusFilters.length > 0 && !statusFilters.includes(run.status)) return false
327
+ if (tagFilters.length > 0 && !tagFilters.every((tag) => run.tags.includes(tag))) return false
328
+ if (cutoff && new Date(run.startedAt).getTime() < cutoff) return false
329
+ if (!search) return true
330
+ return runSearchText(run).includes(search)
331
+ })
332
+ }
333
+
334
+ function rangeCutoff(range: TimeRange) {
335
+ if (range === "all") return null
336
+ const minutes =
337
+ range === "15m" ? 15 : range === "1h" ? 60 : range === "24h" ? 24 * 60 : 7 * 24 * 60
338
+ return Date.now() - minutes * 60_000
339
+ }
340
+
341
+ function runSearchText(run: WorkflowRun) {
342
+ return [
343
+ run.id,
344
+ run.workflowName,
345
+ run.trigger,
346
+ run.correlationId,
347
+ run.status,
348
+ ...run.tags,
349
+ run.error?.message,
350
+ run.error?.code,
351
+ run.input ? JSON.stringify(run.input) : null,
352
+ run.result ? JSON.stringify(run.result) : null,
353
+ ]
354
+ .filter(Boolean)
355
+ .join(" ")
356
+ .toLowerCase()
357
+ }