@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.
- package/LICENSE +201 -0
- package/README.md +2 -3
- package/dist/client.d.ts +1 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +1 -1
- package/dist/components/common.d.ts +30 -0
- package/dist/components/common.d.ts.map +1 -0
- package/dist/components/common.js +108 -0
- package/dist/components/workflow-run-actions.d.ts +7 -0
- package/dist/components/workflow-run-actions.d.ts.map +1 -0
- package/dist/components/workflow-run-actions.js +72 -0
- package/dist/components/workflow-run-detail-page.d.ts +9 -1
- package/dist/components/workflow-run-detail-page.d.ts.map +1 -1
- package/dist/components/workflow-run-detail-page.js +96 -1
- package/dist/components/workflow-runs-filters.d.ts +32 -0
- package/dist/components/workflow-runs-filters.d.ts.map +1 -0
- package/dist/components/workflow-runs-filters.js +97 -0
- package/dist/components/workflow-runs-page.d.ts +11 -1
- package/dist/components/workflow-runs-page.d.ts.map +1 -1
- package/dist/components/workflow-runs-page.js +132 -1
- package/dist/i18n/en.d.ts +3 -2
- package/dist/i18n/en.d.ts.map +1 -1
- package/dist/i18n/en.js +96 -2
- package/dist/i18n/index.d.ts +4 -2
- package/dist/i18n/index.d.ts.map +1 -1
- package/dist/i18n/index.js +3 -2
- package/dist/i18n/messages.d.ts +86 -0
- package/dist/i18n/messages.d.ts.map +1 -0
- package/dist/i18n/messages.js +1 -0
- package/dist/i18n/provider.d.ts +26 -0
- package/dist/i18n/provider.d.ts.map +1 -0
- package/dist/i18n/provider.js +44 -0
- package/dist/i18n/ro.d.ts +3 -2
- package/dist/i18n/ro.d.ts.map +1 -1
- package/dist/i18n/ro.js +98 -2
- package/dist/index.d.ts +6 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -2
- package/dist/types.d.ts +2 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +1 -0
- package/package.json +55 -68
- package/src/components/common.tsx +182 -0
- package/src/components/workflow-run-actions.tsx +160 -0
- package/src/components/workflow-run-detail-page.tsx +393 -0
- package/src/components/workflow-runs-filters.tsx +349 -0
- package/src/components/workflow-runs-page.tsx +357 -0
- 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
|
+
}
|