diff options
Diffstat (limited to 'internal/adminui/assets/app.js')
| -rw-r--r-- | internal/adminui/assets/app.js | 70 |
1 files changed, 70 insertions, 0 deletions
diff --git a/internal/adminui/assets/app.js b/internal/adminui/assets/app.js new file mode 100644 index 0000000..f5ba048 --- /dev/null +++ b/internal/adminui/assets/app.js @@ -0,0 +1,70 @@ +const state = { token: sessionStorage.getItem('aigw_admin_token') || '', tenants: [], projects: [], providers: [], models: [], keys: [] }; +const $ = (selector) => document.querySelector(selector); +const $$ = (selector) => [...document.querySelectorAll(selector)]; + +function esc(value) { + return String(value ?? '').replace(/[&<>'"]/g, (char) => ({ '&':'&', '<':'<', '>':'>', "'":''', '"':'"' }[char])); +} +function date(value) { return value ? new Date(value).toLocaleString() : '—'; } +function toast(message, error = false) { + const node = $('#toast'); node.textContent = message; node.className = `toast visible ${error ? 'error' : ''}`; + setTimeout(() => { node.className = 'toast'; }, 3200); +} +async function api(path, options = {}) { + const response = await fetch(`./api${path}`, { ...options, headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${state.token}`, ...(options.headers || {}) } }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(payload?.error?.message || `Request failed (${response.status})`); + return payload; +} +function setConnected(connected) { + $('#connection-state').textContent = connected ? 'Connected' : 'Offline'; + $('#connection-state').className = `state ${connected ? 'online' : ''}`; +} +function formJSON(form) { return Object.fromEntries(new FormData(form).entries()); } +function selectOptions(items, valueKey, labelKey, empty = 'Select…') { return `<option value="">${empty}</option>${items.map(item => `<option value="${esc(item[valueKey])}">${esc(item[labelKey])}</option>`).join('')}`; } + +async function loadAll() { + if (!state.token) { setConnected(false); return; } + try { + [state.tenants, state.projects, state.providers, state.models, state.keys] = await Promise.all([ + api('/tenants'), api('/projects'), api('/providers'), api('/models'), api('/keys') + ]); + const overview = await api('/overview'); + renderOverview(overview); renderTenants(); renderProjects(); renderKeys(); renderProviders(); renderModels(); renderRouteEditor(); setConnected(true); + } catch (error) { setConnected(false); toast(error.message, true); } +} +function renderOverview(data) { + const propagation = !data.redis_configured ? 'PG polling' : data.redis_connected ? 'Redis live' : 'PG fallback'; + const items = [['Tenants', data.tenants, 'active accounts'], ['Projects', data.projects, 'workspaces'], ['API keys', data.api_keys, 'active credentials'], ['Providers', data.providers, 'enabled upstreams'], ['Models', data.models, 'public routes'], ['Runtime', data.runtime_generation, `PG generation ${data.generation}`], ['Propagation', propagation, data.redis_configured ? 'automatic recovery' : 'Redis not configured']]; + $('#metrics').innerHTML = items.map(([label, value, sub]) => `<article class="metric"><span>${label}</span><strong>${esc(value)}</strong><small>${sub}</small></article>`).join(''); +} +function renderTenants() { $('#tenants-body').innerHTML = state.tenants.map(item => `<tr><td><strong>${esc(item.name)}</strong></td><td><code>${esc(item.slug)}</code></td><td><span class="badge ${item.status}">${esc(item.status)}</span></td><td>${date(item.created_at)}</td></tr>`).join('') || emptyRow(4); $('#project-tenant').innerHTML = selectOptions(state.tenants, 'id', 'name'); $('#key-tenant').innerHTML = selectOptions(state.tenants, 'id', 'name'); } +function renderProjects() { $('#projects-body').innerHTML = state.projects.map(item => `<tr><td><strong>${esc(item.name)}</strong></td><td><code>${esc(item.tenant_id).slice(0, 8)}…</code></td><td>${esc(item.slug)}</td><td><span class="badge ${item.status}">${esc(item.status)}</span></td></tr>`).join('') || emptyRow(4); renderKeyProjects(); } +function renderKeyProjects() { const tenant = $('#key-tenant').value; const projects = state.projects.filter(item => !tenant || item.tenant_id === tenant); $('#key-project').innerHTML = selectOptions(projects, 'id', 'name'); } +function renderKeys() { $('#keys-body').innerHTML = state.keys.map(item => `<tr><td><strong>${esc(item.name)}</strong></td><td><code>${esc(item.key_prefix)}</code></td><td><code>${esc(item.project_id).slice(0, 8)}…</code></td><td>${(item.scopes || []).map(scope => `<span class="tag">${esc(scope)}</span>`).join('')}</td><td><span class="badge ${item.status}">${esc(item.status)}</span></td><td>${item.status === 'active' ? `<button class="text-button danger" data-revoke-key="${esc(item.id)}">Revoke</button>` : ''}</td></tr>`).join('') || emptyRow(6); } +function renderProviders() { $('#providers-body').innerHTML = state.providers.map(item => `<tr><td><strong>${esc(item.name)}</strong></td><td><span class="tag">${esc(item.protocol)}</span></td><td class="truncate">${esc(item.base_url)}</td><td>${esc(item.route_count)}</td><td><span class="badge ${item.enabled ? 'active' : 'suspended'}">${item.enabled ? 'enabled' : 'disabled'}</span></td><td><button class="text-button" data-toggle-provider="${esc(item.id)}" data-enabled="${!item.enabled}">${item.enabled ? 'Disable' : 'Enable'}</button></td></tr>`).join('') || emptyRow(6); } +function renderModels() { $('#models-body').innerHTML = state.models.map(item => `<tr><td><strong>${esc(item.public_id)}</strong></td><td>${esc(item.owned_by || '—')}</td><td><div class="route-list">${(item.routes || []).map(route => `<span>${esc(route.provider_name || route.provider_id).slice(0, 24)} → ${esc(route.upstream_model)} <em>p${route.priority} / w${route.weight}</em></span>`).join('')}</div></td><td><span class="badge ${item.enabled ? 'active' : 'suspended'}">${item.enabled ? 'enabled' : 'disabled'}</span></td><td><button class="text-button" data-toggle-model="${esc(item.id)}" data-enabled="${!item.enabled}">${item.enabled ? 'Disable' : 'Enable'}</button></td></tr>`).join('') || emptyRow(5); } +function renderRouteEditor() { const current = $('#route-editor'); if (!current.children.length) addRoute(); $$('.route-provider').forEach(select => { const selected = select.value; select.innerHTML = selectOptions(state.providers.filter(item => item.enabled), 'id', 'name', 'Provider…'); select.value = selected; }); } +function addRoute() { const wrapper = document.createElement('div'); wrapper.className = 'route-row'; wrapper.innerHTML = `<select class="route-provider" required></select><input class="route-upstream" required placeholder="Upstream model"><input class="route-priority" type="number" min="0" value="0" title="Priority"><input class="route-weight" type="number" min="1" max="100" value="100" title="Weight"><button class="icon-button remove-route" type="button" aria-label="Remove route">×</button>`; $('#route-editor').appendChild(wrapper); renderRouteEditor(); } +function emptyRow(span) { return `<tr><td colspan="${span}" class="empty">No records yet</td></tr>`; } + +document.addEventListener('click', async (event) => { + const tab = event.target.closest('.tab'); if (tab) { $$('.tab').forEach(node => node.classList.toggle('active', node === tab)); $$('.section').forEach(node => node.classList.toggle('active', node.id === tab.dataset.section)); return; } + if (event.target.id === 'reload') { try { await api('/reload', { method: 'POST', body: '{}' }); await loadAll(); toast('Snapshot reloaded'); } catch (error) { toast(error.message, true); } } + if (event.target.id === 'add-route') addRoute(); + if (event.target.closest('.remove-route')) { event.target.closest('.route-row').remove(); } + const revoke = event.target.closest('[data-revoke-key]'); if (revoke && confirm('Revoke this API key?')) { try { await api(`/keys/${revoke.dataset.revokeKey}/revoke`, { method: 'POST', body: '{}' }); await loadAll(); toast('API key revoked'); } catch (error) { toast(error.message, true); } } + const provider = event.target.closest('[data-toggle-provider]'); if (provider) { try { await api(`/providers/${provider.dataset.toggleProvider}/toggle`, { method: 'POST', body: JSON.stringify({ enabled: provider.dataset.enabled === 'true' }) }); await loadAll(); toast('Provider updated'); } catch (error) { toast(error.message, true); } } + const model = event.target.closest('[data-toggle-model]'); if (model) { try { await api(`/models/${model.dataset.toggleModel}/toggle`, { method: 'POST', body: JSON.stringify({ enabled: model.dataset.enabled === 'true' }) }); await loadAll(); toast('Model updated'); } catch (error) { toast(error.message, true); } } +}); +$('#key-tenant').addEventListener('change', renderKeyProjects); +$('#session-form').addEventListener('submit', async (event) => { event.preventDefault(); state.token = $('#admin-token').value.trim(); sessionStorage.setItem('aigw_admin_token', state.token); await loadAll(); }); +$('#tenant-form').addEventListener('submit', async (event) => { event.preventDefault(); try { await api('/tenants', { method: 'POST', body: JSON.stringify(formJSON(event.target)) }); event.target.reset(); await loadAll(); toast('Tenant created'); } catch (error) { toast(error.message, true); } }); +$('#project-form').addEventListener('submit', async (event) => { event.preventDefault(); try { await api('/projects', { method: 'POST', body: JSON.stringify(formJSON(event.target)) }); event.target.reset(); await loadAll(); toast('Project created'); } catch (error) { toast(error.message, true); } }); +$('#key-form').addEventListener('submit', async (event) => { event.preventDefault(); try { const data = formJSON(event.target); data.scopes = data.scopes.split(',').map(value => value.trim()).filter(Boolean); const result = await api('/keys', { method: 'POST', body: JSON.stringify(data) }); event.target.reset(); $('#created-secret').textContent = result.key; $('#secret-dialog').showModal(); await loadAll(); } catch (error) { toast(error.message, true); } }); +$('#provider-form').addEventListener('submit', async (event) => { event.preventDefault(); try { await api('/providers', { method: 'POST', body: JSON.stringify(formJSON(event.target)) }); event.target.reset(); await loadAll(); toast('Provider added'); } catch (error) { toast(error.message, true); } }); +$('#model-form').addEventListener('submit', async (event) => { event.preventDefault(); try { const data = formJSON(event.target); data.routes = $$('.route-row').map(row => ({ provider_id: row.querySelector('.route-provider').value, upstream_model: row.querySelector('.route-upstream').value, priority: Number(row.querySelector('.route-priority').value), weight: Number(row.querySelector('.route-weight').value) })); await api('/models', { method: 'POST', body: JSON.stringify(data) }); event.target.reset(); $('#route-editor').innerHTML = ''; renderRouteEditor(); await loadAll(); toast('Model created'); } catch (error) { toast(error.message, true); } }); +$('#close-dialog').addEventListener('click', () => $('#secret-dialog').close()); +$('#copy-secret').addEventListener('click', async () => { await navigator.clipboard.writeText($('#created-secret').textContent); toast('Key copied'); }); +$('#admin-token').value = state.token; +if (state.token) loadAll(); |
