@zerotal/monitor 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.
@@ -0,0 +1,580 @@
1
+ /**
2
+ * Shared data shapes for the monitoring panel.
3
+ *
4
+ * These describe the *read model* — the aggregated snapshot the Flow page
5
+ * renders. The recorder ({@link ../recorder/MonitorEventBridge.ts}) and the
6
+ * live sources ({@link ../sources}) feed the {@link ../MonitorStore.ts}, which
7
+ * derives a {@link MonitorSnapshot} on demand.
8
+ */
9
+
10
+ import type { StorageInfo } from "./MonitorDb.ts";
11
+ export type { StorageInfo } from "./MonitorDb.ts";
12
+
13
+ /** Selectable time window for the dashboard. */
14
+ export type MonitorRange = "live" | "1h" | "24h" | "7d";
15
+
16
+ /** A coarse tone used to colour values across the UI. */
17
+ export type Tone = "ok" | "warn" | "bad" | "neutral";
18
+
19
+ // ── Overview ──────────────────────────────────────────────────────────────────
20
+
21
+ export interface StatCard {
22
+ label: string;
23
+ value: string;
24
+ /** Percentage delta vs the previous window. */
25
+ delta: number;
26
+ /** Sparkline series (oldest → newest). */
27
+ series: number[];
28
+ }
29
+
30
+ export interface Percentile {
31
+ label: string; // p50 / p95 / p99
32
+ value: number; // milliseconds
33
+ }
34
+
35
+ export interface RouteStat {
36
+ method: string;
37
+ path: string;
38
+ ms: number;
39
+ }
40
+
41
+ /** Count of requests in one HTTP status class (Route detail). */
42
+ export interface StatusClassCount {
43
+ label: "2xx" | "3xx" | "4xx" | "5xx";
44
+ count: number;
45
+ }
46
+
47
+ /**
48
+ * Aggregated latency / throughput / error history for a single route, powering
49
+ * the per-route drill-in page. Derived on demand from `mon_requests` filtered to
50
+ * one method+path within the selected window.
51
+ */
52
+ export interface RouteDetail {
53
+ method: string;
54
+ path: string;
55
+ range: MonitorRange;
56
+ /** Total requests to this route in the window. */
57
+ total: number;
58
+ /** Requests per minute over the window. */
59
+ rpm: number;
60
+ /** Number of 5xx responses. */
61
+ errorCount: number;
62
+ /** 5xx responses as a percentage of total. */
63
+ errorRate: number;
64
+ p50: number;
65
+ p95: number;
66
+ p99: number;
67
+ avgMs: number;
68
+ maxMs: number;
69
+ /** Request counts bucketed over the window (oldest → newest). */
70
+ throughput: number[];
71
+ /** Average latency (ms) bucketed over the window. */
72
+ latency: number[];
73
+ /** 5xx counts bucketed over the window. */
74
+ errors: number[];
75
+ /** Response breakdown by status class. */
76
+ statusDist: StatusClassCount[];
77
+ /** Most-recent requests to this route. */
78
+ recent: RequestEntry[];
79
+ /** Slowest requests to this route. */
80
+ slowest: RequestEntry[];
81
+ }
82
+
83
+ export interface OutgoingHttp {
84
+ host: string;
85
+ calls: number;
86
+ p95: number;
87
+ errRate: number;
88
+ }
89
+
90
+ export interface Deploy {
91
+ /** Seconds-into-window position used to place the marker (0..windowSeconds). */
92
+ at: number;
93
+ sha: string;
94
+ when: string;
95
+ }
96
+
97
+ export interface Alert {
98
+ tone: "red" | "amber";
99
+ text: string;
100
+ }
101
+
102
+ /** Live "right now" gauges for the Overview pulse row — independent of the range tab. */
103
+ export interface PulseStats {
104
+ /** HTTP requests currently being processed (in-flight concurrency). */
105
+ activeRequests: number;
106
+ /** Open Flow WebSocket connections. */
107
+ activeConnections: number;
108
+ /** Requests per second over the last 5 minutes. */
109
+ requestsPerSec: number;
110
+ /** 4xx+5xx rate over the last 5 minutes (%). */
111
+ errorRatePct: number;
112
+ }
113
+
114
+ /** A snapshot of surrounding state captured when an alert fired — the "why". */
115
+ export interface AlertContext {
116
+ errorRate: number;
117
+ rpm: number;
118
+ p50: number;
119
+ p95: number;
120
+ p99: number;
121
+ apdex: number;
122
+ pending: number;
123
+ rolledBack: number;
124
+ slowRoutes: RouteStat[];
125
+ topException: string | null;
126
+ }
127
+
128
+ /** One alert kind, with its firings grouped and the latest firing's context. */
129
+ export interface AlertEntry {
130
+ id: string;
131
+ title: string;
132
+ level: "warning" | "critical";
133
+ status: "ok" | "warn" | "bad" | "info";
134
+ detail: string;
135
+ metric: string;
136
+ value: number;
137
+ threshold: number;
138
+ unit: string;
139
+ /** How many times this alert fired in the window. */
140
+ count: number;
141
+ firstSeen: string;
142
+ lastSeen: string;
143
+ context: AlertContext;
144
+ /** Most-recent firings (newest first), for the timeline. */
145
+ occurrences: { when: string; detail: string; value: number }[];
146
+ }
147
+
148
+ // ── Requests (trace explorer) ───────────────────────────────────────────────
149
+
150
+ export type SpanKind = "boot" | "middleware" | "controller" | "query" | "cache" | "http" | "view";
151
+
152
+ export interface RequestSpan {
153
+ label: string;
154
+ kind: SpanKind;
155
+ start: number;
156
+ dur: number;
157
+ }
158
+
159
+ export interface RequestQuery {
160
+ ms: number;
161
+ sql: string;
162
+ }
163
+
164
+ export interface RequestLog {
165
+ level: "info" | "warn" | "error";
166
+ msg: string;
167
+ }
168
+
169
+ /** Captured request/response headers + bodies (redacted), when payload capture is on. */
170
+ export interface RequestPayload {
171
+ reqHeaders: Record<string, string>;
172
+ reqBody: string;
173
+ resBody: string;
174
+ }
175
+
176
+ export interface RequestEntry {
177
+ id: number;
178
+ method: string;
179
+ path: string;
180
+ status: number;
181
+ ms: number;
182
+ when: string;
183
+ nplus: boolean;
184
+ /** Captured payload (headers + bodies), or null when capture is off / unavailable. */
185
+ payload: RequestPayload | null;
186
+ /** Authenticated user (id or email), or null when the request was unauthenticated. */
187
+ user: string | null;
188
+ /** Client IP address, or null when unavailable. */
189
+ ip: string | null;
190
+ /** Process heap (KB) at request completion — a per-request memory proxy. */
191
+ memKb: number;
192
+ /** Custom metadata attached via `Monitor.context(...)` during the request. */
193
+ context: Record<string, unknown>;
194
+ /** The exception message, when this request failed — links the request to its error. */
195
+ error: string | null;
196
+ spans: RequestSpan[];
197
+ queries: RequestQuery[];
198
+ logs: RequestLog[];
199
+ }
200
+
201
+ /** Top active users by request volume (Application Usage widget). */
202
+ export interface UserUsage {
203
+ /** User id or email. */
204
+ id: string;
205
+ requests: number;
206
+ }
207
+
208
+ /** Heaviest routes by memory (Top Memory Requests widget). */
209
+ export interface MemoryRoute {
210
+ method: string;
211
+ path: string;
212
+ /** Peak heap (KB) observed for this route in the window. */
213
+ memKb: number;
214
+ count: number;
215
+ }
216
+
217
+ // ── Exceptions ───────────────────────────────────────────────────────────────
218
+
219
+ export interface ExceptionGroup {
220
+ type: string;
221
+ message: string;
222
+ location: string;
223
+ count: number;
224
+ users: number;
225
+ lastSeen: string;
226
+ d1: number;
227
+ d7: number;
228
+ d30: number;
229
+ series: number[];
230
+ frames: string[];
231
+ }
232
+
233
+ // ── Queues ───────────────────────────────────────────────────────────────────
234
+
235
+ export interface QueueMetric {
236
+ label: string;
237
+ value: string;
238
+ sub: string;
239
+ tone?: Tone;
240
+ }
241
+
242
+ export interface Worker {
243
+ name: string;
244
+ status: "running" | "paused" | "stopped";
245
+ processes: number;
246
+ jobsPerMin: number;
247
+ series: number[];
248
+ }
249
+
250
+ export interface QueueRow {
251
+ name: string;
252
+ pending: number;
253
+ wait: number;
254
+ throughput: number;
255
+ paused: boolean;
256
+ series: number[];
257
+ }
258
+
259
+ export interface FailedJob {
260
+ id: number;
261
+ name: string;
262
+ error: string;
263
+ failedAt: string;
264
+ attempts: number;
265
+ tags: string[];
266
+ }
267
+
268
+ export interface ScheduledJob {
269
+ name: string;
270
+ runsIn: string;
271
+ queue: string;
272
+ tag: string;
273
+ }
274
+
275
+ export interface DeadJob {
276
+ id: number;
277
+ name: string;
278
+ error: string;
279
+ attempts: number;
280
+ deadAt: string;
281
+ }
282
+
283
+ // ── Database ─────────────────────────────────────────────────────────────────
284
+
285
+ export interface DbStat {
286
+ label: string;
287
+ value: string;
288
+ sub: string;
289
+ tone?: Tone;
290
+ }
291
+
292
+ export interface SlowQuery {
293
+ sql: string;
294
+ ms: number;
295
+ callers: number;
296
+ location: string;
297
+ }
298
+
299
+ // ── Cache ────────────────────────────────────────────────────────────────────
300
+
301
+ export interface CacheKey {
302
+ key: string;
303
+ hits: number;
304
+ rate: number;
305
+ }
306
+
307
+ export interface CacheStats {
308
+ hitRate: number;
309
+ hits: number;
310
+ misses: number;
311
+ evictions: number;
312
+ keys: CacheKey[];
313
+ }
314
+
315
+ // ── Framework-event feeds (security, scheduler, transactions, …) ───────────────
316
+
317
+ /** A single row in an event feed (security, scheduled-task runs, etc.). */
318
+ export interface FeedEvent {
319
+ when: string;
320
+ label: string;
321
+ detail: string;
322
+ status: "ok" | "warn" | "bad" | "info";
323
+ route: string | null;
324
+ }
325
+
326
+ /** A failed or slow background job. */
327
+ export interface JobEntry {
328
+ className: string;
329
+ queue: string;
330
+ status: string;
331
+ ms: number;
332
+ when: string;
333
+ error: string | null;
334
+ }
335
+
336
+ /** An N+1 query offender (Database tab). */
337
+ export interface NPlusOne {
338
+ fingerprint: string;
339
+ occurrences: number;
340
+ worstCount: number;
341
+ route: string | null;
342
+ lastSeen: string;
343
+ }
344
+
345
+ /** Database transaction summary. */
346
+ export interface TxStats {
347
+ committed: number;
348
+ rolledBack: number;
349
+ avgMs: number;
350
+ }
351
+
352
+ /** A migration run (Database tab). */
353
+ export interface MigrationEntry {
354
+ name: string;
355
+ direction: string;
356
+ ms: number;
357
+ ok: boolean;
358
+ when: string;
359
+ }
360
+
361
+ /** A single Flow WebSocket action with its request-scoped context. */
362
+ export interface WsAction {
363
+ /** Stable id (the action's recorded timestamp) — keys the expandable row across pages. */
364
+ id: number;
365
+ component: string;
366
+ action: string;
367
+ ms: number;
368
+ ok: boolean;
369
+ user: string | null;
370
+ ip: string | null;
371
+ nplus: boolean;
372
+ /** Process heap (KB) at action completion — the per-action memory proxy. */
373
+ memKb: number;
374
+ queries: RequestQuery[];
375
+ context: Record<string, unknown>;
376
+ when: string;
377
+ }
378
+
379
+ /** One currently-connected Flow WebSocket client. */
380
+ export interface ConnectedClient {
381
+ id: string;
382
+ ip: string;
383
+ /** Authenticated user (email/id), or null if no action has identified them yet. */
384
+ user: string | null;
385
+ connectedFor: string;
386
+ lastActivity: string;
387
+ actions: number;
388
+ }
389
+
390
+ /** Realtime / Flow WebSocket activity. */
391
+ export interface RealtimeStats {
392
+ activeConnections: number;
393
+ actionsPerMin: number;
394
+ avgActionMs: number;
395
+ /** Average per-action heap (KB) across the window. */
396
+ avgMemKb: number;
397
+ opened: number;
398
+ closed: number;
399
+ series: number[];
400
+ components: { name: string; actions: number; avgMs: number }[];
401
+ slowActions: { component: string; action: string; ms: number; when: string }[];
402
+ recentActions: WsAction[];
403
+ /** Who is connected right now (live, not windowed). */
404
+ clients: ConnectedClient[];
405
+ }
406
+
407
+ /** A delivered notification (one per channel), for the Notifications feed. */
408
+ export interface NotificationEntry {
409
+ notification: string;
410
+ channel: string;
411
+ recipient: string;
412
+ status: "ok" | "bad";
413
+ ms: number;
414
+ when: string;
415
+ }
416
+
417
+ /** A console/Artisan command run, for the Commands feed. */
418
+ export interface CommandEntry {
419
+ name: string;
420
+ status: "ok" | "bad";
421
+ ms: number;
422
+ code: number;
423
+ when: string;
424
+ }
425
+
426
+ /** Per-model created/updated/deleted counts (the models watcher). */
427
+ export interface ModelStat {
428
+ model: string;
429
+ table: string;
430
+ created: number;
431
+ updated: number;
432
+ deleted: number;
433
+ total: number;
434
+ }
435
+
436
+ /** A single model-change event, for the recent-changes timeline. */
437
+ export interface ModelEvent {
438
+ model: string;
439
+ table: string;
440
+ operation: "created" | "updated" | "deleted";
441
+ when: string;
442
+ }
443
+
444
+ // ── Mail ─────────────────────────────────────────────────────────────────────
445
+
446
+ export interface MailEntry {
447
+ id: number;
448
+ subject: string;
449
+ to: string;
450
+ mailer: string;
451
+ status: "sent" | "queued" | "failed";
452
+ when: string;
453
+ ms: number;
454
+ body: string;
455
+ }
456
+
457
+ // ── System / health ──────────────────────────────────────────────────────────
458
+
459
+ export interface HealthEntry {
460
+ name: string;
461
+ ok: boolean;
462
+ detail: string;
463
+ }
464
+
465
+ export interface Gauge {
466
+ label: string;
467
+ value: number;
468
+ sub: string;
469
+ }
470
+
471
+ export interface UptimeCheck {
472
+ name: string;
473
+ ok: boolean;
474
+ ms: number;
475
+ uptime: string;
476
+ }
477
+
478
+ export interface CheckIn {
479
+ name: string;
480
+ cron: string;
481
+ last: string;
482
+ status: "ok" | "late" | "missed";
483
+ }
484
+
485
+ export interface SystemMeta {
486
+ uptime: string;
487
+ bun: string;
488
+ zerotal: string;
489
+ region: string;
490
+ deploy: string;
491
+ /** Deployment environment (production / staging / development …). */
492
+ environment: string;
493
+ }
494
+
495
+ // ── The full snapshot the page renders ──────────────────────────────────────
496
+
497
+ export interface MonitorSnapshot {
498
+ range: MonitorRange;
499
+ alerts: Alert[];
500
+ deploys: Deploy[];
501
+
502
+ // live pulse (in-flight requests, connections, rate, error rate) — not windowed
503
+ pulse: PulseStats;
504
+
505
+ // overview
506
+ statCards: StatCard[];
507
+ percentiles: Percentile[];
508
+ apdex: number;
509
+ throughput: number[];
510
+ jobsSeries: number[];
511
+ slowRoutes: RouteStat[];
512
+ outgoingHttp: OutgoingHttp[];
513
+
514
+ // queues
515
+ queueStats: QueueMetric[];
516
+ workers: Worker[];
517
+ queues: QueueRow[];
518
+ jobTags: string[];
519
+ failedJobs: FailedJob[];
520
+ scheduledJobs: ScheduledJob[];
521
+ deadLetter: DeadJob[];
522
+
523
+ // requests
524
+ requests: RequestEntry[];
525
+ slowRequests: RequestEntry[];
526
+ topUsers: UserUsage[];
527
+ topMemory: MemoryRoute[];
528
+
529
+ // exceptions
530
+ exceptions: ExceptionGroup[];
531
+
532
+ // database
533
+ dbStats: DbStat[];
534
+ slowQueries: SlowQuery[];
535
+ transactions: TxStats;
536
+ migrations: MigrationEntry[];
537
+ nplusOnes: NPlusOne[];
538
+
539
+ // security / audit
540
+ security: FeedEvent[];
541
+
542
+ // threshold-alert history (firings grouped by kind, with captured context)
543
+ alertHistory: AlertEntry[];
544
+
545
+ // application logs
546
+ logs: FeedEvent[];
547
+
548
+ // realtime / websocket
549
+ realtime: RealtimeStats;
550
+
551
+ // scheduler run history + slow jobs
552
+ scheduledRuns: FeedEvent[];
553
+ slowJobs: JobEntry[];
554
+
555
+ // cache
556
+ cache: CacheStats;
557
+
558
+ // mail
559
+ mail: MailEntry[];
560
+
561
+ // notifications (distinct from mail — one row per channel delivery)
562
+ notifications: NotificationEntry[];
563
+
564
+ // console / Artisan command runs
565
+ commands: CommandEntry[];
566
+
567
+ // ORM model-change counts (created/updated/deleted)
568
+ models: ModelStat[];
569
+
570
+ // ORM model-change timeline (most recent first)
571
+ recentModels: ModelEvent[];
572
+
573
+ // system
574
+ health: HealthEntry[];
575
+ gauges: Gauge[];
576
+ uptimeChecks: UptimeCheck[];
577
+ checkins: CheckIn[];
578
+ storage: StorageInfo;
579
+ meta: SystemMeta;
580
+ }
@@ -0,0 +1,25 @@
1
+ /** Time + number formatting helpers shared across the store and UI. */
2
+
3
+ /** Human "x ago" string from a millisecond timestamp. */
4
+ export function ago(tsMs: number): string {
5
+ const s = Math.max(0, Math.floor((Date.now() - tsMs) / 1000));
6
+ if (s < 5) return "just now";
7
+ if (s < 60) return `${s}s ago`;
8
+ const m = Math.floor(s / 60);
9
+ if (m < 60) return `${m}m ago`;
10
+ const h = Math.floor(m / 60);
11
+ if (h < 24) return `${h}h ago`;
12
+ const d = Math.floor(h / 24);
13
+ return `${d}d ago`;
14
+ }
15
+
16
+ /** Thousands-separated integer. */
17
+ export function commas(n: number): string {
18
+ return Math.round(n).toLocaleString("en-US");
19
+ }
20
+
21
+ /** Signed percentage delta between current and previous values. */
22
+ export function delta(current: number, previous: number): number {
23
+ if (previous === 0) return current === 0 ? 0 : 100;
24
+ return Math.round(((current - previous) / previous) * 100);
25
+ }
@@ -0,0 +1,42 @@
1
+ /** @jsxImportSource @zerotal/flow */
2
+ import { Layout } from "@zerotal/flow";
3
+ import type { HtmlNode } from "@zerotal/flow";
4
+ import { flowUiHead } from "@zerotal/flow-ui";
5
+
6
+ /**
7
+ * The shell for the monitoring panel.
8
+ *
9
+ * The panel is built from `@zerotal/flow-ui` and themed with its design tokens,
10
+ * so it inherits light/dark mode and stays visually consistent with everything
11
+ * else Zerotal ships. The only thing the monitor overrides is `--primary`: the
12
+ * panel's own orange, applied on top of the shared palette rather than baked
13
+ * into its markup.
14
+ *
15
+ * Because every component reads `--primary` rather than a literal colour, that
16
+ * one override recolours the whole panel — and an app can re-brand it further by
17
+ * appending its own token CSS.
18
+ */
19
+
20
+ /** The monitor's orange, layered over flow-ui's neutral palette. */
21
+ const MONITOR_TOKENS = `
22
+ :root {
23
+ --primary: 21 90% 48%;
24
+ --primary-foreground: 0 0% 100%;
25
+ --ring: 21 90% 48%;
26
+ }
27
+ .dark {
28
+ --primary: 25 95% 58%;
29
+ --primary-foreground: 224 71% 4%;
30
+ --ring: 25 95% 58%;
31
+ }
32
+ `.trim();
33
+
34
+ export class MonitorLayout extends Layout {
35
+ static override get head(): string {
36
+ return flowUiHead("Zerotal · Monitor", { tokensCss: MONITOR_TOKENS });
37
+ }
38
+
39
+ override render(slot: HtmlNode): HtmlNode {
40
+ return <div class="h-screen bg-background text-foreground antialiased">{slot}</div>;
41
+ }
42
+ }