@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.
- package/CHANGELOG.md +17 -0
- package/LICENSE +21 -0
- package/README.md +174 -0
- package/package.json +74 -0
- package/src/MonitorStore.ts +1471 -0
- package/src/alerting.ts +173 -0
- package/src/config.ts +171 -0
- package/src/facades/Monitor.ts +75 -0
- package/src/index.ts +62 -0
- package/src/instance.ts +16 -0
- package/src/middleware/MonitorAuthMiddleware.ts +20 -0
- package/src/middleware/MonitorPayloadMiddleware.ts +96 -0
- package/src/panel.ts +159 -0
- package/src/prometheus.ts +128 -0
- package/src/provider/MonitorProvider.ts +318 -0
- package/src/recorder/MonitorEventBridge.ts +157 -0
- package/src/recorder/ctxBuffers.ts +91 -0
- package/src/sources/live.ts +249 -0
- package/src/sources/realtime.ts +24 -0
- package/src/sources/system.ts +151 -0
- package/src/store/MonitorDb.ts +415 -0
- package/src/store/RingBuffer.ts +89 -0
- package/src/store/types.ts +580 -0
- package/src/support/time.ts +25 -0
- package/src/ui/MonitorLayout.tsx +42 -0
- package/src/ui/MonitorPage.tsx +3722 -0
- package/src/ui/charts.tsx +55 -0
- package/src/ui/icons.ts +32 -0
- package/src/ui/tones.ts +78 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The monitor's core-signal bridge: subscribes to the framework's request
|
|
3
|
+
* lifecycle (`RequestHandled` / `RequestFailed`), outgoing HTTP calls, and console
|
|
4
|
+
* commands, and records each request together with the query / N+1 / custom-context
|
|
5
|
+
* / payload that feature packages buffered against it while it was in flight (see
|
|
6
|
+
* recorder/ctxBuffers.ts).
|
|
7
|
+
*
|
|
8
|
+
* Feature packages contribute their own events (DB, cache, mail, jobs, auth,
|
|
9
|
+
* realtime) through their own monitor bridge, which resolves the {@link MonitorStore}
|
|
10
|
+
* from the container. The monitor therefore imports none of them, and adding a
|
|
11
|
+
* feature package requires no change here.
|
|
12
|
+
*/
|
|
13
|
+
import { FrameworkEvents } from "@zerotal/core";
|
|
14
|
+
import type {
|
|
15
|
+
RequestHandled,
|
|
16
|
+
RequestFailed,
|
|
17
|
+
OutgoingRequestCompleted,
|
|
18
|
+
CommandRan,
|
|
19
|
+
} from "@zerotal/core";
|
|
20
|
+
import type { MonitorStore } from "../MonitorStore.ts";
|
|
21
|
+
import { collectRequestState, markRecorded } from "./ctxBuffers.ts";
|
|
22
|
+
|
|
23
|
+
// Framework/asset noise the panel shouldn't chart as application traffic.
|
|
24
|
+
const IGNORE_PREFIXES = [
|
|
25
|
+
"/monitor",
|
|
26
|
+
"/metrics",
|
|
27
|
+
"/__flow",
|
|
28
|
+
"/__zerotal",
|
|
29
|
+
"/__dev",
|
|
30
|
+
"/health",
|
|
31
|
+
"/favicon",
|
|
32
|
+
"/assets",
|
|
33
|
+
"/build",
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
interface HttpCtxShape {
|
|
37
|
+
url?: { pathname?: string };
|
|
38
|
+
request?: { method?: string };
|
|
39
|
+
response?: { status?: number };
|
|
40
|
+
_routeDef?: { pattern?: string };
|
|
41
|
+
user?: unknown;
|
|
42
|
+
ip?: () => string | null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Method, matched-route template, raw path, and status from an HttpContext. */
|
|
46
|
+
function _ctxInfo(raw: object): { method: string; path: string; rawPath: string; status: number } {
|
|
47
|
+
const c = raw as HttpCtxShape;
|
|
48
|
+
const rawPath = c.url?.pathname ?? "/";
|
|
49
|
+
return {
|
|
50
|
+
method: (c.request?.method ?? "GET").toUpperCase(),
|
|
51
|
+
path: c._routeDef?.pattern ?? rawPath, // prefer /posts/:id over /posts/42
|
|
52
|
+
rawPath,
|
|
53
|
+
status: c.response?.status ?? 0,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Authenticated user identity (email → id → name), or null when unauthenticated. */
|
|
58
|
+
function _ctxUser(raw: object): string | null {
|
|
59
|
+
const u = (raw as HttpCtxShape).user;
|
|
60
|
+
if (u == null) return null;
|
|
61
|
+
if (typeof u === "string" || typeof u === "number") return String(u);
|
|
62
|
+
if (typeof u === "object") {
|
|
63
|
+
const o = u as Record<string, unknown>;
|
|
64
|
+
const v = o.email ?? o.id ?? o.name;
|
|
65
|
+
return v != null ? String(v) : null;
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Client IP from the HttpContext (best-effort — null when unavailable). */
|
|
71
|
+
function _ctxIp(raw: object): string | null {
|
|
72
|
+
try {
|
|
73
|
+
return (raw as HttpCtxShape).ip?.() ?? null;
|
|
74
|
+
} catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Recover a "XxxError" class name from a framework error message, else "Error". */
|
|
80
|
+
function _errorType(message: string): string {
|
|
81
|
+
const match = /^([A-Za-z]*Error)\b/.exec(message);
|
|
82
|
+
return match?.[1] ?? "Error";
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function installMonitorEventBridge(store: MonitorStore): () => void {
|
|
86
|
+
/** Finalise one request: drain its buffered correlation state, record it once. */
|
|
87
|
+
const recordRequest = (
|
|
88
|
+
ctx: object,
|
|
89
|
+
statusOverride: number,
|
|
90
|
+
durationMs: number,
|
|
91
|
+
error: string | null = null,
|
|
92
|
+
): void => {
|
|
93
|
+
if (!markRecorded(ctx)) return; // success + failure can both fire; record once
|
|
94
|
+
|
|
95
|
+
const info = _ctxInfo(ctx);
|
|
96
|
+
// Drain buffers even for ignored paths so they don't leak into a later request
|
|
97
|
+
// that reuses the context object.
|
|
98
|
+
const { queries, nplus, context, payload } = store.collectRequestState(ctx);
|
|
99
|
+
if (IGNORE_PREFIXES.some((p) => info.rawPath.startsWith(p))) return;
|
|
100
|
+
|
|
101
|
+
store.recordRequest({
|
|
102
|
+
method: info.method,
|
|
103
|
+
path: info.path,
|
|
104
|
+
status: statusOverride || info.status,
|
|
105
|
+
ms: durationMs,
|
|
106
|
+
queries,
|
|
107
|
+
nplus,
|
|
108
|
+
user: _ctxUser(ctx),
|
|
109
|
+
ip: _ctxIp(ctx),
|
|
110
|
+
// Process heap at completion — a per-request memory proxy (the runtime shares
|
|
111
|
+
// one heap, so this is indicative, not isolated like PHP-FPM peak memory).
|
|
112
|
+
memKb: Math.round(process.memoryUsage().heapUsed / 1024),
|
|
113
|
+
context,
|
|
114
|
+
payload,
|
|
115
|
+
error,
|
|
116
|
+
});
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const unsubs: Array<() => void> = [
|
|
120
|
+
// ── Requests — full per-request trace ──────────────────────────────────────
|
|
121
|
+
FrameworkEvents.on<RequestHandled>("RequestHandled", (e) =>
|
|
122
|
+
recordRequest(e.ctx, 0, e.durationMs),
|
|
123
|
+
),
|
|
124
|
+
FrameworkEvents.on<RequestFailed>("RequestFailed", (e) => {
|
|
125
|
+
store.recordException(
|
|
126
|
+
{ type: _errorType(e.error), message: e.error },
|
|
127
|
+
_ctxInfo(e.ctx).path,
|
|
128
|
+
_ctxUser(e.ctx),
|
|
129
|
+
);
|
|
130
|
+
recordRequest(e.ctx, e.status, e.durationMs, e.error);
|
|
131
|
+
}),
|
|
132
|
+
|
|
133
|
+
// ── Outgoing HTTP — per-host calls, p95, error rate ────────────────────────
|
|
134
|
+
FrameworkEvents.on<OutgoingRequestCompleted>("OutgoingRequestCompleted", (e) => {
|
|
135
|
+
store.recordHttp({ host: e.host, ms: e.durationMs, error: !e.ok });
|
|
136
|
+
}),
|
|
137
|
+
|
|
138
|
+
// ── Console / Artisan command runs ─────────────────────────────────────────
|
|
139
|
+
FrameworkEvents.on<CommandRan>("CommandRan", (e) =>
|
|
140
|
+
store.recordEvent({
|
|
141
|
+
kind: "command",
|
|
142
|
+
label: e.name,
|
|
143
|
+
status: e.ok ? "ok" : "bad",
|
|
144
|
+
route: null,
|
|
145
|
+
data: {
|
|
146
|
+
ms: Math.round(e.durationMs),
|
|
147
|
+
code: e.exitCode,
|
|
148
|
+
detail: e.error ?? `exit ${e.exitCode}`,
|
|
149
|
+
},
|
|
150
|
+
}),
|
|
151
|
+
),
|
|
152
|
+
];
|
|
153
|
+
|
|
154
|
+
return () => {
|
|
155
|
+
for (const unsub of unsubs) unsub();
|
|
156
|
+
};
|
|
157
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-request correlation buffers, keyed by the HttpContext object and GC'd with
|
|
3
|
+
* it via WeakMap/WeakSet. Feature packages buffer their per-request signal here
|
|
4
|
+
* (through {@link MonitorStore}'s thin delegating methods) while a request is in
|
|
5
|
+
* flight; the request-lifecycle handler drains it when the request finalises so
|
|
6
|
+
* the recorded row carries the real queries, N+1 flag, custom context, and payload
|
|
7
|
+
* that occurred during it.
|
|
8
|
+
*
|
|
9
|
+
* This is the shared substrate that lets the monitor correlate cross-package
|
|
10
|
+
* activity to a request without any package importing another: the ORM buffers
|
|
11
|
+
* queries, the payload middleware attaches bodies, and the request/Flow-action
|
|
12
|
+
* handlers collect the lot.
|
|
13
|
+
*/
|
|
14
|
+
import type { RequestQuery, RequestPayload } from "../store/types.ts";
|
|
15
|
+
|
|
16
|
+
const _ctxQueries = new WeakMap<object, RequestQuery[]>();
|
|
17
|
+
const _ctxNPlus = new WeakSet<object>();
|
|
18
|
+
const _ctxRecorded = new WeakSet<object>();
|
|
19
|
+
const _ctxContext = new WeakMap<object, Record<string, unknown>>();
|
|
20
|
+
const _ctxPayload = new WeakMap<object, RequestPayload>();
|
|
21
|
+
|
|
22
|
+
/** A request is an N+1 offender once its buffered query count crosses this. */
|
|
23
|
+
const NPLUS_QUERY_THRESHOLD = 25;
|
|
24
|
+
|
|
25
|
+
/** Buffer one query span against the request context it ran under. */
|
|
26
|
+
export function bufferQuery(ctx: object, q: RequestQuery): void {
|
|
27
|
+
let arr = _ctxQueries.get(ctx);
|
|
28
|
+
if (!arr) {
|
|
29
|
+
arr = [];
|
|
30
|
+
_ctxQueries.set(ctx, arr);
|
|
31
|
+
}
|
|
32
|
+
arr.push(q);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Flag the request context as having triggered an N+1 pattern. */
|
|
36
|
+
export function markNPlus(ctx: object): void {
|
|
37
|
+
_ctxNPlus.add(ctx);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Merge custom metadata onto the request context (used by `Monitor.context`). */
|
|
41
|
+
export function addContext(ctx: object, data: Record<string, unknown>): void {
|
|
42
|
+
_ctxContext.set(ctx, { ...(_ctxContext.get(ctx) ?? {}), ...data });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Attach captured headers/bodies to the request (used by MonitorPayloadMiddleware). */
|
|
46
|
+
export function addPayload(ctx: object, payload: RequestPayload): void {
|
|
47
|
+
_ctxPayload.set(ctx, payload);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Record-once guard for a context: returns `true` the first time it is called for
|
|
52
|
+
* a given context and `false` thereafter, so a request that fires both success
|
|
53
|
+
* and failure events is recorded a single time.
|
|
54
|
+
*/
|
|
55
|
+
export function markRecorded(ctx: object): boolean {
|
|
56
|
+
if (_ctxRecorded.has(ctx)) return false;
|
|
57
|
+
_ctxRecorded.add(ctx);
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The correlation state drained for a finalised request or Flow action. */
|
|
62
|
+
export interface RequestState {
|
|
63
|
+
queries: RequestQuery[];
|
|
64
|
+
nplus: boolean;
|
|
65
|
+
context: Record<string, unknown>;
|
|
66
|
+
payload: RequestPayload | null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Read and clear all buffered correlation state for a context. Called once, when
|
|
71
|
+
* the request (or Flow action) that owns the context finalises.
|
|
72
|
+
*/
|
|
73
|
+
export function collectRequestState(ctx: object): RequestState {
|
|
74
|
+
const queries = _ctxQueries.get(ctx) ?? [];
|
|
75
|
+
const nplus = _ctxNPlus.has(ctx) || queries.length > NPLUS_QUERY_THRESHOLD;
|
|
76
|
+
const context = _ctxContext.get(ctx) ?? {};
|
|
77
|
+
const payload = _ctxPayload.get(ctx) ?? null;
|
|
78
|
+
_ctxQueries.delete(ctx);
|
|
79
|
+
_ctxNPlus.delete(ctx);
|
|
80
|
+
_ctxContext.delete(ctx);
|
|
81
|
+
_ctxPayload.delete(ctx);
|
|
82
|
+
return { queries, nplus, context, payload };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Drop any buffered state for a context without recording it (ignored paths). */
|
|
86
|
+
export function discardRequestState(ctx: object): void {
|
|
87
|
+
_ctxQueries.delete(ctx);
|
|
88
|
+
_ctxNPlus.delete(ctx);
|
|
89
|
+
_ctxContext.delete(ctx);
|
|
90
|
+
_ctxPayload.delete(ctx);
|
|
91
|
+
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adapters that read real data from the queue, scheduler, and health
|
|
3
|
+
* subsystems when they are installed. Each reader is defensive: if the
|
|
4
|
+
* subsystem isn't bound in the container (the package is an optional peer
|
|
5
|
+
* dependency), it returns `null` and the store falls back to representative
|
|
6
|
+
* sample data so the panel stays populated and useful.
|
|
7
|
+
*/
|
|
8
|
+
import { Health } from "@zerotal/core/health";
|
|
9
|
+
import type {
|
|
10
|
+
CheckIn,
|
|
11
|
+
DeadJob,
|
|
12
|
+
FailedJob,
|
|
13
|
+
HealthEntry,
|
|
14
|
+
QueueRow,
|
|
15
|
+
ScheduledJob,
|
|
16
|
+
Worker,
|
|
17
|
+
} from "../store/types.ts";
|
|
18
|
+
import { ago } from "../support/time.ts";
|
|
19
|
+
|
|
20
|
+
// ── Structural interfaces (avoid hard type-coupling to optional peers) ────────
|
|
21
|
+
|
|
22
|
+
interface QueueStatsShape {
|
|
23
|
+
processedTotal: number;
|
|
24
|
+
failedTotal: number;
|
|
25
|
+
processedLast5m: number;
|
|
26
|
+
failedLast5m: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface FailedRecordShape {
|
|
30
|
+
id: number;
|
|
31
|
+
queue: string;
|
|
32
|
+
className: string;
|
|
33
|
+
attempts: number;
|
|
34
|
+
maxAttempts?: number;
|
|
35
|
+
error: string;
|
|
36
|
+
failedAt: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface QueueManagerShape {
|
|
40
|
+
queues(): Promise<{ queue: string; pending: number }[]>;
|
|
41
|
+
failed(queue?: string): Promise<FailedRecordShape[]>;
|
|
42
|
+
retryFailed(id: number): Promise<boolean>;
|
|
43
|
+
forgetFailed(id: number): Promise<void>;
|
|
44
|
+
stats(): QueueStatsShape;
|
|
45
|
+
readonly activeJobCount?: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface ScheduledTaskShape {
|
|
49
|
+
readonly _name: string;
|
|
50
|
+
readonly _schedule: string;
|
|
51
|
+
readonly _running: boolean;
|
|
52
|
+
readonly _lastRunAt?: Date;
|
|
53
|
+
readonly _lastOk?: boolean;
|
|
54
|
+
readonly _lastDurationMs?: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface SchedulerManagerShape {
|
|
58
|
+
readonly tasks: ReadonlyMap<string, ScheduledTaskShape>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface ContainerShape {
|
|
62
|
+
tryMake(name: string): unknown;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface AppShape {
|
|
66
|
+
container: ContainerShape;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function resolve<T>(app: AppShape | undefined, binding: string): T | null {
|
|
70
|
+
if (!app) return null;
|
|
71
|
+
try {
|
|
72
|
+
const v = app.container.tryMake(binding);
|
|
73
|
+
return (v ?? null) as T | null;
|
|
74
|
+
} catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── Queues ────────────────────────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
export function queueManager(app?: AppShape): QueueManagerShape | null {
|
|
82
|
+
return resolve<QueueManagerShape>(app, "queue");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function liveQueues(app?: AppShape): Promise<QueueRow[] | null> {
|
|
86
|
+
const mgr = queueManager(app);
|
|
87
|
+
if (!mgr) return null;
|
|
88
|
+
try {
|
|
89
|
+
const rows = await mgr.queues();
|
|
90
|
+
return rows.map((r) => ({
|
|
91
|
+
name: r.queue,
|
|
92
|
+
pending: r.pending,
|
|
93
|
+
wait: 0,
|
|
94
|
+
throughput: 0,
|
|
95
|
+
paused: false,
|
|
96
|
+
series: [],
|
|
97
|
+
}));
|
|
98
|
+
} catch {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function liveFailedJobs(app?: AppShape): Promise<FailedJob[] | null> {
|
|
104
|
+
const mgr = queueManager(app);
|
|
105
|
+
if (!mgr) return null;
|
|
106
|
+
try {
|
|
107
|
+
const rows = await mgr.failed();
|
|
108
|
+
return rows.map((r) => ({
|
|
109
|
+
id: r.id,
|
|
110
|
+
name: r.className,
|
|
111
|
+
error: r.error,
|
|
112
|
+
failedAt: r.failedAt,
|
|
113
|
+
attempts: r.attempts,
|
|
114
|
+
tags: [r.queue],
|
|
115
|
+
}));
|
|
116
|
+
} catch {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// The dead-letter view: jobs that exhausted every retry (attempts ≥ maxAttempts).
|
|
122
|
+
// In the SQLite driver the failed store only holds exhausted jobs, so this is the
|
|
123
|
+
// authoritative "won't run again without intervention" list backing the Requeue action.
|
|
124
|
+
export async function liveDeadLetter(app?: AppShape): Promise<DeadJob[] | null> {
|
|
125
|
+
const mgr = queueManager(app);
|
|
126
|
+
if (!mgr) return null;
|
|
127
|
+
try {
|
|
128
|
+
const rows = await mgr.failed();
|
|
129
|
+
return rows
|
|
130
|
+
.filter((r) => r.attempts >= (r.maxAttempts ?? r.attempts))
|
|
131
|
+
.map((r) => ({
|
|
132
|
+
id: r.id,
|
|
133
|
+
name: r.className,
|
|
134
|
+
error: r.error,
|
|
135
|
+
attempts: r.attempts,
|
|
136
|
+
deadAt: r.failedAt,
|
|
137
|
+
}));
|
|
138
|
+
} catch {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function liveQueueStats(app?: AppShape): QueueStatsShape | null {
|
|
144
|
+
const mgr = queueManager(app);
|
|
145
|
+
if (!mgr) return null;
|
|
146
|
+
try {
|
|
147
|
+
return mgr.stats();
|
|
148
|
+
} catch {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export async function retryFailedJob(app: AppShape | undefined, id: number): Promise<boolean> {
|
|
154
|
+
const mgr = queueManager(app);
|
|
155
|
+
if (!mgr) return false;
|
|
156
|
+
try {
|
|
157
|
+
return await mgr.retryFailed(id);
|
|
158
|
+
} catch {
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export async function forgetFailedJob(app: AppShape | undefined, id: number): Promise<boolean> {
|
|
164
|
+
const mgr = queueManager(app);
|
|
165
|
+
if (!mgr) return false;
|
|
166
|
+
try {
|
|
167
|
+
await mgr.forgetFailed(id);
|
|
168
|
+
return true;
|
|
169
|
+
} catch {
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Workers are not modelled by the SQLite driver; surface active job count as a
|
|
175
|
+
// single logical worker when the manager exposes it.
|
|
176
|
+
export function liveWorkers(app?: AppShape): Worker[] | null {
|
|
177
|
+
const mgr = queueManager(app);
|
|
178
|
+
if (!mgr || typeof mgr.activeJobCount !== "number") return null;
|
|
179
|
+
return [
|
|
180
|
+
{
|
|
181
|
+
name: "worker-1",
|
|
182
|
+
status: "running",
|
|
183
|
+
processes: 1,
|
|
184
|
+
jobsPerMin: mgr.activeJobCount,
|
|
185
|
+
series: [],
|
|
186
|
+
},
|
|
187
|
+
];
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ── Scheduler ──────────────────────────────────────────────────────────────────
|
|
191
|
+
|
|
192
|
+
export function liveCheckins(app?: AppShape): CheckIn[] | null {
|
|
193
|
+
const mgr = resolve<SchedulerManagerShape>(app, "scheduler");
|
|
194
|
+
if (!mgr) return null;
|
|
195
|
+
try {
|
|
196
|
+
const out: CheckIn[] = [];
|
|
197
|
+
for (const task of mgr.tasks.values()) {
|
|
198
|
+
const last = task._lastRunAt ? ago(task._lastRunAt.getTime()) : "never";
|
|
199
|
+
const status: CheckIn["status"] =
|
|
200
|
+
task._lastRunAt == null ? "missed" : task._lastOk === false ? "missed" : "ok";
|
|
201
|
+
out.push({ name: task._name, cron: task._schedule, last, status });
|
|
202
|
+
}
|
|
203
|
+
return out;
|
|
204
|
+
} catch {
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function liveScheduled(app?: AppShape): ScheduledJob[] | null {
|
|
210
|
+
const mgr = resolve<SchedulerManagerShape>(app, "scheduler");
|
|
211
|
+
if (!mgr) return null;
|
|
212
|
+
try {
|
|
213
|
+
const out: ScheduledJob[] = [];
|
|
214
|
+
for (const task of mgr.tasks.values()) {
|
|
215
|
+
out.push({
|
|
216
|
+
name: task._name,
|
|
217
|
+
runsIn: task._running ? "running" : "—",
|
|
218
|
+
queue: "scheduler",
|
|
219
|
+
tag: "cron",
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
return out;
|
|
223
|
+
} catch {
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ── Health ─────────────────────────────────────────────────────────────────────
|
|
229
|
+
|
|
230
|
+
export async function liveHealth(_app?: AppShape): Promise<HealthEntry[] | null> {
|
|
231
|
+
if (Health.names.length === 0) return null;
|
|
232
|
+
try {
|
|
233
|
+
const report = await Health.run({
|
|
234
|
+
name: "app",
|
|
235
|
+
version: "1.1.0",
|
|
236
|
+
environment: process.env.NODE_ENV ?? "production",
|
|
237
|
+
uptime: Math.floor(process.uptime()),
|
|
238
|
+
});
|
|
239
|
+
return Object.entries(report.checks).map(([name, check]) => ({
|
|
240
|
+
name: name.charAt(0).toUpperCase() + name.slice(1),
|
|
241
|
+
ok: check.status === "ok",
|
|
242
|
+
detail: check.message ?? `${check.status} · ${check.durationMs}ms`,
|
|
243
|
+
}));
|
|
244
|
+
} catch {
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export type { AppShape };
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live realtime/WebSocket readings from `@zerotal/flow`. Defensive: if Flow
|
|
3
|
+
* changes or isn't active, falls back to empty/0 rather than throwing.
|
|
4
|
+
*/
|
|
5
|
+
import { flowActiveConnections, flowConnections } from "@zerotal/flow";
|
|
6
|
+
import type { FlowConnection } from "@zerotal/flow";
|
|
7
|
+
|
|
8
|
+
/** Number of currently-open Flow WebSocket connections. */
|
|
9
|
+
export function activeConnections(): number {
|
|
10
|
+
try {
|
|
11
|
+
return flowActiveConnections();
|
|
12
|
+
} catch {
|
|
13
|
+
return 0;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** The currently-connected Flow clients (who, not just how many). */
|
|
18
|
+
export function connectedClients(): FlowConnection[] {
|
|
19
|
+
try {
|
|
20
|
+
return flowConnections();
|
|
21
|
+
} catch {
|
|
22
|
+
return [];
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* System-level readings (memory, uptime, runtime metadata) derived from the
|
|
3
|
+
* running process. CPU and disk are best-effort: Bun does not expose a portable
|
|
4
|
+
* cross-platform CPU% without sampling, so we approximate from load average
|
|
5
|
+
* where available and fall back to a derived value otherwise.
|
|
6
|
+
*/
|
|
7
|
+
import { cpus, loadavg, totalmem, freemem } from "node:os";
|
|
8
|
+
import { readFileSync } from "node:fs";
|
|
9
|
+
import { currentApp } from "@zerotal/core";
|
|
10
|
+
import { httpMetrics } from "@zerotal/core/metrics";
|
|
11
|
+
import type { Gauge, SystemMeta } from "../store/types.ts";
|
|
12
|
+
|
|
13
|
+
/** Bun's native in-flight HTTP count, or 0 when no server is bound (console/test). */
|
|
14
|
+
function bunPendingRequests(): number {
|
|
15
|
+
try {
|
|
16
|
+
return currentApp().serverMetrics().pendingRequests;
|
|
17
|
+
} catch {
|
|
18
|
+
return 0;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Live HTTP pulse — in-flight concurrency plus the recent rate and error rate.
|
|
24
|
+
* These are "right now" readings, independent of the dashboard's range tab, for the
|
|
25
|
+
* Overview pulse row. Active requests come from Bun's authoritative
|
|
26
|
+
* `server.pendingRequests` (https://bun.com/docs/runtime/http/metrics), falling back
|
|
27
|
+
* to the in-process gauge when no server is bound.
|
|
28
|
+
*/
|
|
29
|
+
export function httpPulse(): {
|
|
30
|
+
activeRequests: number;
|
|
31
|
+
requestsPerSec: number;
|
|
32
|
+
errorRatePct: number;
|
|
33
|
+
} {
|
|
34
|
+
try {
|
|
35
|
+
const m = httpMetrics();
|
|
36
|
+
const recent = m.last5m.total;
|
|
37
|
+
return {
|
|
38
|
+
activeRequests: bunPendingRequests() || m.inFlight,
|
|
39
|
+
requestsPerSec: Math.round((m.perMinute / 60) * 10) / 10,
|
|
40
|
+
errorRatePct: recent ? +((m.last5m.errors / recent) * 100).toFixed(1) : 0,
|
|
41
|
+
};
|
|
42
|
+
} catch {
|
|
43
|
+
return { activeRequests: 0, requestsPerSec: 0, errorRatePct: 0 };
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const BOOTED_AT = Date.now();
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Detect the current deploy SHA from the common platform env vars, falling back
|
|
51
|
+
* to reading `.git/HEAD`. Returns a 7-char short SHA, or undefined when unknown.
|
|
52
|
+
*/
|
|
53
|
+
export function detectDeploy(): string | undefined {
|
|
54
|
+
const env = process.env;
|
|
55
|
+
const fromEnv =
|
|
56
|
+
env.DEPLOY_SHA ??
|
|
57
|
+
env.GIT_COMMIT ??
|
|
58
|
+
env.SOURCE_VERSION ?? // Heroku
|
|
59
|
+
env.RENDER_GIT_COMMIT ??
|
|
60
|
+
env.VERCEL_GIT_COMMIT_SHA ??
|
|
61
|
+
env.RAILWAY_GIT_COMMIT_SHA ??
|
|
62
|
+
env.GITHUB_SHA;
|
|
63
|
+
if (fromEnv) return fromEnv.slice(0, 7);
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
const head = readFileSync(`${process.cwd()}/.git/HEAD`, "utf8").trim();
|
|
67
|
+
const ref = head.startsWith("ref:") ? head.slice(4).trim() : null;
|
|
68
|
+
const sha = ref ? readFileSync(`${process.cwd()}/.git/${ref}`, "utf8").trim() : head;
|
|
69
|
+
return sha.slice(0, 7);
|
|
70
|
+
} catch {
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Format a millisecond duration as `Nd Nh` / `Nh Nm` / `Nm`. */
|
|
76
|
+
export function formatUptime(ms: number): string {
|
|
77
|
+
const s = Math.floor(ms / 1000);
|
|
78
|
+
const d = Math.floor(s / 86400);
|
|
79
|
+
const h = Math.floor((s % 86400) / 3600);
|
|
80
|
+
const m = Math.floor((s % 3600) / 60);
|
|
81
|
+
if (d > 0) return `${d}d ${h}h`;
|
|
82
|
+
if (h > 0) return `${h}h ${m}m`;
|
|
83
|
+
return `${m}m`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Memory / CPU / disk gauges as 0..100 percentages. */
|
|
87
|
+
export function systemGauges(): Gauge[] {
|
|
88
|
+
const total = totalmem();
|
|
89
|
+
const free = freemem();
|
|
90
|
+
const usedBytes = total - free;
|
|
91
|
+
const memPct = total > 0 ? Math.round((usedBytes / total) * 100) : 0;
|
|
92
|
+
|
|
93
|
+
const cores = cpus().length || 1;
|
|
94
|
+
// loadavg() is 0 on Windows; clamp to a sane 0..100 against core count.
|
|
95
|
+
const load1 = loadavg()[0] ?? 0;
|
|
96
|
+
const cpuPct = Math.min(100, Math.round((load1 / cores) * 100));
|
|
97
|
+
|
|
98
|
+
const gb = (b: number) => (b / 1024 ** 3).toFixed(1);
|
|
99
|
+
|
|
100
|
+
return [
|
|
101
|
+
{ label: "CPU", value: cpuPct, sub: `${cores} vCPU` },
|
|
102
|
+
{
|
|
103
|
+
label: "Memory",
|
|
104
|
+
value: memPct,
|
|
105
|
+
sub: `${gb(usedBytes)} / ${gb(total)} GB`,
|
|
106
|
+
},
|
|
107
|
+
// Disk is not portably available without a syscall; surface process RSS
|
|
108
|
+
// share of total memory as a proxy so the gauge is meaningful, not faked.
|
|
109
|
+
{
|
|
110
|
+
label: "Heap",
|
|
111
|
+
value: heapPct(),
|
|
112
|
+
sub: heapSub(),
|
|
113
|
+
},
|
|
114
|
+
];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function heapPct(): number {
|
|
118
|
+
const mem = process.memoryUsage();
|
|
119
|
+
if (!mem.heapTotal) return 0;
|
|
120
|
+
return Math.min(100, Math.round((mem.heapUsed / mem.heapTotal) * 100));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function heapSub(): string {
|
|
124
|
+
const mem = process.memoryUsage();
|
|
125
|
+
const mb = (b: number) => Math.round(b / 1024 ** 2);
|
|
126
|
+
return `${mb(mem.heapUsed)} / ${mb(mem.heapTotal)} MB`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Runtime metadata for the System footer. */
|
|
130
|
+
export function systemMeta(opts: {
|
|
131
|
+
zerotal?: string;
|
|
132
|
+
region?: string;
|
|
133
|
+
deploy?: string;
|
|
134
|
+
}): SystemMeta {
|
|
135
|
+
return {
|
|
136
|
+
uptime: formatUptime(Date.now() - BOOTED_AT),
|
|
137
|
+
bun:
|
|
138
|
+
typeof Bun !== "undefined" && Bun.version
|
|
139
|
+
? Bun.version
|
|
140
|
+
: (process.versions.bun ?? process.version.replace(/^v/, "")),
|
|
141
|
+
zerotal: opts.zerotal ?? "v1.1.0",
|
|
142
|
+
region: opts.region ?? process.env.FLY_REGION ?? process.env.AWS_REGION ?? "local",
|
|
143
|
+
deploy: opts.deploy ?? process.env.DEPLOY_SHA?.slice(0, 7) ?? "dev",
|
|
144
|
+
environment: process.env.APP_ENV ?? process.env.NODE_ENV ?? "development",
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Process uptime in milliseconds since the monitor booted. */
|
|
149
|
+
export function uptimeMs(): number {
|
|
150
|
+
return Date.now() - BOOTED_AT;
|
|
151
|
+
}
|