@happyvertical/smrt-svelte 0.38.0 → 0.38.2
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/dist/components/workspace/__tests__/system-feed.test.js +327 -0
- package/dist/components/workspace/live/index.d.ts +17 -0
- package/dist/components/workspace/live/index.d.ts.map +1 -0
- package/dist/components/workspace/live/index.js +16 -0
- package/dist/components/workspace/live/system-feed.recipe.md +169 -0
- package/dist/components/workspace/live/system-feed.svelte.d.ts +129 -0
- package/dist/components/workspace/live/system-feed.svelte.d.ts.map +1 -0
- package/dist/components/workspace/live/system-feed.svelte.js +201 -0
- package/dist/i18n/strings.workspace.d.ts +16 -0
- package/dist/i18n/strings.workspace.d.ts.map +1 -1
- package/dist/i18n/strings.workspace.js +17 -0
- package/package.json +11 -5
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { systemFeed } from '../live/system-feed.svelte.js';
|
|
3
|
+
/**
|
|
4
|
+
* Lifecycle tests for the `systemFeed` polling helper (#1774). Real Svelte
|
|
5
|
+
* `$state` reactivity + vitest fake timers; only the external `fetch` is
|
|
6
|
+
* mocked. Each `dispose()`s its feed in `afterEach` to avoid leaked intervals.
|
|
7
|
+
*/
|
|
8
|
+
const disposers = [];
|
|
9
|
+
function track(controller) {
|
|
10
|
+
disposers.push(() => controller.dispose());
|
|
11
|
+
return controller;
|
|
12
|
+
}
|
|
13
|
+
/** Flush the interval + the async tick's microtasks. */
|
|
14
|
+
async function advance(ms) {
|
|
15
|
+
await vi.advanceTimersByTimeAsync(ms);
|
|
16
|
+
}
|
|
17
|
+
function panel(id, itemCount) {
|
|
18
|
+
return {
|
|
19
|
+
id,
|
|
20
|
+
label: id,
|
|
21
|
+
items: Array.from({ length: itemCount }, (_, index) => ({
|
|
22
|
+
id: `${id}-${index}`,
|
|
23
|
+
label: `${id} ${index}`,
|
|
24
|
+
status: 'running',
|
|
25
|
+
})),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
vi.useFakeTimers();
|
|
30
|
+
});
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
while (disposers.length > 0)
|
|
33
|
+
disposers.pop()?.();
|
|
34
|
+
vi.useRealTimers();
|
|
35
|
+
// Restore any spies (e.g. document.hidden / addEventListener) so they cannot
|
|
36
|
+
// leak into later tests or files.
|
|
37
|
+
vi.restoreAllMocks();
|
|
38
|
+
});
|
|
39
|
+
describe('systemFeed', () => {
|
|
40
|
+
it('fetches immediately and maps the response into panels and chips', async () => {
|
|
41
|
+
const fetch = vi.fn().mockResolvedValue({ jobs: 2 });
|
|
42
|
+
const feed = track(systemFeed({
|
|
43
|
+
fetch,
|
|
44
|
+
intervalMs: 1000,
|
|
45
|
+
pauseWhenHidden: false,
|
|
46
|
+
map: (data) => ({
|
|
47
|
+
panels: [panel('jobs', data.jobs)],
|
|
48
|
+
chips: [{ id: 'jobs', label: 'Jobs', value: data.jobs }],
|
|
49
|
+
}),
|
|
50
|
+
}));
|
|
51
|
+
// Immediate tick is queued synchronously; flush its microtasks.
|
|
52
|
+
await advance(0);
|
|
53
|
+
expect(fetch).toHaveBeenCalledTimes(1);
|
|
54
|
+
expect(feed.status).toBe('success');
|
|
55
|
+
expect(feed.panels).toHaveLength(1);
|
|
56
|
+
expect(feed.panels[0]?.items).toHaveLength(2);
|
|
57
|
+
expect(feed.chips[0]?.value).toBe(2);
|
|
58
|
+
expect(feed.running).toBe(true);
|
|
59
|
+
});
|
|
60
|
+
it('polls again on each interval tick', async () => {
|
|
61
|
+
const fetch = vi.fn().mockResolvedValue({ ok: true });
|
|
62
|
+
track(systemFeed({
|
|
63
|
+
fetch,
|
|
64
|
+
intervalMs: 1000,
|
|
65
|
+
pauseWhenHidden: false,
|
|
66
|
+
map: () => ({}),
|
|
67
|
+
}));
|
|
68
|
+
await advance(0); // immediate
|
|
69
|
+
expect(fetch).toHaveBeenCalledTimes(1);
|
|
70
|
+
await advance(1000);
|
|
71
|
+
expect(fetch).toHaveBeenCalledTimes(2);
|
|
72
|
+
await advance(3000);
|
|
73
|
+
expect(fetch).toHaveBeenCalledTimes(5);
|
|
74
|
+
});
|
|
75
|
+
it('stops polling after the disposer runs', async () => {
|
|
76
|
+
const fetch = vi.fn().mockResolvedValue({ ok: true });
|
|
77
|
+
const feed = systemFeed({
|
|
78
|
+
fetch,
|
|
79
|
+
intervalMs: 1000,
|
|
80
|
+
pauseWhenHidden: false,
|
|
81
|
+
map: () => ({}),
|
|
82
|
+
});
|
|
83
|
+
await advance(0);
|
|
84
|
+
expect(fetch).toHaveBeenCalledTimes(1);
|
|
85
|
+
feed.dispose();
|
|
86
|
+
expect(feed.running).toBe(false);
|
|
87
|
+
expect(feed.status).toBe('stopped');
|
|
88
|
+
await advance(5000);
|
|
89
|
+
// No further ticks after disposal.
|
|
90
|
+
expect(fetch).toHaveBeenCalledTimes(1);
|
|
91
|
+
});
|
|
92
|
+
it('stop() halts the timer while start() re-arms it', async () => {
|
|
93
|
+
const fetch = vi.fn().mockResolvedValue({ ok: true });
|
|
94
|
+
const feed = track(systemFeed({
|
|
95
|
+
fetch,
|
|
96
|
+
intervalMs: 1000,
|
|
97
|
+
pauseWhenHidden: false,
|
|
98
|
+
map: () => ({}),
|
|
99
|
+
}));
|
|
100
|
+
await advance(0);
|
|
101
|
+
expect(fetch).toHaveBeenCalledTimes(1);
|
|
102
|
+
feed.stop();
|
|
103
|
+
expect(feed.running).toBe(false);
|
|
104
|
+
await advance(3000);
|
|
105
|
+
expect(fetch).toHaveBeenCalledTimes(1);
|
|
106
|
+
feed.start();
|
|
107
|
+
expect(feed.running).toBe(true);
|
|
108
|
+
await advance(1000);
|
|
109
|
+
expect(fetch).toHaveBeenCalledTimes(2);
|
|
110
|
+
});
|
|
111
|
+
it('tolerates a rejected fetch without stopping the loop and keeps prior data', async () => {
|
|
112
|
+
const onError = vi.fn();
|
|
113
|
+
const fetch = vi
|
|
114
|
+
.fn()
|
|
115
|
+
.mockResolvedValueOnce({ n: 1 })
|
|
116
|
+
.mockRejectedValueOnce(new Error('boom'))
|
|
117
|
+
.mockResolvedValue({ n: 3 });
|
|
118
|
+
const feed = track(systemFeed({
|
|
119
|
+
fetch,
|
|
120
|
+
intervalMs: 1000,
|
|
121
|
+
pauseWhenHidden: false,
|
|
122
|
+
onError,
|
|
123
|
+
map: (data) => ({
|
|
124
|
+
chips: [{ id: 'n', label: 'N', value: data.n }],
|
|
125
|
+
}),
|
|
126
|
+
}));
|
|
127
|
+
await advance(0);
|
|
128
|
+
expect(feed.chips[0]?.value).toBe(1);
|
|
129
|
+
expect(feed.status).toBe('success');
|
|
130
|
+
// Second tick rejects.
|
|
131
|
+
await advance(1000);
|
|
132
|
+
expect(onError).toHaveBeenCalledTimes(1);
|
|
133
|
+
expect(feed.status).toBe('error');
|
|
134
|
+
expect(feed.error).toBeInstanceOf(Error);
|
|
135
|
+
// Last good data stays on screen.
|
|
136
|
+
expect(feed.chips[0]?.value).toBe(1);
|
|
137
|
+
// Third tick recovers.
|
|
138
|
+
await advance(1000);
|
|
139
|
+
expect(feed.status).toBe('success');
|
|
140
|
+
expect(feed.error).toBeNull();
|
|
141
|
+
expect(feed.chips[0]?.value).toBe(3);
|
|
142
|
+
});
|
|
143
|
+
it('tolerates a throwing mapper as a failed tick', async () => {
|
|
144
|
+
const fetch = vi.fn().mockResolvedValue({ bad: true });
|
|
145
|
+
const feed = track(systemFeed({
|
|
146
|
+
fetch,
|
|
147
|
+
intervalMs: 1000,
|
|
148
|
+
pauseWhenHidden: false,
|
|
149
|
+
map: () => {
|
|
150
|
+
throw new Error('map failed');
|
|
151
|
+
},
|
|
152
|
+
}));
|
|
153
|
+
await advance(0);
|
|
154
|
+
expect(feed.status).toBe('error');
|
|
155
|
+
expect(feed.error).toBeInstanceOf(Error);
|
|
156
|
+
// Loop survives.
|
|
157
|
+
await advance(1000);
|
|
158
|
+
expect(fetch).toHaveBeenCalledTimes(2);
|
|
159
|
+
});
|
|
160
|
+
it('does not fetch on creation when immediate is false', async () => {
|
|
161
|
+
const fetch = vi.fn().mockResolvedValue({ ok: true });
|
|
162
|
+
const feed = track(systemFeed({
|
|
163
|
+
fetch,
|
|
164
|
+
intervalMs: 1000,
|
|
165
|
+
immediate: false,
|
|
166
|
+
pauseWhenHidden: false,
|
|
167
|
+
map: () => ({}),
|
|
168
|
+
}));
|
|
169
|
+
await advance(0);
|
|
170
|
+
expect(fetch).not.toHaveBeenCalled();
|
|
171
|
+
expect(feed.status).toBe('idle');
|
|
172
|
+
// First real load is the timer tick.
|
|
173
|
+
await advance(1000);
|
|
174
|
+
expect(fetch).toHaveBeenCalledTimes(1);
|
|
175
|
+
});
|
|
176
|
+
it('runs a single fetch with no timer when intervalMs <= 0', async () => {
|
|
177
|
+
const fetch = vi.fn().mockResolvedValue({ ok: true });
|
|
178
|
+
const feed = track(systemFeed({
|
|
179
|
+
fetch,
|
|
180
|
+
intervalMs: 0,
|
|
181
|
+
pauseWhenHidden: false,
|
|
182
|
+
map: () => ({}),
|
|
183
|
+
}));
|
|
184
|
+
await advance(0);
|
|
185
|
+
expect(fetch).toHaveBeenCalledTimes(1);
|
|
186
|
+
// No timer is armed, so `running` stays false (it tracks an armed timer).
|
|
187
|
+
expect(feed.running).toBe(false);
|
|
188
|
+
await advance(10_000);
|
|
189
|
+
expect(fetch).toHaveBeenCalledTimes(1);
|
|
190
|
+
// Manual refresh still works.
|
|
191
|
+
await feed.refresh();
|
|
192
|
+
expect(fetch).toHaveBeenCalledTimes(2);
|
|
193
|
+
});
|
|
194
|
+
it('refresh() fetches once off-schedule', async () => {
|
|
195
|
+
const fetch = vi.fn().mockResolvedValue({ ok: true });
|
|
196
|
+
const feed = track(systemFeed({
|
|
197
|
+
fetch,
|
|
198
|
+
intervalMs: 0,
|
|
199
|
+
immediate: false,
|
|
200
|
+
pauseWhenHidden: false,
|
|
201
|
+
map: () => ({}),
|
|
202
|
+
}));
|
|
203
|
+
await advance(0);
|
|
204
|
+
expect(fetch).not.toHaveBeenCalled();
|
|
205
|
+
await feed.refresh();
|
|
206
|
+
expect(fetch).toHaveBeenCalledTimes(1);
|
|
207
|
+
});
|
|
208
|
+
it('skips ticks while the document is hidden and refreshes on becoming visible', async () => {
|
|
209
|
+
let hidden = false;
|
|
210
|
+
const listeners = [];
|
|
211
|
+
vi.spyOn(document, 'hidden', 'get').mockImplementation(() => hidden);
|
|
212
|
+
const addSpy = vi
|
|
213
|
+
.spyOn(document, 'addEventListener')
|
|
214
|
+
.mockImplementation((type, cb) => {
|
|
215
|
+
if (type === 'visibilitychange')
|
|
216
|
+
listeners.push(cb);
|
|
217
|
+
});
|
|
218
|
+
const removeSpy = vi
|
|
219
|
+
.spyOn(document, 'removeEventListener')
|
|
220
|
+
.mockImplementation(() => { });
|
|
221
|
+
const fetch = vi.fn().mockResolvedValue({ ok: true });
|
|
222
|
+
const feed = track(systemFeed({
|
|
223
|
+
fetch,
|
|
224
|
+
intervalMs: 1000,
|
|
225
|
+
// pauseWhenHidden defaults to true.
|
|
226
|
+
map: () => ({}),
|
|
227
|
+
}));
|
|
228
|
+
// Immediate tick while visible.
|
|
229
|
+
await advance(0);
|
|
230
|
+
expect(fetch).toHaveBeenCalledTimes(1);
|
|
231
|
+
// Go hidden — timer ticks are skipped.
|
|
232
|
+
hidden = true;
|
|
233
|
+
await advance(3000);
|
|
234
|
+
expect(fetch).toHaveBeenCalledTimes(1);
|
|
235
|
+
// Back to visible — the visibilitychange listener refreshes once.
|
|
236
|
+
hidden = false;
|
|
237
|
+
for (const cb of listeners)
|
|
238
|
+
cb();
|
|
239
|
+
await advance(0);
|
|
240
|
+
expect(fetch).toHaveBeenCalledTimes(2);
|
|
241
|
+
feed.dispose();
|
|
242
|
+
expect(removeSpy).toHaveBeenCalledWith('visibilitychange', expect.any(Function));
|
|
243
|
+
addSpy.mockRestore();
|
|
244
|
+
removeSpy.mockRestore();
|
|
245
|
+
});
|
|
246
|
+
it('supersedes an in-flight fetch so a stale response cannot clobber newer data', async () => {
|
|
247
|
+
let resolveFirst;
|
|
248
|
+
const fetch = vi
|
|
249
|
+
.fn()
|
|
250
|
+
.mockImplementationOnce(() => new Promise((resolve) => {
|
|
251
|
+
resolveFirst = resolve;
|
|
252
|
+
}))
|
|
253
|
+
.mockResolvedValue({ n: 2 });
|
|
254
|
+
const feed = track(systemFeed({
|
|
255
|
+
fetch,
|
|
256
|
+
intervalMs: 0,
|
|
257
|
+
immediate: false,
|
|
258
|
+
pauseWhenHidden: false,
|
|
259
|
+
map: (data) => ({
|
|
260
|
+
chips: [{ id: 'n', label: 'N', value: data.n }],
|
|
261
|
+
}),
|
|
262
|
+
}));
|
|
263
|
+
// Start a slow first fetch (never resolved yet).
|
|
264
|
+
const first = feed.refresh();
|
|
265
|
+
// Start a second fetch that resolves immediately; it supersedes the first.
|
|
266
|
+
await feed.refresh();
|
|
267
|
+
expect(feed.chips[0]?.value).toBe(2);
|
|
268
|
+
// Now let the stale first fetch resolve — it must be ignored.
|
|
269
|
+
resolveFirst?.({ n: 1 });
|
|
270
|
+
await first;
|
|
271
|
+
expect(feed.chips[0]?.value).toBe(2);
|
|
272
|
+
});
|
|
273
|
+
it('contains a throwing onError callback (no unhandled rejection)', async () => {
|
|
274
|
+
const unhandled = vi.fn();
|
|
275
|
+
process.on('unhandledRejection', unhandled);
|
|
276
|
+
const onError = vi.fn(() => {
|
|
277
|
+
throw new Error('consumer onError blew up');
|
|
278
|
+
});
|
|
279
|
+
const fetch = vi.fn().mockRejectedValue(new Error('fetch failed'));
|
|
280
|
+
const feed = track(systemFeed({
|
|
281
|
+
fetch,
|
|
282
|
+
intervalMs: 0,
|
|
283
|
+
immediate: false,
|
|
284
|
+
pauseWhenHidden: false,
|
|
285
|
+
onError,
|
|
286
|
+
map: () => ({}),
|
|
287
|
+
}));
|
|
288
|
+
// The failing fetch invokes the throwing onError; the tick must still
|
|
289
|
+
// resolve rather than reject.
|
|
290
|
+
await expect(feed.refresh()).resolves.toBeUndefined();
|
|
291
|
+
expect(onError).toHaveBeenCalled();
|
|
292
|
+
expect(feed.status).toBe('error');
|
|
293
|
+
// Give any stray rejection a chance to surface, then assert none did.
|
|
294
|
+
await advance(0);
|
|
295
|
+
process.off('unhandledRejection', unhandled);
|
|
296
|
+
expect(unhandled).not.toHaveBeenCalled();
|
|
297
|
+
});
|
|
298
|
+
it('does not refresh on becoming visible after stop()', async () => {
|
|
299
|
+
let hidden = false;
|
|
300
|
+
const listeners = [];
|
|
301
|
+
vi.spyOn(document, 'hidden', 'get').mockImplementation(() => hidden);
|
|
302
|
+
vi.spyOn(document, 'addEventListener').mockImplementation((type, cb) => {
|
|
303
|
+
if (type === 'visibilitychange')
|
|
304
|
+
listeners.push(cb);
|
|
305
|
+
});
|
|
306
|
+
vi.spyOn(document, 'removeEventListener').mockImplementation(() => { });
|
|
307
|
+
const fetch = vi.fn().mockResolvedValue({ ok: true });
|
|
308
|
+
const feed = track(systemFeed({
|
|
309
|
+
fetch,
|
|
310
|
+
intervalMs: 1000,
|
|
311
|
+
map: () => ({}),
|
|
312
|
+
}));
|
|
313
|
+
await advance(0);
|
|
314
|
+
expect(fetch).toHaveBeenCalledTimes(1);
|
|
315
|
+
// Pause polling (but the visibility listener is still attached — only
|
|
316
|
+
// dispose() detaches it).
|
|
317
|
+
feed.stop();
|
|
318
|
+
expect(feed.running).toBe(false);
|
|
319
|
+
// A hidden -> visible transition must NOT resurrect polling while stopped.
|
|
320
|
+
hidden = true;
|
|
321
|
+
hidden = false;
|
|
322
|
+
for (const cb of listeners)
|
|
323
|
+
cb();
|
|
324
|
+
await advance(0);
|
|
325
|
+
expect(fetch).toHaveBeenCalledTimes(1);
|
|
326
|
+
});
|
|
327
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@happyvertical/smrt-svelte/workspace/live`
|
|
3
|
+
*
|
|
4
|
+
* Opt-in, transport-light live-data helpers for the AdminShell system scope.
|
|
5
|
+
*
|
|
6
|
+
* This subpath is intentionally separate from the `./workspace` presentation
|
|
7
|
+
* barrel and the `./web` (smrt-web / TanStack) entry. The system scope shows
|
|
8
|
+
* jobs / schedules / dispatch, which live in `_smrt_*` system tables that the
|
|
9
|
+
* core change-feed skips and smrt-web does not expose as collections — so this
|
|
10
|
+
* layer polls an app-provided status endpoint rather than a live collection,
|
|
11
|
+
* and carries no smrt-web dependency.
|
|
12
|
+
*
|
|
13
|
+
* See `src/components/workspace/live/system-feed.recipe.md` for wiring an app
|
|
14
|
+
* jobs-status endpoint (server reads `_smrt_*` via `@happyvertical/smrt-jobs`).
|
|
15
|
+
*/
|
|
16
|
+
export { type SystemFeedController, type SystemFeedOptions, type SystemFeedStatus, type SystemFeedView, systemFeed, } from './system-feed.svelte.js';
|
|
17
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/components/workspace/live/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,EACL,KAAK,oBAAoB,EACzB,KAAK,iBAAiB,EACtB,KAAK,gBAAgB,EACrB,KAAK,cAAc,EACnB,UAAU,GACX,MAAM,yBAAyB,CAAC"}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@happyvertical/smrt-svelte/workspace/live`
|
|
3
|
+
*
|
|
4
|
+
* Opt-in, transport-light live-data helpers for the AdminShell system scope.
|
|
5
|
+
*
|
|
6
|
+
* This subpath is intentionally separate from the `./workspace` presentation
|
|
7
|
+
* barrel and the `./web` (smrt-web / TanStack) entry. The system scope shows
|
|
8
|
+
* jobs / schedules / dispatch, which live in `_smrt_*` system tables that the
|
|
9
|
+
* core change-feed skips and smrt-web does not expose as collections — so this
|
|
10
|
+
* layer polls an app-provided status endpoint rather than a live collection,
|
|
11
|
+
* and carries no smrt-web dependency.
|
|
12
|
+
*
|
|
13
|
+
* See `src/components/workspace/live/system-feed.recipe.md` for wiring an app
|
|
14
|
+
* jobs-status endpoint (server reads `_smrt_*` via `@happyvertical/smrt-jobs`).
|
|
15
|
+
*/
|
|
16
|
+
export { systemFeed, } from './system-feed.svelte.js';
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# Recipe: live system-scope data with `systemFeed`
|
|
2
|
+
|
|
3
|
+
The AdminShell **system scope** (the bottom edge) shows operational data — jobs,
|
|
4
|
+
schedules, dispatch, worker liveness. That data lives in the `_smrt_*` **system
|
|
5
|
+
tables**, which are intentionally outside the reactive path used for tenant/app
|
|
6
|
+
data:
|
|
7
|
+
|
|
8
|
+
- The core change-feed **skips** every `_smrt_*` table
|
|
9
|
+
(`packages/core/src/change-feed.ts`).
|
|
10
|
+
- `smrt-web` does **not** expose these as collections.
|
|
11
|
+
|
|
12
|
+
So the system scope is not fed by `liveCollection()`. Instead the **app owns a
|
|
13
|
+
status endpoint** that reads `_smrt_*` on the server, and the browser **polls**
|
|
14
|
+
it with `systemFeed` from `@happyvertical/smrt-svelte/workspace/live`. This
|
|
15
|
+
subpath is transport-light — it pulls no `smrt-web` / TanStack dependency.
|
|
16
|
+
|
|
17
|
+
## 1. Server: expose a jobs-status endpoint
|
|
18
|
+
|
|
19
|
+
Read the system tables through the `@happyvertical/smrt-jobs` query API
|
|
20
|
+
(`SmrtJobCollection`, `SmrtWorkerCollection`, …) — never hand-write SQL against
|
|
21
|
+
`_smrt_*`. A SvelteKit `+server.ts` is shown; any framework's request handler
|
|
22
|
+
works the same way.
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
// src/routes/admin/system/status/+server.ts
|
|
26
|
+
import { json } from '@sveltejs/kit';
|
|
27
|
+
import { getDatabase } from '@happyvertical/sql';
|
|
28
|
+
import { SmrtJobCollection, SmrtWorkerCollection } from '@happyvertical/smrt-jobs';
|
|
29
|
+
|
|
30
|
+
export async function GET() {
|
|
31
|
+
const db = await getDatabase(/* your app's connection options */);
|
|
32
|
+
const jobs = await SmrtJobCollection.create({ db });
|
|
33
|
+
const workers = await SmrtWorkerCollection.create({ db });
|
|
34
|
+
|
|
35
|
+
// Collections read the `_smrt_*` system tables for you.
|
|
36
|
+
const recent = await jobs.list({ orderBy: 'created_at DESC', limit: 20 });
|
|
37
|
+
const live = await workers.list({ limit: 50 });
|
|
38
|
+
|
|
39
|
+
const counts = { queued: 0, running: 0, failed: 0 };
|
|
40
|
+
for (const job of recent) {
|
|
41
|
+
if (job.status === 'pending') counts.queued += 1;
|
|
42
|
+
else if (job.status === 'running') counts.running += 1;
|
|
43
|
+
else if (job.status === 'failed') counts.failed += 1;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return json({
|
|
47
|
+
generatedAt: new Date().toISOString(),
|
|
48
|
+
counts,
|
|
49
|
+
workers: live.length,
|
|
50
|
+
// SmrtJob is a method-dispatch row: `queue` / `objectType` / `method` /
|
|
51
|
+
// `status` / `lastError` — there is no free-text "name" column, so build a
|
|
52
|
+
// label from the fields that exist.
|
|
53
|
+
jobs: recent.map((job) => ({
|
|
54
|
+
id: job.id,
|
|
55
|
+
label: `${job.objectType}.${job.method}`,
|
|
56
|
+
queue: job.queue,
|
|
57
|
+
status: job.status,
|
|
58
|
+
detail: job.lastError ?? undefined,
|
|
59
|
+
updatedAt: job.updatedAt,
|
|
60
|
+
})),
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Guard the route with your own auth/permissions (e.g. the `smrt-users`
|
|
66
|
+
`PermissionResolver`) — it exposes operational internals.
|
|
67
|
+
|
|
68
|
+
## 2. Client: poll it and map into the shell contracts
|
|
69
|
+
|
|
70
|
+
`systemFeed({ fetch, intervalMs, map })` calls your `fetch` on an interval and
|
|
71
|
+
maps the response into `ShellSystemPanel[]` (for `SystemScopePanel`) and
|
|
72
|
+
`ShellStatusChip[]` (for `SystemStatusChips`). It returns a controller whose
|
|
73
|
+
`panels` / `chips` are `$state`-reactive; bind them straight to the components
|
|
74
|
+
and call `dispose()` on teardown.
|
|
75
|
+
|
|
76
|
+
```svelte
|
|
77
|
+
<script lang="ts">
|
|
78
|
+
import { onDestroy } from 'svelte';
|
|
79
|
+
import { systemFeed } from '@happyvertical/smrt-svelte/workspace/live';
|
|
80
|
+
import {
|
|
81
|
+
SystemScopePanel,
|
|
82
|
+
SystemStatusChips,
|
|
83
|
+
type ShellStatusChip,
|
|
84
|
+
type ShellSystemPanel,
|
|
85
|
+
} from '@happyvertical/smrt-svelte/workspace';
|
|
86
|
+
|
|
87
|
+
interface SystemStatus {
|
|
88
|
+
counts: { queued: number; running: number; failed: number };
|
|
89
|
+
workers: number;
|
|
90
|
+
jobs: Array<{
|
|
91
|
+
id: string;
|
|
92
|
+
label: string;
|
|
93
|
+
queue: string;
|
|
94
|
+
status: string;
|
|
95
|
+
detail?: string;
|
|
96
|
+
updatedAt?: string;
|
|
97
|
+
}>;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const feed = systemFeed<SystemStatus>({
|
|
101
|
+
// App-owned transport — the helper only calls it.
|
|
102
|
+
fetch: (signal) =>
|
|
103
|
+
fetch('/admin/system/status', { signal }).then((r) => r.json()),
|
|
104
|
+
intervalMs: 5000,
|
|
105
|
+
map: (status) => ({
|
|
106
|
+
chips: [
|
|
107
|
+
{ id: 'workers', label: 'Workers', value: status.workers, tone: 'info' },
|
|
108
|
+
{
|
|
109
|
+
id: 'running',
|
|
110
|
+
label: 'Running',
|
|
111
|
+
value: status.counts.running,
|
|
112
|
+
tone: 'success',
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
id: 'failed',
|
|
116
|
+
label: 'Failed',
|
|
117
|
+
value: status.counts.failed,
|
|
118
|
+
tone: status.counts.failed > 0 ? 'error' : 'neutral',
|
|
119
|
+
},
|
|
120
|
+
] satisfies ShellStatusChip[],
|
|
121
|
+
panels: [
|
|
122
|
+
{
|
|
123
|
+
id: 'jobs',
|
|
124
|
+
label: 'Jobs',
|
|
125
|
+
items: status.jobs.map((job) => ({
|
|
126
|
+
id: job.id,
|
|
127
|
+
label: job.label,
|
|
128
|
+
status: job.status,
|
|
129
|
+
detail: job.detail,
|
|
130
|
+
updatedAt: job.updatedAt,
|
|
131
|
+
})),
|
|
132
|
+
},
|
|
133
|
+
] satisfies ShellSystemPanel[],
|
|
134
|
+
}),
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
onDestroy(feed.dispose);
|
|
138
|
+
</script>
|
|
139
|
+
|
|
140
|
+
<SystemStatusChips chips={feed.chips} />
|
|
141
|
+
<SystemScopePanel panels={feed.panels} />
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Wire those two snippets into the shell's `systemBar` / `systemPanel` slots as
|
|
145
|
+
usual (see `SystemScopePanel` / `SystemStatusChips`).
|
|
146
|
+
|
|
147
|
+
## Behavior notes
|
|
148
|
+
|
|
149
|
+
- **Immediate first load** — fetches once on creation (disable with
|
|
150
|
+
`immediate: false`), then every `intervalMs`. `intervalMs <= 0` disables the
|
|
151
|
+
timer (single fetch; drive further loads with `refresh()`).
|
|
152
|
+
- **Errors are tolerated** — a rejected `fetch` or a throwing `map` sets
|
|
153
|
+
`status: 'error'` and `error`, calls `onError`, and keeps polling. The last
|
|
154
|
+
good `panels` / `chips` stay on screen.
|
|
155
|
+
- **Backgrounded tabs** — with the default `pauseWhenHidden: true`, ticks are
|
|
156
|
+
skipped while `document.hidden` and one refresh fires when the tab becomes
|
|
157
|
+
visible again. In SSR / non-DOM environments this is a no-op.
|
|
158
|
+
- **No overlap / no late writes** — each tick supersedes any in-flight fetch
|
|
159
|
+
(via `AbortSignal`), and a stale response can never clobber a newer one.
|
|
160
|
+
- **SSR-safe construction** — constructed under SSR (no `document`), the feed is
|
|
161
|
+
inert: no listener, no immediate fetch, no server-side timer. It comes to life
|
|
162
|
+
when the client re-runs the component script, so you can call `systemFeed`
|
|
163
|
+
directly in `<script>` (as above) without an `onMount` guard; the first fetch
|
|
164
|
+
and the poll timer start on the client. `running` is `false` until a timer is
|
|
165
|
+
actually armed (so it is also `false` for single-shot `intervalMs <= 0`).
|
|
166
|
+
- **`onError` is contained** — a throwing `onError` callback is caught and
|
|
167
|
+
swallowed; it never rejects the tick or surfaces as an unhandled rejection.
|
|
168
|
+
- **Teardown** — `dispose()` stops the timer, detaches the visibility listener,
|
|
169
|
+
and aborts any in-flight fetch. Always call it from `onDestroy`.
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `systemFeed` — a transport-light polling helper for the AdminShell system
|
|
3
|
+
* scope (issue #1774, epic #1766).
|
|
4
|
+
*
|
|
5
|
+
* The System edge presents jobs / schedules / dispatch — data that lives in the
|
|
6
|
+
* `_smrt_*` SYSTEM tables. Those tables are intentionally **excluded** from the
|
|
7
|
+
* core change-feed (`packages/core/src/change-feed.ts`) and are **not** surfaced
|
|
8
|
+
* as `smrt-web` collections, so the live-collection path used by tenant/app
|
|
9
|
+
* data does not apply here. Instead, an app exposes its own status endpoint
|
|
10
|
+
* (a `+server.ts` that reads `_smrt_*` through the `smrt-jobs` query API) and
|
|
11
|
+
* this helper polls it on an interval, mapping the response into the
|
|
12
|
+
* presentation contracts the shell already renders:
|
|
13
|
+
* `ShellSystemPanel[]` (for `SystemScopePanel`) and `ShellStatusChip[]`
|
|
14
|
+
* (for `SystemStatusChips`).
|
|
15
|
+
*
|
|
16
|
+
* This module is deliberately kept OUT of the `./workspace` presentation barrel
|
|
17
|
+
* and the `./web` (smrt-web / TanStack) entry: it is opt-in via the
|
|
18
|
+
* `@happyvertical/smrt-svelte/workspace/live` subpath and pulls no runtime
|
|
19
|
+
* dependency beyond Svelte's reactivity. It only imports the shell's *types*.
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* ```svelte
|
|
23
|
+
* <script lang="ts">
|
|
24
|
+
* import { systemFeed } from '@happyvertical/smrt-svelte/workspace/live';
|
|
25
|
+
* import { SystemScopePanel, SystemStatusChips } from
|
|
26
|
+
* '@happyvertical/smrt-svelte/workspace';
|
|
27
|
+
* import { onDestroy } from 'svelte';
|
|
28
|
+
*
|
|
29
|
+
* const feed = systemFeed({
|
|
30
|
+
* fetch: () => fetch('/admin/system/status').then((r) => r.json()),
|
|
31
|
+
* intervalMs: 5000,
|
|
32
|
+
* map: (status) => ({ panels: toPanels(status), chips: toChips(status) }),
|
|
33
|
+
* });
|
|
34
|
+
* onDestroy(feed.dispose);
|
|
35
|
+
* </script>
|
|
36
|
+
*
|
|
37
|
+
* <SystemStatusChips chips={feed.chips} />
|
|
38
|
+
* <SystemScopePanel panels={feed.panels} />
|
|
39
|
+
* ```
|
|
40
|
+
*/
|
|
41
|
+
import type { ShellStatusChip, ShellSystemPanel } from '../admin-shell/types.js';
|
|
42
|
+
/** Lifecycle phase of a {@link SystemFeedController}. */
|
|
43
|
+
export type SystemFeedStatus = 'idle' | 'loading' | 'success' | 'error' | 'stopped';
|
|
44
|
+
/**
|
|
45
|
+
* The shell-facing shape produced by {@link SystemFeedOptions.map}. Either half
|
|
46
|
+
* is optional so a feed can drive only the panels, only the chips, or both.
|
|
47
|
+
*/
|
|
48
|
+
export interface SystemFeedView {
|
|
49
|
+
panels?: ShellSystemPanel[];
|
|
50
|
+
chips?: ShellStatusChip[];
|
|
51
|
+
}
|
|
52
|
+
/** Options for {@link systemFeed}. */
|
|
53
|
+
export interface SystemFeedOptions<T> {
|
|
54
|
+
/**
|
|
55
|
+
* App-supplied async loader. Called on each tick (and once immediately unless
|
|
56
|
+
* `immediate` is `false`). Typically wraps `fetch('/…/status')`. Its resolved
|
|
57
|
+
* value is handed to {@link SystemFeedOptions.map}. Rejections are caught and
|
|
58
|
+
* surfaced via `error` / `onError` without stopping the poll loop.
|
|
59
|
+
*/
|
|
60
|
+
fetch: (signal: AbortSignal) => T | Promise<T>;
|
|
61
|
+
/**
|
|
62
|
+
* Maps a raw `fetch` result into the shell presentation contracts. Runs
|
|
63
|
+
* inside the same try/catch as `fetch`, so a throwing mapper is treated as a
|
|
64
|
+
* failed tick (tolerated, loop continues).
|
|
65
|
+
*/
|
|
66
|
+
map: (data: T) => SystemFeedView;
|
|
67
|
+
/**
|
|
68
|
+
* Poll interval in milliseconds. Values `<= 0` disable the timer (single
|
|
69
|
+
* fetch only, useful for tests / manual `refresh()`). Default `5000`.
|
|
70
|
+
*/
|
|
71
|
+
intervalMs?: number;
|
|
72
|
+
/**
|
|
73
|
+
* Fetch once as soon as the feed is created. Default `true`. When `false`,
|
|
74
|
+
* nothing is fetched until the first timer tick or an explicit `refresh()`.
|
|
75
|
+
*/
|
|
76
|
+
immediate?: boolean;
|
|
77
|
+
/**
|
|
78
|
+
* Skip ticks while `document.hidden` is true and refresh once on the next
|
|
79
|
+
* `visibilitychange` back to visible. Avoids polling a backgrounded tab.
|
|
80
|
+
* Default `true`. Ignored (treated as `false`) when there is no `document`
|
|
81
|
+
* (SSR / non-DOM environments).
|
|
82
|
+
*/
|
|
83
|
+
pauseWhenHidden?: boolean;
|
|
84
|
+
/** Notified on every caught fetch/map error. Never throws into the loop. */
|
|
85
|
+
onError?: (error: unknown) => void;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Reactive controller returned by {@link systemFeed}. `panels` / `chips` are
|
|
89
|
+
* `$state`-backed getters safe to bind straight to `SystemScopePanel` /
|
|
90
|
+
* `SystemStatusChips`. Call {@link SystemFeedController.dispose} (e.g. from
|
|
91
|
+
* `onDestroy`) to stop the timer and detach listeners — this is the disposer.
|
|
92
|
+
*/
|
|
93
|
+
export interface SystemFeedController {
|
|
94
|
+
/** Latest mapped panels; `[]` until the first successful tick. */
|
|
95
|
+
readonly panels: ShellSystemPanel[];
|
|
96
|
+
/** Latest mapped chips; `[]` until the first successful tick. */
|
|
97
|
+
readonly chips: ShellStatusChip[];
|
|
98
|
+
/** Lifecycle phase of the most recent tick. */
|
|
99
|
+
readonly status: SystemFeedStatus;
|
|
100
|
+
/** The most recent caught error, or `null` after any success. */
|
|
101
|
+
readonly error: unknown;
|
|
102
|
+
/**
|
|
103
|
+
* Whether the poll timer is currently armed. Stays `false` for single-shot
|
|
104
|
+
* feeds (`intervalMs <= 0`), under SSR (no `document`), and after `stop()` /
|
|
105
|
+
* `dispose()`.
|
|
106
|
+
*/
|
|
107
|
+
readonly running: boolean;
|
|
108
|
+
/** Fetch + map once, off-schedule. Resolves after state is updated. */
|
|
109
|
+
refresh(): Promise<void>;
|
|
110
|
+
/**
|
|
111
|
+
* (Re)arm the poll timer. Idempotent. No-op when already running, after
|
|
112
|
+
* {@link dispose}, when `intervalMs <= 0`, or under SSR (no `document`).
|
|
113
|
+
*/
|
|
114
|
+
start(): void;
|
|
115
|
+
/** Disarm the poll timer without tearing down listeners. Idempotent. */
|
|
116
|
+
stop(): void;
|
|
117
|
+
/** Stop the timer, detach listeners, abort any in-flight fetch. Terminal. */
|
|
118
|
+
dispose(): void;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Create a polling feed that maps an app status endpoint into the AdminShell
|
|
122
|
+
* system-scope presentation contracts.
|
|
123
|
+
*
|
|
124
|
+
* The returned {@link SystemFeedController} exposes reactive `panels` / `chips`
|
|
125
|
+
* and a `dispose()` disposer. Bind the getters to the shell components and call
|
|
126
|
+
* `dispose()` on teardown.
|
|
127
|
+
*/
|
|
128
|
+
export declare function systemFeed<T>(options: SystemFeedOptions<T>): SystemFeedController;
|
|
129
|
+
//# sourceMappingURL=system-feed.svelte.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"system-feed.svelte.d.ts","sourceRoot":"","sources":["../../../../src/components/workspace/live/system-feed.svelte.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuCG;AAEH,OAAO,KAAK,EACV,eAAe,EACf,gBAAgB,EACjB,MAAM,yBAAyB,CAAC;AAEjC,yDAAyD;AACzD,MAAM,MAAM,gBAAgB,GACxB,MAAM,GACN,SAAS,GACT,SAAS,GACT,OAAO,GACP,SAAS,CAAC;AAEd;;;GAGG;AACH,MAAM,WAAW,cAAc;IAC7B,MAAM,CAAC,EAAE,gBAAgB,EAAE,CAAC;IAC5B,KAAK,CAAC,EAAE,eAAe,EAAE,CAAC;CAC3B;AAED,sCAAsC;AACtC,MAAM,WAAW,iBAAiB,CAAC,CAAC;IAClC;;;;;OAKG;IACH,KAAK,EAAE,CAAC,MAAM,EAAE,WAAW,KAAK,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAAC;IAC/C;;;;OAIG;IACH,GAAG,EAAE,CAAC,IAAI,EAAE,CAAC,KAAK,cAAc,CAAC;IACjC;;;OAGG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;;OAGG;IACH,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB;;;;;OAKG;IACH,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B,4EAA4E;IAC5E,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,CAAC;CACpC;AAED;;;;;GAKG;AACH,MAAM,WAAW,oBAAoB;IACnC,kEAAkE;IAClE,QAAQ,CAAC,MAAM,EAAE,gBAAgB,EAAE,CAAC;IACpC,iEAAiE;IACjE,QAAQ,CAAC,KAAK,EAAE,eAAe,EAAE,CAAC;IAClC,+CAA+C;IAC/C,QAAQ,CAAC,MAAM,EAAE,gBAAgB,CAAC;IAClC,iEAAiE;IACjE,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAC;IACxB;;;;OAIG;IACH,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;IAC1B,uEAAuE;IACvE,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACzB;;;OAGG;IACH,KAAK,IAAI,IAAI,CAAC;IACd,wEAAwE;IACxE,IAAI,IAAI,IAAI,CAAC;IACb,6EAA6E;IAC7E,OAAO,IAAI,IAAI,CAAC;CACjB;AAID;;;;;;;GAOG;AACH,wBAAgB,UAAU,CAAC,CAAC,EAC1B,OAAO,EAAE,iBAAiB,CAAC,CAAC,CAAC,GAC5B,oBAAoB,CA6ItB"}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `systemFeed` — a transport-light polling helper for the AdminShell system
|
|
3
|
+
* scope (issue #1774, epic #1766).
|
|
4
|
+
*
|
|
5
|
+
* The System edge presents jobs / schedules / dispatch — data that lives in the
|
|
6
|
+
* `_smrt_*` SYSTEM tables. Those tables are intentionally **excluded** from the
|
|
7
|
+
* core change-feed (`packages/core/src/change-feed.ts`) and are **not** surfaced
|
|
8
|
+
* as `smrt-web` collections, so the live-collection path used by tenant/app
|
|
9
|
+
* data does not apply here. Instead, an app exposes its own status endpoint
|
|
10
|
+
* (a `+server.ts` that reads `_smrt_*` through the `smrt-jobs` query API) and
|
|
11
|
+
* this helper polls it on an interval, mapping the response into the
|
|
12
|
+
* presentation contracts the shell already renders:
|
|
13
|
+
* `ShellSystemPanel[]` (for `SystemScopePanel`) and `ShellStatusChip[]`
|
|
14
|
+
* (for `SystemStatusChips`).
|
|
15
|
+
*
|
|
16
|
+
* This module is deliberately kept OUT of the `./workspace` presentation barrel
|
|
17
|
+
* and the `./web` (smrt-web / TanStack) entry: it is opt-in via the
|
|
18
|
+
* `@happyvertical/smrt-svelte/workspace/live` subpath and pulls no runtime
|
|
19
|
+
* dependency beyond Svelte's reactivity. It only imports the shell's *types*.
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* ```svelte
|
|
23
|
+
* <script lang="ts">
|
|
24
|
+
* import { systemFeed } from '@happyvertical/smrt-svelte/workspace/live';
|
|
25
|
+
* import { SystemScopePanel, SystemStatusChips } from
|
|
26
|
+
* '@happyvertical/smrt-svelte/workspace';
|
|
27
|
+
* import { onDestroy } from 'svelte';
|
|
28
|
+
*
|
|
29
|
+
* const feed = systemFeed({
|
|
30
|
+
* fetch: () => fetch('/admin/system/status').then((r) => r.json()),
|
|
31
|
+
* intervalMs: 5000,
|
|
32
|
+
* map: (status) => ({ panels: toPanels(status), chips: toChips(status) }),
|
|
33
|
+
* });
|
|
34
|
+
* onDestroy(feed.dispose);
|
|
35
|
+
* </script>
|
|
36
|
+
*
|
|
37
|
+
* <SystemStatusChips chips={feed.chips} />
|
|
38
|
+
* <SystemScopePanel panels={feed.panels} />
|
|
39
|
+
* ```
|
|
40
|
+
*/
|
|
41
|
+
const DEFAULT_INTERVAL_MS = 5000;
|
|
42
|
+
/**
|
|
43
|
+
* Create a polling feed that maps an app status endpoint into the AdminShell
|
|
44
|
+
* system-scope presentation contracts.
|
|
45
|
+
*
|
|
46
|
+
* The returned {@link SystemFeedController} exposes reactive `panels` / `chips`
|
|
47
|
+
* and a `dispose()` disposer. Bind the getters to the shell components and call
|
|
48
|
+
* `dispose()` on teardown.
|
|
49
|
+
*/
|
|
50
|
+
export function systemFeed(options) {
|
|
51
|
+
const { fetch: fetcher, map, intervalMs = DEFAULT_INTERVAL_MS, immediate = true, pauseWhenHidden = true, onError, } = options;
|
|
52
|
+
let panels = $state([]);
|
|
53
|
+
let chips = $state([]);
|
|
54
|
+
let status = $state('idle');
|
|
55
|
+
let error = $state(null);
|
|
56
|
+
let running = $state(false);
|
|
57
|
+
// Non-reactive machinery.
|
|
58
|
+
let timer = null;
|
|
59
|
+
let inFlight = null;
|
|
60
|
+
let disposed = false;
|
|
61
|
+
// Guards against a slow tick resolving after a newer one (or after dispose)
|
|
62
|
+
// and clobbering fresher state.
|
|
63
|
+
let generation = 0;
|
|
64
|
+
const hasDocument = typeof document !== 'undefined';
|
|
65
|
+
const usesVisibility = pauseWhenHidden && hasDocument;
|
|
66
|
+
async function tick() {
|
|
67
|
+
if (disposed)
|
|
68
|
+
return;
|
|
69
|
+
// When hidden, skip the network entirely; the visibility listener will
|
|
70
|
+
// refresh on the way back to visible.
|
|
71
|
+
if (usesVisibility && document.hidden)
|
|
72
|
+
return;
|
|
73
|
+
const myGeneration = ++generation;
|
|
74
|
+
// Supersede any still-running fetch so its result can't land late.
|
|
75
|
+
inFlight?.abort();
|
|
76
|
+
const controller = new AbortController();
|
|
77
|
+
inFlight = controller;
|
|
78
|
+
status = 'loading';
|
|
79
|
+
try {
|
|
80
|
+
const raw = await fetcher(controller.signal);
|
|
81
|
+
if (disposed || myGeneration !== generation)
|
|
82
|
+
return;
|
|
83
|
+
const view = map(raw);
|
|
84
|
+
if (disposed || myGeneration !== generation)
|
|
85
|
+
return;
|
|
86
|
+
if (view.panels)
|
|
87
|
+
panels = view.panels;
|
|
88
|
+
if (view.chips)
|
|
89
|
+
chips = view.chips;
|
|
90
|
+
error = null;
|
|
91
|
+
status = 'success';
|
|
92
|
+
}
|
|
93
|
+
catch (caught) {
|
|
94
|
+
if (disposed || myGeneration !== generation)
|
|
95
|
+
return;
|
|
96
|
+
// An abort is an intentional supersede/teardown, not a feed failure.
|
|
97
|
+
if (isAbortError(caught))
|
|
98
|
+
return;
|
|
99
|
+
error = caught;
|
|
100
|
+
status = 'error';
|
|
101
|
+
// Contain a throwing consumer callback: it must not reject the tick and
|
|
102
|
+
// surface as an unhandled rejection from the interval / visibility path.
|
|
103
|
+
try {
|
|
104
|
+
onError?.(caught);
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
// Swallow — the feed's own error handling already recorded `caught`.
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
finally {
|
|
111
|
+
if (inFlight === controller)
|
|
112
|
+
inFlight = null;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
function start() {
|
|
116
|
+
if (disposed || running)
|
|
117
|
+
return;
|
|
118
|
+
// Arm a timer only in a DOM environment (never spin a server-side interval
|
|
119
|
+
// during SSR) and only when an interval is requested. `running` reflects an
|
|
120
|
+
// actually-armed timer, so it stays false for single-shot feeds
|
|
121
|
+
// (`intervalMs <= 0`) and under SSR.
|
|
122
|
+
if (!hasDocument || intervalMs <= 0)
|
|
123
|
+
return;
|
|
124
|
+
running = true;
|
|
125
|
+
timer = setInterval(() => void tick(), intervalMs);
|
|
126
|
+
}
|
|
127
|
+
function stop() {
|
|
128
|
+
running = false;
|
|
129
|
+
if (timer !== null) {
|
|
130
|
+
clearInterval(timer);
|
|
131
|
+
timer = null;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
function handleVisibility() {
|
|
135
|
+
// Only catch up on becoming visible while actively polling — a stopped
|
|
136
|
+
// (or disposed) feed must stay quiet.
|
|
137
|
+
if (disposed || !running)
|
|
138
|
+
return;
|
|
139
|
+
if (!document.hidden)
|
|
140
|
+
void tick();
|
|
141
|
+
}
|
|
142
|
+
function dispose() {
|
|
143
|
+
if (disposed)
|
|
144
|
+
return;
|
|
145
|
+
disposed = true;
|
|
146
|
+
stop();
|
|
147
|
+
// Invalidate any in-flight tick and abort its fetch.
|
|
148
|
+
generation++;
|
|
149
|
+
inFlight?.abort();
|
|
150
|
+
inFlight = null;
|
|
151
|
+
if (usesVisibility) {
|
|
152
|
+
document.removeEventListener('visibilitychange', handleVisibility);
|
|
153
|
+
}
|
|
154
|
+
status = 'stopped';
|
|
155
|
+
}
|
|
156
|
+
// Everything below activates the feed. Under SSR (no `document`) the feed is
|
|
157
|
+
// created inert — no listener, no immediate fetch, no timer — so a consumer
|
|
158
|
+
// can safely construct it in a component script; the client re-runs this and
|
|
159
|
+
// brings it to life. Callers can also drive an inert feed manually via
|
|
160
|
+
// `refresh()` / `start()`.
|
|
161
|
+
if (hasDocument) {
|
|
162
|
+
if (usesVisibility) {
|
|
163
|
+
document.addEventListener('visibilitychange', handleVisibility);
|
|
164
|
+
}
|
|
165
|
+
if (immediate)
|
|
166
|
+
void tick();
|
|
167
|
+
start();
|
|
168
|
+
}
|
|
169
|
+
return {
|
|
170
|
+
get panels() {
|
|
171
|
+
return panels;
|
|
172
|
+
},
|
|
173
|
+
get chips() {
|
|
174
|
+
return chips;
|
|
175
|
+
},
|
|
176
|
+
get status() {
|
|
177
|
+
return status;
|
|
178
|
+
},
|
|
179
|
+
get error() {
|
|
180
|
+
return error;
|
|
181
|
+
},
|
|
182
|
+
get running() {
|
|
183
|
+
return running;
|
|
184
|
+
},
|
|
185
|
+
refresh: tick,
|
|
186
|
+
start,
|
|
187
|
+
stop,
|
|
188
|
+
dispose,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
function isAbortError(error) {
|
|
192
|
+
// Guard `DOMException` — it is undefined in some non-DOM runtimes, so a bare
|
|
193
|
+
// `instanceof` would throw a ReferenceError and defeat the SSR-safe contract.
|
|
194
|
+
if (typeof DOMException !== 'undefined' && error instanceof DOMException) {
|
|
195
|
+
return error.name === 'AbortError';
|
|
196
|
+
}
|
|
197
|
+
return (typeof error === 'object' &&
|
|
198
|
+
error !== null &&
|
|
199
|
+
'name' in error &&
|
|
200
|
+
error.name === 'AbortError');
|
|
201
|
+
}
|
|
@@ -56,5 +56,21 @@ export declare const M: {
|
|
|
56
56
|
readonly 'ui.system_scope_panel.no_running_work': "ui.system_scope_panel.no_running_work";
|
|
57
57
|
readonly 'ui.system_status_chips.system_status': "ui.system_status_chips.system_status";
|
|
58
58
|
readonly 'ui.tenant_nav.tenant_navigation': "ui.tenant_nav.tenant_navigation";
|
|
59
|
+
readonly 'ui.system_feed.title': "ui.system_feed.title";
|
|
60
|
+
readonly 'ui.system_feed.description': "ui.system_feed.description";
|
|
61
|
+
readonly 'ui.system_feed.pause': "ui.system_feed.pause";
|
|
62
|
+
readonly 'ui.system_feed.resume': "ui.system_feed.resume";
|
|
63
|
+
readonly 'ui.system_feed.refresh': "ui.system_feed.refresh";
|
|
64
|
+
readonly 'ui.system_feed.fail_next': "ui.system_feed.fail_next";
|
|
65
|
+
readonly 'ui.system_feed.status_label': "ui.system_feed.status_label";
|
|
66
|
+
readonly 'ui.system_feed.tick_label': "ui.system_feed.tick_label";
|
|
67
|
+
readonly 'ui.system_feed.error_label': "ui.system_feed.error_label";
|
|
68
|
+
readonly 'ui.system_feed.jobs_panel': "ui.system_feed.jobs_panel";
|
|
69
|
+
readonly 'ui.system_feed.schedules_panel': "ui.system_feed.schedules_panel";
|
|
70
|
+
readonly 'ui.system_feed.dispatch_panel': "ui.system_feed.dispatch_panel";
|
|
71
|
+
readonly 'ui.system_feed.chip_workers': "ui.system_feed.chip_workers";
|
|
72
|
+
readonly 'ui.system_feed.chip_running': "ui.system_feed.chip_running";
|
|
73
|
+
readonly 'ui.system_feed.chip_queued': "ui.system_feed.chip_queued";
|
|
74
|
+
readonly 'ui.system_feed.chip_failed': "ui.system_feed.chip_failed";
|
|
59
75
|
};
|
|
60
76
|
//# sourceMappingURL=strings.workspace.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"strings.workspace.d.ts","sourceRoot":"","sources":["../../src/i18n/strings.workspace.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAIH,eAAO,MAAM,CAAC
|
|
1
|
+
{"version":3,"file":"strings.workspace.d.ts","sourceRoot":"","sources":["../../src/i18n/strings.workspace.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAIH,eAAO,MAAM,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA2EZ,CAAC"}
|
|
@@ -61,4 +61,21 @@ export const M = defineMessages({
|
|
|
61
61
|
'ui.system_scope_panel.no_running_work': 'No running work',
|
|
62
62
|
'ui.system_status_chips.system_status': 'System status',
|
|
63
63
|
'ui.tenant_nav.tenant_navigation': 'Tenant navigation',
|
|
64
|
+
// components/workspace/live/* (systemFeed demo — issue #1774)
|
|
65
|
+
'ui.system_feed.title': 'System feed',
|
|
66
|
+
'ui.system_feed.description': 'Polls an app-provided status endpoint and maps it into the system-scope panels and status chips.',
|
|
67
|
+
'ui.system_feed.pause': 'Pause polling',
|
|
68
|
+
'ui.system_feed.resume': 'Resume polling',
|
|
69
|
+
'ui.system_feed.refresh': 'Refresh now',
|
|
70
|
+
'ui.system_feed.fail_next': 'Fail next fetch',
|
|
71
|
+
'ui.system_feed.status_label': 'Feed status',
|
|
72
|
+
'ui.system_feed.tick_label': 'Ticks',
|
|
73
|
+
'ui.system_feed.error_label': 'Last error',
|
|
74
|
+
'ui.system_feed.jobs_panel': 'Jobs',
|
|
75
|
+
'ui.system_feed.schedules_panel': 'Schedules',
|
|
76
|
+
'ui.system_feed.dispatch_panel': 'Dispatch',
|
|
77
|
+
'ui.system_feed.chip_workers': 'Workers',
|
|
78
|
+
'ui.system_feed.chip_running': 'Running',
|
|
79
|
+
'ui.system_feed.chip_queued': 'Queued',
|
|
80
|
+
'ui.system_feed.chip_failed': 'Failed',
|
|
64
81
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@happyvertical/smrt-svelte",
|
|
3
|
-
"version": "0.38.
|
|
3
|
+
"version": "0.38.2",
|
|
4
4
|
"description": "Svelte 5 components for SMRT user management - auth, users, tenants, roles, permissions, groups",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"smrtRawPrimitives": "strict",
|
|
@@ -36,6 +36,12 @@
|
|
|
36
36
|
"import": "./dist/components/workspace/server/index.js",
|
|
37
37
|
"default": "./dist/components/workspace/server/index.js"
|
|
38
38
|
},
|
|
39
|
+
"./workspace/live": {
|
|
40
|
+
"types": "./dist/components/workspace/live/index.d.ts",
|
|
41
|
+
"svelte": "./dist/components/workspace/live/index.js",
|
|
42
|
+
"import": "./dist/components/workspace/live/index.js",
|
|
43
|
+
"default": "./dist/components/workspace/live/index.js"
|
|
44
|
+
},
|
|
39
45
|
"./browser-ai": {
|
|
40
46
|
"types": "./dist/browser-ai/index.d.ts",
|
|
41
47
|
"import": "./dist/browser-ai/index.js",
|
|
@@ -86,10 +92,10 @@
|
|
|
86
92
|
"@tanstack/db": "^0.6.14",
|
|
87
93
|
"@tanstack/svelte-db": "^0.1.91",
|
|
88
94
|
"esm-env": "^1.2.2",
|
|
89
|
-
"@happyvertical/smrt-
|
|
90
|
-
"@happyvertical/smrt-
|
|
91
|
-
"@happyvertical/smrt-
|
|
92
|
-
"@happyvertical/smrt-
|
|
95
|
+
"@happyvertical/smrt-types": "0.38.2",
|
|
96
|
+
"@happyvertical/smrt-ui": "0.38.2",
|
|
97
|
+
"@happyvertical/smrt-web": "0.38.2",
|
|
98
|
+
"@happyvertical/smrt-languages": "0.38.2"
|
|
93
99
|
},
|
|
94
100
|
"peerDependencies": {
|
|
95
101
|
"@huggingface/transformers": ">=3.8.1",
|