@gajae-code/stats 0.11.7 → 0.11.9
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/README.md +7 -1
- package/dist/client/index.js +39 -39
- package/dist/types/client/api.d.ts +7 -1
- package/dist/types/server.d.ts +12 -1
- package/package.json +4 -3
- package/src/client/api.ts +9 -3
- package/src/server.ts +97 -39
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
import type { BehaviorDashboardStats, CostDashboardStats, DashboardStats, MessageStats, ModelDashboardStats, OverviewStats, RequestDetails } from "./types";
|
|
2
|
+
interface SyncResponse {
|
|
3
|
+
processed: number;
|
|
4
|
+
files: number;
|
|
5
|
+
totalMessages: number;
|
|
6
|
+
}
|
|
2
7
|
export declare function getStats(range?: string): Promise<DashboardStats>;
|
|
3
8
|
export declare function getOverviewStats(range?: string): Promise<OverviewStats>;
|
|
4
9
|
export declare function getModelDashboardStats(range?: string): Promise<ModelDashboardStats>;
|
|
@@ -6,5 +11,6 @@ export declare function getCostDashboardStats(range?: string): Promise<CostDashb
|
|
|
6
11
|
export declare function getRecentRequests(limit?: number): Promise<MessageStats[]>;
|
|
7
12
|
export declare function getRecentErrors(limit?: number): Promise<MessageStats[]>;
|
|
8
13
|
export declare function getRequestDetails(id: number): Promise<RequestDetails>;
|
|
9
|
-
export declare function sync(): Promise<
|
|
14
|
+
export declare function sync(): Promise<SyncResponse>;
|
|
10
15
|
export declare function getBehaviorDashboardStats(range?: string): Promise<BehaviorDashboardStats>;
|
|
16
|
+
export {};
|
package/dist/types/server.d.ts
CHANGED
|
@@ -1,7 +1,18 @@
|
|
|
1
|
+
import type { DashboardStats } from "./types";
|
|
2
|
+
interface SyncResult {
|
|
3
|
+
processed: number;
|
|
4
|
+
files: number;
|
|
5
|
+
}
|
|
6
|
+
export interface StatsServerOptions {
|
|
7
|
+
getDashboardStats?: (range?: string | null) => Promise<DashboardStats>;
|
|
8
|
+
syncAllSessions?: () => Promise<SyncResult>;
|
|
9
|
+
getTotalMessageCount?: () => Promise<number>;
|
|
10
|
+
}
|
|
1
11
|
/**
|
|
2
12
|
* Start the HTTP server.
|
|
3
13
|
*/
|
|
4
|
-
export declare function startServer(port?: number): Promise<{
|
|
14
|
+
export declare function startServer(port?: number, options?: StatsServerOptions): Promise<{
|
|
5
15
|
port: number;
|
|
6
16
|
stop: () => void;
|
|
7
17
|
}>;
|
|
18
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@gajae-code/stats",
|
|
4
|
-
"version": "0.11.
|
|
4
|
+
"version": "0.11.9",
|
|
5
5
|
"description": "Local observability dashboard for pi AI usage statistics",
|
|
6
6
|
"homepage": "https://gajae-code.com",
|
|
7
7
|
"author": "Yeachan-Heo",
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
"scripts": {
|
|
31
31
|
"build": "bun run build.ts",
|
|
32
32
|
"dev": "bun run src/index.ts",
|
|
33
|
+
"test": "bun test",
|
|
33
34
|
"check": "biome check . && bun run check:types",
|
|
34
35
|
"check:types": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.client.json --noEmit",
|
|
35
36
|
"lint": "biome lint .",
|
|
@@ -37,8 +38,8 @@
|
|
|
37
38
|
"fmt": "biome format --write ."
|
|
38
39
|
},
|
|
39
40
|
"dependencies": {
|
|
40
|
-
"@gajae-code/ai": "0.11.
|
|
41
|
-
"@gajae-code/utils": "0.11.
|
|
41
|
+
"@gajae-code/ai": "0.11.9",
|
|
42
|
+
"@gajae-code/utils": "0.11.9",
|
|
42
43
|
"@tailwindcss/node": "^4.2.4",
|
|
43
44
|
"chart.js": "^4.5.1",
|
|
44
45
|
"date-fns": "^4.1.0",
|
package/src/client/api.ts
CHANGED
|
@@ -10,6 +10,12 @@ import type {
|
|
|
10
10
|
|
|
11
11
|
const API_BASE = "/api";
|
|
12
12
|
|
|
13
|
+
interface SyncResponse {
|
|
14
|
+
processed: number;
|
|
15
|
+
files: number;
|
|
16
|
+
totalMessages: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
13
19
|
export async function getStats(range = "24h"): Promise<DashboardStats> {
|
|
14
20
|
const res = await fetch(`${API_BASE}/stats?range=${encodeURIComponent(range)}`);
|
|
15
21
|
if (!res.ok) throw new Error("Failed to fetch stats");
|
|
@@ -52,10 +58,10 @@ export async function getRequestDetails(id: number): Promise<RequestDetails> {
|
|
|
52
58
|
return res.json() as Promise<RequestDetails>;
|
|
53
59
|
}
|
|
54
60
|
|
|
55
|
-
export async function sync(): Promise<
|
|
56
|
-
const res = await fetch(`${API_BASE}/sync
|
|
61
|
+
export async function sync(): Promise<SyncResponse> {
|
|
62
|
+
const res = await fetch(`${API_BASE}/sync`, { method: "POST" });
|
|
57
63
|
if (!res.ok) throw new Error("Failed to sync");
|
|
58
|
-
return res.json()
|
|
64
|
+
return res.json() as Promise<SyncResponse>;
|
|
59
65
|
}
|
|
60
66
|
|
|
61
67
|
export async function getBehaviorDashboardStats(range = "24h"): Promise<BehaviorDashboardStats> {
|
package/src/server.ts
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
} from "./aggregator";
|
|
16
16
|
import { createCompiledClientAssetHandler } from "./compiled-client-assets";
|
|
17
17
|
import embeddedClientArchiveTxt from "./embedded-client.generated.txt";
|
|
18
|
+
import type { DashboardStats } from "./types";
|
|
18
19
|
|
|
19
20
|
const getEmbeddedClientArchive = (() => {
|
|
20
21
|
const txt = embeddedClientArchiveTxt.replaceAll(/[\s\r\n]/g, "").trim();
|
|
@@ -31,6 +32,24 @@ const IS_BUN_COMPILED =
|
|
|
31
32
|
import.meta.url.includes("%7EBUN");
|
|
32
33
|
const compiledClientAssets = createCompiledClientAssetHandler(() => getEmbeddedClientArchive?.() ?? null);
|
|
33
34
|
|
|
35
|
+
interface SyncResult {
|
|
36
|
+
processed: number;
|
|
37
|
+
files: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface StatsServerOptions {
|
|
41
|
+
getDashboardStats?: (range?: string | null) => Promise<DashboardStats>;
|
|
42
|
+
syncAllSessions?: () => Promise<SyncResult>;
|
|
43
|
+
getTotalMessageCount?: () => Promise<number>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface ApiContext {
|
|
47
|
+
getDashboardStats: (range?: string | null) => Promise<DashboardStats>;
|
|
48
|
+
syncAllSessions: () => Promise<SyncResult>;
|
|
49
|
+
getTotalMessageCount: () => Promise<number>;
|
|
50
|
+
syncInProgress: boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
34
53
|
async function getLatestMtime(dir: string): Promise<number> {
|
|
35
54
|
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
36
55
|
|
|
@@ -115,7 +134,7 @@ const ensureClientBuild = async () => {
|
|
|
115
134
|
/**
|
|
116
135
|
* Handle API requests.
|
|
117
136
|
*/
|
|
118
|
-
async function handleApi(req: Request): Promise<Response> {
|
|
137
|
+
async function handleApi(req: Request, context: ApiContext): Promise<Response> {
|
|
119
138
|
const url = new URL(req.url);
|
|
120
139
|
const path = url.pathname;
|
|
121
140
|
|
|
@@ -123,7 +142,7 @@ async function handleApi(req: Request): Promise<Response> {
|
|
|
123
142
|
const range = url.searchParams.get("range");
|
|
124
143
|
|
|
125
144
|
if (path === "/api/stats") {
|
|
126
|
-
const stats = await getDashboardStats(range);
|
|
145
|
+
const stats = await context.getDashboardStats(range);
|
|
127
146
|
return Response.json(stats);
|
|
128
147
|
}
|
|
129
148
|
|
|
@@ -160,17 +179,17 @@ async function handleApi(req: Request): Promise<Response> {
|
|
|
160
179
|
}
|
|
161
180
|
|
|
162
181
|
if (path === "/api/stats/models") {
|
|
163
|
-
const stats = await getDashboardStats(range);
|
|
182
|
+
const stats = await context.getDashboardStats(range);
|
|
164
183
|
return Response.json(stats.byModel);
|
|
165
184
|
}
|
|
166
185
|
|
|
167
186
|
if (path === "/api/stats/folders") {
|
|
168
|
-
const stats = await getDashboardStats(range);
|
|
187
|
+
const stats = await context.getDashboardStats(range);
|
|
169
188
|
return Response.json(stats.byFolder);
|
|
170
189
|
}
|
|
171
190
|
|
|
172
191
|
if (path === "/api/stats/timeseries") {
|
|
173
|
-
const stats = await getDashboardStats(range);
|
|
192
|
+
const stats = await context.getDashboardStats(range);
|
|
174
193
|
return Response.json(stats.timeSeries);
|
|
175
194
|
}
|
|
176
195
|
|
|
@@ -183,14 +202,62 @@ async function handleApi(req: Request): Promise<Response> {
|
|
|
183
202
|
}
|
|
184
203
|
|
|
185
204
|
if (path === "/api/sync") {
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
205
|
+
if (context.syncInProgress) {
|
|
206
|
+
return Response.json({ error: "Sync already in progress" }, { status: 409 });
|
|
207
|
+
}
|
|
208
|
+
context.syncInProgress = true;
|
|
209
|
+
try {
|
|
210
|
+
const result = await context.syncAllSessions();
|
|
211
|
+
const count = await context.getTotalMessageCount();
|
|
212
|
+
return Response.json({ ...result, totalMessages: count });
|
|
213
|
+
} finally {
|
|
214
|
+
context.syncInProgress = false;
|
|
215
|
+
}
|
|
189
216
|
}
|
|
190
217
|
|
|
191
218
|
return new Response("Not Found", { status: 404 });
|
|
192
219
|
}
|
|
193
220
|
|
|
221
|
+
function forbidden(): Response {
|
|
222
|
+
return new Response("Forbidden", { status: 403 });
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function methodNotAllowed(allowedMethod: "GET" | "POST"): Response {
|
|
226
|
+
return new Response("Method Not Allowed", { status: 405, headers: { Allow: allowedMethod } });
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function validateApiRequest(req: Request, url: URL, boundPort: number): Response | null {
|
|
230
|
+
const authority = req.headers.get("Host");
|
|
231
|
+
const allowedAuthorities =
|
|
232
|
+
boundPort === 80
|
|
233
|
+
? new Set(["localhost", "localhost:80", "127.0.0.1", "127.0.0.1:80"])
|
|
234
|
+
: new Set([`localhost:${boundPort}`, `127.0.0.1:${boundPort}`]);
|
|
235
|
+
if (!authority || !allowedAuthorities.has(authority)) return forbidden();
|
|
236
|
+
|
|
237
|
+
if (url.protocol !== "http:" || (url.hostname !== "localhost" && url.hostname !== "127.0.0.1")) {
|
|
238
|
+
return forbidden();
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const requestPort = url.port ? Number.parseInt(url.port, 10) : 80;
|
|
242
|
+
if (requestPort !== boundPort) return forbidden();
|
|
243
|
+
|
|
244
|
+
const origin = req.headers.get("Origin");
|
|
245
|
+
if (origin !== null) {
|
|
246
|
+
try {
|
|
247
|
+
const parsedOrigin = new URL(origin);
|
|
248
|
+
if (parsedOrigin.origin !== origin || origin !== url.origin) return forbidden();
|
|
249
|
+
} catch {
|
|
250
|
+
return forbidden();
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const allowedMethod = url.pathname === "/api/sync" ? "POST" : "GET";
|
|
255
|
+
if (req.method !== allowedMethod) return methodNotAllowed(allowedMethod);
|
|
256
|
+
if (url.pathname === "/api/sync" && origin === null) return forbidden();
|
|
257
|
+
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
|
|
194
261
|
/**
|
|
195
262
|
* Handle static file requests.
|
|
196
263
|
*/
|
|
@@ -217,52 +284,43 @@ async function handleStatic(requestPath: string): Promise<Response> {
|
|
|
217
284
|
/**
|
|
218
285
|
* Start the HTTP server.
|
|
219
286
|
*/
|
|
220
|
-
export async function startServer(
|
|
287
|
+
export async function startServer(
|
|
288
|
+
port = 3847,
|
|
289
|
+
options: StatsServerOptions = {},
|
|
290
|
+
): Promise<{ port: number; stop: () => void }> {
|
|
221
291
|
await ensureClientBuild();
|
|
292
|
+
const apiContext: ApiContext = {
|
|
293
|
+
getDashboardStats: options.getDashboardStats ?? getDashboardStats,
|
|
294
|
+
syncAllSessions: options.syncAllSessions ?? syncAllSessions,
|
|
295
|
+
getTotalMessageCount: options.getTotalMessageCount ?? getTotalMessageCount,
|
|
296
|
+
syncInProgress: false,
|
|
297
|
+
};
|
|
222
298
|
|
|
223
299
|
const server = Bun.serve({
|
|
224
300
|
hostname: "127.0.0.1",
|
|
225
301
|
port,
|
|
226
302
|
async fetch(req) {
|
|
227
|
-
|
|
303
|
+
let url: URL;
|
|
304
|
+
try {
|
|
305
|
+
url = new URL(req.url);
|
|
306
|
+
} catch {
|
|
307
|
+
return forbidden();
|
|
308
|
+
}
|
|
228
309
|
const path = url.pathname;
|
|
229
310
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
|
234
|
-
"Access-Control-Allow-Headers": "Content-Type",
|
|
235
|
-
};
|
|
236
|
-
|
|
237
|
-
if (req.method === "OPTIONS") {
|
|
238
|
-
return new Response(null, { headers: corsHeaders });
|
|
311
|
+
if (path.startsWith("/api/")) {
|
|
312
|
+
const policyResponse = validateApiRequest(req, url, server.port ?? port);
|
|
313
|
+
if (policyResponse) return policyResponse;
|
|
239
314
|
}
|
|
240
315
|
|
|
241
316
|
try {
|
|
242
|
-
let response: Response;
|
|
243
|
-
|
|
244
317
|
if (path.startsWith("/api/")) {
|
|
245
|
-
|
|
246
|
-
} else {
|
|
247
|
-
response = await handleStatic(path);
|
|
318
|
+
return await handleApi(req, apiContext);
|
|
248
319
|
}
|
|
249
|
-
|
|
250
|
-
// Add CORS headers to all responses
|
|
251
|
-
const headers = new Headers(response.headers);
|
|
252
|
-
for (const [key, value] of Object.entries(corsHeaders)) {
|
|
253
|
-
headers.set(key, value);
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
return new Response(response.body, {
|
|
257
|
-
status: response.status,
|
|
258
|
-
headers,
|
|
259
|
-
});
|
|
320
|
+
return await handleStatic(path);
|
|
260
321
|
} catch (error) {
|
|
261
322
|
console.error("Server error:", error);
|
|
262
|
-
return Response.json(
|
|
263
|
-
{ error: error instanceof Error ? error.message : "Unknown error" },
|
|
264
|
-
{ status: 500, headers: corsHeaders },
|
|
265
|
-
);
|
|
323
|
+
return Response.json({ error: error instanceof Error ? error.message : "Unknown error" }, { status: 500 });
|
|
266
324
|
}
|
|
267
325
|
},
|
|
268
326
|
});
|