@oh-my-pi/omp-stats 17.0.4 → 17.0.5
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 +6 -0
- package/dist/types/port-conflict.d.ts +4 -0
- package/dist/types/server.d.ts +1 -1
- package/package.json +4 -4
- package/src/port-conflict.ts +250 -0
- package/src/server.ts +42 -14
package/CHANGELOG.md
CHANGED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** Header stamped on every dashboard response so reuse probes can identify us. */
|
|
2
|
+
export declare const STATS_DASHBOARD_HEADER = "x-omp-stats-dashboard";
|
|
3
|
+
/** Reuse a live stats dashboard or reclaim the port from a stale omp runtime. */
|
|
4
|
+
export declare function recoverStatsPort(port: number): Promise<"retry" | "reuse">;
|
package/dist/types/server.d.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
export declare function handleApi(req: Request): Promise<Response>;
|
|
5
5
|
/**
|
|
6
|
-
* Start the HTTP server.
|
|
6
|
+
* Start the HTTP server, reusing a live dashboard or reclaiming a stale omp listener.
|
|
7
7
|
*/
|
|
8
8
|
export declare function startServer(port?: number): Promise<{
|
|
9
9
|
port: number;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/omp-stats",
|
|
4
|
-
"version": "17.0.
|
|
4
|
+
"version": "17.0.5",
|
|
5
5
|
"description": "Local observability dashboard for pi AI usage statistics",
|
|
6
6
|
"homepage": "https://omp.sh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -39,9 +39,9 @@
|
|
|
39
39
|
"fmt": "biome format --write ."
|
|
40
40
|
},
|
|
41
41
|
"dependencies": {
|
|
42
|
-
"@oh-my-pi/pi-ai": "17.0.
|
|
43
|
-
"@oh-my-pi/pi-catalog": "17.0.
|
|
44
|
-
"@oh-my-pi/pi-utils": "17.0.
|
|
42
|
+
"@oh-my-pi/pi-ai": "17.0.5",
|
|
43
|
+
"@oh-my-pi/pi-catalog": "17.0.5",
|
|
44
|
+
"@oh-my-pi/pi-utils": "17.0.5",
|
|
45
45
|
"@tailwindcss/node": "^4.3.2",
|
|
46
46
|
"chart.js": "^4.5.1",
|
|
47
47
|
"date-fns": "^4.4.0",
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import type { Dirent } from "node:fs";
|
|
2
|
+
import * as fs from "node:fs/promises";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { $which } from "@oh-my-pi/pi-utils";
|
|
5
|
+
import { $ } from "bun";
|
|
6
|
+
|
|
7
|
+
const STATS_PROBE_TIMEOUT_MS = 500;
|
|
8
|
+
const PROCESS_EXIT_POLL_MS = 50;
|
|
9
|
+
const PROCESS_EXIT_POLLS = 10;
|
|
10
|
+
const STATS_RUNTIME_IMAGES: Record<string, true> = { bun: true, node: true, omp: true, "omp-stats": true };
|
|
11
|
+
|
|
12
|
+
interface PortHolder {
|
|
13
|
+
pid: number;
|
|
14
|
+
image: string;
|
|
15
|
+
commandLine: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Header stamped on every dashboard response so reuse probes can identify us. */
|
|
19
|
+
export const STATS_DASHBOARD_HEADER = "x-omp-stats-dashboard";
|
|
20
|
+
|
|
21
|
+
async function probeStatsDashboard(port: number): Promise<boolean> {
|
|
22
|
+
try {
|
|
23
|
+
const response = await fetch(`http://localhost:${port}/api/stats/models`, {
|
|
24
|
+
signal: AbortSignal.timeout(STATS_PROBE_TIMEOUT_MS),
|
|
25
|
+
});
|
|
26
|
+
if (response.status !== 200) {
|
|
27
|
+
await response.body?.cancel();
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
// A live omp-stats dashboard stamps this header on every response.
|
|
31
|
+
if (response.headers.get(STATS_DASHBOARD_HEADER)) {
|
|
32
|
+
await response.body?.cancel();
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
// Older dashboards predate the header; fall back to the response shape
|
|
36
|
+
// (`/api/stats/models` returns a JSON array) so we never reuse — or later
|
|
37
|
+
// kill — a foreign 200 responder such as an SPA dev server catch-all.
|
|
38
|
+
if (!(response.headers.get("content-type") ?? "").includes("application/json")) {
|
|
39
|
+
await response.body?.cancel();
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
return Array.isArray(await response.json());
|
|
43
|
+
} catch {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function findLinuxPortHolder(port: number): Promise<PortHolder | null> {
|
|
49
|
+
const socketInodes = new Set<string>();
|
|
50
|
+
for (const tablePath of ["/proc/net/tcp", "/proc/net/tcp6"]) {
|
|
51
|
+
let table: string;
|
|
52
|
+
try {
|
|
53
|
+
table = await Bun.file(tablePath).text();
|
|
54
|
+
} catch {
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
for (const line of table.split("\n").slice(1)) {
|
|
59
|
+
const fields = line.trim().split(/\s+/);
|
|
60
|
+
const localAddress = fields[1];
|
|
61
|
+
const state = fields[3];
|
|
62
|
+
const inode = fields[9];
|
|
63
|
+
if (!localAddress || state !== "0A" || !inode) continue;
|
|
64
|
+
const encodedPort = localAddress.slice(localAddress.lastIndexOf(":") + 1);
|
|
65
|
+
if (Number.parseInt(encodedPort, 16) === port) socketInodes.add(inode);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (socketInodes.size === 0) return null;
|
|
69
|
+
|
|
70
|
+
let processes: Dirent[];
|
|
71
|
+
try {
|
|
72
|
+
processes = await fs.readdir("/proc", { withFileTypes: true });
|
|
73
|
+
} catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
for (const entry of processes) {
|
|
78
|
+
if (!entry.isDirectory() || !/^\d+$/.test(entry.name)) continue;
|
|
79
|
+
const pid = Number.parseInt(entry.name, 10);
|
|
80
|
+
let descriptors: string[];
|
|
81
|
+
try {
|
|
82
|
+
descriptors = await fs.readdir(`/proc/${pid}/fd`);
|
|
83
|
+
} catch {
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
let ownsSocket = false;
|
|
88
|
+
for (const descriptor of descriptors) {
|
|
89
|
+
try {
|
|
90
|
+
const target = await fs.readlink(`/proc/${pid}/fd/${descriptor}`);
|
|
91
|
+
const match = /^socket:\[(\d+)]$/.exec(target);
|
|
92
|
+
if (match?.[1] && socketInodes.has(match[1])) {
|
|
93
|
+
ownsSocket = true;
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
} catch {}
|
|
97
|
+
}
|
|
98
|
+
if (!ownsSocket) continue;
|
|
99
|
+
|
|
100
|
+
let commandLine = "";
|
|
101
|
+
try {
|
|
102
|
+
const rawCommandLine = await Bun.file(`/proc/${pid}/cmdline`).text();
|
|
103
|
+
commandLine = rawCommandLine.split("\0").filter(Boolean).join(" ");
|
|
104
|
+
} catch {}
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
const executable = await fs.readlink(`/proc/${pid}/exe`);
|
|
108
|
+
return { pid, image: path.basename(executable), commandLine };
|
|
109
|
+
} catch {
|
|
110
|
+
const executable = commandLine.split(" ", 1)[0];
|
|
111
|
+
return { pid, image: executable ? path.basename(executable) : "unknown", commandLine };
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function findMacPortHolder(port: number): Promise<PortHolder | null> {
|
|
118
|
+
const lsof = $which("lsof") ?? ((await Bun.file("/usr/sbin/lsof").exists()) ? "/usr/sbin/lsof" : null);
|
|
119
|
+
if (!lsof) return null;
|
|
120
|
+
|
|
121
|
+
const selector = `-iTCP:${port}`;
|
|
122
|
+
const result = await $`${lsof} -nP ${selector} -sTCP:LISTEN -Fpc`.quiet().nothrow();
|
|
123
|
+
if (result.exitCode !== 0) return null;
|
|
124
|
+
|
|
125
|
+
let pid: number | null = null;
|
|
126
|
+
let image = "unknown";
|
|
127
|
+
for (const line of result.text().split("\n")) {
|
|
128
|
+
if (line.startsWith("p")) {
|
|
129
|
+
const parsed = Number.parseInt(line.slice(1), 10);
|
|
130
|
+
pid = Number.isSafeInteger(parsed) ? parsed : null;
|
|
131
|
+
} else if (line.startsWith("c") && pid !== null) {
|
|
132
|
+
image = line.slice(1) || "unknown";
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (pid === null) return null;
|
|
137
|
+
|
|
138
|
+
const ps = $which("ps");
|
|
139
|
+
if (!ps) return { pid, image, commandLine: "" };
|
|
140
|
+
const processInfo = await $`${ps} -ww -p ${pid} -o command=`.quiet().nothrow();
|
|
141
|
+
return { pid, image, commandLine: processInfo.exitCode === 0 ? processInfo.text().trim() : "" };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function findWindowsPortHolder(port: number): Promise<PortHolder | null> {
|
|
145
|
+
const netstat = $which("netstat");
|
|
146
|
+
if (!netstat) return null;
|
|
147
|
+
|
|
148
|
+
const result = await $`${netstat} -ano -p TCP`.quiet().nothrow();
|
|
149
|
+
if (result.exitCode !== 0) return null;
|
|
150
|
+
|
|
151
|
+
let pid: number | null = null;
|
|
152
|
+
for (const line of result.text().split("\n")) {
|
|
153
|
+
const fields = line.trim().split(/\s+/);
|
|
154
|
+
if (fields[0]?.toUpperCase() !== "TCP" || fields[3]?.toUpperCase() !== "LISTENING") continue;
|
|
155
|
+
const localAddress = fields[1];
|
|
156
|
+
if (!localAddress || Number.parseInt(localAddress.slice(localAddress.lastIndexOf(":") + 1), 10) !== port) {
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
const parsed = Number.parseInt(fields[4] ?? "", 10);
|
|
160
|
+
if (Number.isSafeInteger(parsed)) {
|
|
161
|
+
pid = parsed;
|
|
162
|
+
break;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
if (pid === null) return null;
|
|
166
|
+
|
|
167
|
+
let image = "unknown";
|
|
168
|
+
const tasklist = $which("tasklist");
|
|
169
|
+
if (tasklist) {
|
|
170
|
+
const filter = `PID eq ${pid}`;
|
|
171
|
+
const task = await $`${tasklist} /FI ${filter} /FO CSV /NH`.quiet().nothrow();
|
|
172
|
+
if (task.exitCode === 0) {
|
|
173
|
+
const imageMatch = /^"((?:[^"]|"")*)"/.exec(task.text().trim());
|
|
174
|
+
image = imageMatch?.[1]?.replaceAll('""', '"') || "unknown";
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const powershell = $which("powershell") ?? $which("pwsh");
|
|
179
|
+
if (!powershell) return { pid, image, commandLine: "" };
|
|
180
|
+
const command = `(Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}").CommandLine`;
|
|
181
|
+
const processInfo = await $`${powershell} -NoProfile -NonInteractive -Command ${command}`.quiet().nothrow();
|
|
182
|
+
return { pid, image, commandLine: processInfo.exitCode === 0 ? processInfo.text().trim() : "" };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function findPortHolder(port: number): Promise<PortHolder | null> {
|
|
186
|
+
if (process.platform === "linux") return findLinuxPortHolder(port);
|
|
187
|
+
if (process.platform === "darwin") return findMacPortHolder(port);
|
|
188
|
+
if (process.platform === "win32") return findWindowsPortHolder(port);
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function terminatePortHolder(holder: PortHolder): Promise<void> {
|
|
193
|
+
try {
|
|
194
|
+
process.kill(holder.pid, "SIGTERM");
|
|
195
|
+
} catch (error) {
|
|
196
|
+
if (error instanceof Error && "code" in error && error.code === "ESRCH") return;
|
|
197
|
+
throw new Error(`Failed to stop ${holder.image} (PID ${holder.pid})`, { cause: error });
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
for (let attempt = 0; attempt < PROCESS_EXIT_POLLS; attempt++) {
|
|
201
|
+
await Bun.sleep(PROCESS_EXIT_POLL_MS);
|
|
202
|
+
try {
|
|
203
|
+
process.kill(holder.pid, 0);
|
|
204
|
+
} catch (error) {
|
|
205
|
+
if (error instanceof Error && "code" in error && error.code === "ESRCH") return;
|
|
206
|
+
throw new Error(`Failed to inspect ${holder.image} (PID ${holder.pid})`, { cause: error });
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
try {
|
|
211
|
+
process.kill(holder.pid, "SIGKILL");
|
|
212
|
+
} catch (error) {
|
|
213
|
+
if (error instanceof Error && "code" in error && error.code === "ESRCH") return;
|
|
214
|
+
throw new Error(`Failed to kill ${holder.image} (PID ${holder.pid})`, { cause: error });
|
|
215
|
+
}
|
|
216
|
+
await Bun.sleep(PROCESS_EXIT_POLL_MS);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Reuse a live stats dashboard or reclaim the port from a stale omp runtime. */
|
|
220
|
+
export async function recoverStatsPort(port: number): Promise<"retry" | "reuse"> {
|
|
221
|
+
if (await probeStatsDashboard(port)) return "reuse";
|
|
222
|
+
|
|
223
|
+
const holder = await findPortHolder(port);
|
|
224
|
+
if (!holder) {
|
|
225
|
+
throw new Error(`Port ${port} is in use, but the listening process could not be identified.`);
|
|
226
|
+
}
|
|
227
|
+
if (holder.pid === process.pid) {
|
|
228
|
+
throw new Error(`Port ${port} is held by the current process (${holder.image}, PID ${holder.pid}).`);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const normalizedImage = holder.image
|
|
232
|
+
.toLowerCase()
|
|
233
|
+
.replace(/\.exe$/, "")
|
|
234
|
+
.replace(/ \(deleted\)$/, "");
|
|
235
|
+
const normalizedCommand = holder.commandLine.toLowerCase().replaceAll("\\", "/");
|
|
236
|
+
const hasStatsIdentity =
|
|
237
|
+
normalizedImage === "omp-stats" ||
|
|
238
|
+
/(?:^|[/"'\s])omp-stats(?:\.exe)?(?:["'\s]|$)/.test(normalizedCommand) ||
|
|
239
|
+
/\/packages\/stats\/src\/index\.ts(?:["'\s]|$)/.test(normalizedCommand) ||
|
|
240
|
+
(normalizedImage === "omp" && /(?:^|\s)stats(?:\s|$)/.test(normalizedCommand)) ||
|
|
241
|
+
/(?:^|\/)omp(?:\.exe)?["'\s]+stats(?:["'\s]|$)/.test(normalizedCommand);
|
|
242
|
+
if (!STATS_RUNTIME_IMAGES[normalizedImage] || !hasStatsIdentity) {
|
|
243
|
+
throw new Error(
|
|
244
|
+
`Port ${port} is in use by ${holder.image} (PID ${holder.pid}), which is not identifiable as an omp stats dashboard; refusing to stop it.`,
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
await terminatePortHolder(holder);
|
|
249
|
+
return "retry";
|
|
250
|
+
}
|
package/src/server.ts
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
import { decodeEmbeddedClientArchive } from "./embedded-client";
|
|
21
21
|
import embeddedClientArchiveTxt from "./embedded-client.generated.txt";
|
|
22
22
|
import { getGainDashboardStats } from "./gain-aggregator";
|
|
23
|
+
import { recoverStatsPort, STATS_DASHBOARD_HEADER } from "./port-conflict";
|
|
23
24
|
|
|
24
25
|
const EMBEDDED_CLIENT_ARCHIVE = decodeEmbeddedClientArchive(embeddedClientArchiveTxt);
|
|
25
26
|
|
|
@@ -293,23 +294,20 @@ async function handleStatic(requestPath: string): Promise<Response> {
|
|
|
293
294
|
return new Response("Not Found", { status: 404 });
|
|
294
295
|
}
|
|
295
296
|
|
|
296
|
-
|
|
297
|
-
* Start the HTTP server.
|
|
298
|
-
*/
|
|
299
|
-
export async function startServer(port = 3847): Promise<{ port: number; stop: () => void }> {
|
|
300
|
-
await ensureClientBuild();
|
|
301
|
-
|
|
297
|
+
function createDashboardServer(port: number) {
|
|
302
298
|
const server = Bun.serve({
|
|
303
299
|
port,
|
|
304
300
|
async fetch(req) {
|
|
305
301
|
const url = new URL(req.url);
|
|
306
302
|
const path = url.pathname;
|
|
307
303
|
|
|
308
|
-
// CORS headers for local development
|
|
309
|
-
|
|
304
|
+
// CORS headers for local development; the identity header lets another
|
|
305
|
+
// omp session's reuse probe positively recognize this dashboard.
|
|
306
|
+
const corsHeaders: Record<string, string> = {
|
|
310
307
|
"Access-Control-Allow-Origin": "*",
|
|
311
308
|
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
|
312
309
|
"Access-Control-Allow-Headers": "Content-Type",
|
|
310
|
+
[STATS_DASHBOARD_HEADER]: "1",
|
|
313
311
|
};
|
|
314
312
|
|
|
315
313
|
if (req.method === "OPTIONS") {
|
|
@@ -327,8 +325,8 @@ export async function startServer(port = 3847): Promise<{ port: number; stop: ()
|
|
|
327
325
|
|
|
328
326
|
// Add CORS headers to all responses
|
|
329
327
|
const headers = new Headers(response.headers);
|
|
330
|
-
for (const
|
|
331
|
-
headers.set(key,
|
|
328
|
+
for (const key in corsHeaders) {
|
|
329
|
+
headers.set(key, corsHeaders[key]);
|
|
332
330
|
}
|
|
333
331
|
|
|
334
332
|
return new Response(response.body, {
|
|
@@ -344,9 +342,39 @@ export async function startServer(port = 3847): Promise<{ port: number; stop: ()
|
|
|
344
342
|
}
|
|
345
343
|
},
|
|
346
344
|
});
|
|
345
|
+
return server;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Start the HTTP server, reusing a live dashboard or reclaiming a stale omp listener.
|
|
350
|
+
*/
|
|
351
|
+
export async function startServer(port = 3847): Promise<{ port: number; stop: () => void }> {
|
|
352
|
+
await ensureClientBuild();
|
|
353
|
+
|
|
354
|
+
try {
|
|
355
|
+
const server = createDashboardServer(port);
|
|
356
|
+
return {
|
|
357
|
+
port: server.port ?? port,
|
|
358
|
+
stop: () => server.stop(),
|
|
359
|
+
};
|
|
360
|
+
} catch (error) {
|
|
361
|
+
if (!(error instanceof Error && "code" in error && error.code === "EADDRINUSE")) throw error;
|
|
362
|
+
|
|
363
|
+
const recovery = await recoverStatsPort(port);
|
|
364
|
+
if (recovery === "reuse") {
|
|
365
|
+
return { port, stop: () => {} };
|
|
366
|
+
}
|
|
347
367
|
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
368
|
+
try {
|
|
369
|
+
const server = createDashboardServer(port);
|
|
370
|
+
return {
|
|
371
|
+
port: server.port ?? port,
|
|
372
|
+
stop: () => server.stop(),
|
|
373
|
+
};
|
|
374
|
+
} catch (retryError) {
|
|
375
|
+
throw new Error(`Failed to start stats dashboard on port ${port} after reclaiming it.`, {
|
|
376
|
+
cause: retryError,
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
}
|
|
352
380
|
}
|