Add admin web UI (Phase 2): spend dashboard, media gallery, provider management

Fastify + single-page frontend on shared @nexusai/core. Reads the same SQLite
journal and media dir. Features: usage stats (by provider/capability/day),
content gallery (image/audio/video with local file serving + path-traversal
guard), provider CRUD with encrypted keys (masked in UI), enable/disable,
set-default, and real key validation via zero-cost /media/models call.
Adds Journal.getById and NordRouterMediaProvider.checkKey to core.
This commit is contained in:
OpenCode
2026-08-17 17:22:07 +07:00
parent 62c692c5a1
commit 679e56424b
13 changed files with 1495 additions and 5 deletions
+165
View File
@@ -0,0 +1,165 @@
import * as fs from 'fs';
import * as path from 'path';
import type { FastifyInstance } from 'fastify';
import {
NexusError,
NordRouterClient,
NordRouterMediaProvider,
MediaStorage,
createLogger,
} from '@nexusai/core';
import type { AdminContext } from './context.js';
const log = createLogger('admin:api');
const KNOWN_TYPES = ['nordrouter'];
function errorReply(err: unknown): { statusCode: number; body: { error: string; code: string } } {
const e = NexusError.from(err);
const statusCode =
e.code === 'NOT_FOUND' ? 404 : e.code === 'CONFIG' || e.code === 'BAD_REQUEST' ? 400 : 500;
return { statusCode, body: { error: e.message, code: e.code } };
}
export function registerRoutes(app: FastifyInstance, ctx: AdminContext): void {
// --- Providers ---
app.get('/api/providers', async () => {
return { providers: ctx.store.listViews(), knownTypes: KNOWN_TYPES, cryptoEnabled: ctx.cipher.enabled };
});
app.post('/api/providers', async (req, reply) => {
try {
const body = req.body as {
name?: string;
type?: string;
baseUrl?: string;
apiKey?: string;
enabled?: boolean;
isDefault?: boolean;
};
if (!body?.name || !body?.type) {
throw new NexusError('BAD_REQUEST', 'name and type are required');
}
if (body.apiKey && !ctx.cipher.enabled) {
throw new NexusError('CONFIG', 'NEXUS_MASTER_KEY must be set to store an API key');
}
ctx.store.upsert({
name: body.name,
type: body.type,
baseUrl: body.baseUrl ?? null,
apiKey: body.apiKey,
enabled: body.enabled,
isDefault: body.isDefault,
});
return { ok: true, provider: ctx.store.get(body.name) };
} catch (err) {
const { statusCode, body } = errorReply(err);
return reply.code(statusCode).send(body);
}
});
app.post('/api/providers/:name/enabled', async (req, reply) => {
try {
const { name } = req.params as { name: string };
const { enabled } = req.body as { enabled: boolean };
ctx.store.setEnabled(name, enabled);
return { ok: true };
} catch (err) {
const { statusCode, body } = errorReply(err);
return reply.code(statusCode).send(body);
}
});
app.post('/api/providers/:name/default', async (req, reply) => {
try {
const { name } = req.params as { name: string };
ctx.store.setDefault(name);
return { ok: true };
} catch (err) {
const { statusCode, body } = errorReply(err);
return reply.code(statusCode).send(body);
}
});
app.delete('/api/providers/:name', async (req, reply) => {
try {
const { name } = req.params as { name: string };
ctx.store.remove(name);
return { ok: true };
} catch (err) {
const { statusCode, body } = errorReply(err);
return reply.code(statusCode).send(body);
}
});
// Validate a key: uses provided key, or the stored one, or env fallback.
app.post('/api/providers/:name/check', async (req, reply) => {
try {
const { name } = req.params as { name: string };
const body = (req.body ?? {}) as { apiKey?: string; baseUrl?: string };
const row = ctx.store.get(name);
const type = row?.type ?? name;
if (!KNOWN_TYPES.includes(type)) {
throw new NexusError('UNSUPPORTED', `Key check not implemented for type "${type}"`);
}
const apiKey =
body.apiKey ||
ctx.store.getApiKey(name) ||
process.env[`${name.toUpperCase()}_API_KEY`];
if (!apiKey) throw new NexusError('CONFIG', 'No API key available to check');
const baseUrl = body.baseUrl || row?.baseUrl || 'https://nordrouter.com';
const client = new NordRouterClient(apiKey, baseUrl);
const provider = new NordRouterMediaProvider(client, { save: false });
const result = await provider.checkKey();
return result;
} catch (err) {
const { statusCode, body } = errorReply(err);
return reply.code(statusCode).send(body);
}
});
// --- Usage / stats ---
app.get('/api/stats', async (req) => {
const q = req.query as { sinceDays?: string };
const sinceDays = q.sinceDays ? Number.parseInt(q.sinceDays, 10) : undefined;
return ctx.journal.stats(Number.isFinite(sinceDays as number) ? sinceDays : undefined);
});
// --- Generations (gallery) ---
app.get('/api/generations', async (req) => {
const q = req.query as { limit?: string; offset?: string; capability?: string };
const limit = Math.min(200, q.limit ? Number.parseInt(q.limit, 10) : 50);
const offset = q.offset ? Number.parseInt(q.offset, 10) : 0;
const items = ctx.journal.list(limit, offset, q.capability);
// Add a media URL for locally-saved files that still exist.
const withUrls = items.map((it) => ({
...it,
mediaUrl: it.filePath && fs.existsSync(it.filePath) ? `/media/${path.basename(it.filePath)}` : null,
}));
return { items: withUrls, limit, offset };
});
app.get('/api/generations/:id', async (req, reply) => {
const { id } = req.params as { id: string };
const rec = ctx.journal.getById(id);
if (!rec) return reply.code(404).send({ error: 'not found', code: 'NOT_FOUND' });
return {
...rec,
mediaUrl: rec.filePath && fs.existsSync(rec.filePath) ? `/media/${path.basename(rec.filePath)}` : null,
};
});
log.info('routes registered');
}
/** Guard: only serve files that live inside the media dir. */
export function isInsideMediaDir(mediaDir: string, requested: string): boolean {
const resolved = path.resolve(mediaDir, requested);
const rel = path.relative(path.resolve(mediaDir), resolved);
return !rel.startsWith('..') && !path.isAbsolute(rel);
}
export function ensureStorage(mediaDir: string): MediaStorage {
return new MediaStorage(mediaDir);
}