immortal-js 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +577 -0
- package/docs/CHANGELOG.md +21 -0
- package/docs/CONTRIBUTING.md +41 -0
- package/docs/api/chaos.md +179 -0
- package/docs/api/dashboard.md +109 -0
- package/docs/api/isolation.md +191 -0
- package/docs/api/lifecycle.md +187 -0
- package/docs/api/monitoring.md +313 -0
- package/docs/api/plugins.md +217 -0
- package/docs/api/recovery.md +267 -0
- package/docs/api/safe-zone.md +236 -0
- package/docs/api/supervision.md +285 -0
- package/docs/guides/configuration.md +168 -0
- package/docs/guides/express.md +171 -0
- package/docs/guides/fastify.md +188 -0
- package/docs/guides/koa.md +102 -0
- package/docs/guides/nestjs.md +182 -0
- package/docs/guides/testing.md +91 -0
- package/examples/express-basic/index.ts +462 -0
- package/examples/express-basic/package.json +21 -0
- package/examples/express-basic/tsconfig.json +24 -0
- package/examples/fastify-microservice/index.ts +342 -0
- package/examples/fastify-microservice/package.json +22 -0
- package/examples/fastify-microservice/tsconfig.json +24 -0
- package/examples/invoice-service/data/invoices.db +0 -0
- package/examples/invoice-service/data/invoices.db-shm +0 -0
- package/examples/invoice-service/data/invoices.db-wal +0 -0
- package/examples/invoice-service/package.json +25 -0
- package/examples/invoice-service/public/index.html +5025 -0
- package/examples/invoice-service/src/db.ts +608 -0
- package/examples/invoice-service/src/pdf.ts +358 -0
- package/examples/invoice-service/src/server.ts +527 -0
- package/examples/invoice-service/src/store.ts +159 -0
- package/examples/invoice-service/src/types.ts +193 -0
- package/examples/invoice-service/tsconfig.json +23 -0
- package/examples/nestjs-enterprise/app.module.ts +561 -0
- package/examples/nestjs-enterprise/main.ts +67 -0
- package/examples/nestjs-enterprise/package.json +26 -0
- package/examples/nestjs-enterprise/tsconfig.json +27 -0
- package/immortal-js-1.0.0.tgz +0 -0
- package/package.json +33 -0
- package/packages/adapter-express/package.json +34 -0
- package/packages/adapter-express/src/index.ts +349 -0
- package/packages/adapter-express/tsconfig.json +14 -0
- package/packages/adapter-fastify/package.json +56 -0
- package/packages/adapter-fastify/src/plugin.ts +226 -0
- package/packages/adapter-fastify/tsconfig.json +14 -0
- package/packages/adapter-koa/package.json +55 -0
- package/packages/adapter-koa/src/index.ts +207 -0
- package/packages/adapter-koa/tsconfig.json +14 -0
- package/packages/adapter-nestjs/package.json +61 -0
- package/packages/adapter-nestjs/src/immortal.module.ts +313 -0
- package/packages/adapter-nestjs/src/index.ts +14 -0
- package/packages/adapter-nestjs/tsconfig.json +16 -0
- package/packages/core/package.json +56 -0
- package/packages/core/src/chaos/ChaosEngine.ts +249 -0
- package/packages/core/src/config/defaults.ts +200 -0
- package/packages/core/src/config/schema.ts +199 -0
- package/packages/core/src/event-bus.ts +168 -0
- package/packages/core/src/index.ts +164 -0
- package/packages/core/src/isolation/BulkheadPool.ts +279 -0
- package/packages/core/src/isolation/WorkerSandbox.ts +306 -0
- package/packages/core/src/isolation/index.ts +8 -0
- package/packages/core/src/lifecycle/GracefulShutdown.ts +161 -0
- package/packages/core/src/logger.ts +104 -0
- package/packages/core/src/monitoring/DiagnosticsChannel.ts +248 -0
- package/packages/core/src/monitoring/HealthMonitor.ts +191 -0
- package/packages/core/src/monitoring/MemoryLeakGuard.ts +340 -0
- package/packages/core/src/monitoring/MetricsCollector.ts +219 -0
- package/packages/core/src/monitoring/index.ts +10 -0
- package/packages/core/src/plugins/BuiltinPlugins.ts +269 -0
- package/packages/core/src/recovery/CircuitBreaker.ts +334 -0
- package/packages/core/src/recovery/FallbackCache.ts +328 -0
- package/packages/core/src/recovery/RetryEngine.ts +225 -0
- package/packages/core/src/recovery/Timeout.ts +97 -0
- package/packages/core/src/recovery/index.ts +11 -0
- package/packages/core/src/runtime.ts +242 -0
- package/packages/core/src/safe-zone/AsyncBoundary.ts +114 -0
- package/packages/core/src/safe-zone/ErrorTrap.ts +347 -0
- package/packages/core/src/safe-zone/SafeWrapper.ts +317 -0
- package/packages/core/src/safe-zone/index.ts +23 -0
- package/packages/core/src/supervision/ClusterManager.ts +243 -0
- package/packages/core/src/supervision/RestartStrategy.ts +68 -0
- package/packages/core/src/supervision/Supervisor.ts +311 -0
- package/packages/core/src/supervision/index.ts +11 -0
- package/packages/core/src/types.ts +470 -0
- package/packages/core/test/bulkhead.test.ts +310 -0
- package/packages/core/test/circuit-breaker.test.ts +153 -0
- package/packages/core/test/memory-guard.test.ts +213 -0
- package/packages/core/test/retry.test.ts +110 -0
- package/packages/core/test/safe-zone.test.ts +271 -0
- package/packages/core/test/supervisor.test.ts +310 -0
- package/packages/core/tsconfig.json +13 -0
- package/packages/dashboard/package.json +56 -0
- package/packages/dashboard/server/DashboardServer.ts +454 -0
- package/packages/dashboard/tsconfig.json +14 -0
- package/tsconfig.json +25 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "../../tsconfig.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"rootDir": "./src",
|
|
5
|
+
"outDir": "./dist",
|
|
6
|
+
"declarationDir": "./dist",
|
|
7
|
+
"composite": true,
|
|
8
|
+
"incremental": true,
|
|
9
|
+
"tsBuildInfoFile": "./dist/.tsbuildinfo"
|
|
10
|
+
},
|
|
11
|
+
"include": ["src/**/*"],
|
|
12
|
+
"exclude": ["node_modules", "dist", "test"]
|
|
13
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@immortal/dashboard",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Immortal.js Live Dashboard — real-time metrics, SSE stream, WebSocket, and Prometheus endpoint",
|
|
5
|
+
"author": "Immortal.js Team",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"homepage": "https://github.com/immortal-js/immortal#readme",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "https://github.com/immortal-js/immortal.git",
|
|
11
|
+
"directory": "packages/dashboard"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"dashboard",
|
|
15
|
+
"metrics",
|
|
16
|
+
"prometheus",
|
|
17
|
+
"sse",
|
|
18
|
+
"websocket",
|
|
19
|
+
"observability",
|
|
20
|
+
"immortal"
|
|
21
|
+
],
|
|
22
|
+
"type": "module",
|
|
23
|
+
"main": "./dist/server/DashboardServer.js",
|
|
24
|
+
"types": "./dist/server/DashboardServer.d.ts",
|
|
25
|
+
"exports": {
|
|
26
|
+
".": {
|
|
27
|
+
"import": "./dist/server/DashboardServer.js",
|
|
28
|
+
"types": "./dist/server/DashboardServer.d.ts"
|
|
29
|
+
},
|
|
30
|
+
"./server": {
|
|
31
|
+
"import": "./dist/server/DashboardServer.js",
|
|
32
|
+
"types": "./dist/server/DashboardServer.d.ts"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"scripts": {
|
|
36
|
+
"build": "tsc --project tsconfig.json",
|
|
37
|
+
"dev": "tsc --project tsconfig.json --watch",
|
|
38
|
+
"clean": "rm -rf dist",
|
|
39
|
+
"typecheck": "tsc --noEmit"
|
|
40
|
+
},
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"@immortal/core": "*"
|
|
43
|
+
},
|
|
44
|
+
"optionalDependencies": {
|
|
45
|
+
"ws": "^8.17.0"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@types/node": "^20.0.0",
|
|
49
|
+
"@types/ws": "^8.5.10",
|
|
50
|
+
"typescript": "^5.4.0",
|
|
51
|
+
"ws": "^8.17.0"
|
|
52
|
+
},
|
|
53
|
+
"engines": {
|
|
54
|
+
"node": ">=18.0.0"
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file DashboardServer.ts
|
|
3
|
+
* @description Immortal.js Live Dashboard Server
|
|
4
|
+
*
|
|
5
|
+
* Serves:
|
|
6
|
+
* 1. WebSocket feed — real-time metrics stream (1s intervals)
|
|
7
|
+
* 2. SSE feed — Server-Sent Events for browser clients
|
|
8
|
+
* 3. REST API — historical data queries
|
|
9
|
+
* 4. Static UI — React dashboard (served from dist/)
|
|
10
|
+
* 5. Prometheus metrics endpoint
|
|
11
|
+
*
|
|
12
|
+
* The dashboard is intentionally a separate process/port from the application
|
|
13
|
+
* so that dashboard availability doesn't affect application performance.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import http from "node:http";
|
|
17
|
+
import { EventEmitter } from "node:events";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
import type { SystemMetrics, ImmortalEvent, DashboardConfig } from "@immortal/core";
|
|
20
|
+
import type { ImmortalEventBus } from "@immortal/core";
|
|
21
|
+
import type { MetricsCollector } from "@immortal/core";
|
|
22
|
+
import type { DiagnosticsChannel } from "@immortal/core";
|
|
23
|
+
import { getLogger } from "@immortal/core";
|
|
24
|
+
|
|
25
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
26
|
+
// DASHBOARD SERVER CLASS
|
|
27
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
export class DashboardServer extends EventEmitter {
|
|
30
|
+
private server?: http.Server;
|
|
31
|
+
private readonly config: Required<DashboardConfig>;
|
|
32
|
+
private readonly bus: ImmortalEventBus;
|
|
33
|
+
private readonly metricsCollector: MetricsCollector;
|
|
34
|
+
private readonly diagnosticsChannel: DiagnosticsChannel;
|
|
35
|
+
private sseClients = new Set<http.ServerResponse>();
|
|
36
|
+
private wsClients = new Set<unknown>(); // ws.WebSocket
|
|
37
|
+
private metricsInterval?: ReturnType<typeof setInterval>;
|
|
38
|
+
private readonly eventHistory: ImmortalEvent[] = [];
|
|
39
|
+
private readonly maxHistorySize = 500;
|
|
40
|
+
|
|
41
|
+
constructor(
|
|
42
|
+
config: Required<DashboardConfig>,
|
|
43
|
+
bus: ImmortalEventBus,
|
|
44
|
+
metricsCollector: MetricsCollector,
|
|
45
|
+
diagnosticsChannel: DiagnosticsChannel
|
|
46
|
+
) {
|
|
47
|
+
super();
|
|
48
|
+
this.config = config;
|
|
49
|
+
this.bus = bus;
|
|
50
|
+
this.metricsCollector = metricsCollector;
|
|
51
|
+
this.diagnosticsChannel = diagnosticsChannel;
|
|
52
|
+
|
|
53
|
+
// Collect event history for the "Timeline" view
|
|
54
|
+
this.bus.onAny((event: ImmortalEvent) => {
|
|
55
|
+
this.eventHistory.push(event);
|
|
56
|
+
if (this.eventHistory.length > this.maxHistorySize) {
|
|
57
|
+
this.eventHistory.shift();
|
|
58
|
+
}
|
|
59
|
+
this.broadcastEvent(event);
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Start the dashboard HTTP server
|
|
65
|
+
*/
|
|
66
|
+
async start(): Promise<void> {
|
|
67
|
+
const logger = getLogger();
|
|
68
|
+
|
|
69
|
+
this.server = http.createServer((req, res) => {
|
|
70
|
+
this.handleRequest(req, res);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// Optional WebSocket upgrade (using ws library if available)
|
|
74
|
+
if (this.config.enableWebSocket) {
|
|
75
|
+
this.server.on("upgrade", async (request, socket, head) => {
|
|
76
|
+
try {
|
|
77
|
+
const { WebSocketServer } = await import("ws");
|
|
78
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
79
|
+
wss.handleUpgrade(request, socket as never, head, (ws) => {
|
|
80
|
+
this.wsClients.add(ws);
|
|
81
|
+
ws.on("close", () => this.wsClients.delete(ws));
|
|
82
|
+
// Send initial state
|
|
83
|
+
const metrics = this.metricsCollector.getSnapshot();
|
|
84
|
+
ws.send(JSON.stringify({ type: "metrics", data: metrics }));
|
|
85
|
+
});
|
|
86
|
+
} catch {
|
|
87
|
+
socket.destroy();
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return new Promise((resolve, reject) => {
|
|
93
|
+
this.server!.listen(this.config.port, () => {
|
|
94
|
+
logger.info(`Immortal.js Dashboard running on port ${this.config.port}`, {
|
|
95
|
+
status: `http://localhost:${this.config.port}${this.config.path}`,
|
|
96
|
+
prometheus: `http://localhost:${this.config.port}${this.config.path}/metrics`,
|
|
97
|
+
health: `http://localhost:${this.config.port}${this.config.path}/health`,
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// Start metrics broadcast interval
|
|
101
|
+
this.metricsInterval = setInterval(() => {
|
|
102
|
+
this.broadcastMetrics();
|
|
103
|
+
}, 1000).unref();
|
|
104
|
+
|
|
105
|
+
resolve();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
this.server!.on("error", reject);
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Stop the dashboard server
|
|
114
|
+
*/
|
|
115
|
+
async stop(): Promise<void> {
|
|
116
|
+
if (this.metricsInterval) {
|
|
117
|
+
clearInterval(this.metricsInterval);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Close all SSE connections
|
|
121
|
+
for (const client of this.sseClients) {
|
|
122
|
+
client.end();
|
|
123
|
+
}
|
|
124
|
+
this.sseClients.clear();
|
|
125
|
+
|
|
126
|
+
await new Promise<void>((resolve) => {
|
|
127
|
+
this.server?.close(() => resolve());
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
getLogger().info("Immortal.js Dashboard stopped");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
134
|
+
// REQUEST HANDLING
|
|
135
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
private handleRequest(req: http.IncomingMessage, res: http.ServerResponse): void {
|
|
138
|
+
// CORS headers
|
|
139
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
140
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
|
|
141
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
|
142
|
+
|
|
143
|
+
if (req.method === "OPTIONS") {
|
|
144
|
+
res.writeHead(204);
|
|
145
|
+
res.end();
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Basic auth check
|
|
150
|
+
if (this.config.auth) {
|
|
151
|
+
const authHeader = req.headers["authorization"];
|
|
152
|
+
if (!this.checkAuth(authHeader)) {
|
|
153
|
+
res.writeHead(401, { "WWW-Authenticate": 'Basic realm="Immortal Dashboard"' });
|
|
154
|
+
res.end("Unauthorized");
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const urlPath = req.url?.split("?")[0] ?? "/";
|
|
160
|
+
const dashPath = this.config.path;
|
|
161
|
+
|
|
162
|
+
// ── API Routes ─────────────────────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
if (urlPath === `${dashPath}/metrics`) {
|
|
165
|
+
this.diagnosticsChannel.updateMetrics(this.metricsCollector.getSnapshot());
|
|
166
|
+
res.writeHead(200, { "Content-Type": "text/plain; version=0.0.4" });
|
|
167
|
+
res.end(this.diagnosticsChannel.getPrometheusMetrics());
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (urlPath === `${dashPath}/status`) {
|
|
172
|
+
this.sendJSON(res, this.metricsCollector.getSnapshot());
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (urlPath === `${dashPath}/events`) {
|
|
177
|
+
this.sendJSON(res, {
|
|
178
|
+
events: this.eventHistory.slice(-100),
|
|
179
|
+
total: this.eventHistory.length,
|
|
180
|
+
});
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (urlPath === `${dashPath}/health`) {
|
|
185
|
+
const metrics = this.metricsCollector.getSnapshot();
|
|
186
|
+
const isHealthy = metrics.eventLoopLag.p99 < 500 && metrics.memory.heapUsedMb < 1500;
|
|
187
|
+
res.writeHead(isHealthy ? 200 : 503, { "Content-Type": "application/json" });
|
|
188
|
+
res.end(JSON.stringify({
|
|
189
|
+
status: isHealthy ? "healthy" : "degraded",
|
|
190
|
+
timestamp: new Date().toISOString(),
|
|
191
|
+
metrics: {
|
|
192
|
+
eventLoopLag: metrics.eventLoopLag,
|
|
193
|
+
memory: { heapUsedMb: metrics.memory.heapUsedMb },
|
|
194
|
+
requests: metrics.requests,
|
|
195
|
+
},
|
|
196
|
+
}));
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ── SSE Stream ─────────────────────────────────────────────────────────
|
|
201
|
+
if (urlPath === `${dashPath}/stream` && this.config.enableSSE) {
|
|
202
|
+
this.handleSSE(req, res);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ── Static UI ──────────────────────────────────────────────────────────
|
|
207
|
+
if (urlPath === dashPath || urlPath === `${dashPath}/`) {
|
|
208
|
+
this.serveUI(res);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
213
|
+
res.end(JSON.stringify({ error: "Not Found", path: urlPath }));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
private handleSSE(req: http.IncomingMessage, res: http.ServerResponse): void {
|
|
217
|
+
res.writeHead(200, {
|
|
218
|
+
"Content-Type": "text/event-stream",
|
|
219
|
+
"Cache-Control": "no-cache",
|
|
220
|
+
"Connection": "keep-alive",
|
|
221
|
+
"X-Accel-Buffering": "no", // Nginx SSE fix
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
// Send initial state
|
|
225
|
+
const metrics = this.metricsCollector.getSnapshot();
|
|
226
|
+
res.write(`data: ${JSON.stringify({ type: "metrics", data: metrics })}\n\n`);
|
|
227
|
+
|
|
228
|
+
this.sseClients.add(res);
|
|
229
|
+
|
|
230
|
+
req.on("close", () => {
|
|
231
|
+
this.sseClients.delete(res);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
// Keepalive ping every 30s
|
|
235
|
+
const keepalive = setInterval(() => {
|
|
236
|
+
res.write(": keepalive\n\n");
|
|
237
|
+
}, 30_000);
|
|
238
|
+
|
|
239
|
+
req.on("close", () => clearInterval(keepalive));
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
private serveUI(res: http.ServerResponse): void {
|
|
243
|
+
// Serve embedded minimal dashboard HTML if React build not available
|
|
244
|
+
const html = this.generateDashboardHTML();
|
|
245
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
246
|
+
res.end(html);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
private broadcastMetrics(): void {
|
|
250
|
+
const metrics = this.metricsCollector.getSnapshot();
|
|
251
|
+
const message = JSON.stringify({ type: "metrics", data: metrics });
|
|
252
|
+
|
|
253
|
+
// SSE broadcast
|
|
254
|
+
for (const client of this.sseClients) {
|
|
255
|
+
try {
|
|
256
|
+
client.write(`data: ${message}\n\n`);
|
|
257
|
+
} catch {
|
|
258
|
+
this.sseClients.delete(client);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// WebSocket broadcast
|
|
263
|
+
for (const ws of this.wsClients) {
|
|
264
|
+
try {
|
|
265
|
+
(ws as { send: (data: string) => void }).send(message);
|
|
266
|
+
} catch {
|
|
267
|
+
this.wsClients.delete(ws);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
private broadcastEvent(event: ImmortalEvent): void {
|
|
273
|
+
const message = JSON.stringify({ type: "event", data: event });
|
|
274
|
+
|
|
275
|
+
for (const client of this.sseClients) {
|
|
276
|
+
try {
|
|
277
|
+
client.write(`data: ${message}\n\n`);
|
|
278
|
+
} catch {
|
|
279
|
+
this.sseClients.delete(client);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
private sendJSON(res: http.ServerResponse, data: unknown): void {
|
|
285
|
+
const body = JSON.stringify(data, null, 2);
|
|
286
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
287
|
+
res.end(body);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
private checkAuth(authHeader: string | undefined): boolean {
|
|
291
|
+
if (!this.config.auth) return true;
|
|
292
|
+
if (!authHeader?.startsWith("Basic ")) return false;
|
|
293
|
+
|
|
294
|
+
const credentials = Buffer.from(authHeader.slice(6), "base64").toString();
|
|
295
|
+
const [username, password] = credentials.split(":");
|
|
296
|
+
return username === this.config.auth.username && password === this.config.auth.password;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
private generateDashboardHTML(): string {
|
|
300
|
+
return `<!DOCTYPE html>
|
|
301
|
+
<html lang="en">
|
|
302
|
+
<head>
|
|
303
|
+
<meta charset="UTF-8">
|
|
304
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
305
|
+
<title>Immortal.js Dashboard</title>
|
|
306
|
+
<style>
|
|
307
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
308
|
+
body { font-family: 'SF Mono', 'Fira Code', monospace; background: #0d1117; color: #e6edf3; padding: 20px; }
|
|
309
|
+
h1 { font-size: 1.4rem; color: #58a6ff; margin-bottom: 20px; }
|
|
310
|
+
h1 span { color: #3fb950; }
|
|
311
|
+
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 16px; margin-bottom: 20px; }
|
|
312
|
+
.card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 16px; }
|
|
313
|
+
.card h2 { font-size: 0.8rem; color: #8b949e; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 12px; }
|
|
314
|
+
.metric { display: flex; justify-content: space-between; align-items: center; margin: 6px 0; font-size: 0.85rem; }
|
|
315
|
+
.metric-name { color: #8b949e; }
|
|
316
|
+
.metric-value { color: #e6edf3; font-weight: bold; }
|
|
317
|
+
.ok { color: #3fb950; }
|
|
318
|
+
.warn { color: #d29922; }
|
|
319
|
+
.critical { color: #f85149; }
|
|
320
|
+
.circuit { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 0.75rem; }
|
|
321
|
+
.circuit.CLOSED { background: #1a3d1a; color: #3fb950; }
|
|
322
|
+
.circuit.OPEN { background: #3d1a1a; color: #f85149; }
|
|
323
|
+
.circuit.HALF_OPEN { background: #3d2d0a; color: #d29922; }
|
|
324
|
+
.status { font-size: 0.7rem; color: #8b949e; margin-top: 20px; }
|
|
325
|
+
.tagline { color: #8b949e; font-size: 0.85rem; font-style: italic; margin-bottom: 20px; }
|
|
326
|
+
#connection { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: #f85149; margin-right: 6px; }
|
|
327
|
+
#connection.connected { background: #3fb950; }
|
|
328
|
+
</style>
|
|
329
|
+
</head>
|
|
330
|
+
<body>
|
|
331
|
+
<h1>☽ Immortal<span>.js</span> Dashboard</h1>
|
|
332
|
+
<p class="tagline">"We don't promise you won't fall — we guarantee you stand back up before anyone notices."</p>
|
|
333
|
+
<div id="connection-status">
|
|
334
|
+
<span id="connection"></span><span id="conn-text">Connecting...</span>
|
|
335
|
+
</div>
|
|
336
|
+
<div class="grid" id="metrics-grid">
|
|
337
|
+
<div class="card"><h2>Loading...</h2></div>
|
|
338
|
+
</div>
|
|
339
|
+
<div class="status">
|
|
340
|
+
Auto-refreshing via SSE · <a href="${this.config.path}/metrics" style="color:#58a6ff">Prometheus</a> ·
|
|
341
|
+
<a href="${this.config.path}/status" style="color:#58a6ff">JSON API</a> ·
|
|
342
|
+
<a href="${this.config.path}/events" style="color:#58a6ff">Events</a>
|
|
343
|
+
</div>
|
|
344
|
+
|
|
345
|
+
<script>
|
|
346
|
+
const dashPath = '${this.config.path}';
|
|
347
|
+
|
|
348
|
+
function classify(val, warn, critical) {
|
|
349
|
+
if (val >= critical) return 'critical';
|
|
350
|
+
if (val >= warn) return 'warn';
|
|
351
|
+
return 'ok';
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function renderMetrics(metrics) {
|
|
355
|
+
const m = metrics;
|
|
356
|
+
const grid = document.getElementById('metrics-grid');
|
|
357
|
+
|
|
358
|
+
grid.innerHTML = \`
|
|
359
|
+
<div class="card">
|
|
360
|
+
<h2>Event Loop</h2>
|
|
361
|
+
<div class="metric"><span class="metric-name">p50</span><span class="metric-value \${classify(m.eventLoopLag.p50, 50, 200)}">\${m.eventLoopLag.p50.toFixed(1)}ms</span></div>
|
|
362
|
+
<div class="metric"><span class="metric-name">p95</span><span class="metric-value \${classify(m.eventLoopLag.p95, 100, 300)}">\${m.eventLoopLag.p95.toFixed(1)}ms</span></div>
|
|
363
|
+
<div class="metric"><span class="metric-name">p99</span><span class="metric-value \${classify(m.eventLoopLag.p99, 100, 500)}">\${m.eventLoopLag.p99.toFixed(1)}ms</span></div>
|
|
364
|
+
<div class="metric"><span class="metric-name">max</span><span class="metric-value">\${m.eventLoopLag.max.toFixed(1)}ms</span></div>
|
|
365
|
+
</div>
|
|
366
|
+
|
|
367
|
+
<div class="card">
|
|
368
|
+
<h2>Memory</h2>
|
|
369
|
+
<div class="metric"><span class="metric-name">Heap Used</span><span class="metric-value \${classify(m.memory.heapUsedMb, 512, 1024)}">\${m.memory.heapUsedMb.toFixed(1)} MB</span></div>
|
|
370
|
+
<div class="metric"><span class="metric-name">RSS</span><span class="metric-value">\${m.memory.rssMb.toFixed(1)} MB</span></div>
|
|
371
|
+
<div class="metric"><span class="metric-name">Growth Rate</span><span class="metric-value \${m.memory.heapGrowthRate > 20 ? 'warn' : 'ok'}">\${m.memory.heapGrowthRate.toFixed(2)} MB/min</span></div>
|
|
372
|
+
<div class="metric"><span class="metric-name">GC/min</span><span class="metric-value">\${m.memory.gcFrequency.toFixed(1)}</span></div>
|
|
373
|
+
</div>
|
|
374
|
+
|
|
375
|
+
<div class="card">
|
|
376
|
+
<h2>CPU</h2>
|
|
377
|
+
<div class="metric"><span class="metric-name">User</span><span class="metric-value \${classify(m.cpu.percentUser, 60, 90)}">\${m.cpu.percentUser.toFixed(1)}%</span></div>
|
|
378
|
+
<div class="metric"><span class="metric-name">System</span><span class="metric-value">\${m.cpu.percentSystem.toFixed(1)}%</span></div>
|
|
379
|
+
<div class="metric"><span class="metric-name">Active Handles</span><span class="metric-value">\${m.handles.active}</span></div>
|
|
380
|
+
</div>
|
|
381
|
+
|
|
382
|
+
<div class="card">
|
|
383
|
+
<h2>Requests</h2>
|
|
384
|
+
<div class="metric"><span class="metric-name">Total</span><span class="metric-value">\${m.requests.total.toLocaleString()}</span></div>
|
|
385
|
+
<div class="metric"><span class="metric-name">Active</span><span class="metric-value">\${m.requests.active}</span></div>
|
|
386
|
+
<div class="metric"><span class="metric-name">Rate</span><span class="metric-value">\${m.requests.perSecond.toFixed(1)} req/s</span></div>
|
|
387
|
+
<div class="metric"><span class="metric-name">Error Rate</span><span class="metric-value \${classify(m.requests.errorRate * 100, 5, 20)}">\${(m.requests.errorRate * 100).toFixed(2)}%</span></div>
|
|
388
|
+
</div>
|
|
389
|
+
|
|
390
|
+
\${Object.keys(m.circuits).length > 0 ? \`
|
|
391
|
+
<div class="card">
|
|
392
|
+
<h2>Circuit Breakers</h2>
|
|
393
|
+
\${Object.entries(m.circuits).map(([name, c]) => \`
|
|
394
|
+
<div class="metric">
|
|
395
|
+
<span class="metric-name">\${name}</span>
|
|
396
|
+
<span class="circuit \${c.state}">\${c.state}</span>
|
|
397
|
+
</div>
|
|
398
|
+
<div class="metric" style="font-size:0.75rem">
|
|
399
|
+
<span class="metric-name">Failure Rate</span>
|
|
400
|
+
<span class="metric-value">\${c.failureRate.toFixed(1)}%</span>
|
|
401
|
+
</div>
|
|
402
|
+
\`).join('')}
|
|
403
|
+
</div>\` : ''}
|
|
404
|
+
|
|
405
|
+
\${Object.keys(m.bulkheads).length > 0 ? \`
|
|
406
|
+
<div class="card">
|
|
407
|
+
<h2>Bulkhead Pools</h2>
|
|
408
|
+
\${Object.entries(m.bulkheads).map(([name, b]) => \`
|
|
409
|
+
<div class="metric">
|
|
410
|
+
<span class="metric-name">\${name}</span>
|
|
411
|
+
<span class="metric-value">\${b.active}/\${b.maxConcurrent} (\${b.queued} queued)</span>
|
|
412
|
+
</div>
|
|
413
|
+
\`).join('')}
|
|
414
|
+
</div>\` : ''}
|
|
415
|
+
|
|
416
|
+
\${m.workers.length > 0 ? \`
|
|
417
|
+
<div class="card">
|
|
418
|
+
<h2>Workers</h2>
|
|
419
|
+
\${m.workers.map(w => \`
|
|
420
|
+
<div class="metric">
|
|
421
|
+
<span class="metric-name">Worker \${w.id}</span>
|
|
422
|
+
<span class="metric-value \${w.state === 'running' ? 'ok' : w.state === 'escalated' ? 'critical' : 'warn'}">\${w.state}</span>
|
|
423
|
+
</div>
|
|
424
|
+
\`).join('')}
|
|
425
|
+
</div>\` : ''}
|
|
426
|
+
\`;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// SSE connection
|
|
430
|
+
const evtSource = new EventSource(dashPath + '/stream');
|
|
431
|
+
const connEl = document.getElementById('connection');
|
|
432
|
+
const connText = document.getElementById('conn-text');
|
|
433
|
+
|
|
434
|
+
evtSource.onopen = () => {
|
|
435
|
+
connEl.className = 'connected';
|
|
436
|
+
connText.textContent = 'Live';
|
|
437
|
+
};
|
|
438
|
+
|
|
439
|
+
evtSource.onmessage = (e) => {
|
|
440
|
+
const msg = JSON.parse(e.data);
|
|
441
|
+
if (msg.type === 'metrics') {
|
|
442
|
+
renderMetrics(msg.data);
|
|
443
|
+
}
|
|
444
|
+
};
|
|
445
|
+
|
|
446
|
+
evtSource.onerror = () => {
|
|
447
|
+
connEl.className = '';
|
|
448
|
+
connText.textContent = 'Reconnecting...';
|
|
449
|
+
};
|
|
450
|
+
</script>
|
|
451
|
+
</body>
|
|
452
|
+
</html>`;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "../../tsconfig.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"rootDir": "./",
|
|
5
|
+
"outDir": "./dist",
|
|
6
|
+
"declarationDir": "./dist",
|
|
7
|
+
"composite": true,
|
|
8
|
+
"incremental": true,
|
|
9
|
+
"tsBuildInfoFile": "./dist/.tsbuildinfo"
|
|
10
|
+
},
|
|
11
|
+
"include": ["server/**/*"],
|
|
12
|
+
"exclude": ["node_modules", "dist"],
|
|
13
|
+
"references": [{ "path": "../core" }]
|
|
14
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "Node16",
|
|
5
|
+
"moduleResolution": "Node16",
|
|
6
|
+
"lib": ["ES2022"],
|
|
7
|
+
"strict": true,
|
|
8
|
+
"strictNullChecks": true,
|
|
9
|
+
"noUncheckedIndexedAccess": true,
|
|
10
|
+
"exactOptionalPropertyTypes": true,
|
|
11
|
+
"noImplicitOverride": true,
|
|
12
|
+
"noPropertyAccessFromIndexSignature": true,
|
|
13
|
+
"declaration": true,
|
|
14
|
+
"declarationMap": true,
|
|
15
|
+
"sourceMap": true,
|
|
16
|
+
"esModuleInterop": true,
|
|
17
|
+
"allowSyntheticDefaultImports": true,
|
|
18
|
+
"forceConsistentCasingInFileNames": true,
|
|
19
|
+
"skipLibCheck": true,
|
|
20
|
+
"composite": true,
|
|
21
|
+
"incremental": true,
|
|
22
|
+
"outDir": "dist"
|
|
23
|
+
},
|
|
24
|
+
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
|
|
25
|
+
}
|