@linxiraos/pi-stats 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.
Files changed (126) hide show
  1. package/CHANGELOG.md +276 -0
  2. package/README.md +82 -0
  3. package/build.ts +95 -0
  4. package/dist/client/index.css +1 -0
  5. package/dist/client/index.html +24 -0
  6. package/dist/client/index.js +257 -0
  7. package/dist/client/styles.css +1695 -0
  8. package/dist/types/aggregator.d.ts +98 -0
  9. package/dist/types/client/App.d.ts +1 -0
  10. package/dist/types/client/api.d.ts +22 -0
  11. package/dist/types/client/app/AppLayout.d.ts +16 -0
  12. package/dist/types/client/app/NavRail.d.ts +7 -0
  13. package/dist/types/client/app/RangeControl.d.ts +7 -0
  14. package/dist/types/client/app/SyncButton.d.ts +14 -0
  15. package/dist/types/client/app/ThemeToggle.d.ts +1 -0
  16. package/dist/types/client/app/TopBar.d.ts +15 -0
  17. package/dist/types/client/app/routes.d.ts +12 -0
  18. package/dist/types/client/components/AgentTokenShare.d.ts +5 -0
  19. package/dist/types/client/components/chart-shared.d.ts +173 -0
  20. package/dist/types/client/components/models-table-shared.d.ts +175 -0
  21. package/dist/types/client/components/range-meta.d.ts +21 -0
  22. package/dist/types/client/data/charts.d.ts +1 -0
  23. package/dist/types/client/data/formatters.d.ts +8 -0
  24. package/dist/types/client/data/useHashRoute.d.ts +8 -0
  25. package/dist/types/client/data/useResource.d.ts +13 -0
  26. package/dist/types/client/data/view-models.d.ts +74 -0
  27. package/dist/types/client/index.d.ts +2 -0
  28. package/dist/types/client/routes/BehaviorRoute.d.ts +7 -0
  29. package/dist/types/client/routes/CostsRoute.d.ts +7 -0
  30. package/dist/types/client/routes/ErrorsRoute.d.ts +8 -0
  31. package/dist/types/client/routes/GainRoute.d.ts +7 -0
  32. package/dist/types/client/routes/ModelsRoute.d.ts +7 -0
  33. package/dist/types/client/routes/OverviewRoute.d.ts +8 -0
  34. package/dist/types/client/routes/ProjectsRoute.d.ts +7 -0
  35. package/dist/types/client/routes/ProvidersRoute.d.ts +7 -0
  36. package/dist/types/client/routes/RequestsRoute.d.ts +8 -0
  37. package/dist/types/client/routes/ToolsRoute.d.ts +7 -0
  38. package/dist/types/client/routes/index.d.ts +10 -0
  39. package/dist/types/client/types.d.ts +63 -0
  40. package/dist/types/client/ui/AsyncBoundary.d.ts +12 -0
  41. package/dist/types/client/ui/DataTable.d.ts +17 -0
  42. package/dist/types/client/ui/EmptyState.d.ts +7 -0
  43. package/dist/types/client/ui/ErrorState.d.ts +6 -0
  44. package/dist/types/client/ui/JsonBlock.d.ts +7 -0
  45. package/dist/types/client/ui/MetricCluster.d.ts +5 -0
  46. package/dist/types/client/ui/Panel.d.ts +7 -0
  47. package/dist/types/client/ui/RequestDrawer.d.ts +5 -0
  48. package/dist/types/client/ui/SegmentedControl.d.ts +12 -0
  49. package/dist/types/client/ui/Skeleton.d.ts +8 -0
  50. package/dist/types/client/ui/StatusPill.d.ts +7 -0
  51. package/dist/types/client/ui/index.d.ts +11 -0
  52. package/dist/types/client/useSystemTheme.d.ts +11 -0
  53. package/dist/types/db.d.ts +159 -0
  54. package/dist/types/embedded-client.d.ts +18 -0
  55. package/dist/types/gain-aggregator.d.ts +26 -0
  56. package/dist/types/index.d.ts +7 -0
  57. package/dist/types/parser.d.ts +53 -0
  58. package/dist/types/port-conflict.d.ts +14 -0
  59. package/dist/types/server.d.ts +12 -0
  60. package/dist/types/shared-types.d.ts +403 -0
  61. package/dist/types/sync-worker.d.ts +31 -0
  62. package/dist/types/types.d.ts +164 -0
  63. package/dist/types/usage-windows.d.ts +45 -0
  64. package/dist/types/user-metrics.d.ts +81 -0
  65. package/package.json +94 -0
  66. package/src/aggregator.ts +615 -0
  67. package/src/client/App.tsx +112 -0
  68. package/src/client/api.ts +118 -0
  69. package/src/client/app/AppLayout.tsx +93 -0
  70. package/src/client/app/NavRail.tsx +44 -0
  71. package/src/client/app/RangeControl.tsx +39 -0
  72. package/src/client/app/SyncButton.tsx +75 -0
  73. package/src/client/app/ThemeToggle.tsx +37 -0
  74. package/src/client/app/TopBar.tsx +73 -0
  75. package/src/client/app/routes.ts +86 -0
  76. package/src/client/components/AgentTokenShare.tsx +68 -0
  77. package/src/client/components/chart-shared.tsx +257 -0
  78. package/src/client/components/models-table-shared.tsx +255 -0
  79. package/src/client/components/range-meta.ts +73 -0
  80. package/src/client/css.d.ts +1 -0
  81. package/src/client/data/charts.ts +14 -0
  82. package/src/client/data/formatters.ts +45 -0
  83. package/src/client/data/useHashRoute.ts +88 -0
  84. package/src/client/data/useResource.ts +154 -0
  85. package/src/client/data/view-models.ts +259 -0
  86. package/src/client/index.tsx +10 -0
  87. package/src/client/routes/BehaviorRoute.tsx +623 -0
  88. package/src/client/routes/CostsRoute.tsx +234 -0
  89. package/src/client/routes/ErrorsRoute.tsx +118 -0
  90. package/src/client/routes/GainRoute.tsx +226 -0
  91. package/src/client/routes/ModelsRoute.tsx +430 -0
  92. package/src/client/routes/OverviewRoute.tsx +342 -0
  93. package/src/client/routes/ProjectsRoute.tsx +163 -0
  94. package/src/client/routes/ProvidersRoute.tsx +492 -0
  95. package/src/client/routes/RequestsRoute.tsx +123 -0
  96. package/src/client/routes/ToolsRoute.tsx +463 -0
  97. package/src/client/routes/index.ts +10 -0
  98. package/src/client/styles.css +1384 -0
  99. package/src/client/types.ts +80 -0
  100. package/src/client/ui/AsyncBoundary.tsx +54 -0
  101. package/src/client/ui/DataTable.tsx +122 -0
  102. package/src/client/ui/EmptyState.tsx +16 -0
  103. package/src/client/ui/ErrorState.tsx +25 -0
  104. package/src/client/ui/JsonBlock.tsx +75 -0
  105. package/src/client/ui/MetricCluster.tsx +78 -0
  106. package/src/client/ui/Panel.tsx +24 -0
  107. package/src/client/ui/RequestDrawer.tsx +208 -0
  108. package/src/client/ui/SegmentedControl.tsx +36 -0
  109. package/src/client/ui/Skeleton.tsx +17 -0
  110. package/src/client/ui/StatusPill.tsx +15 -0
  111. package/src/client/ui/index.ts +11 -0
  112. package/src/client/useSystemTheme.ts +87 -0
  113. package/src/db.ts +1702 -0
  114. package/src/embedded-client.generated.txt +0 -0
  115. package/src/embedded-client.ts +26 -0
  116. package/src/gain-aggregator.ts +311 -0
  117. package/src/index.ts +194 -0
  118. package/src/parser.ts +499 -0
  119. package/src/port-conflict.ts +263 -0
  120. package/src/server.ts +396 -0
  121. package/src/shared-types.ts +432 -0
  122. package/src/sync-worker.ts +40 -0
  123. package/src/types.ts +171 -0
  124. package/src/usage-windows.ts +304 -0
  125. package/src/user-metrics.ts +695 -0
  126. package/tailwind.config.js +40 -0
@@ -0,0 +1,263 @@
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 "@linxiraos/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
+ /** Identity-header value for dashboards enforcing loopback-only, same-origin access. */
22
+ export const STATS_DASHBOARD_SECURITY_VERSION = "2";
23
+
24
+ /** IPv4 loopback address shared by the dashboard server and reuse probe. */
25
+ export const STATS_DASHBOARD_HOSTNAME = "127.0.0.1";
26
+
27
+ type StatsDashboardProbe = "reusable" | "occupied" | "unreachable";
28
+
29
+ async function probeStatsDashboard(port: number): Promise<StatsDashboardProbe> {
30
+ try {
31
+ const response = await fetch(`http://${STATS_DASHBOARD_HOSTNAME}:${port}/api/stats/models`, {
32
+ signal: AbortSignal.timeout(STATS_PROBE_TIMEOUT_MS),
33
+ });
34
+ const reusable =
35
+ response.status === 200 &&
36
+ response.headers.get(STATS_DASHBOARD_HEADER) === STATS_DASHBOARD_SECURITY_VERSION &&
37
+ !response.headers.has("Access-Control-Allow-Origin");
38
+ await response.body?.cancel();
39
+ return reusable ? "reusable" : "occupied";
40
+ } catch {
41
+ return "unreachable";
42
+ }
43
+ }
44
+
45
+ async function findLinuxPortHolder(port: number): Promise<PortHolder | null> {
46
+ const socketInodes = new Set<string>();
47
+ for (const tablePath of ["/proc/net/tcp", "/proc/net/tcp6"]) {
48
+ let table: string;
49
+ try {
50
+ table = await Bun.file(tablePath).text();
51
+ } catch {
52
+ continue;
53
+ }
54
+
55
+ for (const line of table.split("\n").slice(1)) {
56
+ const fields = line.trim().split(/\s+/);
57
+ const localAddress = fields[1];
58
+ const state = fields[3];
59
+ const inode = fields[9];
60
+ if (!localAddress || state !== "0A" || !inode) continue;
61
+ const encodedPort = localAddress.slice(localAddress.lastIndexOf(":") + 1);
62
+ if (Number.parseInt(encodedPort, 16) === port) socketInodes.add(inode);
63
+ }
64
+ }
65
+ if (socketInodes.size === 0) return null;
66
+
67
+ let processes: Dirent[];
68
+ try {
69
+ processes = await fs.readdir("/proc", { withFileTypes: true });
70
+ } catch {
71
+ return null;
72
+ }
73
+
74
+ for (const entry of processes) {
75
+ if (!entry.isDirectory() || !/^\d+$/.test(entry.name)) continue;
76
+ const pid = Number.parseInt(entry.name, 10);
77
+ let descriptors: string[];
78
+ try {
79
+ descriptors = await fs.readdir(`/proc/${pid}/fd`);
80
+ } catch {
81
+ continue;
82
+ }
83
+
84
+ let ownsSocket = false;
85
+ for (const descriptor of descriptors) {
86
+ try {
87
+ const target = await fs.readlink(`/proc/${pid}/fd/${descriptor}`);
88
+ const match = /^socket:\[(\d+)]$/.exec(target);
89
+ if (match?.[1] && socketInodes.has(match[1])) {
90
+ ownsSocket = true;
91
+ break;
92
+ }
93
+ } catch {}
94
+ }
95
+ if (!ownsSocket) continue;
96
+
97
+ let commandLine = "";
98
+ try {
99
+ const rawCommandLine = await Bun.file(`/proc/${pid}/cmdline`).text();
100
+ commandLine = rawCommandLine.split("\0").filter(Boolean).join(" ");
101
+ } catch {}
102
+
103
+ try {
104
+ const executable = await fs.readlink(`/proc/${pid}/exe`);
105
+ return { pid, image: path.basename(executable), commandLine };
106
+ } catch {
107
+ const executable = commandLine.split(" ", 1)[0];
108
+ return { pid, image: executable ? path.basename(executable) : "unknown", commandLine };
109
+ }
110
+ }
111
+ return null;
112
+ }
113
+
114
+ async function findMacPortHolder(port: number): Promise<PortHolder | null> {
115
+ const lsof = $which("lsof") ?? ((await Bun.file("/usr/sbin/lsof").exists()) ? "/usr/sbin/lsof" : null);
116
+ if (!lsof) return null;
117
+
118
+ const selector = `-iTCP:${port}`;
119
+ const result = await $`${lsof} -nP ${selector} -sTCP:LISTEN -Fpc`.quiet().nothrow();
120
+ if (result.exitCode !== 0) return null;
121
+
122
+ let pid: number | null = null;
123
+ let image = "unknown";
124
+ for (const line of result.text().split("\n")) {
125
+ if (line.startsWith("p")) {
126
+ const parsed = Number.parseInt(line.slice(1), 10);
127
+ pid = Number.isSafeInteger(parsed) ? parsed : null;
128
+ } else if (line.startsWith("c") && pid !== null) {
129
+ image = line.slice(1) || "unknown";
130
+ break;
131
+ }
132
+ }
133
+ if (pid === null) return null;
134
+
135
+ const ps = $which("ps");
136
+ if (!ps) return { pid, image, commandLine: "" };
137
+ const processInfo = await $`${ps} -ww -p ${pid} -o command=`.quiet().nothrow();
138
+ return { pid, image, commandLine: processInfo.exitCode === 0 ? processInfo.text().trim() : "" };
139
+ }
140
+
141
+ async function findWindowsPortHolder(port: number): Promise<PortHolder | null> {
142
+ const netstat = $which("netstat");
143
+ if (!netstat) return null;
144
+
145
+ const result = await $`${netstat} -ano -p TCP`.quiet().nothrow();
146
+ if (result.exitCode !== 0) return null;
147
+
148
+ let pid: number | null = null;
149
+ for (const line of result.text().split("\n")) {
150
+ const fields = line.trim().split(/\s+/);
151
+ if (fields[0]?.toUpperCase() !== "TCP" || fields[3]?.toUpperCase() !== "LISTENING") continue;
152
+ const localAddress = fields[1];
153
+ if (!localAddress || Number.parseInt(localAddress.slice(localAddress.lastIndexOf(":") + 1), 10) !== port) {
154
+ continue;
155
+ }
156
+ const parsed = Number.parseInt(fields[4] ?? "", 10);
157
+ if (Number.isSafeInteger(parsed)) {
158
+ pid = parsed;
159
+ break;
160
+ }
161
+ }
162
+ if (pid === null) return null;
163
+
164
+ let image = "unknown";
165
+ const tasklist = $which("tasklist");
166
+ if (tasklist) {
167
+ const filter = `PID eq ${pid}`;
168
+ const task = await $`${tasklist} /FI ${filter} /FO CSV /NH`.quiet().nothrow();
169
+ if (task.exitCode === 0) {
170
+ const imageMatch = /^"((?:[^"]|"")*)"/.exec(task.text().trim());
171
+ image = imageMatch?.[1]?.replaceAll('""', '"') || "unknown";
172
+ }
173
+ }
174
+
175
+ const powershell = $which("powershell") ?? $which("pwsh");
176
+ if (!powershell) return { pid, image, commandLine: "" };
177
+ const command = `(Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}").CommandLine`;
178
+ const processInfo = await $`${powershell} -NoProfile -NonInteractive -Command ${command}`.quiet().nothrow();
179
+ return { pid, image, commandLine: processInfo.exitCode === 0 ? processInfo.text().trim() : "" };
180
+ }
181
+
182
+ async function findPortHolder(port: number): Promise<PortHolder | null> {
183
+ if (process.platform === "linux") return findLinuxPortHolder(port);
184
+ if (process.platform === "darwin") return findMacPortHolder(port);
185
+ if (process.platform === "win32") return findWindowsPortHolder(port);
186
+ return null;
187
+ }
188
+
189
+ async function terminatePortHolder(holder: PortHolder): Promise<void> {
190
+ try {
191
+ process.kill(holder.pid, "SIGTERM");
192
+ } catch (error) {
193
+ if (error instanceof Error && "code" in error && error.code === "ESRCH") return;
194
+ throw new Error(`Failed to stop ${holder.image} (PID ${holder.pid})`, { cause: error });
195
+ }
196
+
197
+ for (let attempt = 0; attempt < PROCESS_EXIT_POLLS; attempt++) {
198
+ await Bun.sleep(PROCESS_EXIT_POLL_MS);
199
+ try {
200
+ process.kill(holder.pid, 0);
201
+ } catch (error) {
202
+ if (error instanceof Error && "code" in error && error.code === "ESRCH") return;
203
+ throw new Error(`Failed to inspect ${holder.image} (PID ${holder.pid})`, { cause: error });
204
+ }
205
+ }
206
+
207
+ try {
208
+ process.kill(holder.pid, "SIGKILL");
209
+ } catch (error) {
210
+ if (error instanceof Error && "code" in error && error.code === "ESRCH") return;
211
+ throw new Error(`Failed to kill ${holder.image} (PID ${holder.pid})`, { cause: error });
212
+ }
213
+ await Bun.sleep(PROCESS_EXIT_POLL_MS);
214
+ }
215
+
216
+ async function reclaimStatsPort(port: number): Promise<"retry"> {
217
+ const holder = await findPortHolder(port);
218
+ if (!holder) {
219
+ throw new Error(`Port ${port} is in use, but the listening process could not be identified.`);
220
+ }
221
+ if (holder.pid === process.pid) {
222
+ throw new Error(`Port ${port} is held by the current process (${holder.image}, PID ${holder.pid}).`);
223
+ }
224
+
225
+ const normalizedImage = holder.image
226
+ .toLowerCase()
227
+ .replace(/\.exe$/, "")
228
+ .replace(/ \(deleted\)$/, "");
229
+ const normalizedCommand = holder.commandLine.toLowerCase().replaceAll("\\", "/");
230
+ const hasStatsIdentity =
231
+ normalizedImage === "omp-stats" ||
232
+ /(?:^|[/"'\s])omp-stats(?:\.exe)?(?:["'\s]|$)/.test(normalizedCommand) ||
233
+ /\/packages\/stats\/src\/index\.ts(?:["'\s]|$)/.test(normalizedCommand) ||
234
+ (normalizedImage === "omp" && /(?:^|\s)stats(?:\s|$)/.test(normalizedCommand)) ||
235
+ /(?:^|\/)omp(?:\.exe)?["'\s]+stats(?:["'\s]|$)/.test(normalizedCommand);
236
+ if (!STATS_RUNTIME_IMAGES[normalizedImage] || !hasStatsIdentity) {
237
+ throw new Error(
238
+ `Port ${port} is in use by ${holder.image} (PID ${holder.pid}), which is not identifiable as an omp stats dashboard; refusing to stop it.`,
239
+ );
240
+ }
241
+
242
+ await terminatePortHolder(holder);
243
+ return "retry";
244
+ }
245
+
246
+ /**
247
+ * Reuse a secure dashboard or reclaim an insecure HTTP dashboard before binding.
248
+ * The preflight is needed on platforms that permit wildcard and loopback-specific
249
+ * listeners to coexist on one port.
250
+ */
251
+ export async function prepareStatsPort(port: number): Promise<"retry" | "reuse"> {
252
+ if (port === 0) return "retry";
253
+ const probe = await probeStatsDashboard(port);
254
+ if (probe === "reusable") return "reuse";
255
+ if (probe === "occupied") return reclaimStatsPort(port);
256
+ return "retry";
257
+ }
258
+
259
+ /** 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";
262
+ return reclaimStatsPort(port);
263
+ }
package/src/server.ts ADDED
@@ -0,0 +1,396 @@
1
+ import type { Dirent } from "node:fs";
2
+ import * as fs from "node:fs/promises";
3
+ import * as os from "node:os";
4
+ import * as path from "node:path";
5
+ import { isEnoent } from "@linxiraos/pi-utils";
6
+ import { $ } from "bun";
7
+ import {
8
+ getBehaviorDashboardStats,
9
+ getCostDashboardStats,
10
+ getDashboardStats,
11
+ getModelDashboardStats,
12
+ getOverviewStats,
13
+ getProviderDashboardStats,
14
+ getRecentErrors,
15
+ getRecentRequests,
16
+ getRequestDetails,
17
+ getToolDashboardStats,
18
+ getTotalMessageCount,
19
+ syncAllSessions,
20
+ } from "./aggregator";
21
+ import { decodeEmbeddedClientArchive } from "./embedded-client";
22
+ import embeddedClientArchiveTxt from "./embedded-client.generated.txt";
23
+ import { getGainDashboardStats } from "./gain-aggregator";
24
+ import {
25
+ prepareStatsPort,
26
+ recoverStatsPort,
27
+ STATS_DASHBOARD_HEADER,
28
+ STATS_DASHBOARD_HOSTNAME,
29
+ STATS_DASHBOARD_SECURITY_VERSION,
30
+ } from "./port-conflict";
31
+
32
+ const EMBEDDED_CLIENT_ARCHIVE = decodeEmbeddedClientArchive(embeddedClientArchiveTxt);
33
+
34
+ const CLIENT_DIR = path.join(import.meta.dir, "client");
35
+ const STATIC_DIR = path.join(import.meta.dir, "..", "dist", "client");
36
+ const IS_BUN_COMPILED =
37
+ Boolean(process.env.PI_COMPILED || Bun.env.PI_COMPILED) ||
38
+ import.meta.url.includes("$bunfs") ||
39
+ import.meta.url.includes("~BUN") ||
40
+ import.meta.url.includes("%7EBUN");
41
+ // The prepacked npm bundle (coding-agent dist/cli.js) constant-folds
42
+ // process.env.PI_BUNDLED at build time. Like compiled binaries, it ships no
43
+ // dashboard sources or prebuilt dist/client next to the bundle, so the
44
+ // embedded archive is the only viable asset source.
45
+ const IS_PREBUILT = IS_BUN_COMPILED || Boolean(process.env.PI_BUNDLED || Bun.env.PI_BUNDLED);
46
+ const USE_EMBEDDED_CLIENT = EMBEDDED_CLIENT_ARCHIVE !== null || IS_PREBUILT;
47
+
48
+ const EMBEDDED_CLIENT_DIR_ROOT = path.join(os.tmpdir(), "omp-stats-client");
49
+ let embeddedClientDirPromise: Promise<string> | null = null;
50
+
51
+ function sanitizeArchivePath(archivePath: string): string | null {
52
+ const normalized = archivePath.replaceAll("\\", "/").replace(/^\.\//, "");
53
+ if (!normalized || normalized === ".") return null;
54
+ if (normalized.includes("..") || path.isAbsolute(normalized)) return null;
55
+ return normalized;
56
+ }
57
+
58
+ async function extractEmbeddedClientArchive(archiveBytes: Buffer, outputDir: string): Promise<void> {
59
+ const archive = new Bun.Archive(archiveBytes);
60
+ const files = await archive.files();
61
+ const extractRoot = path.resolve(outputDir);
62
+
63
+ for (const [archivePath, file] of files) {
64
+ const sanitizedPath = sanitizeArchivePath(archivePath);
65
+ if (!sanitizedPath) continue;
66
+ const destinationPath = path.resolve(extractRoot, sanitizedPath);
67
+ if (!destinationPath.startsWith(extractRoot + path.sep)) {
68
+ throw new Error(`Archive entry escapes extraction directory: ${archivePath}`);
69
+ }
70
+ await Bun.write(destinationPath, file);
71
+ }
72
+ }
73
+
74
+ async function getEmbeddedClientDir(): Promise<string> {
75
+ if (!USE_EMBEDDED_CLIENT) return STATIC_DIR;
76
+ if (embeddedClientDirPromise) return embeddedClientDirPromise;
77
+
78
+ if (!EMBEDDED_CLIENT_ARCHIVE) {
79
+ throw new Error(
80
+ "Embedded stats client bundle missing. Rebuild the omp binary or npm bundle with embedded stats assets.",
81
+ );
82
+ }
83
+
84
+ embeddedClientDirPromise = (async () => {
85
+ const bundleHash = Bun.hash(EMBEDDED_CLIENT_ARCHIVE).toString(16);
86
+ const outputDir = path.join(EMBEDDED_CLIENT_DIR_ROOT, bundleHash);
87
+ const markerPath = path.join(outputDir, "index.html");
88
+ try {
89
+ const marker = await fs.stat(markerPath);
90
+ if (marker.isFile()) return outputDir;
91
+ } catch {}
92
+
93
+ await fs.rm(outputDir, { recursive: true, force: true });
94
+ await fs.mkdir(outputDir, { recursive: true });
95
+ await extractEmbeddedClientArchive(EMBEDDED_CLIENT_ARCHIVE, outputDir);
96
+ return outputDir;
97
+ })();
98
+
99
+ return embeddedClientDirPromise;
100
+ }
101
+
102
+ async function getLatestMtime(dir: string): Promise<number> {
103
+ let entries: Dirent[];
104
+ try {
105
+ entries = await fs.readdir(dir, { withFileTypes: true });
106
+ } catch (err) {
107
+ // Tolerate missing source trees (e.g. installs without the dashboard
108
+ // sources); the caller falls back to prebuilt assets or a clear build
109
+ // failure instead of crashing on the scan.
110
+ if (isEnoent(err)) return 0;
111
+ throw err;
112
+ }
113
+
114
+ const promises = [];
115
+ for (const entry of entries) {
116
+ const fullPath = path.join(dir, entry.name);
117
+ if (entry.isDirectory()) {
118
+ promises.push(getLatestMtime(fullPath));
119
+ } else if (entry.isFile()) {
120
+ promises.push(fs.stat(fullPath).then(stats => stats.mtimeMs));
121
+ }
122
+ }
123
+
124
+ let latest = 0;
125
+ await Promise.allSettled(promises).then(results => {
126
+ for (const result of results) {
127
+ if (result.status === "fulfilled") {
128
+ latest = Math.max(latest, result.value);
129
+ }
130
+ }
131
+ });
132
+ return latest;
133
+ }
134
+
135
+ const ensureClientBuild = async () => {
136
+ if (USE_EMBEDDED_CLIENT) return;
137
+ const indexPath = path.join(STATIC_DIR, "index.html");
138
+ const cssPath = path.join(STATIC_DIR, "styles.css");
139
+ const clientSourceMtime = await getLatestMtime(CLIENT_DIR);
140
+ const tailwindConfigPath = path.join(import.meta.dir, "..", "tailwind.config.js");
141
+ let tailwindConfigMtime = 0;
142
+ try {
143
+ const tailwindConfigStats = await fs.stat(tailwindConfigPath);
144
+ tailwindConfigMtime = tailwindConfigStats.mtimeMs;
145
+ } catch {}
146
+ const sourceMtime = Math.max(clientSourceMtime, tailwindConfigMtime);
147
+ let shouldBuild = true;
148
+ try {
149
+ const [indexStats, cssStats] = await Promise.all([fs.stat(indexPath), fs.stat(cssPath)]);
150
+ if (
151
+ indexStats.isFile() &&
152
+ cssStats.isFile() &&
153
+ indexStats.mtimeMs >= sourceMtime &&
154
+ cssStats.mtimeMs >= sourceMtime
155
+ ) {
156
+ shouldBuild = false;
157
+ }
158
+ } catch {
159
+ shouldBuild = true;
160
+ }
161
+
162
+ if (!shouldBuild) return;
163
+
164
+ await fs.rm(STATIC_DIR, { recursive: true, force: true });
165
+
166
+ console.log("Building stats client...");
167
+ const packageRoot = path.join(import.meta.dir, "..");
168
+ const buildResult = await $`bun run build.ts`.cwd(packageRoot).quiet().nothrow();
169
+ if (buildResult.exitCode !== 0) {
170
+ const output = buildResult.text().trim();
171
+ const details = output ? `\n${output}` : "";
172
+ throw new Error(`Failed to build stats client (exit ${buildResult.exitCode})${details}`);
173
+ }
174
+
175
+ const indexHtml = `<!DOCTYPE html>
176
+ <html lang="en">
177
+ <head>
178
+ <meta charset="UTF-8">
179
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
180
+ <title>AI Usage Statistics</title>
181
+ <link rel="stylesheet" href="styles.css">
182
+ </head>
183
+ <body>
184
+ <div id="root"></div>
185
+ <script src="index.js" type="module"></script>
186
+ </body>
187
+ </html>`;
188
+
189
+ await Bun.write(path.join(STATIC_DIR, "index.html"), indexHtml);
190
+ };
191
+
192
+ /**
193
+ * Handle API requests.
194
+ */
195
+ export async function handleApi(req: Request): Promise<Response> {
196
+ const url = new URL(req.url);
197
+ const path = url.pathname;
198
+
199
+ // Stats reads are DB-only; explicit /api/sync does the expensive session scan.
200
+ const range = url.searchParams.get("range");
201
+
202
+ if (path === "/api/stats") {
203
+ const stats = await getDashboardStats(range);
204
+ return Response.json(stats);
205
+ }
206
+
207
+ if (path === "/api/stats/overview") {
208
+ const stats = await getOverviewStats(range);
209
+ return Response.json(stats);
210
+ }
211
+
212
+ if (path === "/api/stats/model-dashboard") {
213
+ const stats = await getModelDashboardStats(range);
214
+ return Response.json(stats);
215
+ }
216
+
217
+ if (path === "/api/stats/costs") {
218
+ const stats = await getCostDashboardStats(range);
219
+ return Response.json(stats);
220
+ }
221
+
222
+ if (path === "/api/stats/behavior") {
223
+ const stats = await getBehaviorDashboardStats(range);
224
+ return Response.json(stats);
225
+ }
226
+
227
+ if (path === "/api/stats/tools") {
228
+ const stats = await getToolDashboardStats(range);
229
+ return Response.json(stats);
230
+ }
231
+
232
+ if (path === "/api/stats/providers") {
233
+ const stats = await getProviderDashboardStats(range);
234
+ return Response.json(stats);
235
+ }
236
+
237
+ if (path === "/api/stats/recent") {
238
+ const limit = url.searchParams.get("limit");
239
+ const stats = await getRecentRequests(limit ? parseInt(limit, 10) : undefined);
240
+ return Response.json(stats);
241
+ }
242
+
243
+ if (path === "/api/stats/errors") {
244
+ const limit = url.searchParams.get("limit");
245
+ const stats = await getRecentErrors(range, limit ? parseInt(limit, 10) : undefined);
246
+ return Response.json(stats);
247
+ }
248
+
249
+ if (path === "/api/stats/models") {
250
+ const stats = await getDashboardStats(range);
251
+ return Response.json(stats.byModel);
252
+ }
253
+
254
+ if (path === "/api/stats/folders") {
255
+ const stats = await getDashboardStats(range);
256
+ return Response.json(stats.byFolder);
257
+ }
258
+
259
+ if (path === "/api/stats/timeseries") {
260
+ const stats = await getDashboardStats(range);
261
+ return Response.json(stats.timeSeries);
262
+ }
263
+
264
+ if (path.startsWith("/api/request/")) {
265
+ const id = path.split("/").pop();
266
+ if (!id) return new Response("Bad Request", { status: 400 });
267
+ const details = await getRequestDetails(parseInt(id, 10));
268
+ if (!details) return new Response("Not Found", { status: 404 });
269
+ return Response.json(details);
270
+ }
271
+
272
+ if (path === "/api/sync") {
273
+ const result = await syncAllSessions();
274
+ const count = await getTotalMessageCount();
275
+ return Response.json({ ...result, totalMessages: count });
276
+ }
277
+
278
+ if (path === "/api/stats/gain") {
279
+ const project = url.searchParams.get("project");
280
+ const stats = await getGainDashboardStats(range, project);
281
+ return Response.json(stats);
282
+ }
283
+
284
+ return new Response("Not Found", { status: 404 });
285
+ }
286
+
287
+ /**
288
+ * Handle static file requests.
289
+ */
290
+ async function handleStatic(requestPath: string): Promise<Response> {
291
+ const staticDir = await getEmbeddedClientDir();
292
+ const filePath = requestPath === "/" ? "/index.html" : requestPath;
293
+ const fullPath = path.join(staticDir, filePath);
294
+
295
+ const file = Bun.file(fullPath);
296
+ if (await file.exists()) {
297
+ return new Response(file);
298
+ }
299
+
300
+ // SPA fallback
301
+ const index = Bun.file(path.join(staticDir, "index.html"));
302
+ if (await index.exists()) {
303
+ return new Response(index);
304
+ }
305
+
306
+ return new Response("Not Found", { status: 404 });
307
+ }
308
+
309
+ function createDashboardServer(port: number) {
310
+ const server = Bun.serve({
311
+ port,
312
+ hostname: STATS_DASHBOARD_HOSTNAME,
313
+ async fetch(req) {
314
+ const url = new URL(req.url);
315
+ const path = url.pathname;
316
+
317
+ // The identity header lets another omp session's reuse probe positively
318
+ // recognize this dashboard without allowing cross-origin API reads.
319
+ const dashboardHeaders: Record<string, string> = {
320
+ [STATS_DASHBOARD_HEADER]: STATS_DASHBOARD_SECURITY_VERSION,
321
+ };
322
+
323
+ if (req.method === "OPTIONS") {
324
+ return new Response(null, { headers: dashboardHeaders });
325
+ }
326
+
327
+ try {
328
+ let response: Response;
329
+
330
+ if (path.startsWith("/api/")) {
331
+ response = await handleApi(req);
332
+ } else {
333
+ response = await handleStatic(path);
334
+ }
335
+
336
+ // Add the dashboard identity header to all responses.
337
+ const headers = new Headers(response.headers);
338
+ for (const key in dashboardHeaders) {
339
+ headers.set(key, dashboardHeaders[key]);
340
+ }
341
+
342
+ return new Response(response.body, {
343
+ status: response.status,
344
+ headers,
345
+ });
346
+ } catch (error) {
347
+ console.error("Server error:", error);
348
+ return Response.json(
349
+ { error: error instanceof Error ? error.message : "Unknown error" },
350
+ { status: 500, headers: dashboardHeaders },
351
+ );
352
+ }
353
+ },
354
+ });
355
+ return server;
356
+ }
357
+
358
+ /**
359
+ * Start the HTTP server, reusing a live dashboard or reclaiming a stale omp listener.
360
+ */
361
+ export async function startServer(port = 3847): Promise<{ hostname: string; port: number; stop: () => void }> {
362
+ await ensureClientBuild();
363
+ const preparation = await prepareStatsPort(port);
364
+ if (preparation === "reuse") {
365
+ return { hostname: STATS_DASHBOARD_HOSTNAME, port, stop: () => {} };
366
+ }
367
+
368
+ try {
369
+ const server = createDashboardServer(port);
370
+ return {
371
+ hostname: STATS_DASHBOARD_HOSTNAME,
372
+ port: server.port ?? port,
373
+ stop: () => server.stop(),
374
+ };
375
+ } catch (error) {
376
+ if (!(error instanceof Error && "code" in error && error.code === "EADDRINUSE")) throw error;
377
+
378
+ const recovery = await recoverStatsPort(port);
379
+ if (recovery === "reuse") {
380
+ return { hostname: STATS_DASHBOARD_HOSTNAME, port, stop: () => {} };
381
+ }
382
+
383
+ try {
384
+ const server = createDashboardServer(port);
385
+ return {
386
+ hostname: STATS_DASHBOARD_HOSTNAME,
387
+ port: server.port ?? port,
388
+ stop: () => server.stop(),
389
+ };
390
+ } catch (retryError) {
391
+ throw new Error(`Failed to start stats dashboard on port ${port} after reclaiming it.`, {
392
+ cause: retryError,
393
+ });
394
+ }
395
+ }
396
+ }