@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,81 @@
1
+ export const DEFAULT_TIME_RANGE = "1h";
2
+
3
+ export const TIME_OPTIONS: [string, string][] = [
4
+ ["30s", "Last 30 seconds"],
5
+ ["1m", "Last 1 minute"],
6
+ ["10m", "Last 10 minutes"],
7
+ ["30m", "Last 30 minutes"],
8
+ ["1h", "Last 1 hour"],
9
+ ["12h", "Last 12 hours"],
10
+ ["1d", "Last 1 day"],
11
+ ["3d", "Last 3 days"],
12
+ ["7d", "Last 7 days"],
13
+ ["30d", "Last 30 days"],
14
+ ];
15
+
16
+ const UNIT_MS: Record<string, number> = {
17
+ s: 1_000,
18
+ m: 60_000,
19
+ h: 3_600_000,
20
+ d: 86_400_000,
21
+ };
22
+
23
+ export function parseWindowMs(time: string): number | null {
24
+ const match = /^(\d+)([smhd])$/.exec(time.trim());
25
+ if (!match) return null;
26
+ const [, count, unit] = match;
27
+ const ms = unit ? UNIT_MS[unit] : undefined;
28
+ return ms ? Number(count) * ms : null;
29
+ }
30
+
31
+ export function timeLabel(time: string): string {
32
+ const preset = TIME_OPTIONS.find(([key]) => key === time);
33
+ return preset ? preset[1] : `Last ${time}`;
34
+ }
35
+
36
+ // Quantize "now" so the cutoff is stable across renders (an unquantized value would
37
+ // change every millisecond and thrash the query cache). The window still slides
38
+ // forward once per bucket; freshness within a bucket comes from polling.
39
+ const SINCE_BUCKET_MS = 15_000;
40
+
41
+ // Widths the series chart snaps to (1m..1d). Targeting ~48 buckets across the window
42
+ // keeps bars readable.
43
+ const NICE_BUCKET_SECONDS = [60, 300, 900, 1800, 3600, 21_600, 86_400];
44
+ const WIDEST_BUCKET_SECONDS = 86_400;
45
+ const TARGET_BUCKETS = 48;
46
+ const DEFAULT_BUCKET_SECONDS = 3600;
47
+
48
+ export function seriesBucketSeconds(time: string): number {
49
+ const windowMs = parseWindowMs(time);
50
+ if (windowMs == null) return DEFAULT_BUCKET_SECONDS;
51
+ const ideal = windowMs / 1000 / TARGET_BUCKETS;
52
+ return NICE_BUCKET_SECONDS.find((s) => s >= ideal) ?? WIDEST_BUCKET_SECONDS;
53
+ }
54
+
55
+ export const TIME_ZONES = ["local", "utc"] as const;
56
+ export type TimeZoneMode = (typeof TIME_ZONES)[number];
57
+
58
+ // The viewer's auto-detected IANA zone (e.g. "Europe/Madrid"), shown so "Local"
59
+ // names the zone it resolves to.
60
+ export const localTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
61
+
62
+ // An undefined timeZone renders in the viewer's local zone (Intl's default).
63
+ export function bucketTickLabel(
64
+ ts: string,
65
+ bucketSeconds: number,
66
+ tz: TimeZoneMode = "local",
67
+ ): string {
68
+ const d = new Date(ts);
69
+ const timeZone = tz === "utc" ? "UTC" : undefined;
70
+ if (bucketSeconds >= 86_400) {
71
+ return d.toLocaleDateString([], { month: "short", day: "numeric", timeZone });
72
+ }
73
+ return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", timeZone });
74
+ }
75
+
76
+ export function windowSince(time: string): string | undefined {
77
+ const windowMs = parseWindowMs(time);
78
+ if (windowMs == null) return undefined;
79
+ const now = Math.floor(Date.now() / SINCE_BUCKET_MS) * SINCE_BUCKET_MS;
80
+ return new Date(now - windowMs).toISOString();
81
+ }
@@ -0,0 +1,6 @@
1
+ import { clsx, type ClassValue } from "clsx";
2
+ import { twMerge } from "tailwind-merge";
3
+
4
+ export function cn(...inputs: ClassValue[]) {
5
+ return twMerge(clsx(inputs));
6
+ }
@@ -0,0 +1,176 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type {
3
+ DeliveryStatus,
4
+ WebhookDelivery,
5
+ WebhookEndpoint,
6
+ WebhookEndpointStats,
7
+ WebhookReceiver,
8
+ } from "@durablex/react";
9
+ import {
10
+ codePillClass,
11
+ deliveryView,
12
+ endpointHealth,
13
+ endpointLabel,
14
+ endpointMeta,
15
+ groupDeliveriesByEndpoint,
16
+ receiverLabel,
17
+ shortUrl,
18
+ successRate,
19
+ } from "./webhook-view";
20
+
21
+ function delivery(over: Partial<WebhookDelivery> = {}): WebhookDelivery {
22
+ return {
23
+ id: "d1",
24
+ app: "billing",
25
+ url: "https://hooks.test/x",
26
+ eventKind: "run.failed",
27
+ status: "succeeded",
28
+ attemptCount: 1,
29
+ maxAttempts: 5,
30
+ createdAt: "2030-01-01T00:00:00Z",
31
+ updatedAt: "2030-01-01T00:00:00Z",
32
+ ...over,
33
+ };
34
+ }
35
+
36
+ function endpoint(over: Partial<WebhookEndpoint> = {}): WebhookEndpoint {
37
+ return {
38
+ id: "e1",
39
+ url: "https://hooks.test/x",
40
+ scheme: "hmac_sha256",
41
+ eventKinds: ["run.failed"],
42
+ enabled: true,
43
+ createdAt: "2030-01-01T00:00:00Z",
44
+ updatedAt: "2030-01-01T00:00:00Z",
45
+ ...over,
46
+ };
47
+ }
48
+
49
+ describe("deliveryView", () => {
50
+ const cases: [DeliveryStatus, number, string][] = [
51
+ ["succeeded", 1, "delivered"],
52
+ ["exhausted", 3, "failed"],
53
+ ["dead", 3, "failed"],
54
+ ["delivering", 1, "retrying"],
55
+ ["failed", 2, "retrying"],
56
+ ["pending", 0, "queued"],
57
+ ["pending", 1, "retrying"],
58
+ ];
59
+ it.each(cases)("%s with %d attempts -> %s", (status, attemptCount, view) => {
60
+ expect(deliveryView({ status, attemptCount })).toBe(view);
61
+ });
62
+ });
63
+
64
+ describe("endpointHealth", () => {
65
+ it("is disabled when the endpoint is off, regardless of deliveries", () => {
66
+ expect(endpointHealth(endpoint({ enabled: false }), [delivery({ status: "succeeded" })])).toBe(
67
+ "disabled",
68
+ );
69
+ });
70
+ it("is failing when the most recent delivery is terminally failed", () => {
71
+ expect(endpointHealth(endpoint(), [delivery({ status: "dead", attemptCount: 5 })])).toBe(
72
+ "failing",
73
+ );
74
+ });
75
+ it("is active when enabled and the latest delivery succeeded", () => {
76
+ expect(endpointHealth(endpoint(), [delivery({ status: "succeeded" })])).toBe("active");
77
+ });
78
+ it("is active with no deliveries yet", () => {
79
+ expect(endpointHealth(endpoint(), [])).toBe("active");
80
+ });
81
+ });
82
+
83
+ describe("successRate", () => {
84
+ const stats = (over: Partial<WebhookEndpointStats>): WebhookEndpointStats => ({
85
+ endpointId: "e1",
86
+ delivered: 0,
87
+ succeeded: 0,
88
+ failed: 0,
89
+ ...over,
90
+ });
91
+ it("is null when nothing has settled", () => {
92
+ expect(successRate(undefined)).toBeNull();
93
+ expect(successRate(stats({ delivered: 4, succeeded: 0, failed: 0 }))).toBeNull();
94
+ });
95
+ it("divides by settled deliveries, not in-flight volume", () => {
96
+ // 4 delivered rows but only 3 settled (2 ok + 1 failed): rate is 67%, not 50%.
97
+ expect(successRate(stats({ delivered: 4, succeeded: 2, failed: 1 }))).toBe(67);
98
+ });
99
+ it("is 100 when all settled deliveries succeeded", () => {
100
+ expect(successRate(stats({ delivered: 5, succeeded: 5, failed: 0 }))).toBe(100);
101
+ });
102
+ });
103
+
104
+ describe("groupDeliveriesByEndpoint", () => {
105
+ it("groups by endpointId and skips custom deliveries with no endpoint", () => {
106
+ const grouped = groupDeliveriesByEndpoint([
107
+ delivery({ id: "a", endpointId: "e1" }),
108
+ delivery({ id: "b", endpointId: "e1" }),
109
+ delivery({ id: "c", endpointId: "e2" }),
110
+ delivery({ id: "d", endpointId: undefined }),
111
+ ]);
112
+ expect(grouped.get("e1")?.map((d) => d.id)).toEqual(["a", "b"]);
113
+ expect(grouped.get("e2")?.map((d) => d.id)).toEqual(["c"]);
114
+ expect(grouped.has("")).toBe(false);
115
+ expect(grouped.size).toBe(2);
116
+ });
117
+ });
118
+
119
+ describe("endpointMeta", () => {
120
+ it("resolves a known endpoint to its label and failing-state", () => {
121
+ const ep = endpoint({ id: "e1", name: "Billing hook" });
122
+ const byEndpoint = new Map([["e1", [delivery({ status: "dead", attemptCount: 5 })]]]);
123
+ expect(endpointMeta(delivery({ endpointId: "e1" }), [ep], byEndpoint)).toEqual({
124
+ label: "Billing hook",
125
+ failing: true,
126
+ });
127
+ });
128
+ it("falls back to the delivery URL for a custom (no-endpoint) delivery", () => {
129
+ expect(
130
+ endpointMeta(delivery({ endpointId: undefined, url: "https://x.test/h" }), [], new Map()),
131
+ ).toEqual({
132
+ label: "x.test/h",
133
+ failing: false,
134
+ });
135
+ });
136
+ });
137
+
138
+ describe("labels and formatting", () => {
139
+ it("endpointLabel prefers name, falls back to the host", () => {
140
+ expect(endpointLabel(endpoint({ name: "Edge" }))).toBe("Edge");
141
+ expect(endpointLabel(endpoint({ name: " ", url: "https://hooks.test/x" }))).toBe(
142
+ "hooks.test/x",
143
+ );
144
+ });
145
+ it("receiverLabel prefers name, falls back to slug", () => {
146
+ const rc: WebhookReceiver = {
147
+ id: "r1",
148
+ slug: "abc123",
149
+ eventName: "stripe.charge",
150
+ scheme: "hmac_sha256",
151
+ enabled: true,
152
+ createdAt: "2030-01-01T00:00:00Z",
153
+ updatedAt: "2030-01-01T00:00:00Z",
154
+ };
155
+ expect(receiverLabel(rc)).toBe("abc123");
156
+ expect(receiverLabel({ ...rc, name: "Stripe" })).toBe("Stripe");
157
+ });
158
+ it("shortUrl strips the scheme", () => {
159
+ expect(shortUrl("https://hooks.test/x")).toBe("hooks.test/x");
160
+ expect(shortUrl("http://localhost:9000/h")).toBe("localhost:9000/h");
161
+ });
162
+ });
163
+
164
+ describe("codePillClass", () => {
165
+ it.each([
166
+ [undefined, "none"],
167
+ [0, "none"],
168
+ [200, "ok"],
169
+ [201, "ok"],
170
+ [404, "warn"],
171
+ [500, "err"],
172
+ [503, "err"],
173
+ ])("%s -> %s", (code, cls) => {
174
+ expect(codePillClass(code)).toBe(cls);
175
+ });
176
+ });
@@ -0,0 +1,113 @@
1
+ import type {
2
+ WebhookDelivery,
3
+ WebhookEndpoint,
4
+ WebhookEndpointStats,
5
+ WebhookReceiver,
6
+ } from "@durablex/react";
7
+
8
+ // WEBHOOK_TABS are the three sub-views of the Webhooks page, mirrored into the URL so a
9
+ // tab (and a selected delivery) is shareable like the Runs view.
10
+ export const WEBHOOK_TABS = ["deliveries", "endpoints", "receivers"] as const;
11
+ export type WebhookTab = (typeof WEBHOOK_TABS)[number];
12
+
13
+ // DeliveryView collapses the engine's six delivery statuses into the four the dashboard
14
+ // shows. failed (awaiting a retry) and delivering both read as "retrying"; a pending row
15
+ // is "queued" until its first attempt; exhausted/dead are terminal "failed".
16
+ export const DELIVERY_VIEWS = ["delivered", "retrying", "failed", "queued"] as const;
17
+ export type DeliveryView = (typeof DELIVERY_VIEWS)[number];
18
+
19
+ export function deliveryView(d: Pick<WebhookDelivery, "status" | "attemptCount">): DeliveryView {
20
+ switch (d.status) {
21
+ case "succeeded":
22
+ return "delivered";
23
+ case "exhausted":
24
+ case "dead":
25
+ return "failed";
26
+ case "delivering":
27
+ case "failed":
28
+ return "retrying";
29
+ default:
30
+ return d.attemptCount > 0 ? "retrying" : "queued";
31
+ }
32
+ }
33
+
34
+ export const DELIVERY_VIEW_TOKEN: Record<DeliveryView, string> = {
35
+ delivered: "succeeded",
36
+ retrying: "running",
37
+ failed: "failed",
38
+ queued: "queued",
39
+ };
40
+
41
+ export const DELIVERY_VIEW_LABEL: Record<DeliveryView, string> = {
42
+ delivered: "Delivered",
43
+ retrying: "Retrying",
44
+ failed: "Failed",
45
+ queued: "Queued",
46
+ };
47
+
48
+ export type EndpointHealth = "active" | "failing" | "disabled";
49
+
50
+ export function endpointHealth(
51
+ endpoint: WebhookEndpoint,
52
+ recent: WebhookDelivery[],
53
+ ): EndpointHealth {
54
+ if (!endpoint.enabled) return "disabled";
55
+ const latest = recent[0];
56
+ if (latest && deliveryView(latest) === "failed") return "failing";
57
+ return "active";
58
+ }
59
+
60
+ export const shortUrl = (u: string) => u.replace(/^https?:\/\//, "");
61
+
62
+ export const displayName = (name: string | undefined, fallback: string) =>
63
+ name && name.trim() ? name : fallback;
64
+
65
+ export const endpointLabel = (e: WebhookEndpoint) => displayName(e.name, shortUrl(e.url));
66
+ export const receiverLabel = (r: WebhookReceiver) => displayName(r.name, r.slug);
67
+
68
+ export const DELIVERY_LIST_LIMIT = 200;
69
+
70
+ export function groupDeliveriesByEndpoint(
71
+ deliveries: WebhookDelivery[],
72
+ ): Map<string, WebhookDelivery[]> {
73
+ const m = new Map<string, WebhookDelivery[]>();
74
+ for (const d of deliveries) {
75
+ if (!d.endpointId) continue;
76
+ const list = m.get(d.endpointId);
77
+ if (list) list.push(d);
78
+ else m.set(d.endpointId, [d]);
79
+ }
80
+ return m;
81
+ }
82
+
83
+ export function codePillClass(code: number | undefined): string {
84
+ if (code == null || code === 0) return "none";
85
+ if (code < 300) return "ok";
86
+ if (code < 500) return "warn";
87
+ return "err";
88
+ }
89
+
90
+ // successRate is computed over settled deliveries only (succeeded vs terminal-failed).
91
+ // stats.delivered counts every row in the window including in-flight ones, so dividing by
92
+ // it would understate the rate while retries are outstanding. null when nothing has settled.
93
+ export function successRate(stats?: WebhookEndpointStats): number | null {
94
+ if (!stats) return null;
95
+ const settled = stats.succeeded + stats.failed;
96
+ if (settled === 0) return null;
97
+ return Math.round((stats.succeeded / settled) * 100);
98
+ }
99
+
100
+ // endpointMeta resolves the display label and failing-state for a delivery. Custom
101
+ // deliveries (ctx.webhook.send) carry no endpointId, so fall back to the delivery URL.
102
+ export function endpointMeta(
103
+ d: WebhookDelivery,
104
+ endpoints: WebhookEndpoint[],
105
+ byEndpoint: Map<string, WebhookDelivery[]>,
106
+ ): { label: string; failing: boolean } {
107
+ const ep = d.endpointId ? endpoints.find((e) => e.id === d.endpointId) : undefined;
108
+ if (!ep) return { label: shortUrl(d.url), failing: false };
109
+ return {
110
+ label: endpointLabel(ep),
111
+ failing: endpointHealth(ep, byEndpoint.get(ep.id) ?? []) === "failing",
112
+ };
113
+ }
@@ -0,0 +1,55 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { WorkflowDef, WorkflowTrigger } from "@durablex/react";
3
+ import { workflowRunPlan } from "./workflow-run";
4
+
5
+ function workflow(over: Partial<WorkflowDef> = {}): WorkflowDef {
6
+ return {
7
+ name: "send-invoice",
8
+ app: "billing",
9
+ maxAttempts: 3,
10
+ backoff: "exponential",
11
+ scheduled: false,
12
+ registeredAt: "2030-01-01T00:00:00Z",
13
+ updatedAt: "2030-01-01T00:00:00Z",
14
+ ...over,
15
+ };
16
+ }
17
+
18
+ describe("workflowRunPlan", () => {
19
+ it("runs on an event matching its own name when it has no triggers", () => {
20
+ const plan = workflowRunPlan(workflow({ triggers: undefined }));
21
+ expect(plan.runnable).toBe(true);
22
+ expect(plan.options).toEqual([
23
+ { value: "send-invoice", pattern: "send-invoice", isWildcard: false },
24
+ ]);
25
+ });
26
+
27
+ it("surfaces each event trigger as an option and carries the CEL filter", () => {
28
+ const triggers: WorkflowTrigger[] = [{ event: "invoice.created", if: "data.amount > 0" }];
29
+ const plan = workflowRunPlan(workflow({ triggers }));
30
+ expect(plan.runnable).toBe(true);
31
+ expect(plan.options).toEqual([
32
+ {
33
+ value: "invoice.created",
34
+ pattern: "invoice.created",
35
+ isWildcard: false,
36
+ filter: "data.amount > 0",
37
+ },
38
+ ]);
39
+ });
40
+
41
+ it("strips the trailing star from a wildcard so the user completes the name", () => {
42
+ const plan = workflowRunPlan(workflow({ triggers: [{ event: "invoice.*" }] }));
43
+ expect(plan.options[0]).toMatchObject({
44
+ value: "invoice.",
45
+ pattern: "invoice.*",
46
+ isWildcard: true,
47
+ });
48
+ });
49
+
50
+ it("is not runnable for a cron-only workflow (no fireable event)", () => {
51
+ const plan = workflowRunPlan(workflow({ scheduled: true, triggers: [{ cron: "0 * * * *" }] }));
52
+ expect(plan.runnable).toBe(false);
53
+ expect(plan.options).toEqual([]);
54
+ });
55
+ });
@@ -0,0 +1,45 @@
1
+ import type { WorkflowDef } from "@durablex/react";
2
+
3
+ // A single event a workflow can be run from. `value` is what to pre-fill into the
4
+ // event-name field: a wildcard pattern has its trailing "*" stripped so the user
5
+ // completes a concrete name. `filter` is the trigger's CEL `if`, surfaced as a hint.
6
+ export interface EventRunOption {
7
+ value: string;
8
+ pattern: string;
9
+ isWildcard: boolean;
10
+ filter?: string;
11
+ }
12
+
13
+ // A cron-only workflow has no event to fire and no manual fire-now endpoint, so it
14
+ // is not runnable from the dashboard yet (Phase 4). A workflow with no triggers at
15
+ // all runs on an event matching its own name.
16
+ export interface WorkflowRunPlan {
17
+ runnable: boolean;
18
+ options: EventRunOption[];
19
+ }
20
+
21
+ export function workflowRunPlan(workflow: WorkflowDef): WorkflowRunPlan {
22
+ const triggers = workflow.triggers ?? [];
23
+
24
+ if (triggers.length === 0) {
25
+ return {
26
+ runnable: true,
27
+ options: [{ value: workflow.name, pattern: workflow.name, isWildcard: false }],
28
+ };
29
+ }
30
+
31
+ const options = triggers.flatMap<EventRunOption>((t) => {
32
+ if (!t.event) return [];
33
+ const isWildcard = t.event.endsWith("*");
34
+ return [
35
+ {
36
+ value: isWildcard ? t.event.slice(0, -1) : t.event,
37
+ pattern: t.event,
38
+ isWildcard,
39
+ filter: t.if,
40
+ },
41
+ ];
42
+ });
43
+
44
+ return { runnable: options.length > 0, options };
45
+ }
@@ -0,0 +1,34 @@
1
+ import type { ReactNode } from "react";
2
+ import { SidebarInset, SidebarProvider } from "../ui/sidebar";
3
+ import { Sidebar, type NavGroup } from "./Sidebar";
4
+ import { Topbar } from "./Topbar";
5
+
6
+ export function AppShell({
7
+ groups,
8
+ active,
9
+ onSelect,
10
+ title,
11
+ subtitle,
12
+ rightSlot,
13
+ sidebarFooter,
14
+ children,
15
+ }: {
16
+ groups: NavGroup[];
17
+ active: string;
18
+ onSelect(key: string): void;
19
+ title: string;
20
+ subtitle?: string;
21
+ rightSlot?: ReactNode;
22
+ sidebarFooter?: ReactNode;
23
+ children: ReactNode;
24
+ }) {
25
+ return (
26
+ <SidebarProvider>
27
+ <Sidebar groups={groups} active={active} onSelect={onSelect} footer={sidebarFooter} />
28
+ <SidebarInset className="min-w-0">
29
+ <Topbar title={title} subtitle={subtitle} rightSlot={rightSlot} />
30
+ {children}
31
+ </SidebarInset>
32
+ </SidebarProvider>
33
+ );
34
+ }
@@ -0,0 +1,78 @@
1
+ import type { LucideIcon } from "lucide-react";
2
+ import type { ReactNode } from "react";
3
+ import { DurablexLogo } from "../components/DurablexLogo";
4
+ import {
5
+ Sidebar as SidebarPrimitive,
6
+ SidebarContent,
7
+ SidebarFooter,
8
+ SidebarGroup,
9
+ SidebarGroupContent,
10
+ SidebarGroupLabel,
11
+ SidebarHeader,
12
+ SidebarMenu,
13
+ SidebarMenuButton,
14
+ SidebarMenuItem,
15
+ } from "../ui/sidebar";
16
+
17
+ export interface NavItem {
18
+ key: string;
19
+ label: string;
20
+ icon: LucideIcon;
21
+ count?: number;
22
+ }
23
+
24
+ export interface NavGroup {
25
+ label: string;
26
+ items: NavItem[];
27
+ }
28
+
29
+ export function Sidebar({
30
+ groups,
31
+ active,
32
+ onSelect,
33
+ footer,
34
+ }: {
35
+ groups: NavGroup[];
36
+ active: string;
37
+ onSelect(key: string): void;
38
+ footer?: ReactNode;
39
+ }) {
40
+ return (
41
+ <SidebarPrimitive collapsible="icon">
42
+ <SidebarHeader>
43
+ <div className="flex items-center gap-2 px-2 py-2.5">
44
+ <DurablexLogo />
45
+ </div>
46
+ </SidebarHeader>
47
+ <SidebarContent>
48
+ {groups.map((group) => (
49
+ <SidebarGroup key={group.label}>
50
+ <SidebarGroupLabel>{group.label}</SidebarGroupLabel>
51
+ <SidebarGroupContent>
52
+ <SidebarMenu>
53
+ {group.items.map((item) => (
54
+ <SidebarMenuItem key={item.key}>
55
+ <SidebarMenuButton
56
+ isActive={active === item.key}
57
+ tooltip={item.label}
58
+ onClick={() => onSelect(item.key)}
59
+ >
60
+ <item.icon />
61
+ <span>{item.label}</span>
62
+ {item.count != null && (
63
+ <span className="text-muted-foreground ml-auto font-mono text-[11px] tabular-nums group-data-[collapsible=icon]:hidden">
64
+ {item.count}
65
+ </span>
66
+ )}
67
+ </SidebarMenuButton>
68
+ </SidebarMenuItem>
69
+ ))}
70
+ </SidebarMenu>
71
+ </SidebarGroupContent>
72
+ </SidebarGroup>
73
+ ))}
74
+ </SidebarContent>
75
+ {footer && <SidebarFooter>{footer}</SidebarFooter>}
76
+ </SidebarPrimitive>
77
+ );
78
+ }
@@ -0,0 +1,22 @@
1
+ import type { ReactNode } from "react";
2
+ import { SidebarTrigger } from "../ui/sidebar";
3
+
4
+ export function Topbar({
5
+ title,
6
+ subtitle,
7
+ rightSlot,
8
+ }: {
9
+ title: string;
10
+ subtitle?: string;
11
+ rightSlot?: ReactNode;
12
+ }) {
13
+ return (
14
+ <header className="flex h-12 shrink-0 items-center gap-2 border-b px-4">
15
+ <SidebarTrigger />
16
+ <h1 className="text-sm font-semibold tracking-tight">{title}</h1>
17
+ {subtitle && <span className="text-muted-foreground text-xs">{subtitle}</span>}
18
+ <span className="flex-1" />
19
+ {rightSlot}
20
+ </header>
21
+ );
22
+ }