station-kit 1.0.0

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 (181) hide show
  1. package/LICENSE +21 -0
  2. package/dist/cli-main.d.ts +2 -0
  3. package/dist/cli-main.d.ts.map +1 -0
  4. package/dist/cli-main.js +58 -0
  5. package/dist/cli-main.js.map +1 -0
  6. package/dist/cli.d.ts +3 -0
  7. package/dist/cli.d.ts.map +1 -0
  8. package/dist/cli.js +25 -0
  9. package/dist/cli.js.map +1 -0
  10. package/dist/config/loader.d.ts +3 -0
  11. package/dist/config/loader.d.ts.map +1 -0
  12. package/dist/config/loader.js +29 -0
  13. package/dist/config/loader.js.map +1 -0
  14. package/dist/config/schema.d.ts +36 -0
  15. package/dist/config/schema.d.ts.map +1 -0
  16. package/dist/config/schema.js +40 -0
  17. package/dist/config/schema.js.map +1 -0
  18. package/dist/index.d.ts +4 -0
  19. package/dist/index.d.ts.map +1 -0
  20. package/dist/index.js +4 -0
  21. package/dist/index.js.map +1 -0
  22. package/dist/server/auth/keys.d.ts +28 -0
  23. package/dist/server/auth/keys.d.ts.map +1 -0
  24. package/dist/server/auth/keys.js +91 -0
  25. package/dist/server/auth/keys.js.map +1 -0
  26. package/dist/server/auth/session.d.ts +9 -0
  27. package/dist/server/auth/session.d.ts.map +1 -0
  28. package/dist/server/auth/session.js +42 -0
  29. package/dist/server/auth/session.js.map +1 -0
  30. package/dist/server/index.d.ts +7 -0
  31. package/dist/server/index.d.ts.map +1 -0
  32. package/dist/server/index.js +253 -0
  33. package/dist/server/index.js.map +1 -0
  34. package/dist/server/log-buffer.d.ts +20 -0
  35. package/dist/server/log-buffer.d.ts.map +1 -0
  36. package/dist/server/log-buffer.js +33 -0
  37. package/dist/server/log-buffer.js.map +1 -0
  38. package/dist/server/log-store.d.ts +11 -0
  39. package/dist/server/log-store.d.ts.map +1 -0
  40. package/dist/server/log-store.js +40 -0
  41. package/dist/server/log-store.js.map +1 -0
  42. package/dist/server/metadata.d.ts +38 -0
  43. package/dist/server/metadata.d.ts.map +1 -0
  44. package/dist/server/metadata.js +130 -0
  45. package/dist/server/metadata.js.map +1 -0
  46. package/dist/server/middleware/auth.d.ts +12 -0
  47. package/dist/server/middleware/auth.d.ts.map +1 -0
  48. package/dist/server/middleware/auth.js +42 -0
  49. package/dist/server/middleware/auth.js.map +1 -0
  50. package/dist/server/middleware/rate-limit.d.ts +15 -0
  51. package/dist/server/middleware/rate-limit.d.ts.map +1 -0
  52. package/dist/server/middleware/rate-limit.js +36 -0
  53. package/dist/server/middleware/rate-limit.js.map +1 -0
  54. package/dist/server/middleware/scope-guard.d.ts +9 -0
  55. package/dist/server/middleware/scope-guard.d.ts.map +1 -0
  56. package/dist/server/middleware/scope-guard.js +17 -0
  57. package/dist/server/middleware/scope-guard.js.map +1 -0
  58. package/dist/server/routes/broadcasts.d.ts +12 -0
  59. package/dist/server/routes/broadcasts.d.ts.map +1 -0
  60. package/dist/server/routes/broadcasts.js +135 -0
  61. package/dist/server/routes/broadcasts.js.map +1 -0
  62. package/dist/server/routes/health.d.ts +9 -0
  63. package/dist/server/routes/health.d.ts.map +1 -0
  64. package/dist/server/routes/health.js +27 -0
  65. package/dist/server/routes/health.js.map +1 -0
  66. package/dist/server/routes/runs.d.ts +12 -0
  67. package/dist/server/routes/runs.d.ts.map +1 -0
  68. package/dist/server/routes/runs.js +122 -0
  69. package/dist/server/routes/runs.js.map +1 -0
  70. package/dist/server/routes/signals.d.ts +10 -0
  71. package/dist/server/routes/signals.d.ts.map +1 -0
  72. package/dist/server/routes/signals.js +120 -0
  73. package/dist/server/routes/signals.js.map +1 -0
  74. package/dist/server/routes/v1/auth.d.ts +7 -0
  75. package/dist/server/routes/v1/auth.d.ts.map +1 -0
  76. package/dist/server/routes/v1/auth.js +28 -0
  77. package/dist/server/routes/v1/auth.js.map +1 -0
  78. package/dist/server/routes/v1/broadcasts.d.ts +10 -0
  79. package/dist/server/routes/v1/broadcasts.d.ts.map +1 -0
  80. package/dist/server/routes/v1/broadcasts.js +68 -0
  81. package/dist/server/routes/v1/broadcasts.js.map +1 -0
  82. package/dist/server/routes/v1/events.d.ts +7 -0
  83. package/dist/server/routes/v1/events.d.ts.map +1 -0
  84. package/dist/server/routes/v1/events.js +57 -0
  85. package/dist/server/routes/v1/events.js.map +1 -0
  86. package/dist/server/routes/v1/health.d.ts +9 -0
  87. package/dist/server/routes/v1/health.d.ts.map +1 -0
  88. package/dist/server/routes/v1/health.js +31 -0
  89. package/dist/server/routes/v1/health.js.map +1 -0
  90. package/dist/server/routes/v1/keys.d.ts +7 -0
  91. package/dist/server/routes/v1/keys.d.ts.map +1 -0
  92. package/dist/server/routes/v1/keys.js +43 -0
  93. package/dist/server/routes/v1/keys.js.map +1 -0
  94. package/dist/server/routes/v1/runs.d.ts +12 -0
  95. package/dist/server/routes/v1/runs.d.ts.map +1 -0
  96. package/dist/server/routes/v1/runs.js +76 -0
  97. package/dist/server/routes/v1/runs.js.map +1 -0
  98. package/dist/server/routes/v1/signals.d.ts +9 -0
  99. package/dist/server/routes/v1/signals.d.ts.map +1 -0
  100. package/dist/server/routes/v1/signals.js +33 -0
  101. package/dist/server/routes/v1/signals.js.map +1 -0
  102. package/dist/server/routes/v1/trigger.d.ts +12 -0
  103. package/dist/server/routes/v1/trigger.d.ts.map +1 -0
  104. package/dist/server/routes/v1/trigger.js +73 -0
  105. package/dist/server/routes/v1/trigger.js.map +1 -0
  106. package/dist/server/sse.d.ts +19 -0
  107. package/dist/server/sse.d.ts.map +1 -0
  108. package/dist/server/sse.js +51 -0
  109. package/dist/server/sse.js.map +1 -0
  110. package/dist/server/subscriber.d.ts +128 -0
  111. package/dist/server/subscriber.d.ts.map +1 -0
  112. package/dist/server/subscriber.js +246 -0
  113. package/dist/server/subscriber.js.map +1 -0
  114. package/dist/server/ws.d.ts +15 -0
  115. package/dist/server/ws.d.ts.map +1 -0
  116. package/dist/server/ws.js +32 -0
  117. package/dist/server/ws.js.map +1 -0
  118. package/next-env.d.ts +6 -0
  119. package/next.config.ts +10 -0
  120. package/package.json +49 -0
  121. package/src/app/broadcasts/[id]/page.tsx +511 -0
  122. package/src/app/broadcasts/page.tsx +158 -0
  123. package/src/app/components/auth-provider.tsx +75 -0
  124. package/src/app/components/breadcrumb-provider.tsx +18 -0
  125. package/src/app/components/dag-view.tsx +380 -0
  126. package/src/app/components/empty-state.tsx +7 -0
  127. package/src/app/components/json-viewer.tsx +153 -0
  128. package/src/app/components/login-page.tsx +78 -0
  129. package/src/app/components/node-detail.tsx +158 -0
  130. package/src/app/components/pulse-dot.tsx +8 -0
  131. package/src/app/components/relative-time.tsx +34 -0
  132. package/src/app/components/run-table.tsx +96 -0
  133. package/src/app/components/schema-form.tsx +121 -0
  134. package/src/app/components/shell.tsx +203 -0
  135. package/src/app/components/status-badge.tsx +10 -0
  136. package/src/app/components/step-timeline.tsx +134 -0
  137. package/src/app/components/theme-provider.tsx +45 -0
  138. package/src/app/components/workflow-node-sidebar.tsx +68 -0
  139. package/src/app/globals.css +1523 -0
  140. package/src/app/hooks/use-api.ts +129 -0
  141. package/src/app/hooks/use-breadcrumb.ts +37 -0
  142. package/src/app/hooks/use-realtime.ts +68 -0
  143. package/src/app/hooks/use-station.tsx +34 -0
  144. package/src/app/layout.tsx +42 -0
  145. package/src/app/page.tsx +275 -0
  146. package/src/app/runs/[id]/page.tsx +277 -0
  147. package/src/app/signals/[name]/page.tsx +250 -0
  148. package/src/app/signals/page.tsx +99 -0
  149. package/src/cli-main.ts +70 -0
  150. package/src/cli.ts +27 -0
  151. package/src/config/loader.ts +33 -0
  152. package/src/config/schema.ts +80 -0
  153. package/src/index.ts +7 -0
  154. package/src/server/auth/keys.ts +112 -0
  155. package/src/server/auth/session.ts +48 -0
  156. package/src/server/index.ts +296 -0
  157. package/src/server/log-buffer.ts +43 -0
  158. package/src/server/log-store.ts +56 -0
  159. package/src/server/metadata.ts +180 -0
  160. package/src/server/middleware/auth.ts +50 -0
  161. package/src/server/middleware/rate-limit.ts +61 -0
  162. package/src/server/middleware/scope-guard.ts +20 -0
  163. package/src/server/routes/broadcasts.ts +160 -0
  164. package/src/server/routes/health.ts +37 -0
  165. package/src/server/routes/runs.ts +149 -0
  166. package/src/server/routes/signals.ts +153 -0
  167. package/src/server/routes/v1/auth.ts +47 -0
  168. package/src/server/routes/v1/broadcasts.ts +84 -0
  169. package/src/server/routes/v1/events.ts +71 -0
  170. package/src/server/routes/v1/health.ts +41 -0
  171. package/src/server/routes/v1/keys.ts +57 -0
  172. package/src/server/routes/v1/runs.ts +97 -0
  173. package/src/server/routes/v1/signals.ts +44 -0
  174. package/src/server/routes/v1/trigger.ts +111 -0
  175. package/src/server/sse.ts +70 -0
  176. package/src/server/subscriber.ts +288 -0
  177. package/src/server/ws.ts +44 -0
  178. package/station.config.example.ts +16 -0
  179. package/tsconfig.json +12 -0
  180. package/tsconfig.next.json +15 -0
  181. package/tsconfig.tsbuildinfo +1 -0
@@ -0,0 +1,129 @@
1
+ "use client";
2
+
3
+ const API_BASE = process.env.NEXT_PUBLIC_STATION_API ?? "http://localhost:4400";
4
+
5
+ interface ApiResponse<T> {
6
+ data: T;
7
+ meta?: { total?: number };
8
+ }
9
+
10
+ interface ApiError {
11
+ error: string;
12
+ message: string;
13
+ }
14
+
15
+ async function fetchApi<T>(path: string, options?: RequestInit): Promise<ApiResponse<T>> {
16
+ const res = await fetch(`${API_BASE}/api${path}`, {
17
+ ...options,
18
+ credentials: "include",
19
+ headers: {
20
+ "Content-Type": "application/json",
21
+ ...options?.headers,
22
+ },
23
+ });
24
+ if (!res.ok) {
25
+ const err: ApiError = await res.json().catch(() => ({ error: "unknown", message: "Request failed." }));
26
+ throw new Error(err.message);
27
+ }
28
+ return res.json();
29
+ }
30
+
31
+ export async function checkAuth(): Promise<{ authenticated: boolean; authRequired: boolean }> {
32
+ const res = await fetch(`${API_BASE}/api/auth/check`, { credentials: "include" });
33
+ const json = await res.json();
34
+ return json.data;
35
+ }
36
+
37
+ export async function login(username: string, password: string): Promise<boolean> {
38
+ const res = await fetch(`${API_BASE}/api/auth/login`, {
39
+ method: "POST",
40
+ credentials: "include",
41
+ headers: { "Content-Type": "application/json" },
42
+ body: JSON.stringify({ username, password }),
43
+ });
44
+ return res.ok;
45
+ }
46
+
47
+ export async function logout(): Promise<void> {
48
+ await fetch(`${API_BASE}/api/auth/logout`, {
49
+ method: "POST",
50
+ credentials: "include",
51
+ });
52
+ }
53
+
54
+ export interface SchemaField {
55
+ type: string;
56
+ required: boolean;
57
+ properties?: Record<string, SchemaField>;
58
+ items?: SchemaField;
59
+ values?: string[];
60
+ }
61
+
62
+ export interface SignalMeta {
63
+ name: string;
64
+ filePath: string;
65
+ inputSchema: SchemaField | null;
66
+ outputSchema: SchemaField | null;
67
+ interval: string | null;
68
+ timeout: number;
69
+ maxAttempts: number;
70
+ maxConcurrency: number | null;
71
+ hasSteps: boolean;
72
+ stepNames: string[];
73
+ }
74
+
75
+ export interface BroadcastMeta {
76
+ name: string;
77
+ filePath: string;
78
+ nodes: Array<{ name: string; signalName: string; dependsOn: string[] }>;
79
+ failurePolicy: string;
80
+ timeout: number | null;
81
+ interval: string | null;
82
+ }
83
+
84
+ export function useApi() {
85
+ return {
86
+ // Health
87
+ getHealth: () => fetchApi<{ ok: boolean; signal: boolean; broadcast: boolean | null }>("/health"),
88
+
89
+ // Signals
90
+ getSignals: () => fetchApi<SignalMeta[]>("/signals"),
91
+ getScheduledSignals: () =>
92
+ fetchApi<Array<{ name: string; interval: string; nextRunAt: string | null; lastRunAt: string | null; lastStatus: string | null }>>("/signals/scheduled"),
93
+ getSignal: (name: string) => fetchApi<SignalMeta>(`/signals/${encodeURIComponent(name)}`),
94
+ getSignalRuns: (name: string) => fetchApi<any[]>(`/signals/${encodeURIComponent(name)}/runs`),
95
+ triggerSignal: (name: string, input?: unknown) =>
96
+ fetchApi<{ id: string }>(`/signals/${encodeURIComponent(name)}/trigger`, {
97
+ method: "POST",
98
+ body: JSON.stringify({ input: input ?? {} }),
99
+ }),
100
+
101
+ // Runs
102
+ getRuns: (params?: { status?: string; signalName?: string }) => {
103
+ const query = new URLSearchParams();
104
+ if (params?.status) query.set("status", params.status);
105
+ if (params?.signalName) query.set("signalName", params.signalName);
106
+ const qs = query.toString();
107
+ return fetchApi<any[]>(`/runs${qs ? `?${qs}` : ""}`);
108
+ },
109
+ getRunStats: () => fetchApi<{ pending: number; running: number; completed: number; failed: number; cancelled: number }>("/runs/stats"),
110
+ getRun: (id: string) => fetchApi<any>(`/runs/${id}`),
111
+ getRunSteps: (id: string) => fetchApi<any[]>(`/runs/${id}/steps`),
112
+ getRunLogs: (id: string) => fetchApi<Array<{ runId: string; signalName: string; level: string; message: string; timestamp: string }>>(`/runs/${id}/logs`),
113
+ cancelRun: (id: string) => fetchApi<{ cancelled: boolean }>(`/runs/${id}/cancel`, { method: "POST" }),
114
+
115
+ // Broadcasts
116
+ getBroadcasts: () => fetchApi<BroadcastMeta[]>("/broadcasts"),
117
+ getBroadcast: (name: string) => fetchApi<BroadcastMeta>(`/broadcasts/${encodeURIComponent(name)}`),
118
+ triggerBroadcast: (name: string, input?: unknown) =>
119
+ fetchApi<{ id: string }>(`/broadcasts/${encodeURIComponent(name)}/trigger`, {
120
+ method: "POST",
121
+ body: JSON.stringify({ input: input ?? {} }),
122
+ }),
123
+ getBroadcastRuns: (name: string) => fetchApi<any[]>(`/broadcasts/${encodeURIComponent(name)}/runs`),
124
+ getBroadcastRun: (id: string) => fetchApi<any>(`/broadcast-runs/${id}`),
125
+ getBroadcastRunNodes: (id: string) => fetchApi<any[]>(`/broadcast-runs/${id}/nodes`),
126
+ getBroadcastRunLogs: (id: string) => fetchApi<Array<{ runId: string; signalName: string; level: string; message: string; timestamp: string; nodeName: string }>>(`/broadcast-runs/${id}/logs`),
127
+ cancelBroadcastRun: (id: string) => fetchApi<{ cancelled: boolean }>(`/broadcast-runs/${id}/cancel`, { method: "POST" }),
128
+ };
129
+ }
@@ -0,0 +1,37 @@
1
+ "use client";
2
+
3
+ import { createContext, useContext, useEffect } from "react";
4
+
5
+ export interface BreadcrumbSegment {
6
+ label: string;
7
+ href?: string;
8
+ }
9
+
10
+ export interface BreadcrumbContextValue {
11
+ segments: BreadcrumbSegment[];
12
+ activeSection: string | null;
13
+ setSegments: (segments: BreadcrumbSegment[]) => void;
14
+ setActiveSection: (section: string | null) => void;
15
+ }
16
+
17
+ export const BreadcrumbContext = createContext<BreadcrumbContextValue>({
18
+ segments: [],
19
+ activeSection: null,
20
+ setSegments: () => {},
21
+ setActiveSection: () => {},
22
+ });
23
+
24
+ export function useBreadcrumb(
25
+ segments: BreadcrumbSegment[],
26
+ section: string,
27
+ ) {
28
+ const ctx = useContext(BreadcrumbContext);
29
+ useEffect(() => {
30
+ ctx.setSegments(segments);
31
+ ctx.setActiveSection(section);
32
+ }, [JSON.stringify(segments), section]);
33
+ }
34
+
35
+ export function useBreadcrumbContext() {
36
+ return useContext(BreadcrumbContext);
37
+ }
@@ -0,0 +1,68 @@
1
+ "use client";
2
+
3
+ import { useEffect, useRef, useCallback, useState } from "react";
4
+
5
+ const WS_BASE = process.env.NEXT_PUBLIC_STATION_API ?? "http://localhost:4400";
6
+
7
+ export interface StationEvent {
8
+ type: string;
9
+ timestamp: string;
10
+ data: Record<string, unknown>;
11
+ }
12
+
13
+ export function useRealtime(onEvent: (event: StationEvent) => void): { connected: boolean } {
14
+ const [connected, setConnected] = useState(false);
15
+ const onEventRef = useRef(onEvent);
16
+ onEventRef.current = onEvent;
17
+
18
+ useEffect(() => {
19
+ let ws: WebSocket | null = null;
20
+ let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
21
+ let attempt = 0;
22
+ let closed = false;
23
+
24
+ function connect() {
25
+ if (closed) return;
26
+
27
+ const wsUrl = WS_BASE.replace(/^http/, "ws") + "/api/events";
28
+ ws = new WebSocket(wsUrl);
29
+
30
+ ws.onopen = () => {
31
+ setConnected(true);
32
+ attempt = 0;
33
+ };
34
+
35
+ ws.onmessage = (e) => {
36
+ try {
37
+ const event = JSON.parse(e.data) as StationEvent;
38
+ onEventRef.current(event);
39
+ } catch (err) {
40
+ console.error("Failed to parse WebSocket message:", err);
41
+ }
42
+ };
43
+
44
+ ws.onclose = () => {
45
+ setConnected(false);
46
+ if (!closed) {
47
+ const delay = Math.min(1000 * Math.pow(2, attempt), 10000);
48
+ attempt++;
49
+ reconnectTimer = setTimeout(connect, delay);
50
+ }
51
+ };
52
+
53
+ ws.onerror = () => {
54
+ ws?.close();
55
+ };
56
+ }
57
+
58
+ connect();
59
+
60
+ return () => {
61
+ closed = true;
62
+ if (reconnectTimer) clearTimeout(reconnectTimer);
63
+ ws?.close();
64
+ };
65
+ }, []);
66
+
67
+ return { connected };
68
+ }
@@ -0,0 +1,34 @@
1
+ "use client";
2
+
3
+ import { createContext, useContext, useCallback, useEffect, useState, type ReactNode } from "react";
4
+ import { useRealtime, type StationEvent } from "./use-realtime";
5
+
6
+ interface StationState {
7
+ connected: boolean;
8
+ events: StationEvent[];
9
+ }
10
+
11
+ const StationContext = createContext<StationState>({
12
+ connected: false,
13
+ events: [],
14
+ });
15
+
16
+ export function useStation() {
17
+ return useContext(StationContext);
18
+ }
19
+
20
+ export function StationProvider({ children }: { children: ReactNode }) {
21
+ const [events, setEvents] = useState<StationEvent[]>([]);
22
+
23
+ const handleEvent = useCallback((event: StationEvent) => {
24
+ setEvents((prev) => [event, ...prev].slice(0, 100));
25
+ }, []);
26
+
27
+ const { connected } = useRealtime(handleEvent);
28
+
29
+ return (
30
+ <StationContext.Provider value={{ connected, events }}>
31
+ {children}
32
+ </StationContext.Provider>
33
+ );
34
+ }
@@ -0,0 +1,42 @@
1
+ import type { Metadata } from "next";
2
+ import "./globals.css";
3
+ import { Shell } from "./components/shell";
4
+ import { ThemeProvider } from "./components/theme-provider";
5
+ import { StationProvider } from "./hooks/use-station";
6
+ import { BreadcrumbProvider } from "./components/breadcrumb-provider";
7
+ import { AuthProvider } from "./components/auth-provider";
8
+ import { LoginPage } from "./components/login-page";
9
+
10
+ export const metadata: Metadata = {
11
+ title: "Station",
12
+ description: "Dashboard for station-signal",
13
+ };
14
+
15
+ export default function RootLayout({ children }: { children: React.ReactNode }) {
16
+ return (
17
+ <html lang="en" suppressHydrationWarning>
18
+ <head>
19
+ <script
20
+ dangerouslySetInnerHTML={{
21
+ __html: `(function(){try{var t=localStorage.getItem("station-theme");if(t==="dark"||t==="light"){document.documentElement.setAttribute("data-theme",t)}else if(window.matchMedia("(prefers-color-scheme:dark)").matches){document.documentElement.setAttribute("data-theme","dark")}else{document.documentElement.setAttribute("data-theme","light")}}catch(e){document.documentElement.setAttribute("data-theme","light")}})()`,
22
+ }}
23
+ />
24
+ <link
25
+ href="https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=IBM+Plex+Mono:wght@400;500&family=Space+Grotesk:wght@300;400;500&display=swap"
26
+ rel="stylesheet"
27
+ />
28
+ </head>
29
+ <body>
30
+ <ThemeProvider>
31
+ <AuthProvider loginPage={<LoginPage />}>
32
+ <StationProvider>
33
+ <BreadcrumbProvider>
34
+ <Shell>{children}</Shell>
35
+ </BreadcrumbProvider>
36
+ </StationProvider>
37
+ </AuthProvider>
38
+ </ThemeProvider>
39
+ </body>
40
+ </html>
41
+ );
42
+ }
@@ -0,0 +1,275 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import Link from "next/link";
5
+ import { useRouter } from "next/navigation";
6
+ import { useApi } from "./hooks/use-api";
7
+ import { useStation } from "./hooks/use-station";
8
+ import { useBreadcrumb } from "./hooks/use-breadcrumb";
9
+ import { StatusBadge } from "./components/status-badge";
10
+ import { RelativeTime } from "./components/relative-time";
11
+
12
+ interface Stats {
13
+ pending: number;
14
+ running: number;
15
+ completed: number;
16
+ failed: number;
17
+ cancelled: number;
18
+ }
19
+
20
+ interface FailedRun {
21
+ id: string;
22
+ signalName: string;
23
+ status: string;
24
+ error?: string;
25
+ createdAt: string;
26
+ }
27
+
28
+ interface ScheduledSignal {
29
+ name: string;
30
+ interval: string;
31
+ nextRunAt: string | null;
32
+ lastRunAt: string | null;
33
+ lastStatus: string | null;
34
+ }
35
+
36
+ function formatCountdown(date: string): string {
37
+ const ms = new Date(date).getTime() - Date.now();
38
+ if (ms <= 0) return "due now";
39
+ const seconds = Math.floor(ms / 1000);
40
+ if (seconds < 60) return `in ${seconds}s`;
41
+ const minutes = Math.floor(seconds / 60);
42
+ if (minutes < 60) return `in ${minutes}m`;
43
+ const hours = Math.floor(minutes / 60);
44
+ if (hours < 24) return `in ${hours}h`;
45
+ return `in ${Math.floor(hours / 24)}d`;
46
+ }
47
+
48
+ export default function OverviewPage() {
49
+ const api = useApi();
50
+ const router = useRouter();
51
+ const { events } = useStation();
52
+ const [stats, setStats] = useState<Stats | null>(null);
53
+ const [failedRuns, setFailedRuns] = useState<FailedRun[]>([]);
54
+ const [scheduled, setScheduled] = useState<ScheduledSignal[]>([]);
55
+ const [loading, setLoading] = useState(true);
56
+
57
+ useBreadcrumb([{ label: "Overview" }], "overview");
58
+
59
+ useEffect(() => {
60
+ async function load() {
61
+ try {
62
+ const [statsRes, failedRes, scheduledRes] = await Promise.all([
63
+ api.getRunStats(),
64
+ api.getRuns({ status: "failed" }),
65
+ api.getScheduledSignals(),
66
+ ]);
67
+ setStats(statsRes.data);
68
+ setFailedRuns(failedRes.data.slice(0, 10));
69
+ setScheduled(scheduledRes.data);
70
+ } catch (err: unknown) {
71
+ if (err instanceof Error) {
72
+ console.error("Failed to load overview data:", err.message);
73
+ }
74
+ }
75
+ setLoading(false);
76
+ }
77
+ load();
78
+ }, []);
79
+
80
+ useEffect(() => {
81
+ if (events.length === 0) return;
82
+ const latestEvent = events[0];
83
+ if (latestEvent.type.startsWith("run:")) {
84
+ api.getRunStats().then((r) => setStats(r.data)).catch((e) => console.error("Failed to refresh stats:", e));
85
+ api.getRuns({ status: "failed" }).then((r) => setFailedRuns(r.data.slice(0, 10))).catch((e) => console.error("Failed to refresh failed runs:", e));
86
+ api.getScheduledSignals().then((r) => setScheduled(r.data)).catch((e) => console.error("Failed to refresh scheduled:", e));
87
+ }
88
+ }, [events.length]);
89
+
90
+ if (loading) {
91
+ return (
92
+ <div>
93
+ <h1 className="page-title">Overview</h1>
94
+ <div className="loading-bar"><div className="loading-bar-fill" /></div>
95
+ </div>
96
+ );
97
+ }
98
+
99
+ return (
100
+ <div>
101
+ <h1 className="page-title">Overview</h1>
102
+
103
+ {stats && (
104
+ <div className="stat-grid">
105
+ <div className="stat-card">
106
+ <div className="stat-card-label">Pending</div>
107
+ <div className="stat-card-value">{stats.pending}</div>
108
+ </div>
109
+ <div className="stat-card">
110
+ <div className="stat-card-label">Running</div>
111
+ <div className="stat-card-value" style={{ color: "var(--patina)" }}>{stats.running}</div>
112
+ </div>
113
+ <div className="stat-card">
114
+ <div className="stat-card-label">Completed</div>
115
+ <div className="stat-card-value" style={{ color: "var(--patina)" }}>{stats.completed}</div>
116
+ </div>
117
+ <div className="stat-card">
118
+ <div className="stat-card-label">Failed</div>
119
+ <div className="stat-card-value" style={{ color: "var(--rust)" }}>{stats.failed}</div>
120
+ </div>
121
+ <div className="stat-card">
122
+ <div className="stat-card-label">Cancelled</div>
123
+ <div className="stat-card-value">{stats.cancelled}</div>
124
+ </div>
125
+ </div>
126
+ )}
127
+
128
+ {scheduled.length > 0 && (
129
+ <div className="detail-section">
130
+ <div className="detail-section-label">Scheduled</div>
131
+ <table className="station-table">
132
+ <thead>
133
+ <tr>
134
+ <th>Signal</th>
135
+ <th>Interval</th>
136
+ <th>Next Run</th>
137
+ <th>Last Run</th>
138
+ <th>Last Status</th>
139
+ </tr>
140
+ </thead>
141
+ <tbody>
142
+ {scheduled.map((sig, i) => (
143
+ <tr
144
+ key={sig.name}
145
+ className="reveal-item clickable-row"
146
+ style={{ animationDelay: `${i * 40}ms` }}
147
+ onClick={() => router.push(`/signals/${encodeURIComponent(sig.name)}`)}
148
+ >
149
+ <td className="mono">{sig.name}</td>
150
+ <td className="mono" style={{ color: "var(--muted)", fontSize: "0.8125rem" }}>
151
+ {sig.interval}
152
+ </td>
153
+ <td className="mono" style={{ fontSize: "0.8125rem", color: sig.nextRunAt ? "var(--patina)" : "var(--muted)" }}>
154
+ {sig.nextRunAt ? formatCountdown(sig.nextRunAt) : "\u2014"}
155
+ </td>
156
+ <td>
157
+ {sig.lastRunAt ? <RelativeTime date={sig.lastRunAt} /> : <span className="mono" style={{ fontSize: "0.8125rem", color: "var(--muted)" }}>{"\u2014"}</span>}
158
+ </td>
159
+ <td>
160
+ {sig.lastStatus ? <StatusBadge status={sig.lastStatus as any} /> : <span className="mono" style={{ fontSize: "0.8125rem", color: "var(--muted)" }}>{"\u2014"}</span>}
161
+ </td>
162
+ </tr>
163
+ ))}
164
+ </tbody>
165
+ </table>
166
+ </div>
167
+ )}
168
+
169
+ {failedRuns.length > 0 && (
170
+ <div className="detail-section">
171
+ <div className="detail-section-label">Recent Failures</div>
172
+ <table className="station-table">
173
+ <thead>
174
+ <tr>
175
+ <th>Status</th>
176
+ <th>Signal</th>
177
+ <th>Error</th>
178
+ <th>Time</th>
179
+ </tr>
180
+ </thead>
181
+ <tbody>
182
+ {failedRuns.map((run, i) => (
183
+ <tr
184
+ key={run.id}
185
+ className="reveal-item clickable-row"
186
+ style={{ animationDelay: `${i * 40}ms` }}
187
+ onClick={() => router.push(`/runs/${run.id}`)}
188
+ >
189
+ <td><StatusBadge status={run.status as any} /></td>
190
+ <td>
191
+ <Link
192
+ href={`/signals/${run.signalName}`}
193
+ className="mono"
194
+ onClick={(e) => e.stopPropagation()}
195
+ >
196
+ {run.signalName}
197
+ </Link>
198
+ </td>
199
+ <td
200
+ style={{
201
+ color: "var(--rust)",
202
+ fontSize: "0.8125rem",
203
+ fontFamily: "var(--font-mono)",
204
+ maxWidth: "300px",
205
+ overflow: "hidden",
206
+ textOverflow: "ellipsis",
207
+ whiteSpace: "nowrap",
208
+ }}
209
+ title={run.error ?? ""}
210
+ >
211
+ {run.error ? (run.error.length > 60 ? run.error.slice(0, 60) + "..." : run.error) : "-"}
212
+ </td>
213
+ <td><RelativeTime date={run.createdAt} /></td>
214
+ </tr>
215
+ ))}
216
+ </tbody>
217
+ </table>
218
+ </div>
219
+ )}
220
+
221
+ {events.length > 0 && (
222
+ <div className="detail-section">
223
+ <div className="detail-section-label">Live Activity</div>
224
+ <div style={{ marginTop: "0.75rem" }}>
225
+ {events.slice(0, 20).map((event, i) => {
226
+ const signalName =
227
+ (event.data.run as Record<string, unknown>)?.signalName ??
228
+ (event.data as Record<string, unknown>).signalName ??
229
+ null;
230
+ const runId =
231
+ (event.data.run as Record<string, unknown>)?.id ??
232
+ (event.data as Record<string, unknown>).runId ??
233
+ null;
234
+ const broadcastRunId =
235
+ (event.data as Record<string, unknown>).broadcastRunId ?? null;
236
+
237
+ const href = broadcastRunId
238
+ ? `/broadcasts/${broadcastRunId}`
239
+ : runId
240
+ ? `/runs/${runId}`
241
+ : null;
242
+
243
+ return (
244
+ <div
245
+ key={`${event.timestamp}-${i}`}
246
+ className={`reveal-item${href ? " activity-row" : ""}`}
247
+ style={{
248
+ animationDelay: `${i * 40}ms`,
249
+ fontFamily: "var(--font-mono)",
250
+ fontSize: "0.75rem",
251
+ color: "var(--muted)",
252
+ padding: "0.375rem 0.5rem",
253
+ borderBottom: "1px solid var(--concrete-dark)",
254
+ display: "flex",
255
+ gap: "0.75rem",
256
+ alignItems: "baseline",
257
+ }}
258
+ onClick={href ? () => router.push(href) : undefined}
259
+ >
260
+ <span style={{ color: "var(--rust)", minWidth: "120px" }}>{event.type}</span>
261
+ {signalName && (
262
+ <span style={{ color: "var(--charcoal)" }}>{String(signalName)}</span>
263
+ )}
264
+ <span style={{ marginLeft: "auto" }}>
265
+ {new Date(event.timestamp).toLocaleTimeString()}
266
+ </span>
267
+ </div>
268
+ );
269
+ })}
270
+ </div>
271
+ </div>
272
+ )}
273
+ </div>
274
+ );
275
+ }