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,29 @@
|
||||
import {
|
||||
Journal,
|
||||
KeyCipher,
|
||||
ProvidersStore,
|
||||
loadConfig,
|
||||
createLogger,
|
||||
type NexusConfig,
|
||||
} from '@nexusai/core';
|
||||
|
||||
const log = createLogger('admin:context');
|
||||
|
||||
export interface AdminContext {
|
||||
config: NexusConfig;
|
||||
journal: Journal;
|
||||
store: ProvidersStore;
|
||||
cipher: KeyCipher;
|
||||
}
|
||||
|
||||
export function buildAdminContext(): AdminContext {
|
||||
const config = loadConfig();
|
||||
const journal = new Journal(config.dbPath);
|
||||
const cipher = new KeyCipher(config.masterKey);
|
||||
const store = new ProvidersStore(config.providersDbPath, cipher);
|
||||
|
||||
if (!cipher.enabled) {
|
||||
log.warn('NEXUS_MASTER_KEY not set — provider keys cannot be added/decrypted from the admin');
|
||||
}
|
||||
return { config, journal, store, cipher };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env node
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import Fastify from 'fastify';
|
||||
import fastifyStatic from '@fastify/static';
|
||||
import { createLogger } from '@nexusai/core';
|
||||
import { buildAdminContext } from './context.js';
|
||||
import { registerRoutes } from './routes.js';
|
||||
|
||||
const log = createLogger('admin');
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const ctx = buildAdminContext();
|
||||
const app = Fastify({ logger: false });
|
||||
|
||||
registerRoutes(app, ctx);
|
||||
|
||||
// Serve generated media files from the configured media dir.
|
||||
const mediaDir = path.resolve(ctx.config.mediaDir);
|
||||
app.get('/media/:file', async (req, reply) => {
|
||||
const { file } = req.params as { file: string };
|
||||
// Prevent path traversal: only a bare filename is allowed.
|
||||
if (file.includes('/') || file.includes('..') || path.isAbsolute(file)) {
|
||||
return reply.code(400).send({ error: 'invalid file name' });
|
||||
}
|
||||
const full = path.join(mediaDir, file);
|
||||
if (!full.startsWith(mediaDir + path.sep) || !fs.existsSync(full)) {
|
||||
return reply.code(404).send({ error: 'not found' });
|
||||
}
|
||||
const stream = fs.createReadStream(full);
|
||||
reply.header('Cache-Control', 'private, max-age=60');
|
||||
return reply.send(stream);
|
||||
});
|
||||
|
||||
// Serve the static frontend (built into ./public next to dist).
|
||||
const publicDir = path.resolve(__dirname, '../public');
|
||||
if (fs.existsSync(publicDir)) {
|
||||
await app.register(fastifyStatic, { root: publicDir, prefix: '/' });
|
||||
} else {
|
||||
log.warn(`public dir not found at ${publicDir}; UI will not be served`);
|
||||
}
|
||||
|
||||
const port = Number.parseInt(process.env.NEXUS_ADMIN_PORT || '4123', 10);
|
||||
const host = process.env.NEXUS_ADMIN_HOST || '127.0.0.1';
|
||||
|
||||
try {
|
||||
await app.listen({ port, host });
|
||||
log.info(`NexusAI admin running at http://${host}:${port}`);
|
||||
log.info(`media dir: ${mediaDir}`);
|
||||
if (!ctx.cipher.enabled) {
|
||||
log.warn('crypto disabled (NEXUS_MASTER_KEY unset): cannot add/decrypt provider keys');
|
||||
}
|
||||
} catch (err) {
|
||||
log.error('failed to start admin', err instanceof Error ? err.message : String(err));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
Reference in New Issue
Block a user