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 +47 -0
- package/dist/server/db/migrate.js +2 -0
- package/dist/server/db/repositories/settings.js +6 -0
- package/dist/server/db/schema.js +3 -0
- package/dist/server/routes/admin/backup.js +112 -8
- package/dist/server/routes/admin/models.js +91 -1
- package/dist/server/routes/admin/requests.js +22 -13
- package/dist/server/routes/admin/settings.js +2 -0
- package/dist/server/routes/admin/stats.js +102 -13
- package/dist/web/assets/index-BprlMazL.css +1 -0
- package/dist/web/assets/index-CHg2RW0K.js +324 -0
- package/dist/web/index.html +2 -2
- package/dist/web/notification.mp3 +0 -0
- package/migrations/0003_notification_settings.sql +2 -0
- package/package.json +1 -1
- package/dist/web/assets/index-B14JoKiA.js +0 -286
- package/dist/web/assets/index-W4Y1q9lE.css +0 -1
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
|
}
|
package/dist/server/db/schema.js
CHANGED
|
@@ -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
|
-
|
|
86
|
-
|
|
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(
|
|
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
|
-
|
|
99
|
-
|
|
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: '
|
|
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
|
-
//
|
|
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
|
-
|
|
8
|
-
|
|
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
|
|
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,
|
|
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
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
149
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
167
|
-
|
|
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;
|