ldrouter 1.7.0 → 1.10.1

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
@@ -4,6 +4,53 @@ All notable changes to this project are documented here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/) and the project adheres to
5
5
  [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## [1.10.1] - 2026-09-01
8
+
9
+ ### Fixed
10
+
11
+ - **Restore now reloads the database hot — no gateway restart needed**: `POST /api/admin/backup/restore` closes the in-process SQLite connection, swaps the file, reopens it in the same process, validates schema, and re-seeds the admin session — the admin stays logged in and sees the restored data immediately. Previously the admin had to restart the gateway after every restore.
12
+ - **Restore no longer loses data on restart**: the restore previously renamed over `data.sqlite` while the app's stale `-wal`/`-shm` sidecars were left behind; on restart SQLite could replay the old WAL over the restored snapshot, making the gateway appear empty (setup screen). The restore now fully closes the old connection before swapping, so the stale sidecars never survive.
13
+ - **Automatic rollback**: if the reopened restored database fails validation (e.g. schema mismatch), the gateway automatically rolls back to the pre-restore snapshot instead of staying broken.
14
+ - **Restore snapshot leak closed**: the pre-restore snapshot connection is now always closed.
15
+
16
+ ### Changed
17
+
18
+ - Settings → Backup & restore now auto-reloads the admin UI after a successful restore (restore toast: "Restored. Reloading…").
19
+
20
+ ## [1.10.0] - 2026-09-01
21
+
22
+ ### Added
23
+
24
+ - **Real streaming model test**: clicking Test on a model now streams the upstream response token-by-token into the test dialog (`POST /api/admin/models/:id/test-stream` SSE endpoint, newline-delimited). Previously the dialog waited ~1s with no feedback before showing a static result; now content appears live as the model generates, with a blinking cursor, live TTFT/elapsed counter, and a final `test_meta` event with full latency/token/attempt stats. The old non-streaming `POST .../test` endpoint remains for backwards compatibility.
25
+
26
+ ### Fixed
27
+
28
+ - **Test dialog no loading state**: the model test modal previously opened instantly but showed nothing for the ~1s the request took, looking like a hang. It now shows an immediate streaming view with progress as soon as the modal opens.
29
+
30
+ ## [1.9.1] - 2026-09-01
31
+
32
+ ### Fixed
33
+
34
+ - **Request-content logging now visible**: Settings → Logging "Request-content logging" was saving payloads to the database (`prompt` / `prompt_and_response` modes) but the admin UI never displayed them, making the setting appear broken. The request detail dialog on `/requests` now shows "Request content" and "Response content" sections (sanitized, scrollable) whenever payloads were logged. Added integration test covering all four `contentLogMode` values end-to-end.
35
+
36
+ ## [1.9.0] - 2026-09-01
37
+
38
+ ### Added
39
+
40
+ - **Realtime monitoring dashboard (/statistics)**: redesigned into a production-grade overview — summary cards now show icons, animated count-up, % delta vs previous period, and mini sparklines; a live **request routing flow** diagram (Incoming Traffic → AI Gateway → Providers with curved paths and animated pulse dots on each active route); a **Recent Requests** table with green/red status dots and time-ago labels; bottom metrics with circular Success Rate progress and Average Latency sparkline. All driven by the existing SSE stream (no new dependencies, CSS/SVG-native animations).
41
+ - **Stats API extensions**: `GET /api/admin/stats` now returns `previous` (same-window comparison for deltas), `recent` (last 10 requests with provider info), `providers` (traffic/error-rate/latency/health per provider), and per-bucket `avgLatency`/`cacheRead` in `series`; `RequestLogSummary` gained `providerId`/`providerName`.
42
+
43
+
44
+
45
+ ### Added
46
+
47
+ - **Notification toggles in Settings**: request notification cards and the notification sound can each be turned on/off in Settings → System ("Notifications" card); preferences persist server-side (`app_settings`) and apply immediately across the whole admin UI.
48
+ - **Real-time /requests page**: the Requests log now subscribes to the SSE stream — new requests appear in the table live (page 1, honoring active filters) plus a "N new requests — refresh" badge, no manual page refresh needed.
49
+
50
+ ### Fixed
51
+
52
+ - **Setup redirect (permanent fix)**: after creating the admin account the app now hard-reloads to `/login` instead of soft-navigating. Root cause: `SetupGate` cached `setupComplete=false` on mount and re-bounced every post-setup route back to `/setup`; a full reload clears the stale state.
53
+
7
54
  ## [1.7.0] - 2026-08-31
8
55
 
9
56
  ### Added
@@ -87,6 +87,8 @@ function buildInitialSchemaSql() {
87
87
  gateway_cache_max_size_mb INTEGER NOT NULL DEFAULT 256,
88
88
  master_key_version INTEGER NOT NULL DEFAULT 1,
89
89
  master_key_configured INTEGER NOT NULL DEFAULT 0,
90
+ notifications_enabled INTEGER NOT NULL DEFAULT 1,
91
+ notification_sound_enabled INTEGER NOT NULL DEFAULT 1,
90
92
  updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
91
93
  );
92
94
 
@@ -17,6 +17,8 @@ function rowToSettings(row) {
17
17
  gatewayCacheMaxSizeMb: row.gatewayCacheMaxSizeMb,
18
18
  masterKeyConfigured: row.masterKeyConfigured,
19
19
  masterKeyVersion: row.masterKeyVersion,
20
+ notificationsEnabled: row.notificationsEnabled,
21
+ notificationSoundEnabled: row.notificationSoundEnabled,
20
22
  };
21
23
  }
22
24
  export function getSettings() {
@@ -55,6 +57,10 @@ export function updateSettings(patch) {
55
57
  update.gatewayCacheMaxSizeMb = patch.gatewayCacheMaxSizeMb;
56
58
  if (patch.masterKeyConfigured !== undefined)
57
59
  update.masterKeyConfigured = patch.masterKeyConfigured;
60
+ if (patch.notificationsEnabled !== undefined)
61
+ update.notificationsEnabled = patch.notificationsEnabled;
62
+ if (patch.notificationSoundEnabled !== undefined)
63
+ update.notificationSoundEnabled = patch.notificationSoundEnabled;
58
64
  db.update(schema.appSettings).set(update).where(eq(schema.appSettings.id, 1)).run();
59
65
  return getSettings();
60
66
  }
@@ -19,6 +19,9 @@ export const appSettings = sqliteTable('app_settings', {
19
19
  gatewayCacheMaxSizeMb: integer('gateway_cache_max_size_mb').notNull().default(256),
20
20
  masterKeyVersion: integer('master_key_version').notNull().default(1),
21
21
  masterKeyConfigured: integer('master_key_configured', { mode: 'boolean' }).notNull().notNull().default(false),
22
+ // Admin UI notification preferences (v1.8.0). Default true: notifications on, sound on.
23
+ notificationsEnabled: integer('notifications_enabled', { mode: 'boolean' }).notNull().default(true),
24
+ notificationSoundEnabled: integer('notification_sound_enabled', { mode: 'boolean' }).notNull().default(true),
22
25
  updatedAt: text('updated_at').notNull().default(sql `(strftime('%Y-%m-%dT%H:%M:%fZ','now'))`),
23
26
  });
24
27
  // ============================================================================
@@ -3,14 +3,54 @@ import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import zlib from 'node:zlib';
5
5
  import crypto from 'node:crypto';
6
- import { getDb } from '../../db/index.js';
6
+ import { getDb, closeDb, openDb, schema } from '../../db/index.js';
7
7
  import { requireAdminAuth } from '../../auth/middleware.js';
8
+ import { sha256Hex } from '../../auth/ids.js';
8
9
  import { recordAudit } from '../../db/repositories/audit.js';
9
10
  import { loadConfig } from '../../config/index.js';
10
11
  import { getSettings } from '../../db/repositories/settings.js';
11
12
  import { GatewayError } from '../../errors.js';
12
13
  import { getAppVersion } from '../../version.js';
14
+ import { eq, sql } from 'drizzle-orm';
13
15
  const BACKUP_VERSION = 1;
16
+ /** Reopen the in-process SQLite connection on the (possibly just-replaced)
17
+ * database file. The old connection must already be closed: a hot restore
18
+ * swaps the file on disk, then the gateway keeps serving from the new data
19
+ * without a restart. Schema migrations and the app_settings bootstrap run
20
+ * automatically on open. */
21
+ function reopenDatabase(dbFile) {
22
+ const dir = path.dirname(dbFile);
23
+ const base = path.basename(dbFile);
24
+ // When the WAL is in non-persistent mode, SQLite keeps `data.sqlite-wal`
25
+ // and `data.sqlite-shm` next to the DB. After we replace the DB file the
26
+ // OLD wal/shm describe the PREVIOUS database — replaying them would
27
+ // resurrect the old data over the restored snapshot (the gateway looked
28
+ // like "nothing was restored"). They are safe to delete: the old
29
+ // connection is closed (wal fully checkpointed) and the restored backup is
30
+ // a consistent standalone snapshot.
31
+ for (const suffix of ['-wal', '-shm']) {
32
+ const stale = path.join(dir, `${base}${suffix}`);
33
+ try {
34
+ if (fs.existsSync(stale))
35
+ fs.unlinkSync(stale);
36
+ }
37
+ catch {
38
+ /* ignore: unlink failure is not fatal, next restart would retry */
39
+ }
40
+ }
41
+ openDb(dbFile);
42
+ }
43
+ /** Verify the current database (as restored) is consistent: matches the
44
+ * schema version we expect and has the bootstrap app_settings row. */
45
+ function assertDatabaseUsable(expectedSchemaVersion) {
46
+ const db = getDb();
47
+ const row = db.select().from(schema.appSettings).where(eq(schema.appSettings.id, 1)).get();
48
+ if (!row)
49
+ throw new Error('restored database is missing the app_settings bootstrap row');
50
+ if (row.schemaVersion !== expectedSchemaVersion) {
51
+ throw new Error(`restored database schema version mismatch: expected ${expectedSchemaVersion}, got ${row.schemaVersion}`);
52
+ }
53
+ }
14
54
  export async function registerBackupRoutes(app) {
15
55
  app.addHook('preHandler', requireAdminAuth);
16
56
  app.post('/api/admin/backup/create', async (req, reply) => {
@@ -82,21 +122,27 @@ export async function registerBackupRoutes(app) {
82
122
  recordAudit({ action: 'db.restore', success: false, ip: req.ip, metadata: { reason: 'not_sqlite' } });
83
123
  throw new GatewayError('invalid_request_error', 'Backup does not contain a valid SQLite database', { status: 400 });
84
124
  }
85
- // Snapshot current DB before restore
86
- const db = getDb();
87
- void db;
125
+ const liveDb = cfg.dbFile;
126
+ // Snapshot current DB before restore (kept for manual rollback).
88
127
  const snapshot = path.join(cfg.dataDir, `pre-restore-${Date.now()}.sqlite`);
89
128
  const Database = (await import('better-sqlite3')).default;
90
- const live = new Database(cfg.dbFile);
129
+ const live = new Database(liveDb);
91
130
  try {
92
131
  await live.backup(snapshot);
93
132
  }
94
133
  catch (e) {
134
+ live.close();
95
135
  recordAudit({ action: 'db.restore', success: false, ip: req.ip, metadata: { reason: 'snapshot_failed', err: String(e) } });
96
136
  throw new GatewayError('gateway_error', 'Could not snapshot current database', { status: 500 });
97
137
  }
98
- // Atomic replace
99
- const liveDb = cfg.dbFile;
138
+ live.close();
139
+ // Close the in-process connection before swapping the file. The gateway
140
+ // keeps serving (no restart) but the file must be free: on Windows a
141
+ // rename fails while a handle is open. Reopening runs migrations + the
142
+ // app_settings bootstrap automatically.
143
+ getDb();
144
+ closeDb();
145
+ // Atomic replace.
100
146
  const tempDb = `${liveDb}.restore-${Date.now()}`;
101
147
  fs.writeFileSync(tempDb, buf);
102
148
  try {
@@ -107,7 +153,65 @@ export async function registerBackupRoutes(app) {
107
153
  recordAudit({ action: 'db.restore', success: false, ip: req.ip, metadata: { reason: 'rename_failed', err: String(e) } });
108
154
  throw new GatewayError('gateway_error', 'Restore atomic replace failed', { status: 500 });
109
155
  }
156
+ // Hot-reload the database in-process: reopen the replaced file, validate
157
+ // it, and keep serving. No gateway restart needed.
158
+ try {
159
+ reopenDatabase(liveDb);
160
+ assertDatabaseUsable(envelope.schemaVersion);
161
+ }
162
+ catch (e) {
163
+ // Roll back to the snapshot taken before the restore so the gateway
164
+ // never stays on a broken database.
165
+ try {
166
+ closeDb();
167
+ }
168
+ catch { /* ignore */ }
169
+ try {
170
+ for (const suffix of ['-wal', '-shm']) {
171
+ const stale = path.join(path.dirname(liveDb), `${path.basename(liveDb)}${suffix}`);
172
+ if (fs.existsSync(stale))
173
+ fs.unlinkSync(stale);
174
+ }
175
+ fs.copyFileSync(snapshot, liveDb);
176
+ openDb(liveDb);
177
+ }
178
+ catch (rollbackErr) {
179
+ const err = e.message;
180
+ const rerr = rollbackErr.message;
181
+ recordAudit({ action: 'db.restore', success: false, ip: req.ip, metadata: { reason: 'rollback_failed', err, rollbackErr: rerr } });
182
+ throw new GatewayError('gateway_error', `Restore failed (${err}) and automatic rollback also failed (${rerr}). Please restart the gateway.`, { status: 500 });
183
+ }
184
+ recordAudit({ action: 'db.restore', success: false, ip: req.ip, metadata: { reason: 'validation_failed', err: e.message } });
185
+ throw new GatewayError('gateway_error', `Restore failed: ${e.message}`, { status: 500 });
186
+ }
187
+ // The swap invalidates the previous admin session (its row lived in the
188
+ // old database). Re-create the current session in the restored database so
189
+ // the admin stays logged in across the hot restore.
190
+ const sessionToken = req.cookies['ld_session'];
191
+ const sessionId = req.adminSessionId;
192
+ if (sessionId && sessionToken) {
193
+ const db = getDb();
194
+ const expiresAt = new Date(Date.now() + 12 * 3600 * 1000).toISOString();
195
+ db.delete(schema.adminSessions).where(sql `id = ${sessionId}`).run();
196
+ db.insert(schema.adminSessions).values({
197
+ id: sessionId,
198
+ tokenDigest: sha256Hex(sessionToken),
199
+ expiresAt,
200
+ lastSeenAt: new Date().toISOString(),
201
+ ip: req.ip,
202
+ }).run();
203
+ // Re-seed the CSRF token that was bound to the old session.
204
+ const csrfRow = db.select().from(schema.csrfTokens).where(eq(schema.csrfTokens.sessionId, sessionId)).get();
205
+ if (!csrfRow) {
206
+ db.insert(schema.csrfTokens).values({
207
+ id: crypto.randomUUID(),
208
+ sessionId,
209
+ token: crypto.randomBytes(32).toString('base64url'),
210
+ expiresAt,
211
+ }).run();
212
+ }
213
+ }
110
214
  recordAudit({ action: 'db.restore', success: true, ip: req.ip, metadata: { schemaVersion: envelope.schemaVersion } });
111
- return reply.code(200).send({ ok: true, message: 'Restore completed. Please restart the gateway for changes to take effect.' });
215
+ return reply.code(200).send({ ok: true, message: 'Database restored. The gateway continues running with the restored data no restart needed.' });
112
216
  });
113
217
  }
@@ -167,7 +167,97 @@ export async function registerModelRoutes(app) {
167
167
  recordAudit({ action: 'model.delete', success: true, targetType: 'model', targetId: id, targetName: m.publicModelId, ip: req.ip });
168
168
  return { ok: true };
169
169
  });
170
- // Test endpoint: send a real request through the gateway pipeline to verify the model works
170
+ // Streaming test endpoint: streams SSE tokens to the client in real time.
171
+ app.post('/api/admin/models/:id/test-stream', async (req, reply) => {
172
+ const { id } = req.params;
173
+ const db = getDb();
174
+ const m = db.select().from(schema.models).where(eq(schema.models.id, id)).get();
175
+ if (!m)
176
+ throw new GatewayError('invalid_request_error', 'Model not found', { status: 404 });
177
+ if (!m.enabled)
178
+ throw new GatewayError('invalid_request_error', 'Model is disabled', { status: 400 });
179
+ if (!m.upstreamAvailable)
180
+ throw new GatewayError('invalid_request_error', 'Model is not available upstream', { status: 400 });
181
+ const provider = db.select().from(schema.providers).where(eq(schema.providers.id, m.providerId)).get();
182
+ if (!provider || !provider.enabled)
183
+ throw new GatewayError('invalid_request_error', 'Provider is disabled', { status: 400 });
184
+ // Hijack reply so runner streams SSE directly to the client.
185
+ reply.hijack();
186
+ const res = reply.raw;
187
+ const SSE_HEADERS = {
188
+ 'Content-Type': 'text/event-stream',
189
+ 'Cache-Control': 'no-cache',
190
+ Connection: 'keep-alive',
191
+ };
192
+ let headWritten = false;
193
+ const writeHeadOnce = () => {
194
+ if (headWritten)
195
+ return;
196
+ headWritten = true;
197
+ res.writeHead(200, SSE_HEADERS);
198
+ };
199
+ const send = (event, data) => {
200
+ writeHeadOnce();
201
+ res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
202
+ };
203
+ // The runner calls reply.raw.end() as soon as the upstream completes.
204
+ // Defer that so we can append our own test_meta event before the
205
+ // real end(). Writes are forwarded live, so tokens still stream.
206
+ const fakeRaw = {
207
+ writeHead: () => writeHeadOnce(),
208
+ write: (chunk) => res.write(chunk),
209
+ end: () => { },
210
+ };
211
+ const { GatewayRunner } = await import('../../gateway/runner.js');
212
+ const runner = new GatewayRunner();
213
+ const canonicalReq = {
214
+ model: m.publicModelId,
215
+ messages: [{ role: 'user', content: [{ type: 'text', text: 'Bạn là model gì?' }] }],
216
+ stream: true,
217
+ maxOutputTokens: 256,
218
+ temperature: 0.7,
219
+ };
220
+ const requestId = `test-${uuid()}`;
221
+ const ctx = {
222
+ requestId,
223
+ clientIp: req.ip,
224
+ protocol: 'openai',
225
+ endpoint: 'chat/completions',
226
+ requestedModel: m.publicModelId,
227
+ key: null,
228
+ reply: { raw: fakeRaw },
229
+ };
230
+ const gatewayReq = {
231
+ canonical: canonicalReq,
232
+ protocol: 'openai',
233
+ endpoint: 'chat/completions',
234
+ };
235
+ try {
236
+ const outcome = await runner.execute(gatewayReq, ctx);
237
+ // Runner already wrote [DONE]. Append our own test_meta event so the
238
+ // client knows the test finished with full stats.
239
+ send('test_meta', {
240
+ success: outcome.success,
241
+ latencyMs: outcome.latencyMs,
242
+ ttftMs: outcome.ttftMs ?? null,
243
+ usage: outcome.usage,
244
+ attempts: outcome.attempts.map((a) => ({
245
+ providerName: a.providerName,
246
+ modelId: a.modelId,
247
+ latencyMs: a.latencyMs,
248
+ success: a.success,
249
+ failureReason: a.failureReason,
250
+ })),
251
+ });
252
+ }
253
+ catch (e) {
254
+ send('test_error', { message: e.message });
255
+ }
256
+ finally {
257
+ res.end();
258
+ }
259
+ });
260
+ // Non-streaming test endpoint (kept for backwards compatibility)
171
261
  app.post('/api/admin/models/:id/test', async (req) => {
172
262
  const { id } = req.params;
173
263
  const db = getDb();
@@ -4,10 +4,23 @@ import { getDb, schema } from '../../db/index.js';
4
4
  import { requireAdminAuth } from '../../auth/middleware.js';
5
5
  import { redactJsonString } from '../../security/redact.js';
6
6
  import { onRequestLogged, offRequestLogged } from '../../gateway/events.js';
7
- // Shared row → API summary mapping (used by the list endpoint and the SSE stream).
8
- function toSummary(r, keyMap, modelMap) {
7
+ export function loadSummaryMaps() {
8
+ const db = getDb();
9
+ const keys = db.select().from(schema.apiKeys).all();
10
+ const models = db.select().from(schema.models).all();
11
+ const providers = db.select().from(schema.providers).all();
12
+ return {
13
+ keyMap: new Map(keys.map((k) => [k.id, k])),
14
+ modelMap: new Map(models.map((m) => [m.id, m])),
15
+ providerMap: new Map(providers.map((p) => [p.id, p])),
16
+ };
17
+ }
18
+ // Shared row → API summary mapping (used by the list endpoint, the SSE stream, and stats).
19
+ export function toSummary(r, maps) {
20
+ const { keyMap, modelMap, providerMap } = maps;
9
21
  const key = r.apiKeyId ? keyMap.get(r.apiKeyId) : null;
10
22
  const finalModel = r.finalModelId ? modelMap.get(r.finalModelId) : null;
23
+ const provider = finalModel?.providerId ? providerMap.get(finalModel.providerId) : null;
11
24
  return {
12
25
  id: r.id,
13
26
  createdAt: r.createdAt,
@@ -20,6 +33,8 @@ function toSummary(r, keyMap, modelMap) {
20
33
  requestedModel: r.requestedModel,
21
34
  resolvedTargetKind: r.resolvedTargetKind,
22
35
  finalModelPublicId: finalModel?.publicModelId ?? null,
36
+ providerId: provider ? finalModel.providerId : null,
37
+ providerName: provider?.name ?? null,
23
38
  streaming: Boolean(r.streaming),
24
39
  httpStatus: r.httpStatus,
25
40
  success: Boolean(r.success),
@@ -72,13 +87,10 @@ export async function registerRequestRoutes(app) {
72
87
  const whereExpr = conds.length ? and(...conds) : undefined;
73
88
  const rows = db.select().from(schema.requests).where(whereExpr).orderBy(desc(schema.requests.createdAt)).limit(limit).offset(offset).all();
74
89
  const totalRow = db.select({ c: sql `COUNT(*)` }).from(schema.requests).where(whereExpr).get();
75
- const keys = db.select().from(schema.apiKeys).all();
76
- const keyMap = new Map(keys.map((k) => [k.id, k]));
77
- const models = db.select().from(schema.models).all();
78
- const modelMap = new Map(models.map((m) => [m.id, m]));
90
+ const maps = loadSummaryMaps();
79
91
  return {
80
92
  total: totalRow?.c ?? 0,
81
- requests: rows.map((r) => toSummary(r, keyMap, modelMap)),
93
+ requests: rows.map((r) => toSummary(r, maps)),
82
94
  };
83
95
  });
84
96
  // SSE stream of request completions (client reconnects with `since` of its last seen event).
@@ -107,10 +119,7 @@ export async function registerRequestRoutes(app) {
107
119
  }
108
120
  };
109
121
  const db = getDb();
110
- const keys = db.select().from(schema.apiKeys).all();
111
- const keyMap = new Map(keys.map((k) => [k.id, k]));
112
- const models = db.select().from(schema.models).all();
113
- const modelMap = new Map(models.map((m) => [m.id, m]));
122
+ const maps = loadSummaryMaps();
114
123
  // History replay: up to 20 most recent rows after `since` (oldest first so the client renders in order).
115
124
  const historyRows = db
116
125
  .select()
@@ -123,7 +132,7 @@ export async function registerRequestRoutes(app) {
123
132
  const replayIds = new Set();
124
133
  for (const r of historyRows) {
125
134
  replayIds.add(r.id);
126
- send(`event: request\ndata: ${JSON.stringify(toSummary(r, keyMap, modelMap))}\n\n`);
135
+ send(`event: request\ndata: ${JSON.stringify(toSummary(r, maps))}\n\n`);
127
136
  }
128
137
  // Live: subscribe to the event bus.
129
138
  const handleRequestLogged = (requestId) => {
@@ -132,7 +141,7 @@ export async function registerRequestRoutes(app) {
132
141
  const row = db.select().from(schema.requests).where(eq(schema.requests.id, requestId)).get();
133
142
  if (!row)
134
143
  return;
135
- send(`event: request\ndata: ${JSON.stringify(toSummary(row, keyMap, modelMap))}\n\n`);
144
+ send(`event: request\ndata: ${JSON.stringify(toSummary(row, maps))}\n\n`);
136
145
  };
137
146
  onRequestLogged(handleRequestLogged);
138
147
  // Keepalive ping every 25s.
@@ -18,6 +18,8 @@ const UpdateBody = z.object({
18
18
  gatewayCacheEnabled: z.boolean().optional(),
19
19
  gatewayCacheDefaultTtlSeconds: z.number().int().min(1).max(86400).optional(),
20
20
  gatewayCacheMaxSizeMb: z.number().int().min(1).max(10240).optional(),
21
+ notificationsEnabled: z.boolean().optional(),
22
+ notificationSoundEnabled: z.boolean().optional(),
21
23
  });
22
24
  const PasswordChange = z.object({
23
25
  currentPassword: z.string().min(1),
@@ -1,7 +1,8 @@
1
- // Admin API: statistics (Today/7d/30d).
1
+ // Admin API: statistics (Today/7d/30d) + routing dashboard data.
2
2
  import { and, eq, gte, lte, sql, desc } from 'drizzle-orm';
3
3
  import { getDb, schema } from '../../db/index.js';
4
4
  import { requireAdminAuth } from '../../auth/middleware.js';
5
+ import { toSummary, loadSummaryMaps } from './requests.js';
5
6
  const PRESETS = {
6
7
  today: () => {
7
8
  const now = new Date();
@@ -96,6 +97,8 @@ export async function registerStatsRoutes(app) {
96
97
  errors: sql `SUM(CASE WHEN success=0 THEN 1 ELSE 0 END)`,
97
98
  inputTokens: sql `COALESCE(SUM(input_tokens),0)`,
98
99
  outputTokens: sql `COALESCE(SUM(output_tokens),0)`,
100
+ avgLatency: sql `COALESCE(AVG(CASE WHEN success=1 THEN total_latency_ms END),0)`,
101
+ cacheRead: sql `COALESCE(SUM(cache_read_tokens),0)`,
99
102
  })
100
103
  .from(schema.requests)
101
104
  .where(and(...conds))
@@ -134,24 +137,66 @@ export async function registerStatsRoutes(app) {
134
137
  .all();
135
138
  const keys = db.select().from(schema.apiKeys).all();
136
139
  const keyMap = new Map(keys.map((k) => [k.id, k]));
137
- const topProviders = db
140
+ // ─── Previous period (for delta badges in summary cards) ───────────────
141
+ const durationMs = to.getTime() - from.getTime();
142
+ const prevFrom = new Date(from.getTime() - durationMs).toISOString();
143
+ const prevTo = from.toISOString();
144
+ const previousSummary = buildSummary(db, prevFrom, prevTo);
145
+ // ─── Recent requests (last 10, full RequestLogSummary) ─────────────────
146
+ const maps = loadSummaryMaps();
147
+ const recentRows = db
148
+ .select()
149
+ .from(schema.requests)
150
+ .orderBy(desc(schema.requests.createdAt))
151
+ .limit(10)
152
+ .all();
153
+ const recent = recentRows.map((r) => toSummary(r, maps));
154
+ // ─── Providers: traffic + latency + health for routing-flow diagram ────
155
+ const providerAggs = db
138
156
  .select({
139
157
  providerId: schema.requestAttempts.providerId,
140
- c: sql `COUNT(*)`,
158
+ total: sql `COUNT(*)`,
141
159
  err: sql `SUM(CASE WHEN success=0 THEN 1 ELSE 0 END)`,
160
+ avgLat: sql `COALESCE(AVG(CASE WHEN success=1 THEN latency_ms END),0)`,
142
161
  })
143
162
  .from(schema.requestAttempts)
144
163
  .where(and(gte(schema.requestAttempts.startedAt, fromIso), lte(schema.requestAttempts.startedAt, toIso)))
145
164
  .groupBy(schema.requestAttempts.providerId)
146
- .orderBy(desc(sql `COUNT(*)`))
147
165
  .all();
148
- const providers = db.select().from(schema.providers).all();
149
- const providerMap = new Map(providers.map((p) => [p.id, p]));
166
+ const allProviders = db.select().from(schema.providers).all();
167
+ const providerLookup = new Map(allProviders.map((p) => [p.id, p]));
168
+ const modelCounts = db
169
+ .select({ providerId: schema.models.providerId, c: sql `COUNT(*)` })
170
+ .from(schema.models)
171
+ .groupBy(schema.models.providerId)
172
+ .all();
173
+ const countMap = new Map(modelCounts.map((r) => [r.providerId, Number(r.c)]));
174
+ const providers = providerAggs
175
+ .map((r) => {
176
+ const p = providerLookup.get(r.providerId);
177
+ if (!p)
178
+ return null;
179
+ const total = Number(r.total);
180
+ return {
181
+ id: r.providerId,
182
+ name: p.name,
183
+ slug: p.slug,
184
+ health: p.healthState,
185
+ enabled: p.enabled,
186
+ modelCount: countMap.get(r.providerId) ?? 0,
187
+ requests: total,
188
+ errorRate: total > 0 ? Number(r.err) / total : 0,
189
+ avgLatencyMs: Number(r.avgLat),
190
+ };
191
+ })
192
+ .filter((x) => x !== null)
193
+ .sort((a, b) => b.requests - a.requests);
150
194
  const range = { from: fromIso, to: toIso, bucket };
151
195
  return {
152
196
  range,
153
197
  summary: statsSummary,
154
- series: seriesRows.map((r) => ({ t: r.t, requests: Number(r.requests), errors: Number(r.errors), inputTokens: Number(r.inputTokens), outputTokens: Number(r.outputTokens) })),
198
+ previous: previousSummary,
199
+ series: seriesRows.map((r) => ({ t: r.t, requests: Number(r.requests), errors: Number(r.errors), inputTokens: Number(r.inputTokens), outputTokens: Number(r.outputTokens), avgLatency: Number(r.avgLatency ?? 0), cacheRead: Number(r.cacheRead ?? 0) })),
155
200
  topModels: topModels.map((r) => ({
156
201
  publicId: r.modelId ? modelMap.get(r.modelId)?.publicModelId ?? r.modelId : 'unknown',
157
202
  requests: Number(r.c),
@@ -163,15 +208,59 @@ export async function registerStatsRoutes(app) {
163
208
  requests: Number(r.c),
164
209
  totalTokens: Number(r.tokens),
165
210
  })),
166
- topProviders: topProviders.map((r) => ({
167
- name: providerMap.get(r.providerId)?.name ?? r.providerId,
168
- slug: providerMap.get(r.providerId)?.slug ?? '',
169
- requests: Number(r.c),
170
- errorRate: Number(r.c) > 0 ? Number(r.err) / Number(r.c) : 0,
171
- })),
211
+ recent,
212
+ providers,
172
213
  };
173
214
  });
174
215
  }
216
+ /**
217
+ * Fast summary (no percentiles / TTFT — used for previous window and live
218
+ * incremental aggregation). Returns counts sufficient to derive ratios.
219
+ */
220
+ function buildSummary(db, fromIso, toIso) {
221
+ const conds = [gte(schema.requests.createdAt, fromIso), lte(schema.requests.createdAt, toIso)];
222
+ const r = db
223
+ .select({
224
+ total: sql `COUNT(*)`,
225
+ success: sql `SUM(CASE WHEN success=1 THEN 1 ELSE 0 END)`,
226
+ failed: sql `SUM(CASE WHEN success=0 THEN 1 ELSE 0 END)`,
227
+ inputTokens: sql `COALESCE(SUM(input_tokens),0)`,
228
+ outputTokens: sql `COALESCE(SUM(output_tokens),0)`,
229
+ cacheRead: sql `COALESCE(SUM(cache_read_tokens),0)`,
230
+ cacheWrite: sql `COALESCE(SUM(cache_write_tokens),0)`,
231
+ reasoning: sql `COALESCE(SUM(reasoning_tokens),0)`,
232
+ avgLatency: sql `COALESCE(AVG(CASE WHEN success=1 THEN total_latency_ms END),0)`,
233
+ avgTtft: sql `AVG(CASE WHEN success=1 AND ttft_ms IS NOT NULL THEN ttft_ms END)`,
234
+ gatewayCacheHits: sql `SUM(CASE WHEN gateway_cache_hit=1 THEN 1 ELSE 0 END)`,
235
+ fallbacks: sql `SUM(CASE WHEN attempts_count > 1 THEN 1 ELSE 0 END)`,
236
+ })
237
+ .from(schema.requests)
238
+ .where(and(...conds))
239
+ .get();
240
+ const total = Number(r?.total ?? 0);
241
+ const success = Number(r?.success ?? 0);
242
+ const cacheRead = Number(r?.cacheRead ?? 0);
243
+ const inputTokens = Number(r?.inputTokens ?? 0);
244
+ return {
245
+ totalRequests: total,
246
+ successfulRequests: success,
247
+ failedRequests: Number(r?.failed ?? 0),
248
+ successRate: total ? success / total : 0,
249
+ inputTokens,
250
+ outputTokens: Number(r?.outputTokens ?? 0),
251
+ totalTokens: inputTokens + Number(r?.outputTokens ?? 0),
252
+ cacheReadTokens: cacheRead,
253
+ cacheWriteTokens: Number(r?.cacheWrite ?? 0),
254
+ reasoningTokens: Number(r?.reasoning ?? 0),
255
+ averageLatencyMs: Number(r?.avgLatency ?? 0),
256
+ p95LatencyMs: 0, // caller fills via percentile query
257
+ averageTtftMs: (r?.avgTtft ?? null),
258
+ p95TtftMs: null,
259
+ cacheHitRate: success ? cacheRead > 0 ? cacheRead / Math.max(1, inputTokens + cacheRead) : 0 : 0,
260
+ gatewayCacheHitRate: total ? Number(r?.gatewayCacheHits ?? 0) / total : 0,
261
+ fallbackRate: total ? Number(r?.fallbacks ?? 0) / total : 0,
262
+ };
263
+ }
175
264
  function percentile(sorted, p) {
176
265
  if (sorted.length === 0)
177
266
  return 0;