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
+169
View File
@@ -0,0 +1,169 @@
'use strict';
const $ = (sel) => document.querySelector(sel);
const $$ = (sel) => Array.from(document.querySelectorAll(sel));
function toast(msg) {
const el = document.createElement('div');
el.className = 'toast';
el.textContent = msg;
document.body.appendChild(el);
setTimeout(() => el.remove(), 2600);
}
async function api(path, opts) {
const res = await fetch(path, {
headers: { 'Content-Type': 'application/json' },
...opts,
});
const text = await res.text();
let data = null;
try { data = text ? JSON.parse(text) : null; } catch { data = { raw: text }; }
if (!res.ok) throw new Error((data && data.error) || `HTTP ${res.status}`);
return data;
}
function usd(n) { return (n == null ? 0 : n).toFixed(4); }
// --- Tabs ---
$$('nav button').forEach((btn) => {
btn.addEventListener('click', () => {
$$('nav button').forEach((b) => b.classList.remove('active'));
btn.classList.add('active');
$$('main section').forEach((s) => s.classList.add('hidden'));
$(`#tab-${btn.dataset.tab}`).classList.remove('hidden');
if (btn.dataset.tab === 'usage') loadStats();
if (btn.dataset.tab === 'gallery') loadGallery();
if (btn.dataset.tab === 'providers') loadProviders();
});
});
// --- Usage ---
async function loadStats() {
const range = $('#statsRange').value;
const q = range ? `?sinceDays=${range}` : '';
const s = await api(`/api/stats${q}`);
$('#statCards').innerHTML = `
<div class="stat"><div class="n">$${usd(s.totalCostUsd)}</div><div class="l">Всего потрачено</div></div>
<div class="stat"><div class="n">${s.totalCount}</div><div class="l">Всего операций</div></div>
<div class="stat"><div class="n">${s.byProvider.length}</div><div class="l">Провайдеров</div></div>
<div class="stat"><div class="n">${s.byCapability.length}</div><div class="l">Типов операций</div></div>`;
const fill = (id, rows, key) => {
$(`#${id} tbody`).innerHTML = rows.map((r) =>
`<tr><td>${r[key]}</td><td>${r.count}</td><td>$${usd(r.costUsd)}</td></tr>`).join('')
|| '<tr><td colspan="3" class="muted">Нет данных</td></tr>';
};
fill('byProvider', s.byProvider, 'provider');
fill('byCapability', s.byCapability, 'capability');
fill('byDay', s.byDay, 'day');
}
$('#statsRange').addEventListener('change', loadStats);
// --- Gallery ---
async function loadGallery() {
const cap = $('#galleryFilter').value;
const q = cap ? `?capability=${cap}&limit=60` : '?limit=60';
const { items } = await api(`/api/generations${q}`);
$('#gallery').innerHTML = items.map(renderTile).join('')
|| '<div class="muted">Пока нет сгенерированного контента.</div>';
}
function renderTile(it) {
const cost = it.costUsd != null ? `$${usd(it.costUsd)}` : '';
let media = '';
if (it.mediaUrl) {
if (it.capability === 'image') media = `<img src="${it.mediaUrl}" loading="lazy" />`;
else if (it.capability === 'tts' || it.capability === 'music') media = `<div style="padding:10px"><audio controls src="${it.mediaUrl}"></audio></div>`;
else if (it.capability === 'video') media = `<video controls style="width:100%;height:150px;background:#000" src="${it.mediaUrl}"></video>`;
else media = `<a href="${it.mediaUrl}" target="_blank" style="display:block;padding:20px;text-align:center">Открыть файл</a>`;
} else {
const status = it.status === 'failed' ? '⚠ ошибка' : 'нет локального файла';
media = `<div style="height:150px;display:flex;align-items:center;justify-content:center" class="muted">${status}</div>`;
}
return `<div class="tile">${media}<div class="meta">
<div>${it.capability} · <span class="m">${cost}</span></div>
<div class="m">${it.model}</div>
<div class="m">${new Date(it.createdAt).toLocaleString()}</div>
</div></div>`;
}
$('#galleryReload').addEventListener('click', loadGallery);
$('#galleryFilter').addEventListener('change', loadGallery);
// --- Providers ---
let CRYPTO_ENABLED = true;
async function loadProviders() {
const { providers, knownTypes, cryptoEnabled } = await api('/api/providers');
CRYPTO_ENABLED = cryptoEnabled;
$('#cryptoBadge').textContent = cryptoEnabled
? 'Шифрование ключей: включено'
: '⚠ NEXUS_MASTER_KEY не задан — ключи из БД недоступны';
const typeSel = $('#pType');
if (!typeSel.options.length) {
typeSel.innerHTML = knownTypes.map((t) => `<option value="${t}">${t}</option>`).join('');
}
$('#providersTable tbody').innerHTML = providers.map((p) => {
const status = [
p.isDefault ? '<span class="pill def">default</span>' : '',
p.enabled ? '<span class="pill on">включён</span>' : '<span class="pill off">выключен</span>',
].join(' ');
return `<tr>
<td>${p.name}</td>
<td>${p.type}</td>
<td>${p.hasKey ? `<code>${p.keyHint || '••••'}</code>` : '<span class="muted">нет</span>'}</td>
<td>${status}</td>
<td>
<button class="ghost" data-check="${p.name}">Проверить</button>
<button class="ghost" data-toggle="${p.name}" data-en="${p.enabled}">${p.enabled ? 'Выключить' : 'Включить'}</button>
<button class="ghost" data-default="${p.name}">Сделать default</button>
<button class="ghost danger" data-del="${p.name}">Удалить</button>
</td>
</tr>`;
}).join('') || '<tr><td colspan="5" class="muted">Нет провайдеров. Добавьте выше.</td></tr>';
$$('[data-check]').forEach((b) => b.onclick = () => checkProvider(b.dataset.check));
$$('[data-toggle]').forEach((b) => b.onclick = () => toggleProvider(b.dataset.toggle, b.dataset.en !== 'true'));
$$('[data-default]').forEach((b) => b.onclick = () => setDefault(b.dataset.default));
$$('[data-del]').forEach((b) => b.onclick = () => delProvider(b.dataset.del));
}
$('#pSave').addEventListener('click', async () => {
try {
const body = {
name: $('#pName').value.trim(),
type: $('#pType').value,
baseUrl: $('#pBaseUrl').value.trim() || undefined,
apiKey: $('#pKey').value.trim() || undefined,
isDefault: $('#pDefault').value === 'true',
enabled: $('#pEnabled').value === 'true',
};
if (!body.name) return toast('Укажите имя');
if (body.apiKey && !CRYPTO_ENABLED) return toast('Нельзя сохранить ключ без NEXUS_MASTER_KEY');
await api('/api/providers', { method: 'POST', body: JSON.stringify(body) });
$('#pKey').value = '';
toast('Сохранено');
loadProviders();
} catch (e) { toast('Ошибка: ' + e.message); }
});
async function checkProvider(name) {
try {
toast('Проверяю ключ...');
const r = await api(`/api/providers/${encodeURIComponent(name)}/check`, { method: 'POST', body: '{}' });
toast(r.ok ? `OK · моделей: ${r.modelsCount}` : `Ошибка ключа: ${r.error}`);
} catch (e) { toast('Ошибка: ' + e.message); }
}
async function toggleProvider(name, enabled) {
try { await api(`/api/providers/${encodeURIComponent(name)}/enabled`, { method: 'POST', body: JSON.stringify({ enabled }) }); loadProviders(); }
catch (e) { toast('Ошибка: ' + e.message); }
}
async function setDefault(name) {
try { await api(`/api/providers/${encodeURIComponent(name)}/default`, { method: 'POST', body: '{}' }); loadProviders(); }
catch (e) { toast('Ошибка: ' + e.message); }
}
async function delProvider(name) {
if (!confirm(`Удалить провайдера "${name}"?`)) return;
try { await api(`/api/providers/${encodeURIComponent(name)}`, { method: 'DELETE' }); loadProviders(); }
catch (e) { toast('Ошибка: ' + e.message); }
}
// initial
loadStats();