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:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user