@durablex/react-ui 0.1.0-beta.3
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 +202 -0
- package/NOTICE +5 -0
- package/dist/index.d.ts +1078 -0
- package/dist/index.js +6407 -0
- package/dist/index.js.map +1 -0
- package/package.json +86 -0
- package/src/components/AnimatedDurablexMark.tsx +35 -0
- package/src/components/AppStatusBadge.tsx +17 -0
- package/src/components/AppTag.tsx +17 -0
- package/src/components/AppsView.tsx +226 -0
- package/src/components/BulkReplayButton.tsx +52 -0
- package/src/components/CursorPager.tsx +50 -0
- package/src/components/DeliveriesSplit.tsx +187 -0
- package/src/components/DeliveryDetail.tsx +188 -0
- package/src/components/DurablexLogo.tsx +12 -0
- package/src/components/EndpointFormDialog.tsx +153 -0
- package/src/components/EndpointRow.tsx +172 -0
- package/src/components/EndpointsTab.tsx +83 -0
- package/src/components/EventsList.tsx +170 -0
- package/src/components/EventsView.tsx +24 -0
- package/src/components/Facts.tsx +14 -0
- package/src/components/FlowControlBadge.tsx +23 -0
- package/src/components/FlowControlSection.tsx +82 -0
- package/src/components/FlowSummary.tsx +47 -0
- package/src/components/FormField.tsx +10 -0
- package/src/components/GlyphBadge.tsx +41 -0
- package/src/components/JsonBlock.tsx +48 -0
- package/src/components/JsonEditor.tsx +91 -0
- package/src/components/LogList.tsx +45 -0
- package/src/components/Meta.tsx +31 -0
- package/src/components/OverviewView.tsx +39 -0
- package/src/components/PayloadTabs.tsx +70 -0
- package/src/components/ReceiverFormDialog.tsx +123 -0
- package/src/components/ReceiversTab.tsx +194 -0
- package/src/components/ReplayRunDialog.tsx +112 -0
- package/src/components/ResumeMark.tsx +38 -0
- package/src/components/RetryFromStepButton.tsx +44 -0
- package/src/components/RunCancelButton.tsx +23 -0
- package/src/components/RunControlHistory.tsx +71 -0
- package/src/components/RunInspector.test.tsx +78 -0
- package/src/components/RunInspector.tsx +297 -0
- package/src/components/RunInspectorActions.tsx +40 -0
- package/src/components/RunPauseButton.tsx +34 -0
- package/src/components/RunnerLiveBadge.tsx +11 -0
- package/src/components/RunsFilterBar.tsx +180 -0
- package/src/components/RunsTable.tsx +110 -0
- package/src/components/RunsTableHead.tsx +19 -0
- package/src/components/RunsTableLoader.tsx +10 -0
- package/src/components/RunsTablePlaceholder.tsx +19 -0
- package/src/components/RunsTableRow.tsx +103 -0
- package/src/components/RunsView.test.tsx +46 -0
- package/src/components/RunsView.tsx +243 -0
- package/src/components/ScheduledBadge.tsx +15 -0
- package/src/components/SecretReveal.tsx +45 -0
- package/src/components/SectionHeader.tsx +10 -0
- package/src/components/StatTileGrid.tsx +71 -0
- package/src/components/StatsTiles.tsx +66 -0
- package/src/components/StatusBadge.tsx +50 -0
- package/src/components/StepFlow.tsx +105 -0
- package/src/components/StepGlyph.tsx +25 -0
- package/src/components/StepInspector.tsx +44 -0
- package/src/components/StepRow.tsx +69 -0
- package/src/components/StepTabsView.tsx +51 -0
- package/src/components/StepTimeline.tsx +87 -0
- package/src/components/TableStatusRows.tsx +54 -0
- package/src/components/TriggerEventDialog.tsx +180 -0
- package/src/components/TriggerEventResult.tsx +61 -0
- package/src/components/WebhookBadges.tsx +69 -0
- package/src/components/WebhookStatusBadge.tsx +25 -0
- package/src/components/WebhooksView.tsx +69 -0
- package/src/components/WorkflowDetail.tsx +149 -0
- package/src/components/WorkflowRunAction.tsx +46 -0
- package/src/components/WorkflowRunDialog.tsx +187 -0
- package/src/components/WorkflowsView.tsx +168 -0
- package/src/components/charts/ChartCard.tsx +19 -0
- package/src/components/charts/RunCharts.tsx +31 -0
- package/src/components/charts/RunLatencyChart.tsx +71 -0
- package/src/components/charts/RunsOverTimeChart.tsx +60 -0
- package/src/components/filters/AppFilter.tsx +65 -0
- package/src/components/filters/FilterDropdown.tsx +33 -0
- package/src/components/filters/FilterDropdownButton.tsx +31 -0
- package/src/components/filters/FilterDropdownItem.tsx +37 -0
- package/src/components/filters/TimeRangeFilter.tsx +43 -0
- package/src/components/filters/TimeZoneFilter.tsx +40 -0
- package/src/components/filters/use-click-outside.ts +18 -0
- package/src/components/filters-pager.test.tsx +94 -0
- package/src/components/marks-geometry.ts +10 -0
- package/src/components/replay-dialog.test.tsx +18 -0
- package/src/components/run-components.test.tsx +126 -0
- package/src/components/run-controls.test.tsx +97 -0
- package/src/hooks/use-confirm-action.ts +19 -0
- package/src/hooks/use-copy.ts +22 -0
- package/src/hooks/use-keyset-pager.ts +34 -0
- package/src/hooks/use-mobile.ts +16 -0
- package/src/index.ts +165 -0
- package/src/lib/app-color.test.ts +32 -0
- package/src/lib/app-color.ts +8 -0
- package/src/lib/control-action.ts +36 -0
- package/src/lib/flow-control.ts +77 -0
- package/src/lib/format.test.ts +102 -0
- package/src/lib/format.ts +45 -0
- package/src/lib/json-highlight.test.ts +36 -0
- package/src/lib/json-highlight.ts +64 -0
- package/src/lib/run-filters.ts +8 -0
- package/src/lib/run-logs.test.ts +80 -0
- package/src/lib/run-logs.ts +34 -0
- package/src/lib/run-progress.test.ts +109 -0
- package/src/lib/run-progress.ts +44 -0
- package/src/lib/run-sort.test.ts +40 -0
- package/src/lib/run-sort.ts +19 -0
- package/src/lib/status-label.test.ts +35 -0
- package/src/lib/status-label.ts +13 -0
- package/src/lib/step-detail.test.ts +122 -0
- package/src/lib/step-detail.ts +35 -0
- package/src/lib/step-display.test.ts +19 -0
- package/src/lib/step-display.ts +13 -0
- package/src/lib/step-timeline.test.ts +89 -0
- package/src/lib/step-timeline.ts +50 -0
- package/src/lib/table.ts +2 -0
- package/src/lib/theme.ts +35 -0
- package/src/lib/time-range.ts +81 -0
- package/src/lib/utils.ts +6 -0
- package/src/lib/webhook-view.test.ts +176 -0
- package/src/lib/webhook-view.ts +113 -0
- package/src/lib/workflow-run.test.ts +55 -0
- package/src/lib/workflow-run.ts +45 -0
- package/src/shell/AppShell.tsx +34 -0
- package/src/shell/Sidebar.tsx +78 -0
- package/src/shell/Topbar.tsx +22 -0
- package/src/styles.css +2204 -0
- package/src/test-utils.tsx +130 -0
- package/src/ui/button.tsx +67 -0
- package/src/ui/chart.tsx +337 -0
- package/src/ui/dialog.tsx +145 -0
- package/src/ui/input.tsx +19 -0
- package/src/ui/resizable.tsx +40 -0
- package/src/ui/separator.tsx +28 -0
- package/src/ui/sheet.tsx +128 -0
- package/src/ui/sidebar.tsx +665 -0
- package/src/ui/skeleton.tsx +15 -0
- package/src/ui/sonner.tsx +35 -0
- package/src/ui/table.tsx +87 -0
- package/src/ui/tooltip.tsx +51 -0
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { ChevronDown, ChevronRight, Radio } from "lucide-react";
|
|
2
|
+
import { Fragment, useState } from "react";
|
|
3
|
+
import type { EventLogRecord, TriggeredOutcome } from "@durablex/react";
|
|
4
|
+
import { formatRelative } from "../lib/format";
|
|
5
|
+
import { TABLE_HEAD_CLASS } from "../lib/table";
|
|
6
|
+
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../ui/table";
|
|
7
|
+
import { AppTag } from "./AppTag";
|
|
8
|
+
import { TableStatusRows } from "./TableStatusRows";
|
|
9
|
+
|
|
10
|
+
const EVENT_COLUMNS = 5;
|
|
11
|
+
|
|
12
|
+
interface EventsListProps {
|
|
13
|
+
events?: EventLogRecord[];
|
|
14
|
+
isLoading: boolean;
|
|
15
|
+
isError: boolean;
|
|
16
|
+
error: Error | null;
|
|
17
|
+
onOpenRun?: (runId: string) => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// outcomeSummary describes what an event did: runs created, gated matches, and
|
|
21
|
+
// resumed waiters. An event that matched nothing reads as a dash.
|
|
22
|
+
function outcomeSummary(ev: EventLogRecord): string {
|
|
23
|
+
const runs = ev.triggered.filter((t) => t.runId).length;
|
|
24
|
+
const gated = ev.triggered.length - runs;
|
|
25
|
+
const parts: string[] = [];
|
|
26
|
+
if (runs > 0) parts.push(`${runs} run${runs === 1 ? "" : "s"}`);
|
|
27
|
+
if (gated > 0) parts.push(`${gated} gated`);
|
|
28
|
+
if (ev.woke > 0) parts.push(`woke ${ev.woke}`);
|
|
29
|
+
return parts.length > 0 ? parts.join(" · ") : "-";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Flags are mutually exclusive per the engine's trigger().
|
|
33
|
+
function outcomeLabel(t: TriggeredOutcome): string {
|
|
34
|
+
if (t.runId) return "ran";
|
|
35
|
+
if (t.deduped) return "deduped";
|
|
36
|
+
if (t.debounced) return "debounced";
|
|
37
|
+
if (t.batched) return "batched";
|
|
38
|
+
if (t.dropped) return "dropped";
|
|
39
|
+
if (t.skipped) return "skipped";
|
|
40
|
+
return "no run";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function OutcomeBadge({ outcome }: { outcome: TriggeredOutcome }) {
|
|
44
|
+
const ran = Boolean(outcome.runId);
|
|
45
|
+
return (
|
|
46
|
+
<span
|
|
47
|
+
className={
|
|
48
|
+
"border px-1.5 py-0.5 font-mono text-[10px] " +
|
|
49
|
+
(ran
|
|
50
|
+
? "border-emerald-500/40 text-emerald-600 dark:text-emerald-400"
|
|
51
|
+
: "text-muted-foreground")
|
|
52
|
+
}
|
|
53
|
+
>
|
|
54
|
+
{outcomeLabel(outcome)}
|
|
55
|
+
</span>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function TriggeredRow({
|
|
60
|
+
outcome,
|
|
61
|
+
onOpenRun,
|
|
62
|
+
}: {
|
|
63
|
+
outcome: TriggeredOutcome;
|
|
64
|
+
onOpenRun?: (runId: string) => void;
|
|
65
|
+
}) {
|
|
66
|
+
const runId = outcome.runId;
|
|
67
|
+
return (
|
|
68
|
+
<div className="flex items-center gap-2 py-0.5">
|
|
69
|
+
<OutcomeBadge outcome={outcome} />
|
|
70
|
+
<span className="text-xs font-medium">{outcome.workflow}</span>
|
|
71
|
+
{outcome.app && <AppTag app={outcome.app} />}
|
|
72
|
+
{runId &&
|
|
73
|
+
(onOpenRun ? (
|
|
74
|
+
<button
|
|
75
|
+
type="button"
|
|
76
|
+
className="text-muted-foreground hover:text-foreground font-mono text-[11px] underline underline-offset-2"
|
|
77
|
+
onClick={() => onOpenRun(runId)}
|
|
78
|
+
>
|
|
79
|
+
{runId}
|
|
80
|
+
</button>
|
|
81
|
+
) : (
|
|
82
|
+
<span className="text-muted-foreground font-mono text-[11px]">{runId}</span>
|
|
83
|
+
))}
|
|
84
|
+
</div>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function EventsList({ events, isLoading, isError, error, onOpenRun }: EventsListProps) {
|
|
89
|
+
const [expanded, setExpanded] = useState<string | null>(null);
|
|
90
|
+
const toggle = (id: string) => setExpanded((cur) => (cur === id ? null : id));
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<div className="rounded-md border">
|
|
94
|
+
<Table>
|
|
95
|
+
<TableHeader>
|
|
96
|
+
<TableRow>
|
|
97
|
+
<TableHead className={TABLE_HEAD_CLASS}>Received</TableHead>
|
|
98
|
+
<TableHead className={TABLE_HEAD_CLASS}>Event</TableHead>
|
|
99
|
+
<TableHead className={TABLE_HEAD_CLASS}>App</TableHead>
|
|
100
|
+
<TableHead className={TABLE_HEAD_CLASS}>Source</TableHead>
|
|
101
|
+
<TableHead className={TABLE_HEAD_CLASS}>Outcome</TableHead>
|
|
102
|
+
</TableRow>
|
|
103
|
+
</TableHeader>
|
|
104
|
+
<TableBody>
|
|
105
|
+
<TableStatusRows
|
|
106
|
+
colSpan={EVENT_COLUMNS}
|
|
107
|
+
isLoading={isLoading}
|
|
108
|
+
hasData={(events?.length ?? 0) > 0}
|
|
109
|
+
isError={isError}
|
|
110
|
+
error={error}
|
|
111
|
+
emptyMessage="No events yet. Fire one with POST /events or emit from a workflow."
|
|
112
|
+
errorFallback="Failed to load events"
|
|
113
|
+
/>
|
|
114
|
+
{events?.map((ev) => {
|
|
115
|
+
const open = expanded === ev.id;
|
|
116
|
+
const Chevron = open ? ChevronDown : ChevronRight;
|
|
117
|
+
return (
|
|
118
|
+
<Fragment key={ev.id}>
|
|
119
|
+
<TableRow className="cursor-pointer" onClick={() => toggle(ev.id)}>
|
|
120
|
+
<TableCell className="text-muted-foreground px-2 py-1.5 font-mono text-[11px] whitespace-nowrap">
|
|
121
|
+
{formatRelative(ev.receivedAt)}
|
|
122
|
+
</TableCell>
|
|
123
|
+
<TableCell className="px-2 py-1.5">
|
|
124
|
+
<div className="flex items-center gap-1.5">
|
|
125
|
+
<Chevron className="text-muted-foreground size-3.5 shrink-0" />
|
|
126
|
+
<Radio className="text-muted-foreground size-3.5 shrink-0 opacity-70" />
|
|
127
|
+
<span className="text-xs font-medium">{ev.name}</span>
|
|
128
|
+
</div>
|
|
129
|
+
</TableCell>
|
|
130
|
+
<TableCell className="px-2 py-1.5">
|
|
131
|
+
<AppTag app={ev.app} />
|
|
132
|
+
</TableCell>
|
|
133
|
+
<TableCell className="text-muted-foreground px-2 py-1.5 font-mono text-[11px]">
|
|
134
|
+
{ev.source}
|
|
135
|
+
</TableCell>
|
|
136
|
+
<TableCell className="text-muted-foreground px-2 py-1.5 font-mono text-[11px]">
|
|
137
|
+
{outcomeSummary(ev)}
|
|
138
|
+
</TableCell>
|
|
139
|
+
</TableRow>
|
|
140
|
+
{open && (
|
|
141
|
+
<TableRow>
|
|
142
|
+
<TableCell colSpan={EVENT_COLUMNS} className="bg-muted/30 px-2 py-2">
|
|
143
|
+
{ev.triggered.length > 0 ? (
|
|
144
|
+
<div className="flex flex-col gap-0.5 pl-6">
|
|
145
|
+
{ev.triggered.map((t) => (
|
|
146
|
+
<TriggeredRow
|
|
147
|
+
key={`${t.workflow}-${t.app ?? ""}`}
|
|
148
|
+
outcome={t}
|
|
149
|
+
onOpenRun={onOpenRun}
|
|
150
|
+
/>
|
|
151
|
+
))}
|
|
152
|
+
</div>
|
|
153
|
+
) : (
|
|
154
|
+
<span className="text-muted-foreground pl-6 font-mono text-[11px]">
|
|
155
|
+
{ev.woke > 0
|
|
156
|
+
? `Woke ${ev.woke} waiting run(s); no new run.`
|
|
157
|
+
: "Matched no workflow."}
|
|
158
|
+
</span>
|
|
159
|
+
)}
|
|
160
|
+
</TableCell>
|
|
161
|
+
</TableRow>
|
|
162
|
+
)}
|
|
163
|
+
</Fragment>
|
|
164
|
+
);
|
|
165
|
+
})}
|
|
166
|
+
</TableBody>
|
|
167
|
+
</Table>
|
|
168
|
+
</div>
|
|
169
|
+
);
|
|
170
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { useEvents } from "@durablex/react";
|
|
2
|
+
import { EventsList } from "./EventsList";
|
|
3
|
+
import { TriggerEventDialog } from "./TriggerEventDialog";
|
|
4
|
+
|
|
5
|
+
export function EventsView({ onOpenRun }: { onOpenRun?: (runId: string) => void }) {
|
|
6
|
+
const { data: events, isLoading, isError, error } = useEvents();
|
|
7
|
+
return (
|
|
8
|
+
<div className="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto p-4">
|
|
9
|
+
<div className="flex items-center justify-between gap-2">
|
|
10
|
+
<span className="text-muted-foreground font-mono text-[11px]">
|
|
11
|
+
{events?.length ?? 0} events · live
|
|
12
|
+
</span>
|
|
13
|
+
<TriggerEventDialog onOpenRun={onOpenRun} />
|
|
14
|
+
</div>
|
|
15
|
+
<EventsList
|
|
16
|
+
events={events}
|
|
17
|
+
isLoading={isLoading}
|
|
18
|
+
isError={isError}
|
|
19
|
+
error={error}
|
|
20
|
+
onOpenRun={onOpenRun}
|
|
21
|
+
/>
|
|
22
|
+
</div>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export function Facts({ rows }: { rows: [string, string][] }) {
|
|
2
|
+
return (
|
|
3
|
+
<dl className="grid grid-cols-[max-content_1fr] gap-x-4 gap-y-1.5 text-xs">
|
|
4
|
+
{rows.map(([k, v]) => (
|
|
5
|
+
<div key={k} className="contents">
|
|
6
|
+
<dt className="text-muted-foreground">{k}</dt>
|
|
7
|
+
<dd className="min-w-0 truncate font-mono" title={v}>
|
|
8
|
+
{v}
|
|
9
|
+
</dd>
|
|
10
|
+
</div>
|
|
11
|
+
))}
|
|
12
|
+
</dl>
|
|
13
|
+
);
|
|
14
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { SlidersHorizontal } from "lucide-react";
|
|
2
|
+
import { GlyphBadge } from "./GlyphBadge";
|
|
3
|
+
|
|
4
|
+
// Marks a workflow that has flow-control adapters configured. compact is a bare
|
|
5
|
+
// glyph for dense table cells; the full form adds a labelled chip.
|
|
6
|
+
export function FlowControlBadge({
|
|
7
|
+
compact,
|
|
8
|
+
className,
|
|
9
|
+
}: {
|
|
10
|
+
compact?: boolean;
|
|
11
|
+
className?: string;
|
|
12
|
+
}) {
|
|
13
|
+
return (
|
|
14
|
+
<GlyphBadge
|
|
15
|
+
icon={SlidersHorizontal}
|
|
16
|
+
tone="running"
|
|
17
|
+
label="Flow control"
|
|
18
|
+
ariaLabel="Flow control configured"
|
|
19
|
+
compact={compact}
|
|
20
|
+
className={className}
|
|
21
|
+
/>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { FlowControl, RunStats } from "@durablex/react";
|
|
2
|
+
import { useFlowState } from "@durablex/react";
|
|
3
|
+
import { SectionHeader } from "./SectionHeader";
|
|
4
|
+
import { type FlowAdapter, type FlowRow, flowControlRows } from "../lib/flow-control";
|
|
5
|
+
import { formatNextFire, formatRelative } from "../lib/format";
|
|
6
|
+
|
|
7
|
+
interface Live {
|
|
8
|
+
stats?: RunStats;
|
|
9
|
+
pending?: number;
|
|
10
|
+
nextFireAt?: string;
|
|
11
|
+
buffered?: number;
|
|
12
|
+
oldestAt?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// The live runtime number for the adapters that have one. concurrency reads from
|
|
16
|
+
// run stats (in-flight + queued); debounce and batch read their buffer backlogs.
|
|
17
|
+
// The other adapters are config-only and return null. The in-flight count is
|
|
18
|
+
// workflow-wide; a keyed limit applies per scope, so it is shown as a plain count
|
|
19
|
+
// (the configured limit sits in the value column) rather than a count/limit ratio.
|
|
20
|
+
function liveLabel(adapter: FlowAdapter, l: Live): string | null {
|
|
21
|
+
switch (adapter) {
|
|
22
|
+
case "concurrency": {
|
|
23
|
+
const queued = l.stats?.queued ?? 0;
|
|
24
|
+
const base = `${l.stats?.running ?? 0} in-flight`;
|
|
25
|
+
return queued > 0 ? `${base} · ${queued} queued` : base;
|
|
26
|
+
}
|
|
27
|
+
case "debounce":
|
|
28
|
+
return `${l.pending ?? 0} pending${l.nextFireAt ? ` · next ${formatNextFire(l.nextFireAt)}` : ""}`;
|
|
29
|
+
case "batch":
|
|
30
|
+
return `${l.buffered ?? 0} buffered${l.oldestAt ? ` · oldest ${formatRelative(l.oldestAt)}` : ""}`;
|
|
31
|
+
default:
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function FlowControlRow({ row, live }: { row: FlowRow; live: string | null }) {
|
|
37
|
+
return (
|
|
38
|
+
<div className="fc-row">
|
|
39
|
+
<span className="fc-label">{row.label}</span>
|
|
40
|
+
<span className="fc-value">{row.value}</span>
|
|
41
|
+
{row.scopeKey && <span className="fc-key">key {row.scopeKey}</span>}
|
|
42
|
+
{live && <span className="fc-live">{live}</span>}
|
|
43
|
+
</div>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function FlowControlSection({
|
|
48
|
+
fc,
|
|
49
|
+
app,
|
|
50
|
+
workflow,
|
|
51
|
+
stats,
|
|
52
|
+
}: {
|
|
53
|
+
fc: FlowControl;
|
|
54
|
+
app: string;
|
|
55
|
+
workflow: string;
|
|
56
|
+
stats?: RunStats;
|
|
57
|
+
}) {
|
|
58
|
+
const flow = useFlowState({ app, workflow });
|
|
59
|
+
const debounce = flow.data?.debounce.find((d) => d.workflow === workflow);
|
|
60
|
+
const batch = flow.data?.batch.find((b) => b.workflow === workflow);
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<section className="flowctl">
|
|
64
|
+
<SectionHeader>Flow control</SectionHeader>
|
|
65
|
+
<div className="fc-rows">
|
|
66
|
+
{flowControlRows(fc).map((row) => (
|
|
67
|
+
<FlowControlRow
|
|
68
|
+
key={row.adapter}
|
|
69
|
+
row={row}
|
|
70
|
+
live={liveLabel(row.adapter, {
|
|
71
|
+
stats,
|
|
72
|
+
pending: debounce?.pending,
|
|
73
|
+
nextFireAt: debounce?.nextFireAt,
|
|
74
|
+
buffered: batch?.buffered,
|
|
75
|
+
oldestAt: batch?.oldestAt,
|
|
76
|
+
})}
|
|
77
|
+
/>
|
|
78
|
+
))}
|
|
79
|
+
</div>
|
|
80
|
+
</section>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { RunStats } from "@durablex/react";
|
|
2
|
+
import { useFlowState } from "@durablex/react";
|
|
3
|
+
import { SectionHeader } from "./SectionHeader";
|
|
4
|
+
import { StatTileGrid } from "./StatTileGrid";
|
|
5
|
+
|
|
6
|
+
// Namespace-wide (or per-app) flow-control state: queue depth and in-flight from run
|
|
7
|
+
// stats, plus the buffered totals across the debounce and batch adapters.
|
|
8
|
+
export function FlowSummary({ app, stats }: { app?: string; stats?: RunStats }) {
|
|
9
|
+
const flow = useFlowState(app ? { app } : {});
|
|
10
|
+
const debouncePending = (flow.data?.debounce ?? []).reduce((n, d) => n + d.pending, 0);
|
|
11
|
+
const batchBuffered = (flow.data?.batch ?? []).reduce((n, b) => n + b.buffered, 0);
|
|
12
|
+
|
|
13
|
+
return (
|
|
14
|
+
<section className="flowsum">
|
|
15
|
+
<SectionHeader>Flow control</SectionHeader>
|
|
16
|
+
<StatTileGrid
|
|
17
|
+
tiles={[
|
|
18
|
+
{
|
|
19
|
+
label: "Queue depth",
|
|
20
|
+
value: stats?.queued ?? 0,
|
|
21
|
+
sub: "waiting to start",
|
|
22
|
+
token: "--st-queued-fg",
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
label: "In-flight",
|
|
26
|
+
value: stats?.running ?? 0,
|
|
27
|
+
sub: "executing now",
|
|
28
|
+
token: "--st-running-fg",
|
|
29
|
+
mark: true,
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
label: "Debounce pending",
|
|
33
|
+
value: debouncePending,
|
|
34
|
+
sub: "coalescing",
|
|
35
|
+
token: "--st-waiting-fg",
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
label: "Batch buffered",
|
|
39
|
+
value: batchBuffered,
|
|
40
|
+
sub: "awaiting flush",
|
|
41
|
+
token: "--st-waiting-fg",
|
|
42
|
+
},
|
|
43
|
+
]}
|
|
44
|
+
/>
|
|
45
|
+
</section>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
export function FormField({ label, children }: { label: string; children: ReactNode }) {
|
|
4
|
+
return (
|
|
5
|
+
<label className="flex flex-col gap-1">
|
|
6
|
+
<span className="text-muted-foreground text-[11px] tracking-wide uppercase">{label}</span>
|
|
7
|
+
{children}
|
|
8
|
+
</label>
|
|
9
|
+
);
|
|
10
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { LucideIcon } from "lucide-react";
|
|
2
|
+
import { cn } from "../lib/utils";
|
|
3
|
+
|
|
4
|
+
// tone maps to the --st-<tone>-bg/-fg CSS token pair.
|
|
5
|
+
export function GlyphBadge({
|
|
6
|
+
icon: Icon,
|
|
7
|
+
tone,
|
|
8
|
+
label,
|
|
9
|
+
ariaLabel,
|
|
10
|
+
compact,
|
|
11
|
+
className,
|
|
12
|
+
}: {
|
|
13
|
+
icon: LucideIcon;
|
|
14
|
+
tone: string;
|
|
15
|
+
label: string;
|
|
16
|
+
ariaLabel?: string;
|
|
17
|
+
compact?: boolean;
|
|
18
|
+
className?: string;
|
|
19
|
+
}) {
|
|
20
|
+
if (compact) {
|
|
21
|
+
return (
|
|
22
|
+
<Icon
|
|
23
|
+
className={cn("size-[13px] shrink-0", className)}
|
|
24
|
+
style={{ color: `var(--st-${tone}-fg)` }}
|
|
25
|
+
aria-label={ariaLabel ?? label}
|
|
26
|
+
/>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
return (
|
|
30
|
+
<span
|
|
31
|
+
className={cn(
|
|
32
|
+
"inline-flex items-center gap-1 border border-transparent font-medium leading-none whitespace-nowrap h-[17px] px-1.5 text-[10px]",
|
|
33
|
+
className,
|
|
34
|
+
)}
|
|
35
|
+
style={{ backgroundColor: `var(--st-${tone}-bg)`, color: `var(--st-${tone}-fg)` }}
|
|
36
|
+
>
|
|
37
|
+
<Icon className="size-[11px] shrink-0" />
|
|
38
|
+
{label}
|
|
39
|
+
</span>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { useCopyToClipboard } from "../hooks/use-copy";
|
|
2
|
+
import { highlightedJsonSpans } from "../lib/json-highlight";
|
|
3
|
+
|
|
4
|
+
export function JsonBlock({ value }: { value: unknown }) {
|
|
5
|
+
if (value === null || value === undefined) {
|
|
6
|
+
return <p className="text-muted-foreground px-4 py-2.5 font-mono text-xs italic">null</p>;
|
|
7
|
+
}
|
|
8
|
+
const text = JSON.stringify(value, null, 2);
|
|
9
|
+
return (
|
|
10
|
+
<div
|
|
11
|
+
className="relative"
|
|
12
|
+
style={{ backgroundColor: "color-mix(in oklch, var(--foreground) 5%, var(--card))" }}
|
|
13
|
+
>
|
|
14
|
+
<CopyButton text={text} />
|
|
15
|
+
<pre className="text-foreground max-h-72 overflow-auto px-4 py-2.5 font-mono text-[11.5px] leading-[1.7] tabular-nums">
|
|
16
|
+
{highlightedJsonSpans(text).map((tok) => (
|
|
17
|
+
<span
|
|
18
|
+
key={tok.key}
|
|
19
|
+
style={{
|
|
20
|
+
color: tok.color,
|
|
21
|
+
fontWeight: tok.bold ? 500 : undefined,
|
|
22
|
+
fontStyle: tok.italic ? "italic" : undefined,
|
|
23
|
+
}}
|
|
24
|
+
>
|
|
25
|
+
{tok.text}
|
|
26
|
+
</span>
|
|
27
|
+
))}
|
|
28
|
+
</pre>
|
|
29
|
+
</div>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function CopyButton({ text }: { text: string }) {
|
|
34
|
+
const { copied, copy } = useCopyToClipboard();
|
|
35
|
+
return (
|
|
36
|
+
<button
|
|
37
|
+
type="button"
|
|
38
|
+
onClick={() => copy(text)}
|
|
39
|
+
className="text-muted-foreground hover:text-foreground hover:bg-accent absolute top-[8px] right-[8px] border px-[7px] py-[2px] font-mono text-[9.5px] tracking-[0.05em] uppercase"
|
|
40
|
+
style={{
|
|
41
|
+
backgroundColor: "color-mix(in oklch, var(--card) 78%, transparent)",
|
|
42
|
+
backdropFilter: "blur(3px)",
|
|
43
|
+
}}
|
|
44
|
+
>
|
|
45
|
+
{copied ? "copied" : "copy"}
|
|
46
|
+
</button>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { useRef, type UIEvent } from "react";
|
|
2
|
+
import { highlightedJsonSpans } from "../lib/json-highlight";
|
|
3
|
+
|
|
4
|
+
// A textarea overlaid on a syntax-highlighted <pre>: the textarea's own text is
|
|
5
|
+
// transparent (only its caret/selection show) so the highlighted layer reads
|
|
6
|
+
// through. Both layers share identical font metrics and padding so the glyphs line
|
|
7
|
+
// up; the textarea drives scrolling and the pre is scroll-synced behind it.
|
|
8
|
+
const SURFACE = "whitespace-pre-wrap break-words px-2.5 py-2 font-mono text-xs leading-[1.6]";
|
|
9
|
+
|
|
10
|
+
export function JsonEditor({
|
|
11
|
+
value,
|
|
12
|
+
onChange,
|
|
13
|
+
invalid = false,
|
|
14
|
+
minHeight = 150,
|
|
15
|
+
ariaLabel = "JSON payload",
|
|
16
|
+
}: {
|
|
17
|
+
value: string;
|
|
18
|
+
onChange: (next: string) => void;
|
|
19
|
+
invalid?: boolean;
|
|
20
|
+
minHeight?: number;
|
|
21
|
+
ariaLabel?: string;
|
|
22
|
+
}) {
|
|
23
|
+
const preRef = useRef<HTMLPreElement>(null);
|
|
24
|
+
|
|
25
|
+
const syncScroll = (e: UIEvent<HTMLTextAreaElement>) => {
|
|
26
|
+
const pre = preRef.current;
|
|
27
|
+
if (!pre) return;
|
|
28
|
+
pre.scrollTop = e.currentTarget.scrollTop;
|
|
29
|
+
pre.scrollLeft = e.currentTarget.scrollLeft;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const format = () => {
|
|
33
|
+
try {
|
|
34
|
+
onChange(JSON.stringify(JSON.parse(value), null, 2));
|
|
35
|
+
} catch {
|
|
36
|
+
// Invalid JSON has nothing to format; the invalid border already signals it.
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<div
|
|
42
|
+
data-invalid={invalid}
|
|
43
|
+
className="relative overflow-hidden rounded-lg border border-input transition-colors focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50 data-[invalid=true]:border-destructive data-[invalid=true]:ring-3 data-[invalid=true]:ring-destructive/20"
|
|
44
|
+
style={{ backgroundColor: "color-mix(in oklch, var(--foreground) 4%, var(--card))" }}
|
|
45
|
+
>
|
|
46
|
+
<button
|
|
47
|
+
type="button"
|
|
48
|
+
onClick={format}
|
|
49
|
+
className="text-muted-foreground hover:text-foreground hover:bg-accent absolute top-[6px] right-[6px] z-10 border px-[7px] py-[2px] font-mono text-[9.5px] tracking-[0.05em] uppercase"
|
|
50
|
+
style={{
|
|
51
|
+
backgroundColor: "color-mix(in oklch, var(--card) 78%, transparent)",
|
|
52
|
+
backdropFilter: "blur(3px)",
|
|
53
|
+
}}
|
|
54
|
+
>
|
|
55
|
+
format
|
|
56
|
+
</button>
|
|
57
|
+
<pre
|
|
58
|
+
ref={preRef}
|
|
59
|
+
aria-hidden
|
|
60
|
+
className={`text-foreground pointer-events-none m-0 overflow-hidden ${SURFACE}`}
|
|
61
|
+
style={{ minHeight, maxHeight: 320 }}
|
|
62
|
+
>
|
|
63
|
+
{highlightedJsonSpans(value).map((tok) => (
|
|
64
|
+
<span
|
|
65
|
+
key={tok.key}
|
|
66
|
+
style={{
|
|
67
|
+
color: tok.color,
|
|
68
|
+
fontWeight: tok.bold ? 500 : undefined,
|
|
69
|
+
fontStyle: tok.italic ? "italic" : undefined,
|
|
70
|
+
}}
|
|
71
|
+
>
|
|
72
|
+
{tok.text}
|
|
73
|
+
</span>
|
|
74
|
+
))}
|
|
75
|
+
{"\n"}
|
|
76
|
+
</pre>
|
|
77
|
+
<textarea
|
|
78
|
+
aria-label={ariaLabel}
|
|
79
|
+
aria-invalid={invalid}
|
|
80
|
+
value={value}
|
|
81
|
+
onChange={(e) => onChange(e.target.value)}
|
|
82
|
+
onScroll={syncScroll}
|
|
83
|
+
spellCheck={false}
|
|
84
|
+
autoCapitalize="off"
|
|
85
|
+
autoCorrect="off"
|
|
86
|
+
className={`absolute inset-0 resize-none overflow-auto bg-transparent text-transparent outline-none ${SURFACE}`}
|
|
87
|
+
style={{ caretColor: "var(--foreground)" }}
|
|
88
|
+
/>
|
|
89
|
+
</div>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { LogFrame } from "@durablex/react";
|
|
2
|
+
import { formatTime } from "../lib/format";
|
|
3
|
+
|
|
4
|
+
const LEVEL_FG: Record<LogFrame["level"], string> = {
|
|
5
|
+
debug: "var(--st-skipped-fg)",
|
|
6
|
+
info: "var(--st-running-fg)",
|
|
7
|
+
warn: "var(--st-waiting-fg)",
|
|
8
|
+
error: "var(--st-failed-fg)",
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export function LogList({ logs }: { logs: LogFrame[] }) {
|
|
12
|
+
if (logs.length === 0) {
|
|
13
|
+
return <p className="px-4 py-2.5 font-mono text-xs text-muted-foreground italic">No logs.</p>;
|
|
14
|
+
}
|
|
15
|
+
return (
|
|
16
|
+
<div
|
|
17
|
+
className="flex max-h-72 flex-col gap-1.5 overflow-auto px-4 py-2.5 text-[12px] leading-[1.6]"
|
|
18
|
+
style={{ backgroundColor: "color-mix(in oklch, var(--foreground) 5%, var(--card))" }}
|
|
19
|
+
>
|
|
20
|
+
{logs.map((log) => {
|
|
21
|
+
const fields = log.fields && Object.keys(log.fields).length > 0 ? log.fields : null;
|
|
22
|
+
return (
|
|
23
|
+
<div key={log.seq} className="flex w-max items-baseline gap-2 whitespace-nowrap">
|
|
24
|
+
<span className="tnum text-[11px] text-muted-foreground">{formatTime(log.ts)}</span>
|
|
25
|
+
<span
|
|
26
|
+
className="font-medium uppercase text-[10px]"
|
|
27
|
+
style={{ color: LEVEL_FG[log.level] }}
|
|
28
|
+
>
|
|
29
|
+
{log.level}
|
|
30
|
+
</span>
|
|
31
|
+
<span>{log.message}</span>
|
|
32
|
+
{fields && (
|
|
33
|
+
<>
|
|
34
|
+
<span className="text-muted-foreground" aria-hidden>
|
|
35
|
+
·
|
|
36
|
+
</span>
|
|
37
|
+
<code className="text-[11px] text-muted-foreground">{JSON.stringify(fields)}</code>
|
|
38
|
+
</>
|
|
39
|
+
)}
|
|
40
|
+
</div>
|
|
41
|
+
);
|
|
42
|
+
})}
|
|
43
|
+
</div>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export function Meta({
|
|
2
|
+
label,
|
|
3
|
+
value,
|
|
4
|
+
error,
|
|
5
|
+
onClick,
|
|
6
|
+
title,
|
|
7
|
+
}: {
|
|
8
|
+
label: string;
|
|
9
|
+
value: string;
|
|
10
|
+
error?: boolean;
|
|
11
|
+
onClick?: () => void;
|
|
12
|
+
title?: string;
|
|
13
|
+
}) {
|
|
14
|
+
return (
|
|
15
|
+
<div className="meta">
|
|
16
|
+
<span className="meta-k">{label}</span>
|
|
17
|
+
{onClick ? (
|
|
18
|
+
<button
|
|
19
|
+
type="button"
|
|
20
|
+
className="meta-v cursor-pointer border-0 bg-transparent p-0 text-left underline underline-offset-2"
|
|
21
|
+
title={title}
|
|
22
|
+
onClick={onClick}
|
|
23
|
+
>
|
|
24
|
+
{value}
|
|
25
|
+
</button>
|
|
26
|
+
) : (
|
|
27
|
+
<span className={"meta-v" + (error ? " err" : "")}>{value}</span>
|
|
28
|
+
)}
|
|
29
|
+
</div>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { useRunStats, useRunTimeSeries } from "@durablex/react";
|
|
3
|
+
import { StatsTiles } from "./StatsTiles";
|
|
4
|
+
import { AppFilter } from "./filters/AppFilter";
|
|
5
|
+
import { TimeRangeFilter } from "./filters/TimeRangeFilter";
|
|
6
|
+
import { RunCharts } from "./charts/RunCharts";
|
|
7
|
+
import { FlowSummary } from "./FlowSummary";
|
|
8
|
+
import { ALL_FILTER } from "../lib/run-filters";
|
|
9
|
+
import { DEFAULT_TIME_RANGE, seriesBucketSeconds, timeLabel, windowSince } from "../lib/time-range";
|
|
10
|
+
|
|
11
|
+
export function OverviewView() {
|
|
12
|
+
const [time, setTime] = useState(DEFAULT_TIME_RANGE);
|
|
13
|
+
const [app, setApp] = useState<string>(ALL_FILTER);
|
|
14
|
+
|
|
15
|
+
const since = windowSince(time);
|
|
16
|
+
const appParam = app === ALL_FILTER ? undefined : app;
|
|
17
|
+
const bucket = seriesBucketSeconds(time);
|
|
18
|
+
|
|
19
|
+
const stats = useRunStats({ app: appParam, since });
|
|
20
|
+
// Queue depth / in-flight are point-in-time facts, so the flow summary reads an
|
|
21
|
+
// unwindowed count - a run started before the selected range is still in flight.
|
|
22
|
+
const liveStats = useRunStats({ app: appParam });
|
|
23
|
+
const { data: series, isLoading } = useRunTimeSeries({ app: appParam, since, bucket });
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<div className="flex h-full min-h-0 flex-col">
|
|
27
|
+
<StatsTiles stats={stats.data} rangeLabel={timeLabel(time)} />
|
|
28
|
+
<div className="filterbar">
|
|
29
|
+
<TimeRangeFilter time={time} onTime={setTime} />
|
|
30
|
+
<AppFilter app={app} onApp={setApp} />
|
|
31
|
+
</div>
|
|
32
|
+
|
|
33
|
+
<div className="min-h-0 flex-1 overflow-auto">
|
|
34
|
+
<FlowSummary app={appParam} stats={liveStats.data} />
|
|
35
|
+
<RunCharts series={series} loading={isLoading} />
|
|
36
|
+
</div>
|
|
37
|
+
</div>
|
|
38
|
+
);
|
|
39
|
+
}
|