@a5c-ai/babysitter-observer-dashboard 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.
- package/LICENSE +21 -0
- package/README.md +490 -0
- package/next.config.mjs +25 -0
- package/package.json +104 -0
- package/postcss.config.mjs +8 -0
- package/src/app/actions/__tests__/approve-breakpoint.test.ts +246 -0
- package/src/app/actions/approve-breakpoint.ts +145 -0
- package/src/app/api/config/route.ts +137 -0
- package/src/app/api/digest/route.ts +45 -0
- package/src/app/api/runs/[runId]/events/route.ts +56 -0
- package/src/app/api/runs/[runId]/route.ts +84 -0
- package/src/app/api/runs/[runId]/tasks/[effectId]/route.ts +44 -0
- package/src/app/api/runs/route.ts +48 -0
- package/src/app/api/stream/route.ts +136 -0
- package/src/app/api/test/route.ts +1 -0
- package/src/app/api/version/route.ts +57 -0
- package/src/app/globals.css +555 -0
- package/src/app/icon.svg +20 -0
- package/src/app/layout.tsx +39 -0
- package/src/app/not-found.tsx +16 -0
- package/src/app/page.tsx +120 -0
- package/src/app/runs/[runId]/page.tsx +279 -0
- package/src/cli.ts +271 -0
- package/src/components/breakpoint/__tests__/breakpoint-approval.test.tsx +212 -0
- package/src/components/breakpoint/__tests__/breakpoint-panel.test.tsx +130 -0
- package/src/components/breakpoint/__tests__/file-preview.test.tsx +313 -0
- package/src/components/breakpoint/breakpoint-approval.tsx +138 -0
- package/src/components/breakpoint/breakpoint-panel.tsx +95 -0
- package/src/components/breakpoint/file-preview.tsx +215 -0
- package/src/components/dashboard/.gitkeep +0 -0
- package/src/components/dashboard/__tests__/breakpoint-banner.test.tsx +177 -0
- package/src/components/dashboard/__tests__/catch-up-banner.test.tsx +141 -0
- package/src/components/dashboard/__tests__/executive-summary-banner.test.tsx +164 -0
- package/src/components/dashboard/__tests__/kpi-grid.test.tsx +101 -0
- package/src/components/dashboard/__tests__/pagination-controls.test.tsx +125 -0
- package/src/components/dashboard/__tests__/project-accordion.test.tsx +97 -0
- package/src/components/dashboard/__tests__/project-list-view.test.tsx +174 -0
- package/src/components/dashboard/__tests__/project-search-input.test.tsx +110 -0
- package/src/components/dashboard/__tests__/project-section-header.test.tsx +91 -0
- package/src/components/dashboard/__tests__/project-section.test.tsx +151 -0
- package/src/components/dashboard/__tests__/run-card.test.tsx +164 -0
- package/src/components/dashboard/__tests__/run-filter-bar.test.tsx +109 -0
- package/src/components/dashboard/__tests__/run-list.test.tsx +123 -0
- package/src/components/dashboard/__tests__/search-filter.test.tsx +150 -0
- package/src/components/dashboard/__tests__/virtualized-run-list.test.tsx +179 -0
- package/src/components/dashboard/breakpoint-banner.tsx +301 -0
- package/src/components/dashboard/catch-up-banner.tsx +88 -0
- package/src/components/dashboard/executive-summary-banner.tsx +174 -0
- package/src/components/dashboard/global-search.tsx +323 -0
- package/src/components/dashboard/kpi-grid.tsx +140 -0
- package/src/components/dashboard/pagination-controls.tsx +100 -0
- package/src/components/dashboard/project-accordion.tsx +72 -0
- package/src/components/dashboard/project-health-card.tsx +536 -0
- package/src/components/dashboard/project-list-view.tsx +246 -0
- package/src/components/dashboard/project-search-input.tsx +41 -0
- package/src/components/dashboard/project-section-header.tsx +73 -0
- package/src/components/dashboard/project-section.tsx +89 -0
- package/src/components/dashboard/run-card.tsx +218 -0
- package/src/components/dashboard/run-filter-bar.tsx +100 -0
- package/src/components/dashboard/run-list.tsx +77 -0
- package/src/components/dashboard/search-filter.tsx +69 -0
- package/src/components/dashboard/virtualized-run-list.tsx +130 -0
- package/src/components/details/.gitkeep +0 -0
- package/src/components/details/__tests__/agent-panel.test.tsx +236 -0
- package/src/components/details/__tests__/json-tree.test.tsx +347 -0
- package/src/components/details/__tests__/log-viewer.test.tsx +168 -0
- package/src/components/details/__tests__/task-detail.test.tsx +212 -0
- package/src/components/details/__tests__/timing-panel.test.tsx +271 -0
- package/src/components/details/agent-panel.tsx +234 -0
- package/src/components/details/json-tree/categorize.ts +131 -0
- package/src/components/details/json-tree/index.tsx +120 -0
- package/src/components/details/json-tree/json-node.tsx +223 -0
- package/src/components/details/json-tree/smart-summary.tsx +596 -0
- package/src/components/details/json-tree/tree-controls.tsx +47 -0
- package/src/components/details/json-tree.tsx +9 -0
- package/src/components/details/log-viewer.tsx +140 -0
- package/src/components/details/task-detail.tsx +114 -0
- package/src/components/details/timing-panel.tsx +247 -0
- package/src/components/events/.gitkeep +0 -0
- package/src/components/events/__tests__/event-item.test.tsx +211 -0
- package/src/components/events/__tests__/event-stream.test.tsx +225 -0
- package/src/components/events/event-item.tsx +121 -0
- package/src/components/events/event-stream.tsx +260 -0
- package/src/components/notifications/.gitkeep +0 -0
- package/src/components/notifications/__tests__/notification-panel.test.tsx +287 -0
- package/src/components/notifications/__tests__/notification-provider.test.tsx +585 -0
- package/src/components/notifications/__tests__/toast-stack.test.tsx +217 -0
- package/src/components/notifications/notification-panel.tsx +124 -0
- package/src/components/notifications/notification-provider.tsx +175 -0
- package/src/components/notifications/toast-stack.tsx +75 -0
- package/src/components/pipeline/.gitkeep +0 -0
- package/src/components/pipeline/__tests__/parallel-group.test.tsx +88 -0
- package/src/components/pipeline/__tests__/pipeline-view.test.tsx +345 -0
- package/src/components/pipeline/__tests__/step-card.test.tsx +330 -0
- package/src/components/pipeline/parallel-group.tsx +39 -0
- package/src/components/pipeline/pipeline-view.tsx +197 -0
- package/src/components/pipeline/step-card.tsx +166 -0
- package/src/components/providers/event-stream-provider.tsx +29 -0
- package/src/components/providers.tsx +24 -0
- package/src/components/shared/.gitkeep +0 -0
- package/src/components/shared/__tests__/empty-state.test.tsx +49 -0
- package/src/components/shared/__tests__/friendly-id.test.tsx +47 -0
- package/src/components/shared/__tests__/kbd.test.tsx +45 -0
- package/src/components/shared/__tests__/kind-badge.test.tsx +71 -0
- package/src/components/shared/__tests__/metrics-row.test.tsx +74 -0
- package/src/components/shared/__tests__/outcome-banner.test.tsx +71 -0
- package/src/components/shared/__tests__/progress-bar.test.tsx +89 -0
- package/src/components/shared/__tests__/session-pill.test.tsx +62 -0
- package/src/components/shared/__tests__/settings-modal.test.tsx +201 -0
- package/src/components/shared/__tests__/shortcuts-help.test.tsx +103 -0
- package/src/components/shared/__tests__/status-badge.test.tsx +98 -0
- package/src/components/shared/__tests__/theme-provider.test.tsx +100 -0
- package/src/components/shared/__tests__/truncated-id.test.tsx +53 -0
- package/src/components/shared/app-footer.tsx +80 -0
- package/src/components/shared/app-header.tsx +160 -0
- package/src/components/shared/empty-state.tsx +18 -0
- package/src/components/shared/error-boundary.tsx +81 -0
- package/src/components/shared/friendly-id.tsx +48 -0
- package/src/components/shared/kbd.tsx +15 -0
- package/src/components/shared/kind-badge.tsx +51 -0
- package/src/components/shared/metrics-row.tsx +106 -0
- package/src/components/shared/outcome-banner.tsx +56 -0
- package/src/components/shared/progress-bar.tsx +42 -0
- package/src/components/shared/session-pill.tsx +69 -0
- package/src/components/shared/settings-modal.tsx +509 -0
- package/src/components/shared/shortcuts-help.tsx +113 -0
- package/src/components/shared/status-badge.tsx +110 -0
- package/src/components/shared/theme-provider.tsx +46 -0
- package/src/components/shared/truncated-id.tsx +51 -0
- package/src/components/ui/.gitkeep +0 -0
- package/src/components/ui/__tests__/accordion.test.tsx +96 -0
- package/src/components/ui/__tests__/badge.test.tsx +69 -0
- package/src/components/ui/__tests__/button.test.tsx +113 -0
- package/src/components/ui/__tests__/tabs.test.tsx +75 -0
- package/src/components/ui/__tests__/tooltip.test.tsx +90 -0
- package/src/components/ui/accordion.tsx +61 -0
- package/src/components/ui/badge.tsx +25 -0
- package/src/components/ui/button.tsx +40 -0
- package/src/components/ui/card.tsx +21 -0
- package/src/components/ui/scroll-area.tsx +35 -0
- package/src/components/ui/separator.tsx +24 -0
- package/src/components/ui/tabs.tsx +64 -0
- package/src/components/ui/tooltip.tsx +37 -0
- package/src/hooks/.gitkeep +0 -0
- package/src/hooks/__tests__/use-animated-number.test.ts +184 -0
- package/src/hooks/__tests__/use-batched-updates.test.ts +315 -0
- package/src/hooks/__tests__/use-event-stream.test.ts +243 -0
- package/src/hooks/__tests__/use-keyboard.test.ts +217 -0
- package/src/hooks/__tests__/use-notifications.test.ts +230 -0
- package/src/hooks/__tests__/use-polling.test.ts +274 -0
- package/src/hooks/__tests__/use-project-runs.test.ts +163 -0
- package/src/hooks/__tests__/use-projects.test.ts +248 -0
- package/src/hooks/__tests__/use-run-dashboard.test.ts +168 -0
- package/src/hooks/__tests__/use-run-detail.test.ts +273 -0
- package/src/hooks/__tests__/use-smart-polling.test.ts +305 -0
- package/src/hooks/use-animated-number.ts +87 -0
- package/src/hooks/use-batched-updates.ts +150 -0
- package/src/hooks/use-event-stream.ts +150 -0
- package/src/hooks/use-keyboard.ts +45 -0
- package/src/hooks/use-notifications.ts +82 -0
- package/src/hooks/use-persisted-state.ts +60 -0
- package/src/hooks/use-polling.ts +60 -0
- package/src/hooks/use-project-runs.ts +51 -0
- package/src/hooks/use-projects.ts +26 -0
- package/src/hooks/use-run-dashboard.ts +207 -0
- package/src/hooks/use-run-detail.ts +77 -0
- package/src/hooks/use-smart-polling.ts +144 -0
- package/src/lib/.gitkeep +0 -0
- package/src/lib/__tests__/cn.test.ts +69 -0
- package/src/lib/__tests__/config-loader.test.ts +210 -0
- package/src/lib/__tests__/config.test.ts +561 -0
- package/src/lib/__tests__/error-handler.test.ts +143 -0
- package/src/lib/__tests__/fetcher.test.ts +517 -0
- package/src/lib/__tests__/global-registry.test.ts +214 -0
- package/src/lib/__tests__/parser.test.ts +1532 -0
- package/src/lib/__tests__/path-resolver.test.ts +112 -0
- package/src/lib/__tests__/run-cache.test.ts +591 -0
- package/src/lib/__tests__/server-init.test.ts +512 -0
- package/src/lib/__tests__/source-discovery.test.ts +246 -0
- package/src/lib/__tests__/utils.test.ts +160 -0
- package/src/lib/__tests__/watcher.test.ts +227 -0
- package/src/lib/cn.ts +6 -0
- package/src/lib/config-loader.ts +195 -0
- package/src/lib/config.ts +20 -0
- package/src/lib/error-handler.ts +76 -0
- package/src/lib/fetcher.ts +394 -0
- package/src/lib/global-registry.ts +117 -0
- package/src/lib/parser.ts +794 -0
- package/src/lib/path-resolver.ts +16 -0
- package/src/lib/run-cache.ts +404 -0
- package/src/lib/server-init.ts +226 -0
- package/src/lib/services/__tests__/run-query-service.test.ts +819 -0
- package/src/lib/services/run-query-service.ts +286 -0
- package/src/lib/source-discovery.ts +216 -0
- package/src/lib/utils.ts +103 -0
- package/src/lib/watcher.ts +265 -0
- package/src/test/fixtures.ts +269 -0
- package/src/test/mocks/handlers.ts +110 -0
- package/src/test/mocks/server.ts +17 -0
- package/src/test/setup.ts +200 -0
- package/src/test/test-utils.tsx +36 -0
- package/src/types/.gitkeep +0 -0
- package/src/types/breakpoint.ts +17 -0
- package/src/types/index.ts +214 -0
- package/tsconfig.json +50 -0
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import { renderHook, act } from '@testing-library/react';
|
|
2
|
+
import { useSmartPolling } from '../use-smart-polling';
|
|
3
|
+
|
|
4
|
+
// Track unsubscribers for cleanup
|
|
5
|
+
let activeUnsubscribers: Array<() => void> = [];
|
|
6
|
+
|
|
7
|
+
type MockEventSourceInstance = {
|
|
8
|
+
onopen: ((event: Event) => void) | null;
|
|
9
|
+
onmessage: ((event: MessageEvent) => void) | null;
|
|
10
|
+
onerror: ((event: Event) => void) | null;
|
|
11
|
+
close: ReturnType<typeof vi.fn>;
|
|
12
|
+
readyState: number;
|
|
13
|
+
url: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
let mockEventSourceInstances: MockEventSourceInstance[] = [];
|
|
17
|
+
|
|
18
|
+
class MockEventSource {
|
|
19
|
+
static CONNECTING = 0;
|
|
20
|
+
static OPEN = 1;
|
|
21
|
+
static CLOSED = 2;
|
|
22
|
+
|
|
23
|
+
onopen: ((event: Event) => void) | null = null;
|
|
24
|
+
onmessage: ((event: MessageEvent) => void) | null = null;
|
|
25
|
+
onerror: ((event: Event) => void) | null = null;
|
|
26
|
+
close = vi.fn();
|
|
27
|
+
readyState = MockEventSource.OPEN;
|
|
28
|
+
url: string;
|
|
29
|
+
|
|
30
|
+
constructor(url: string) {
|
|
31
|
+
this.url = url;
|
|
32
|
+
mockEventSourceInstances.push(this);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function mockFetchSuccess(data: unknown) {
|
|
37
|
+
return vi.fn().mockResolvedValue(
|
|
38
|
+
new Response(JSON.stringify(data), {
|
|
39
|
+
status: 200,
|
|
40
|
+
headers: { 'Content-Type': 'application/json' },
|
|
41
|
+
})
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe('useSmartPolling', () => {
|
|
46
|
+
beforeEach(() => {
|
|
47
|
+
vi.useFakeTimers();
|
|
48
|
+
mockEventSourceInstances = [];
|
|
49
|
+
vi.stubGlobal('EventSource', MockEventSource);
|
|
50
|
+
vi.stubGlobal('fetch', mockFetchSuccess({ items: [1, 2, 3] }));
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
afterEach(() => {
|
|
54
|
+
for (const unsub of activeUnsubscribers) {
|
|
55
|
+
unsub();
|
|
56
|
+
}
|
|
57
|
+
activeUnsubscribers = [];
|
|
58
|
+
vi.useRealTimers();
|
|
59
|
+
vi.restoreAllMocks();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('fetches data immediately on mount', async () => {
|
|
63
|
+
const { result } = renderHook(() =>
|
|
64
|
+
useSmartPolling<{ items: number[] }>('/api/data', { interval: 5000 })
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
expect(result.current.loading).toBe(true);
|
|
68
|
+
|
|
69
|
+
await act(async () => {
|
|
70
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
expect(result.current.data).toEqual({ items: [1, 2, 3] });
|
|
74
|
+
expect(result.current.loading).toBe(false);
|
|
75
|
+
expect(result.current.error).toBeNull();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('polls at specified interval', async () => {
|
|
79
|
+
renderHook(() =>
|
|
80
|
+
useSmartPolling('/api/data', { interval: 5000 })
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
await act(async () => {
|
|
84
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
85
|
+
});
|
|
86
|
+
expect(fetch).toHaveBeenCalledTimes(1);
|
|
87
|
+
|
|
88
|
+
await act(async () => {
|
|
89
|
+
await vi.advanceTimersByTimeAsync(5000);
|
|
90
|
+
});
|
|
91
|
+
expect(fetch).toHaveBeenCalledTimes(2);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('does not fetch when enabled is false', async () => {
|
|
95
|
+
const { result } = renderHook(() =>
|
|
96
|
+
useSmartPolling('/api/data', { enabled: false })
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
await act(async () => {
|
|
100
|
+
await vi.advanceTimersByTimeAsync(10000);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
expect(fetch).not.toHaveBeenCalled();
|
|
104
|
+
expect(result.current.loading).toBe(false);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('handles fetch errors', async () => {
|
|
108
|
+
// Use 422 (not 404, because 404 is retryable in resilientFetch)
|
|
109
|
+
vi.stubGlobal(
|
|
110
|
+
'fetch',
|
|
111
|
+
vi.fn().mockResolvedValue(
|
|
112
|
+
new Response('HTTP 422', { status: 422 })
|
|
113
|
+
)
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
const { result } = renderHook(() =>
|
|
117
|
+
useSmartPolling('/api/data', { interval: 5000 })
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
await act(async () => {
|
|
121
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
expect(result.current.error).toBe('HTTP 422');
|
|
125
|
+
expect(result.current.loading).toBe(false);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('triggers debounced refetch when SSE event matches filter', async () => {
|
|
129
|
+
const sseFilter = vi.fn().mockReturnValue(true);
|
|
130
|
+
|
|
131
|
+
renderHook(() =>
|
|
132
|
+
useSmartPolling('/api/data', {
|
|
133
|
+
interval: 5000,
|
|
134
|
+
sseFilter,
|
|
135
|
+
})
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
// Initial fetch
|
|
139
|
+
await act(async () => {
|
|
140
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
const initialCallCount = (fetch as ReturnType<typeof vi.fn>).mock.calls.length;
|
|
144
|
+
|
|
145
|
+
// Simulate SSE message via the EventSource
|
|
146
|
+
const instance = mockEventSourceInstances[0];
|
|
147
|
+
if (instance?.onmessage) {
|
|
148
|
+
act(() => {
|
|
149
|
+
instance.onmessage!(
|
|
150
|
+
new MessageEvent('message', {
|
|
151
|
+
data: JSON.stringify({ type: 'run_updated', runId: 'run-1' }),
|
|
152
|
+
})
|
|
153
|
+
);
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Before debounce window (1500ms), no additional fetch
|
|
158
|
+
await act(async () => {
|
|
159
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
160
|
+
});
|
|
161
|
+
expect((fetch as ReturnType<typeof vi.fn>).mock.calls.length).toBe(initialCallCount);
|
|
162
|
+
|
|
163
|
+
// After debounce window completes (1500ms total)
|
|
164
|
+
await act(async () => {
|
|
165
|
+
await vi.advanceTimersByTimeAsync(500);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// Should have triggered an additional fetch after 1500ms debounce
|
|
169
|
+
expect((fetch as ReturnType<typeof vi.fn>).mock.calls.length).toBeGreaterThan(initialCallCount);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('coalesces rapid SSE events within debounce window into single fetch', async () => {
|
|
173
|
+
const sseFilter = vi.fn().mockReturnValue(true);
|
|
174
|
+
|
|
175
|
+
renderHook(() =>
|
|
176
|
+
useSmartPolling('/api/data', {
|
|
177
|
+
interval: 5000,
|
|
178
|
+
sseFilter,
|
|
179
|
+
})
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
// Initial fetch
|
|
183
|
+
await act(async () => {
|
|
184
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
const initialCallCount = (fetch as ReturnType<typeof vi.fn>).mock.calls.length;
|
|
188
|
+
|
|
189
|
+
const instance = mockEventSourceInstances[0];
|
|
190
|
+
if (instance?.onmessage) {
|
|
191
|
+
// Fire 3 rapid SSE events within 100ms
|
|
192
|
+
act(() => {
|
|
193
|
+
instance.onmessage!(
|
|
194
|
+
new MessageEvent('message', {
|
|
195
|
+
data: JSON.stringify({ type: 'update', runId: 'run-1' }),
|
|
196
|
+
})
|
|
197
|
+
);
|
|
198
|
+
});
|
|
199
|
+
await act(async () => {
|
|
200
|
+
await vi.advanceTimersByTimeAsync(50);
|
|
201
|
+
});
|
|
202
|
+
act(() => {
|
|
203
|
+
instance.onmessage!(
|
|
204
|
+
new MessageEvent('message', {
|
|
205
|
+
data: JSON.stringify({ type: 'update', runId: 'run-2' }),
|
|
206
|
+
})
|
|
207
|
+
);
|
|
208
|
+
});
|
|
209
|
+
await act(async () => {
|
|
210
|
+
await vi.advanceTimersByTimeAsync(50);
|
|
211
|
+
});
|
|
212
|
+
act(() => {
|
|
213
|
+
instance.onmessage!(
|
|
214
|
+
new MessageEvent('message', {
|
|
215
|
+
data: JSON.stringify({ type: 'update', runId: 'run-3' }),
|
|
216
|
+
})
|
|
217
|
+
);
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Wait for the debounce to fire (1500ms from last event)
|
|
222
|
+
await act(async () => {
|
|
223
|
+
await vi.advanceTimersByTimeAsync(1500);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
// Should only trigger ONE additional fetch despite 3 events
|
|
227
|
+
expect((fetch as ReturnType<typeof vi.fn>).mock.calls.length).toBe(initialCallCount + 1);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('does not refetch when SSE event does not match filter', async () => {
|
|
231
|
+
const sseFilter = vi.fn().mockReturnValue(false);
|
|
232
|
+
|
|
233
|
+
renderHook(() =>
|
|
234
|
+
useSmartPolling('/api/data', {
|
|
235
|
+
interval: 5000,
|
|
236
|
+
sseFilter,
|
|
237
|
+
})
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
// Initial fetch
|
|
241
|
+
await act(async () => {
|
|
242
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
const callCount = (fetch as ReturnType<typeof vi.fn>).mock.calls.length;
|
|
246
|
+
|
|
247
|
+
// Simulate SSE message that does NOT match filter
|
|
248
|
+
const instance = mockEventSourceInstances[0];
|
|
249
|
+
if (instance?.onmessage) {
|
|
250
|
+
act(() => {
|
|
251
|
+
instance.onmessage!(
|
|
252
|
+
new MessageEvent('message', {
|
|
253
|
+
data: JSON.stringify({ type: 'run_updated', runId: 'run-2' }),
|
|
254
|
+
})
|
|
255
|
+
);
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
await act(async () => {
|
|
260
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
// No additional fetch should have happened
|
|
264
|
+
expect((fetch as ReturnType<typeof vi.fn>).mock.calls.length).toBe(callCount);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it('provides a manual refresh function', async () => {
|
|
268
|
+
const { result } = renderHook(() =>
|
|
269
|
+
useSmartPolling('/api/data', { interval: 5000 })
|
|
270
|
+
);
|
|
271
|
+
|
|
272
|
+
await act(async () => {
|
|
273
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
274
|
+
});
|
|
275
|
+
expect(fetch).toHaveBeenCalledTimes(1);
|
|
276
|
+
|
|
277
|
+
await act(async () => {
|
|
278
|
+
result.current.refresh();
|
|
279
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
280
|
+
});
|
|
281
|
+
expect(fetch).toHaveBeenCalledTimes(2);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it('cleans up polling on unmount', async () => {
|
|
285
|
+
const { unmount } = renderHook(() =>
|
|
286
|
+
useSmartPolling('/api/data', {
|
|
287
|
+
interval: 5000,
|
|
288
|
+
})
|
|
289
|
+
);
|
|
290
|
+
|
|
291
|
+
await act(async () => {
|
|
292
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
293
|
+
});
|
|
294
|
+
const callsAfterMount = (fetch as ReturnType<typeof vi.fn>).mock.calls.length;
|
|
295
|
+
|
|
296
|
+
unmount();
|
|
297
|
+
|
|
298
|
+
// Advance time - no more fetches should happen
|
|
299
|
+
await act(async () => {
|
|
300
|
+
await vi.advanceTimersByTimeAsync(30000);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
expect((fetch as ReturnType<typeof vi.fn>).mock.calls.length).toBe(callsAfterMount);
|
|
304
|
+
});
|
|
305
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useState, useEffect, useRef, useCallback } from "react";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Hook that smoothly animates a number from its previous value to the target.
|
|
6
|
+
* Uses requestAnimationFrame for smooth 60fps transitions.
|
|
7
|
+
*
|
|
8
|
+
* @param target - The number to animate towards
|
|
9
|
+
* @param duration - Animation duration in ms (default 600)
|
|
10
|
+
* @returns The current animated value (integer)
|
|
11
|
+
*/
|
|
12
|
+
export function useAnimatedNumber(target: number, duration = 600): number {
|
|
13
|
+
const [display, setDisplay] = useState(target);
|
|
14
|
+
const animRef = useRef<number | null>(null);
|
|
15
|
+
const startRef = useRef<{ value: number; time: number } | null>(null);
|
|
16
|
+
const targetRef = useRef(target);
|
|
17
|
+
const displayRef = useRef(target);
|
|
18
|
+
|
|
19
|
+
// Easing function: ease-out cubic for a natural deceleration feel
|
|
20
|
+
const easeOutCubic = useCallback((t: number): number => {
|
|
21
|
+
return 1 - Math.pow(1 - t, 3);
|
|
22
|
+
}, []);
|
|
23
|
+
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
const prevTarget = targetRef.current;
|
|
26
|
+
targetRef.current = target;
|
|
27
|
+
|
|
28
|
+
// No change — skip animation
|
|
29
|
+
if (prevTarget === target) return;
|
|
30
|
+
|
|
31
|
+
// Cancel any running animation
|
|
32
|
+
if (animRef.current !== null) {
|
|
33
|
+
cancelAnimationFrame(animRef.current);
|
|
34
|
+
animRef.current = null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const startValue = displayRef.current;
|
|
38
|
+
const diff = target - startValue;
|
|
39
|
+
|
|
40
|
+
// If diff is 0, just snap
|
|
41
|
+
if (diff === 0) {
|
|
42
|
+
setDisplay(target);
|
|
43
|
+
displayRef.current = target;
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// For very small changes (1-2), snap immediately
|
|
48
|
+
if (Math.abs(diff) <= 2) {
|
|
49
|
+
setDisplay(target);
|
|
50
|
+
displayRef.current = target;
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const startTime = performance.now();
|
|
55
|
+
startRef.current = { value: startValue, time: startTime };
|
|
56
|
+
|
|
57
|
+
function animate(now: number) {
|
|
58
|
+
const elapsed = now - startTime;
|
|
59
|
+
const progress = Math.min(elapsed / duration, 1);
|
|
60
|
+
const easedProgress = easeOutCubic(progress);
|
|
61
|
+
|
|
62
|
+
const current = Math.round(startValue + diff * easedProgress);
|
|
63
|
+
displayRef.current = current;
|
|
64
|
+
setDisplay(current);
|
|
65
|
+
|
|
66
|
+
if (progress < 1) {
|
|
67
|
+
animRef.current = requestAnimationFrame(animate);
|
|
68
|
+
} else {
|
|
69
|
+
animRef.current = null;
|
|
70
|
+
// Ensure we land exactly on target
|
|
71
|
+
displayRef.current = target;
|
|
72
|
+
setDisplay(target);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
animRef.current = requestAnimationFrame(animate);
|
|
77
|
+
|
|
78
|
+
return () => {
|
|
79
|
+
if (animRef.current !== null) {
|
|
80
|
+
cancelAnimationFrame(animRef.current);
|
|
81
|
+
animRef.current = null;
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
}, [target, duration, easeOutCubic]);
|
|
85
|
+
|
|
86
|
+
return display;
|
|
87
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useState, useEffect, useRef, useCallback } from "react";
|
|
3
|
+
import { subscribe, StreamEvent } from "./use-event-stream";
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Configuration
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
/** Window (ms) for counting rapid SSE events to detect burst mode. */
|
|
10
|
+
const BURST_WINDOW_MS = 5000;
|
|
11
|
+
|
|
12
|
+
/** Number of SSE events within the burst window that triggers catch-up mode. */
|
|
13
|
+
const BURST_THRESHOLD = 10;
|
|
14
|
+
|
|
15
|
+
/** How long (ms) to hold catch-up mode after the burst subsides. */
|
|
16
|
+
const CATCHUP_HOLD_MS = 3000;
|
|
17
|
+
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Types
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
export interface CatchUpState {
|
|
23
|
+
/** Whether we are in catch-up mode (burst of events detected). */
|
|
24
|
+
active: boolean;
|
|
25
|
+
/** Number of batched/buffered updates while in catch-up mode. */
|
|
26
|
+
bufferedCount: number;
|
|
27
|
+
/** Dismiss catch-up mode and apply buffered updates immediately. */
|
|
28
|
+
flush: () => void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface UseBatchedUpdatesOptions {
|
|
32
|
+
/** SSE filter: only count events matching this predicate for burst detection. */
|
|
33
|
+
sseFilter?: (event: StreamEvent) => boolean;
|
|
34
|
+
/** Callback invoked when catch-up mode ends (either by timeout or flush). */
|
|
35
|
+
onFlush?: () => void;
|
|
36
|
+
/** Whether the hook is enabled (default true). */
|
|
37
|
+
enabled?: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// Hook
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Monitors SSE event rate and activates "catch-up mode" when a burst of
|
|
46
|
+
* events is detected (e.g. opening dashboard after overnight runs).
|
|
47
|
+
*
|
|
48
|
+
* In catch-up mode, the caller should suppress real-time UI updates and
|
|
49
|
+
* instead show a summary notification ("12 runs updated"). When the burst
|
|
50
|
+
* subsides or the user clicks "refresh now", catch-up mode ends and the
|
|
51
|
+
* caller should do a single full refresh.
|
|
52
|
+
*/
|
|
53
|
+
export function useBatchedUpdates(
|
|
54
|
+
options: UseBatchedUpdatesOptions = {}
|
|
55
|
+
): CatchUpState {
|
|
56
|
+
const { sseFilter, onFlush, enabled = true } = options;
|
|
57
|
+
const [active, setActive] = useState(false);
|
|
58
|
+
const [bufferedCount, setBufferedCount] = useState(0);
|
|
59
|
+
|
|
60
|
+
// Track SSE event timestamps within the burst window
|
|
61
|
+
const eventTimestampsRef = useRef<number[]>([]);
|
|
62
|
+
const catchUpActiveRef = useRef(false);
|
|
63
|
+
const holdTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
64
|
+
const sseFilterRef = useRef(sseFilter);
|
|
65
|
+
sseFilterRef.current = sseFilter;
|
|
66
|
+
const onFlushRef = useRef(onFlush);
|
|
67
|
+
onFlushRef.current = onFlush;
|
|
68
|
+
|
|
69
|
+
const exitCatchUp = useCallback(() => {
|
|
70
|
+
catchUpActiveRef.current = false;
|
|
71
|
+
setActive(false);
|
|
72
|
+
setBufferedCount(0);
|
|
73
|
+
eventTimestampsRef.current = [];
|
|
74
|
+
if (holdTimerRef.current) {
|
|
75
|
+
clearTimeout(holdTimerRef.current);
|
|
76
|
+
holdTimerRef.current = null;
|
|
77
|
+
}
|
|
78
|
+
onFlushRef.current?.();
|
|
79
|
+
}, []);
|
|
80
|
+
|
|
81
|
+
const flush = useCallback(() => {
|
|
82
|
+
if (catchUpActiveRef.current) {
|
|
83
|
+
exitCatchUp();
|
|
84
|
+
}
|
|
85
|
+
}, [exitCatchUp]);
|
|
86
|
+
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
if (!enabled) return;
|
|
89
|
+
|
|
90
|
+
const unsubscribe = subscribe((event: StreamEvent) => {
|
|
91
|
+
// Ignore non-data events
|
|
92
|
+
if (
|
|
93
|
+
event.type === "connected" ||
|
|
94
|
+
event.type === "disconnect" ||
|
|
95
|
+
event.type === "error"
|
|
96
|
+
) {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Apply optional filter
|
|
101
|
+
if (sseFilterRef.current && !sseFilterRef.current(event)) return;
|
|
102
|
+
|
|
103
|
+
const now = Date.now();
|
|
104
|
+
|
|
105
|
+
// Record timestamp and prune old entries
|
|
106
|
+
eventTimestampsRef.current.push(now);
|
|
107
|
+
eventTimestampsRef.current = eventTimestampsRef.current.filter(
|
|
108
|
+
(t) => now - t < BURST_WINDOW_MS
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
if (catchUpActiveRef.current) {
|
|
112
|
+
// Already in catch-up mode — increment buffered count
|
|
113
|
+
setBufferedCount((c) => c + 1);
|
|
114
|
+
|
|
115
|
+
// Reset the hold timer since events are still arriving
|
|
116
|
+
if (holdTimerRef.current) {
|
|
117
|
+
clearTimeout(holdTimerRef.current);
|
|
118
|
+
}
|
|
119
|
+
holdTimerRef.current = setTimeout(() => {
|
|
120
|
+
exitCatchUp();
|
|
121
|
+
}, CATCHUP_HOLD_MS);
|
|
122
|
+
} else {
|
|
123
|
+
// Check if we should enter catch-up mode
|
|
124
|
+
if (eventTimestampsRef.current.length >= BURST_THRESHOLD) {
|
|
125
|
+
catchUpActiveRef.current = true;
|
|
126
|
+
setActive(true);
|
|
127
|
+
setBufferedCount(eventTimestampsRef.current.length);
|
|
128
|
+
|
|
129
|
+
// Set initial hold timer
|
|
130
|
+
holdTimerRef.current = setTimeout(() => {
|
|
131
|
+
exitCatchUp();
|
|
132
|
+
}, CATCHUP_HOLD_MS);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
return () => {
|
|
138
|
+
unsubscribe();
|
|
139
|
+
if (holdTimerRef.current) {
|
|
140
|
+
clearTimeout(holdTimerRef.current);
|
|
141
|
+
holdTimerRef.current = null;
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
}, [enabled, exitCatchUp]);
|
|
145
|
+
|
|
146
|
+
return { active, bufferedCount, flush };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Export constants for testing
|
|
150
|
+
export { BURST_WINDOW_MS, BURST_THRESHOLD, CATCHUP_HOLD_MS };
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useState, useEffect, useRef } from "react";
|
|
3
|
+
|
|
4
|
+
export interface StreamEvent {
|
|
5
|
+
type: string;
|
|
6
|
+
runId?: string;
|
|
7
|
+
/** Batched runIds from leading-edge debounce (SSE broadcast level). */
|
|
8
|
+
runIds?: string[];
|
|
9
|
+
status?: string;
|
|
10
|
+
timestamp?: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
type EventCallback = (event: StreamEvent) => void;
|
|
14
|
+
|
|
15
|
+
// Module-level state for shared EventSource connection
|
|
16
|
+
let sharedEventSource: EventSource | null = null;
|
|
17
|
+
let subscriberCount = 0;
|
|
18
|
+
let reconnectAttempts = 0;
|
|
19
|
+
let reconnectTimeout: NodeJS.Timeout | null = null;
|
|
20
|
+
const subscribers: Set<EventCallback> = new Set();
|
|
21
|
+
|
|
22
|
+
function getReconnectDelay(): number {
|
|
23
|
+
const delays = [1000, 2000, 4000, 8000, 30000];
|
|
24
|
+
return delays[Math.min(reconnectAttempts, delays.length - 1)];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function createEventSource() {
|
|
28
|
+
if (typeof EventSource === "undefined") {
|
|
29
|
+
// eslint-disable-next-line no-console
|
|
30
|
+
console.warn("EventSource not supported in this environment");
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const source = new EventSource("/api/stream");
|
|
35
|
+
|
|
36
|
+
source.onopen = () => {
|
|
37
|
+
// eslint-disable-next-line no-console
|
|
38
|
+
console.log("SSE connected");
|
|
39
|
+
reconnectAttempts = 0;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
source.onmessage = (event) => {
|
|
43
|
+
try {
|
|
44
|
+
const data = JSON.parse(event.data);
|
|
45
|
+
subscribers.forEach((callback) => callback(data));
|
|
46
|
+
} catch (err) {
|
|
47
|
+
console.error("Failed to parse SSE message:", err);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
source.onerror = () => {
|
|
52
|
+
console.error("SSE connection error");
|
|
53
|
+
source.close();
|
|
54
|
+
sharedEventSource = null;
|
|
55
|
+
|
|
56
|
+
// Notify subscribers of disconnect so they can fall back to polling
|
|
57
|
+
subscribers.forEach((callback) => callback({ type: "disconnect" }));
|
|
58
|
+
|
|
59
|
+
// Auto-reconnect with exponential backoff
|
|
60
|
+
if (subscriberCount > 0) {
|
|
61
|
+
reconnectAttempts++;
|
|
62
|
+
const delay = getReconnectDelay();
|
|
63
|
+
// eslint-disable-next-line no-console
|
|
64
|
+
console.log(`Reconnecting in ${delay}ms (attempt ${reconnectAttempts})`);
|
|
65
|
+
reconnectTimeout = setTimeout(() => {
|
|
66
|
+
if (subscriberCount > 0) {
|
|
67
|
+
sharedEventSource = createEventSource();
|
|
68
|
+
}
|
|
69
|
+
}, delay);
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
return source;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function subscribe(callback: EventCallback): () => void {
|
|
77
|
+
subscribers.add(callback);
|
|
78
|
+
subscriberCount++;
|
|
79
|
+
|
|
80
|
+
// Open connection on first subscriber
|
|
81
|
+
if (subscriberCount === 1 && !sharedEventSource) {
|
|
82
|
+
sharedEventSource = createEventSource();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Return unsubscribe function
|
|
86
|
+
return () => {
|
|
87
|
+
subscribers.delete(callback);
|
|
88
|
+
subscriberCount--;
|
|
89
|
+
|
|
90
|
+
// Close connection on last unsubscribe
|
|
91
|
+
if (subscriberCount === 0) {
|
|
92
|
+
if (reconnectTimeout) {
|
|
93
|
+
clearTimeout(reconnectTimeout);
|
|
94
|
+
reconnectTimeout = null;
|
|
95
|
+
}
|
|
96
|
+
if (sharedEventSource) {
|
|
97
|
+
sharedEventSource.close();
|
|
98
|
+
sharedEventSource = null;
|
|
99
|
+
}
|
|
100
|
+
reconnectAttempts = 0;
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function useEventStream() {
|
|
106
|
+
const [connected, setConnected] = useState(false);
|
|
107
|
+
const [lastEvent, setLastEvent] = useState<StreamEvent | null>(null);
|
|
108
|
+
const [error, setError] = useState<string | null>(null);
|
|
109
|
+
const mountedRef = useRef(true);
|
|
110
|
+
|
|
111
|
+
useEffect(() => {
|
|
112
|
+
mountedRef.current = true;
|
|
113
|
+
|
|
114
|
+
if (typeof EventSource === "undefined") {
|
|
115
|
+
setError("EventSource not supported");
|
|
116
|
+
setConnected(false);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const handleEvent = (event: StreamEvent) => {
|
|
121
|
+
if (mountedRef.current) {
|
|
122
|
+
setLastEvent(event);
|
|
123
|
+
// Only mark as connected for real data events, not disconnect/error
|
|
124
|
+
if (event.type !== "disconnect" && event.type !== "error") {
|
|
125
|
+
setConnected(true);
|
|
126
|
+
setError(null);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const unsubscribe = subscribe(handleEvent);
|
|
132
|
+
|
|
133
|
+
// Check connection status
|
|
134
|
+
const checkConnection = setInterval(() => {
|
|
135
|
+
if (mountedRef.current) {
|
|
136
|
+
setConnected(
|
|
137
|
+
sharedEventSource !== null && sharedEventSource.readyState === EventSource.OPEN
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
}, 1000);
|
|
141
|
+
|
|
142
|
+
return () => {
|
|
143
|
+
mountedRef.current = false;
|
|
144
|
+
unsubscribe();
|
|
145
|
+
clearInterval(checkConnection);
|
|
146
|
+
};
|
|
147
|
+
}, []);
|
|
148
|
+
|
|
149
|
+
return { connected, lastEvent, error };
|
|
150
|
+
}
|