ldrouter 1.7.0 → 1.10.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.
- package/CHANGELOG.md +34 -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/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-C1VxsEBt.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,40 @@ 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.0] - 2026-09-01
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **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.
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- **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.
|
|
16
|
+
|
|
17
|
+
## [1.9.1] - 2026-09-01
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
|
|
21
|
+
- **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.
|
|
22
|
+
|
|
23
|
+
## [1.9.0] - 2026-09-01
|
|
24
|
+
|
|
25
|
+
### Added
|
|
26
|
+
|
|
27
|
+
- **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).
|
|
28
|
+
- **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`.
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
### Added
|
|
33
|
+
|
|
34
|
+
- **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.
|
|
35
|
+
- **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.
|
|
36
|
+
|
|
37
|
+
### Fixed
|
|
38
|
+
|
|
39
|
+
- **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.
|
|
40
|
+
|
|
7
41
|
## [1.7.0] - 2026-08-31
|
|
8
42
|
|
|
9
43
|
### 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
|
// ============================================================================
|
|
@@ -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;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
*,:before,:after{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }*,:before,:after{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}:before,:after{--tw-content: ""}html,:host{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}ol,ul,menu{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}button,[role=button]{cursor:pointer}:disabled{cursor:default}img,svg,video,canvas,audio,iframe,embed,object{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}:root{--background: 0 0% 100%;--foreground: 240 10% 3.9%;--card: 0 0% 100%;--card-foreground: 240 10% 3.9%;--popover: 0 0% 100%;--popover-foreground: 240 10% 3.9%;--primary: 341 86% 41%;--primary-foreground: 0 0% 100%;--secondary: 240 4.8% 95.9%;--secondary-foreground: 240 5.9% 10%;--muted: 240 4.8% 95.9%;--muted-foreground: 240 3.8% 46.1%;--accent: 240 4.8% 95.9%;--accent-foreground: 240 5.9% 10%;--destructive: 0 84.2% 60.2%;--destructive-foreground: 0 0% 98%;--border: 240 5.9% 90%;--input: 240 5.9% 90%;--ring: 341 86% 41%;--sidebar: 240 5.9% 98%;--sidebar-foreground: 240 10% 3.9%;--radius: .5rem}.dark{--background: 240 10% 3.9%;--foreground: 0 0% 98%;--card: 240 10% 6%;--card-foreground: 0 0% 98%;--popover: 240 10% 5%;--popover-foreground: 0 0% 98%;--primary: 341 86% 55%;--primary-foreground: 0 0% 100%;--secondary: 240 3.7% 15.9%;--secondary-foreground: 0 0% 98%;--muted: 240 3.7% 15.9%;--muted-foreground: 240 5% 64.9%;--accent: 240 3.7% 15.9%;--accent-foreground: 0 0% 98%;--destructive: 0 72% 51%;--destructive-foreground: 0 0% 98%;--border: 240 3.7% 15.9%;--input: 240 3.7% 15.9%;--ring: 341 86% 55%;--sidebar: 240 10% 5%;--sidebar-foreground: 0 0% 98%}*{border-color:hsl(var(--border))}body{background-color:hsl(var(--background));color:hsl(var(--foreground));font-feature-settings:"rlig" 1,"calt" 1}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}.pointer-events-none{pointer-events:none}.visible{visibility:visible}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.inset-0{top:0;right:0;bottom:0;left:0}.left-1\/2{left:50%}.left-2{left:.5rem}.left-2\.5{left:.625rem}.left-\[50\%\]{left:50%}.right-2{right:.5rem}.right-4{right:1rem}.top-1\/2{top:50%}.top-16{top:4rem}.top-2\.5{top:.625rem}.top-4{top:1rem}.top-\[50\%\]{top:50%}.z-50{z-index:50}.-mx-1{margin-left:-.25rem;margin-right:-.25rem}.my-1{margin-top:.25rem;margin-bottom:.25rem}.mb-1{margin-bottom:.25rem}.mb-3{margin-bottom:.75rem}.mb-6{margin-bottom:1.5rem}.ml-0\.5{margin-left:.125rem}.ml-1{margin-left:.25rem}.ml-1\.5{margin-left:.375rem}.ml-2{margin-left:.5rem}.mr-0\.5{margin-right:.125rem}.mr-1{margin-right:.25rem}.mr-2{margin-right:.5rem}.mt-1{margin-top:.25rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-6{margin-top:1.5rem}.block{display:block}.inline-block{display:inline-block}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.hidden{display:none}.h-10{height:2.5rem}.h-14{height:3.5rem}.h-2{height:.5rem}.h-20{height:5rem}.h-3{height:.75rem}.h-3\.5{height:.875rem}.h-4{height:1rem}.h-40{height:10rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-7{height:1.75rem}.h-8{height:2rem}.h-9{height:2.25rem}.h-auto{height:auto}.h-px{height:1px}.h-screen{height:100vh}.max-h-40{max-height:10rem}.max-h-48{max-height:12rem}.max-h-60{max-height:15rem}.max-h-80{max-height:20rem}.max-h-96{max-height:24rem}.max-h-\[calc\(100vh-8rem\)\]{max-height:calc(100vh - 8rem)}.min-h-\[60px\]{min-height:60px}.min-h-screen{min-height:100vh}.w-1\.5{width:.375rem}.w-2{width:.5rem}.w-20{width:5rem}.w-28{width:7rem}.w-3{width:.75rem}.w-3\.5{width:.875rem}.w-32{width:8rem}.w-36{width:9rem}.w-4{width:1rem}.w-40{width:10rem}.w-56{width:14rem}.w-60{width:15rem}.w-7{width:1.75rem}.w-8{width:2rem}.w-80{width:20rem}.w-9{width:2.25rem}.w-full{width:100%}.min-w-0{min-width:0px}.min-w-\[8rem\]{min-width:8rem}.min-w-\[var\(--radix-select-trigger-width\)\]{min-width:var(--radix-select-trigger-width)}.max-w-2xl{max-width:42rem}.max-w-3xl{max-width:48rem}.max-w-lg{max-width:32rem}.max-w-md{max-width:28rem}.max-w-sm{max-width:24rem}.max-w-xs{max-width:20rem}.flex-1{flex:1 1 0%}.shrink-0{flex-shrink:0}.caption-bottom{caption-side:bottom}.translate-x-\[-50\%\]{--tw-translate-x: -50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.translate-y-\[-50\%\]{--tw-translate-y: -50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.-rotate-90{--tw-rotate: -90deg;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes pulse{50%{opacity:.5}}.animate-pulse{animation:pulse 2s cubic-bezier(.4,0,.6,1) infinite}@keyframes spin{to{transform:rotate(360deg)}}.animate-spin{animation:spin 1s linear infinite}.cursor-default{cursor:default}.cursor-pointer{cursor:pointer}.select-none{-webkit-user-select:none;-moz-user-select:none;user-select:none}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.flex-row{flex-direction:row}.flex-col{flex-direction:column}.flex-col-reverse{flex-direction:column-reverse}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.space-x-1>:not([hidden])~:not([hidden]){--tw-space-x-reverse: 0;margin-right:calc(.25rem * var(--tw-space-x-reverse));margin-left:calc(.25rem * calc(1 - var(--tw-space-x-reverse)))}.space-y-0\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.125rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.125rem * var(--tw-space-y-reverse))}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.25rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem * var(--tw-space-y-reverse))}.space-y-1\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.375rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.375rem * var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem * var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.75rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem * var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem * var(--tw-space-y-reverse))}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:var(--radius)}.rounded-md{border-radius:calc(var(--radius) - 2px)}.rounded-sm{border-radius:calc(var(--radius) - 4px)}.border{border-width:1px}.border-2{border-width:2px}.border-b{border-bottom-width:1px}.border-r{border-right-width:1px}.border-t{border-top-width:1px}.border-amber-500\/40{border-color:#f59e0b66}.border-amber-600\/50{border-color:#d9770680}.border-border{border-color:hsl(var(--border))}.border-destructive{border-color:hsl(var(--destructive))}.border-destructive\/40{border-color:hsl(var(--destructive) / .4)}.border-input{border-color:hsl(var(--input))}.border-primary{border-color:hsl(var(--primary))}.border-transparent{border-color:transparent}.bg-amber-400{--tw-bg-opacity: 1;background-color:rgb(251 191 36 / var(--tw-bg-opacity, 1))}.bg-amber-50{--tw-bg-opacity: 1;background-color:rgb(255 251 235 / var(--tw-bg-opacity, 1))}.bg-amber-500{--tw-bg-opacity: 1;background-color:rgb(245 158 11 / var(--tw-bg-opacity, 1))}.bg-amber-500\/90{background-color:#f59e0be6}.bg-background{background-color:hsl(var(--background))}.bg-black\/15{background-color:#00000026}.bg-black\/80{background-color:#000c}.bg-card{background-color:hsl(var(--card))}.bg-destructive{background-color:hsl(var(--destructive))}.bg-destructive\/10{background-color:hsl(var(--destructive) / .1)}.bg-emerald-100{--tw-bg-opacity: 1;background-color:rgb(209 250 229 / var(--tw-bg-opacity, 1))}.bg-emerald-400{--tw-bg-opacity: 1;background-color:rgb(52 211 153 / var(--tw-bg-opacity, 1))}.bg-emerald-600{--tw-bg-opacity: 1;background-color:rgb(5 150 105 / var(--tw-bg-opacity, 1))}.bg-green-100{--tw-bg-opacity: 1;background-color:rgb(220 252 231 / var(--tw-bg-opacity, 1))}.bg-muted{background-color:hsl(var(--muted))}.bg-popover{background-color:hsl(var(--popover))}.bg-primary{background-color:hsl(var(--primary))}.bg-primary\/10{background-color:hsl(var(--primary) / .1)}.bg-primary\/70{background-color:hsl(var(--primary) / .7)}.bg-red-100{--tw-bg-opacity: 1;background-color:rgb(254 226 226 / var(--tw-bg-opacity, 1))}.bg-red-400{--tw-bg-opacity: 1;background-color:rgb(248 113 113 / var(--tw-bg-opacity, 1))}.bg-secondary{background-color:hsl(var(--secondary))}.bg-sidebar{background-color:hsl(var(--sidebar))}.bg-transparent{background-color:transparent}.fill-amber-400{fill:#fbbf24}.fill-amber-500\/10{fill:#f59e0b1a}.fill-emerald-400{fill:#34d399}.fill-emerald-500{fill:#10b981}.fill-foreground{fill:hsl(var(--foreground))}.fill-muted-foreground{fill:hsl(var(--muted-foreground))}.fill-primary\/10{fill:hsl(var(--primary) / .1)}.fill-red-400{fill:#f87171}.fill-red-500{fill:#ef4444}.stroke-amber-500{stroke:#f59e0b}.stroke-emerald-500{stroke:#10b981}.stroke-primary{stroke:hsl(var(--primary))}.object-contain{-o-object-fit:contain;object-fit:contain}.p-1{padding:.25rem}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-6{padding:1.5rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-1\.5{padding-left:.375rem;padding-right:.375rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-4{padding-top:1rem;padding-bottom:1rem}.py-6{padding-top:1.5rem;padding-bottom:1.5rem}.pb-1{padding-bottom:.25rem}.pb-2{padding-bottom:.5rem}.pb-6{padding-bottom:1.5rem}.pl-8{padding-left:2rem}.pr-2{padding-right:.5rem}.pt-0{padding-top:0}.pt-2{padding-top:.5rem}.pt-4{padding-top:1rem}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.align-middle{vertical-align:middle}.align-text-bottom{vertical-align:text-bottom}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xs{font-size:.75rem;line-height:1rem}.font-medium{font-weight:500}.font-semibold{font-weight:600}.tabular-nums{--tw-numeric-spacing: tabular-nums;font-variant-numeric:var(--tw-ordinal) var(--tw-slashed-zero) var(--tw-numeric-figure) var(--tw-numeric-spacing) var(--tw-numeric-fraction)}.leading-none{line-height:1}.tracking-tight{letter-spacing:-.025em}.text-amber-500{--tw-text-opacity: 1;color:rgb(245 158 11 / var(--tw-text-opacity, 1))}.text-amber-600{--tw-text-opacity: 1;color:rgb(217 119 6 / var(--tw-text-opacity, 1))}.text-amber-950{--tw-text-opacity: 1;color:rgb(69 26 3 / var(--tw-text-opacity, 1))}.text-blue-500{--tw-text-opacity: 1;color:rgb(59 130 246 / var(--tw-text-opacity, 1))}.text-card-foreground{color:hsl(var(--card-foreground))}.text-current{color:currentColor}.text-destructive{color:hsl(var(--destructive))}.text-destructive-foreground{color:hsl(var(--destructive-foreground))}.text-emerald-500{--tw-text-opacity: 1;color:rgb(16 185 129 / var(--tw-text-opacity, 1))}.text-emerald-700{--tw-text-opacity: 1;color:rgb(4 120 87 / var(--tw-text-opacity, 1))}.text-foreground{color:hsl(var(--foreground))}.text-green-800{--tw-text-opacity: 1;color:rgb(22 101 52 / var(--tw-text-opacity, 1))}.text-muted-foreground{color:hsl(var(--muted-foreground))}.text-muted-foreground\/60{color:hsl(var(--muted-foreground) / .6)}.text-popover-foreground{color:hsl(var(--popover-foreground))}.text-primary{color:hsl(var(--primary))}.text-primary-foreground{color:hsl(var(--primary-foreground))}.text-red-500{--tw-text-opacity: 1;color:rgb(239 68 68 / var(--tw-text-opacity, 1))}.text-red-700{--tw-text-opacity: 1;color:rgb(185 28 28 / var(--tw-text-opacity, 1))}.text-secondary-foreground{color:hsl(var(--secondary-foreground))}.text-sidebar-foreground{color:hsl(var(--sidebar-foreground))}.text-white{--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity, 1))}.underline-offset-4{text-underline-offset:4px}.opacity-50{opacity:.5}.opacity-60{opacity:.6}.opacity-70{opacity:.7}.opacity-90{opacity:.9}.shadow{--tw-shadow: 0 1px 3px 0 rgb(0 0 0 / .1), 0 1px 2px -1px rgb(0 0 0 / .1);--tw-shadow-colored: 0 1px 3px 0 var(--tw-shadow-color), 0 1px 2px -1px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-lg{--tw-shadow: 0 10px 15px -3px rgb(0 0 0 / .1), 0 4px 6px -4px rgb(0 0 0 / .1);--tw-shadow-colored: 0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-md{--tw-shadow: 0 4px 6px -1px rgb(0 0 0 / .1), 0 2px 4px -2px rgb(0 0 0 / .1);--tw-shadow-colored: 0 4px 6px -1px var(--tw-shadow-color), 0 2px 4px -2px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-sm{--tw-shadow: 0 1px 2px 0 rgb(0 0 0 / .05);--tw-shadow-colored: 0 1px 2px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.outline-none{outline:2px solid transparent;outline-offset:2px}.outline{outline-style:solid}.ring-0{--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.ring-offset-background{--tw-ring-offset-color: hsl(var(--background))}.blur{--tw-blur: blur(8px);filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-transform{transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.duration-200{transition-duration:.2s}::-webkit-scrollbar{width:8px;height:8px}::-webkit-scrollbar-thumb{background:hsl(var(--border));border-radius:4px}::-webkit-scrollbar-track{background:transparent}.file\:border-0::file-selector-button{border-width:0px}.file\:bg-transparent::file-selector-button{background-color:transparent}.file\:text-sm::file-selector-button{font-size:.875rem;line-height:1.25rem}.file\:font-medium::file-selector-button{font-weight:500}.placeholder\:text-muted-foreground::-moz-placeholder{color:hsl(var(--muted-foreground))}.placeholder\:text-muted-foreground::placeholder{color:hsl(var(--muted-foreground))}.last\:border-0:last-child{border-width:0px}.hover\:bg-accent:hover{background-color:hsl(var(--accent))}.hover\:bg-destructive\/90:hover{background-color:hsl(var(--destructive) / .9)}.hover\:bg-muted\/50:hover{background-color:hsl(var(--muted) / .5)}.hover\:bg-primary\/90:hover{background-color:hsl(var(--primary) / .9)}.hover\:bg-secondary\/80:hover{background-color:hsl(var(--secondary) / .8)}.hover\:text-accent-foreground:hover{color:hsl(var(--accent-foreground))}.hover\:text-foreground:hover{color:hsl(var(--foreground))}.hover\:underline:hover{text-decoration-line:underline}.hover\:opacity-100:hover{opacity:1}.hover\:opacity-95:hover{opacity:.95}.focus\:bg-accent:focus{background-color:hsl(var(--accent))}.focus\:text-accent-foreground:focus{color:hsl(var(--accent-foreground))}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.focus\:ring-1:focus{--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.focus\:ring-ring:focus{--tw-ring-color: hsl(var(--ring))}.focus-visible\:outline-none:focus-visible{outline:2px solid transparent;outline-offset:2px}.focus-visible\:ring-1:focus-visible{--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.focus-visible\:ring-2:focus-visible{--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.focus-visible\:ring-ring:focus-visible{--tw-ring-color: hsl(var(--ring))}.focus-visible\:ring-offset-2:focus-visible{--tw-ring-offset-width: 2px}.focus-visible\:ring-offset-background:focus-visible{--tw-ring-offset-color: hsl(var(--background))}.disabled\:pointer-events-none:disabled{pointer-events:none}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-50:disabled{opacity:.5}.peer:disabled~.peer-disabled\:cursor-not-allowed{cursor:not-allowed}.peer:disabled~.peer-disabled\:opacity-70{opacity:.7}.data-\[disabled\]\:pointer-events-none[data-disabled]{pointer-events:none}.data-\[state\=checked\]\:translate-x-4[data-state=checked]{--tw-translate-x: 1rem;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.data-\[state\=unchecked\]\:translate-x-0[data-state=unchecked]{--tw-translate-x: 0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.data-\[state\=active\]\:bg-background[data-state=active]{background-color:hsl(var(--background))}.data-\[state\=checked\]\:bg-primary[data-state=checked]{background-color:hsl(var(--primary))}.data-\[state\=selected\]\:bg-muted[data-state=selected]{background-color:hsl(var(--muted))}.data-\[state\=unchecked\]\:bg-input[data-state=unchecked]{background-color:hsl(var(--input))}.data-\[state\=active\]\:text-foreground[data-state=active]{color:hsl(var(--foreground))}.data-\[state\=checked\]\:text-primary-foreground[data-state=checked]{color:hsl(var(--primary-foreground))}.data-\[disabled\]\:opacity-50[data-disabled]{opacity:.5}.data-\[state\=active\]\:shadow[data-state=active]{--tw-shadow: 0 1px 3px 0 rgb(0 0 0 / .1), 0 1px 2px -1px rgb(0 0 0 / .1);--tw-shadow-colored: 0 1px 3px 0 var(--tw-shadow-color), 0 1px 2px -1px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.dark\:bg-amber-950\/20:is(.dark *){background-color:#451a0333}.dark\:bg-emerald-900\/40:is(.dark *){background-color:#064e3b66}.dark\:bg-green-900:is(.dark *){--tw-bg-opacity: 1;background-color:rgb(20 83 45 / var(--tw-bg-opacity, 1))}.dark\:bg-red-900\/40:is(.dark *){background-color:#7f1d1d66}.dark\:text-emerald-300:is(.dark *){--tw-text-opacity: 1;color:rgb(110 231 183 / var(--tw-text-opacity, 1))}.dark\:text-green-200:is(.dark *){--tw-text-opacity: 1;color:rgb(187 247 208 / var(--tw-text-opacity, 1))}.dark\:text-red-300:is(.dark *){--tw-text-opacity: 1;color:rgb(252 165 165 / var(--tw-text-opacity, 1))}@media(min-width:640px){.sm\:max-w-\[425px\]{max-width:425px}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}.sm\:justify-end{justify-content:flex-end}.sm\:space-x-2>:not([hidden])~:not([hidden]){--tw-space-x-reverse: 0;margin-right:calc(.5rem * var(--tw-space-x-reverse));margin-left:calc(.5rem * calc(1 - var(--tw-space-x-reverse)))}.sm\:rounded-lg{border-radius:var(--radius)}.sm\:text-left{text-align:left}}@media(min-width:768px){.md\:flex{display:flex}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:flex-col{flex-direction:column}}@media(min-width:1024px){.lg\:col-span-2{grid-column:span 2 / span 2}.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}.\[\&_tr\:last-child\]\:border-0 tr:last-child{border-width:0px}.\[\&_tr\]\:border-b tr{border-bottom-width:1px}
|