@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.
Files changed (143) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +5 -0
  3. package/dist/index.d.ts +1078 -0
  4. package/dist/index.js +6407 -0
  5. package/dist/index.js.map +1 -0
  6. package/package.json +86 -0
  7. package/src/components/AnimatedDurablexMark.tsx +35 -0
  8. package/src/components/AppStatusBadge.tsx +17 -0
  9. package/src/components/AppTag.tsx +17 -0
  10. package/src/components/AppsView.tsx +226 -0
  11. package/src/components/BulkReplayButton.tsx +52 -0
  12. package/src/components/CursorPager.tsx +50 -0
  13. package/src/components/DeliveriesSplit.tsx +187 -0
  14. package/src/components/DeliveryDetail.tsx +188 -0
  15. package/src/components/DurablexLogo.tsx +12 -0
  16. package/src/components/EndpointFormDialog.tsx +153 -0
  17. package/src/components/EndpointRow.tsx +172 -0
  18. package/src/components/EndpointsTab.tsx +83 -0
  19. package/src/components/EventsList.tsx +170 -0
  20. package/src/components/EventsView.tsx +24 -0
  21. package/src/components/Facts.tsx +14 -0
  22. package/src/components/FlowControlBadge.tsx +23 -0
  23. package/src/components/FlowControlSection.tsx +82 -0
  24. package/src/components/FlowSummary.tsx +47 -0
  25. package/src/components/FormField.tsx +10 -0
  26. package/src/components/GlyphBadge.tsx +41 -0
  27. package/src/components/JsonBlock.tsx +48 -0
  28. package/src/components/JsonEditor.tsx +91 -0
  29. package/src/components/LogList.tsx +45 -0
  30. package/src/components/Meta.tsx +31 -0
  31. package/src/components/OverviewView.tsx +39 -0
  32. package/src/components/PayloadTabs.tsx +70 -0
  33. package/src/components/ReceiverFormDialog.tsx +123 -0
  34. package/src/components/ReceiversTab.tsx +194 -0
  35. package/src/components/ReplayRunDialog.tsx +112 -0
  36. package/src/components/ResumeMark.tsx +38 -0
  37. package/src/components/RetryFromStepButton.tsx +44 -0
  38. package/src/components/RunCancelButton.tsx +23 -0
  39. package/src/components/RunControlHistory.tsx +71 -0
  40. package/src/components/RunInspector.test.tsx +78 -0
  41. package/src/components/RunInspector.tsx +297 -0
  42. package/src/components/RunInspectorActions.tsx +40 -0
  43. package/src/components/RunPauseButton.tsx +34 -0
  44. package/src/components/RunnerLiveBadge.tsx +11 -0
  45. package/src/components/RunsFilterBar.tsx +180 -0
  46. package/src/components/RunsTable.tsx +110 -0
  47. package/src/components/RunsTableHead.tsx +19 -0
  48. package/src/components/RunsTableLoader.tsx +10 -0
  49. package/src/components/RunsTablePlaceholder.tsx +19 -0
  50. package/src/components/RunsTableRow.tsx +103 -0
  51. package/src/components/RunsView.test.tsx +46 -0
  52. package/src/components/RunsView.tsx +243 -0
  53. package/src/components/ScheduledBadge.tsx +15 -0
  54. package/src/components/SecretReveal.tsx +45 -0
  55. package/src/components/SectionHeader.tsx +10 -0
  56. package/src/components/StatTileGrid.tsx +71 -0
  57. package/src/components/StatsTiles.tsx +66 -0
  58. package/src/components/StatusBadge.tsx +50 -0
  59. package/src/components/StepFlow.tsx +105 -0
  60. package/src/components/StepGlyph.tsx +25 -0
  61. package/src/components/StepInspector.tsx +44 -0
  62. package/src/components/StepRow.tsx +69 -0
  63. package/src/components/StepTabsView.tsx +51 -0
  64. package/src/components/StepTimeline.tsx +87 -0
  65. package/src/components/TableStatusRows.tsx +54 -0
  66. package/src/components/TriggerEventDialog.tsx +180 -0
  67. package/src/components/TriggerEventResult.tsx +61 -0
  68. package/src/components/WebhookBadges.tsx +69 -0
  69. package/src/components/WebhookStatusBadge.tsx +25 -0
  70. package/src/components/WebhooksView.tsx +69 -0
  71. package/src/components/WorkflowDetail.tsx +149 -0
  72. package/src/components/WorkflowRunAction.tsx +46 -0
  73. package/src/components/WorkflowRunDialog.tsx +187 -0
  74. package/src/components/WorkflowsView.tsx +168 -0
  75. package/src/components/charts/ChartCard.tsx +19 -0
  76. package/src/components/charts/RunCharts.tsx +31 -0
  77. package/src/components/charts/RunLatencyChart.tsx +71 -0
  78. package/src/components/charts/RunsOverTimeChart.tsx +60 -0
  79. package/src/components/filters/AppFilter.tsx +65 -0
  80. package/src/components/filters/FilterDropdown.tsx +33 -0
  81. package/src/components/filters/FilterDropdownButton.tsx +31 -0
  82. package/src/components/filters/FilterDropdownItem.tsx +37 -0
  83. package/src/components/filters/TimeRangeFilter.tsx +43 -0
  84. package/src/components/filters/TimeZoneFilter.tsx +40 -0
  85. package/src/components/filters/use-click-outside.ts +18 -0
  86. package/src/components/filters-pager.test.tsx +94 -0
  87. package/src/components/marks-geometry.ts +10 -0
  88. package/src/components/replay-dialog.test.tsx +18 -0
  89. package/src/components/run-components.test.tsx +126 -0
  90. package/src/components/run-controls.test.tsx +97 -0
  91. package/src/hooks/use-confirm-action.ts +19 -0
  92. package/src/hooks/use-copy.ts +22 -0
  93. package/src/hooks/use-keyset-pager.ts +34 -0
  94. package/src/hooks/use-mobile.ts +16 -0
  95. package/src/index.ts +165 -0
  96. package/src/lib/app-color.test.ts +32 -0
  97. package/src/lib/app-color.ts +8 -0
  98. package/src/lib/control-action.ts +36 -0
  99. package/src/lib/flow-control.ts +77 -0
  100. package/src/lib/format.test.ts +102 -0
  101. package/src/lib/format.ts +45 -0
  102. package/src/lib/json-highlight.test.ts +36 -0
  103. package/src/lib/json-highlight.ts +64 -0
  104. package/src/lib/run-filters.ts +8 -0
  105. package/src/lib/run-logs.test.ts +80 -0
  106. package/src/lib/run-logs.ts +34 -0
  107. package/src/lib/run-progress.test.ts +109 -0
  108. package/src/lib/run-progress.ts +44 -0
  109. package/src/lib/run-sort.test.ts +40 -0
  110. package/src/lib/run-sort.ts +19 -0
  111. package/src/lib/status-label.test.ts +35 -0
  112. package/src/lib/status-label.ts +13 -0
  113. package/src/lib/step-detail.test.ts +122 -0
  114. package/src/lib/step-detail.ts +35 -0
  115. package/src/lib/step-display.test.ts +19 -0
  116. package/src/lib/step-display.ts +13 -0
  117. package/src/lib/step-timeline.test.ts +89 -0
  118. package/src/lib/step-timeline.ts +50 -0
  119. package/src/lib/table.ts +2 -0
  120. package/src/lib/theme.ts +35 -0
  121. package/src/lib/time-range.ts +81 -0
  122. package/src/lib/utils.ts +6 -0
  123. package/src/lib/webhook-view.test.ts +176 -0
  124. package/src/lib/webhook-view.ts +113 -0
  125. package/src/lib/workflow-run.test.ts +55 -0
  126. package/src/lib/workflow-run.ts +45 -0
  127. package/src/shell/AppShell.tsx +34 -0
  128. package/src/shell/Sidebar.tsx +78 -0
  129. package/src/shell/Topbar.tsx +22 -0
  130. package/src/styles.css +2204 -0
  131. package/src/test-utils.tsx +130 -0
  132. package/src/ui/button.tsx +67 -0
  133. package/src/ui/chart.tsx +337 -0
  134. package/src/ui/dialog.tsx +145 -0
  135. package/src/ui/input.tsx +19 -0
  136. package/src/ui/resizable.tsx +40 -0
  137. package/src/ui/separator.tsx +28 -0
  138. package/src/ui/sheet.tsx +128 -0
  139. package/src/ui/sidebar.tsx +665 -0
  140. package/src/ui/skeleton.tsx +15 -0
  141. package/src/ui/sonner.tsx +35 -0
  142. package/src/ui/table.tsx +87 -0
  143. 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
+ }