@oh-my-pi/omp-stats 17.3.4 → 17.3.7

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 CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [17.3.6] - 2026-08-17
6
+
7
+ ### Fixed
8
+
9
+ - Fixed the stats dashboard being unreachable from container hosts by accepting an explicit `--host` bind address while preserving loopback-only binding and same-origin API access by default.
10
+
5
11
  ## [17.3.0] - 2026-08-13
6
12
 
7
13
  ### Added
@@ -2,6 +2,16 @@
2
2
  export { getDashboardStats, getToolDashboardStats, getTotalMessageCount, type SyncOptions, type SyncProgress, smokeTestSyncWorker, syncAllSessions, } from "./aggregator.js";
3
3
  export { closeDb } from "./db.js";
4
4
  export { getGainDashboardStats } from "./gain-aggregator.js";
5
- export { startServer } from "./server.js";
5
+ export { formatStatsDashboardUrl, startServer } from "./server.js";
6
6
  export type { GainDashboardStats, GainSource, GainSourceTotals, GainTimeSeriesPoint, } from "./shared-types.js";
7
7
  export type { AggregatedStats, DashboardStats, FolderStats, MessageStats, ModelPerformancePoint, ModelStats, ModelTimeSeriesPoint, TimeSeriesPoint, ToolDashboardStats, ToolModelStats, ToolTimeSeriesPoint, ToolUsageStats, } from "./types.js";
8
+ /** Parsed arguments for the standalone `omp-stats` entry point. */
9
+ export interface StandaloneStatsArgs {
10
+ port: number;
11
+ host: string;
12
+ json: boolean;
13
+ sync: boolean;
14
+ help: boolean;
15
+ }
16
+ /** Parse the standalone `omp-stats` arguments used by the production entry point. */
17
+ export declare function parseStandaloneStatsArgs(args: string[]): StandaloneStatsArgs;
@@ -1,7 +1,9 @@
1
1
  /** Header stamped on every dashboard response so reuse probes can identify us. */
2
2
  export declare const STATS_DASHBOARD_HEADER = "x-omp-stats-dashboard";
3
- /** Identity-header value for dashboards enforcing loopback-only, same-origin access. */
4
- export declare const STATS_DASHBOARD_SECURITY_VERSION = "2";
3
+ /** Header recording the server's requested bind host so reuse cannot change its exposure scope. */
4
+ export declare const STATS_DASHBOARD_HOSTNAME_HEADER = "x-omp-stats-hostname";
5
+ /** Identity-header value for dashboards enforcing an explicit bind host and same-origin access. */
6
+ export declare const STATS_DASHBOARD_SECURITY_VERSION = "3";
5
7
  /** IPv4 loopback address shared by the dashboard server and reuse probe. */
6
8
  export declare const STATS_DASHBOARD_HOSTNAME = "127.0.0.1";
7
9
  /**
@@ -9,6 +11,6 @@ export declare const STATS_DASHBOARD_HOSTNAME = "127.0.0.1";
9
11
  * The preflight is needed on platforms that permit wildcard and loopback-specific
10
12
  * listeners to coexist on one port.
11
13
  */
12
- export declare function prepareStatsPort(port: number): Promise<"retry" | "reuse">;
14
+ export declare function prepareStatsPort(port: number, hostname?: string): Promise<"retry" | "reuse">;
13
15
  /** Reuse or reclaim a listener found after the server bind reports EADDRINUSE. */
14
- export declare function recoverStatsPort(port: number): Promise<"retry" | "reuse">;
16
+ export declare function recoverStatsPort(port: number, hostname?: string): Promise<"retry" | "reuse">;
@@ -2,10 +2,12 @@
2
2
  * Handle API requests.
3
3
  */
4
4
  export declare function handleApi(req: Request): Promise<Response>;
5
+ /** Format a dashboard origin, including brackets required by IPv6 literals. */
6
+ export declare function formatStatsDashboardUrl(hostname: string, port: number): string;
5
7
  /**
6
8
  * Start the HTTP server, reusing a live dashboard or reclaiming a stale omp listener.
7
9
  */
8
- export declare function startServer(port?: number): Promise<{
10
+ export declare function startServer(port?: number, hostname?: string): Promise<{
9
11
  hostname: string;
10
12
  port: number;
11
13
  stop: () => void;
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.3.4",
4
+ "version": "17.3.7",
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.3.4",
43
- "@oh-my-pi/pi-catalog": "17.3.4",
44
- "@oh-my-pi/pi-utils": "17.3.4",
42
+ "@oh-my-pi/pi-ai": "17.3.7",
43
+ "@oh-my-pi/pi-catalog": "17.3.7",
44
+ "@oh-my-pi/pi-utils": "17.3.7",
45
45
  "@tailwindcss/node": "^4.3.2",
46
46
  "chart.js": "^4.5.1",
47
47
  "lucide-react": "^1.24.0",
package/src/index.ts CHANGED
@@ -4,7 +4,7 @@ import { parseArgs } from "node:util";
4
4
  import { formatDuration, formatNumber, formatPercent } from "@oh-my-pi/pi-utils";
5
5
  import { getDashboardStats, getTotalMessageCount, syncAllSessions } from "./aggregator";
6
6
  import { closeDb } from "./db";
7
- import { startServer } from "./server";
7
+ import { formatStatsDashboardUrl, startServer } from "./server";
8
8
 
9
9
  export {
10
10
  getDashboardStats,
@@ -17,7 +17,7 @@ export {
17
17
  } from "./aggregator";
18
18
  export { closeDb } from "./db";
19
19
  export { getGainDashboardStats } from "./gain-aggregator";
20
- export { startServer } from "./server";
20
+ export { formatStatsDashboardUrl, startServer } from "./server";
21
21
  export type {
22
22
  GainDashboardStats,
23
23
  GainSource,
@@ -96,19 +96,42 @@ async function printStats(): Promise<void> {
96
96
  console.log("");
97
97
  }
98
98
 
99
- /**
100
- * Main CLI entry point.
101
- */
102
- async function main(): Promise<void> {
99
+ /** Parsed arguments for the standalone `omp-stats` entry point. */
100
+ export interface StandaloneStatsArgs {
101
+ port: number;
102
+ host: string;
103
+ json: boolean;
104
+ sync: boolean;
105
+ help: boolean;
106
+ }
107
+
108
+ /** Parse the standalone `omp-stats` arguments used by the production entry point. */
109
+ export function parseStandaloneStatsArgs(args: string[]): StandaloneStatsArgs {
103
110
  const { values } = parseArgs({
111
+ args,
104
112
  options: {
105
113
  port: { type: "string", short: "p", default: "3847" },
114
+ host: { type: "string", default: "127.0.0.1" },
106
115
  json: { type: "boolean", short: "j", default: false },
107
116
  sync: { type: "boolean", short: "s", default: false },
108
117
  help: { type: "boolean", short: "h", default: false },
109
118
  },
110
119
  allowPositionals: true,
111
120
  });
121
+ return {
122
+ port: parseInt(values.port || "3847", 10),
123
+ host: values.host || "127.0.0.1",
124
+ json: values.json ?? false,
125
+ sync: values.sync ?? false,
126
+ help: values.help ?? false,
127
+ };
128
+ }
129
+
130
+ /**
131
+ * Main CLI entry point.
132
+ */
133
+ async function main(): Promise<void> {
134
+ const values = parseStandaloneStatsArgs(process.argv.slice(2));
112
135
 
113
136
  if (values.help) {
114
137
  console.log(`
@@ -119,6 +142,7 @@ Usage:
119
142
 
120
143
  Options:
121
144
  -p, --port <port> Port for the dashboard server (default: 3847)
145
+ --host <host> Host to bind (default: 127.0.0.1)
122
146
  -j, --json Output stats as JSON and exit
123
147
  -s, --sync Sync session files and show summary
124
148
  -h, --help Show this help message
@@ -126,7 +150,7 @@ Options:
126
150
  Examples:
127
151
  omp-stats # Start dashboard server
128
152
  omp-stats --json # Print stats as JSON
129
- omp-stats --port 8080 # Start on custom port
153
+ omp-stats --host 0.0.0.0 # Explicitly expose on all IPv4 interfaces
130
154
  omp-stats --sync # Sync and show summary
131
155
  `);
132
156
  return;
@@ -171,9 +195,8 @@ Examples:
171
195
  }
172
196
 
173
197
  // Start server
174
- const port = parseInt(values.port || "3847", 10);
175
- const { hostname, port: actualPort } = await startServer(port);
176
- console.log(`Dashboard available at: http://${hostname}:${actualPort}`);
198
+ const { port: actualPort } = await startServer(values.port, values.host);
199
+ console.log(`Dashboard available at: ${formatStatsDashboardUrl(values.host, actualPort)}`);
177
200
  console.log("Press Ctrl+C to stop\n");
178
201
 
179
202
  // Keep process running
@@ -17,23 +17,28 @@ interface PortHolder {
17
17
 
18
18
  /** Header stamped on every dashboard response so reuse probes can identify us. */
19
19
  export const STATS_DASHBOARD_HEADER = "x-omp-stats-dashboard";
20
+ /** Header recording the server's requested bind host so reuse cannot change its exposure scope. */
21
+ export const STATS_DASHBOARD_HOSTNAME_HEADER = "x-omp-stats-hostname";
20
22
 
21
- /** Identity-header value for dashboards enforcing loopback-only, same-origin access. */
22
- export const STATS_DASHBOARD_SECURITY_VERSION = "2";
23
+ /** Identity-header value for dashboards enforcing an explicit bind host and same-origin access. */
24
+ export const STATS_DASHBOARD_SECURITY_VERSION = "3";
23
25
 
24
26
  /** IPv4 loopback address shared by the dashboard server and reuse probe. */
25
27
  export const STATS_DASHBOARD_HOSTNAME = "127.0.0.1";
26
28
 
27
29
  type StatsDashboardProbe = "reusable" | "occupied" | "unreachable";
28
30
 
29
- async function probeStatsDashboard(port: number): Promise<StatsDashboardProbe> {
31
+ async function probeStatsDashboard(port: number, hostname: string): Promise<StatsDashboardProbe> {
32
+ const probeHostname = hostname === "0.0.0.0" ? STATS_DASHBOARD_HOSTNAME : hostname === "::" ? "::1" : hostname;
33
+ const urlHostname = probeHostname.includes(":") ? `[${probeHostname}]` : probeHostname;
30
34
  try {
31
- const response = await fetch(`http://${STATS_DASHBOARD_HOSTNAME}:${port}/api/stats/models`, {
35
+ const response = await fetch(`http://${urlHostname}:${port}/api/stats/models`, {
32
36
  signal: AbortSignal.timeout(STATS_PROBE_TIMEOUT_MS),
33
37
  });
34
38
  const reusable =
35
39
  response.status === 200 &&
36
40
  response.headers.get(STATS_DASHBOARD_HEADER) === STATS_DASHBOARD_SECURITY_VERSION &&
41
+ response.headers.get(STATS_DASHBOARD_HOSTNAME_HEADER) === hostname &&
37
42
  !response.headers.has("Access-Control-Allow-Origin");
38
43
  await response.body?.cancel();
39
44
  return reusable ? "reusable" : "occupied";
@@ -248,16 +253,16 @@ async function reclaimStatsPort(port: number): Promise<"retry"> {
248
253
  * The preflight is needed on platforms that permit wildcard and loopback-specific
249
254
  * listeners to coexist on one port.
250
255
  */
251
- export async function prepareStatsPort(port: number): Promise<"retry" | "reuse"> {
256
+ export async function prepareStatsPort(port: number, hostname = STATS_DASHBOARD_HOSTNAME): Promise<"retry" | "reuse"> {
252
257
  if (port === 0) return "retry";
253
- const probe = await probeStatsDashboard(port);
258
+ const probe = await probeStatsDashboard(port, hostname);
254
259
  if (probe === "reusable") return "reuse";
255
260
  if (probe === "occupied") return reclaimStatsPort(port);
256
261
  return "retry";
257
262
  }
258
263
 
259
264
  /** Reuse or reclaim a listener found after the server bind reports EADDRINUSE. */
260
- export async function recoverStatsPort(port: number): Promise<"retry" | "reuse"> {
261
- if ((await probeStatsDashboard(port)) === "reusable") return "reuse";
265
+ export async function recoverStatsPort(port: number, hostname = STATS_DASHBOARD_HOSTNAME): Promise<"retry" | "reuse"> {
266
+ if ((await probeStatsDashboard(port, hostname)) === "reusable") return "reuse";
262
267
  return reclaimStatsPort(port);
263
268
  }
package/src/server.ts CHANGED
@@ -26,6 +26,7 @@ import {
26
26
  recoverStatsPort,
27
27
  STATS_DASHBOARD_HEADER,
28
28
  STATS_DASHBOARD_HOSTNAME,
29
+ STATS_DASHBOARD_HOSTNAME_HEADER,
29
30
  STATS_DASHBOARD_SECURITY_VERSION,
30
31
  } from "./port-conflict";
31
32
 
@@ -306,10 +307,16 @@ async function handleStatic(requestPath: string): Promise<Response> {
306
307
  return new Response("Not Found", { status: 404 });
307
308
  }
308
309
 
309
- function createDashboardServer(port: number) {
310
+ /** Format a dashboard origin, including brackets required by IPv6 literals. */
311
+ export function formatStatsDashboardUrl(hostname: string, port: number): string {
312
+ const urlHostname = hostname.includes(":") && !hostname.startsWith("[") ? `[${hostname}]` : hostname;
313
+ return `http://${urlHostname}:${port}`;
314
+ }
315
+
316
+ function createDashboardServer(port: number, hostname: string) {
310
317
  const server = Bun.serve({
311
318
  port,
312
- hostname: STATS_DASHBOARD_HOSTNAME,
319
+ hostname,
313
320
  async fetch(req) {
314
321
  const url = new URL(req.url);
315
322
  const path = url.pathname;
@@ -318,6 +325,7 @@ function createDashboardServer(port: number) {
318
325
  // recognize this dashboard without allowing cross-origin API reads.
319
326
  const dashboardHeaders: Record<string, string> = {
320
327
  [STATS_DASHBOARD_HEADER]: STATS_DASHBOARD_SECURITY_VERSION,
328
+ [STATS_DASHBOARD_HOSTNAME_HEADER]: hostname,
321
329
  };
322
330
 
323
331
  if (req.method === "OPTIONS") {
@@ -358,37 +366,40 @@ function createDashboardServer(port: number) {
358
366
  /**
359
367
  * Start the HTTP server, reusing a live dashboard or reclaiming a stale omp listener.
360
368
  */
361
- export async function startServer(port = 3847): Promise<{ hostname: string; port: number; stop: () => void }> {
369
+ export async function startServer(
370
+ port = 3847,
371
+ hostname = STATS_DASHBOARD_HOSTNAME,
372
+ ): Promise<{ hostname: string; port: number; stop: () => void }> {
362
373
  await ensureClientBuild();
363
- const preparation = await prepareStatsPort(port);
374
+ const preparation = await prepareStatsPort(port, hostname);
364
375
  if (preparation === "reuse") {
365
- return { hostname: STATS_DASHBOARD_HOSTNAME, port, stop: () => {} };
376
+ return { hostname, port, stop: () => {} };
366
377
  }
367
378
 
368
379
  try {
369
- const server = createDashboardServer(port);
380
+ const server = createDashboardServer(port, hostname);
370
381
  return {
371
- hostname: STATS_DASHBOARD_HOSTNAME,
382
+ hostname,
372
383
  port: server.port ?? port,
373
384
  stop: () => server.stop(),
374
385
  };
375
386
  } catch (error) {
376
387
  if (!(error instanceof Error && "code" in error && error.code === "EADDRINUSE")) throw error;
377
388
 
378
- const recovery = await recoverStatsPort(port);
389
+ const recovery = await recoverStatsPort(port, hostname);
379
390
  if (recovery === "reuse") {
380
- return { hostname: STATS_DASHBOARD_HOSTNAME, port, stop: () => {} };
391
+ return { hostname, port, stop: () => {} };
381
392
  }
382
393
 
383
394
  try {
384
- const server = createDashboardServer(port);
395
+ const server = createDashboardServer(port, hostname);
385
396
  return {
386
- hostname: STATS_DASHBOARD_HOSTNAME,
397
+ hostname,
387
398
  port: server.port ?? port,
388
399
  stop: () => server.stop(),
389
400
  };
390
401
  } catch (retryError) {
391
- throw new Error(`Failed to start stats dashboard on port ${port} after reclaiming it.`, {
402
+ throw new Error(`Failed to start stats dashboard on ${hostname}:${port} after reclaiming it.`, {
392
403
  cause: retryError,
393
404
  });
394
405
  }