Changeset 0.22.3 (#97)
This commit is contained in:
@@ -8,6 +8,7 @@
|
||||
const ADMIN_SECTIONS = {
|
||||
people: ['users', 'teams', 'groups'],
|
||||
ai: ['providers', 'models', 'presets', 'roles', 'knowledgeBases', 'memory'],
|
||||
routing: ['health', 'routing', 'capabilities'],
|
||||
system: ['settings', 'storage', 'extensions'],
|
||||
monitoring: ['usage', 'audit', 'stats'],
|
||||
};
|
||||
@@ -15,6 +16,7 @@ const ADMIN_SECTIONS = {
|
||||
const ADMIN_LABELS = {
|
||||
users: 'Users', teams: 'Teams', groups: 'Groups',
|
||||
providers: 'Providers', models: 'Models', presets: 'Personas', roles: 'Roles', knowledgeBases: 'Knowledge', memory: 'Memory',
|
||||
health: 'Health', routing: 'Routing', capabilities: 'Capabilities',
|
||||
settings: 'Settings', storage: 'Storage', extensions: 'Extensions',
|
||||
usage: 'Usage', audit: 'Audit', stats: 'Stats',
|
||||
};
|
||||
@@ -39,6 +41,9 @@ const ADMIN_LOADERS = {
|
||||
settings: () => UI.loadAdminSettings(),
|
||||
storage: () => typeof loadAdminStorage === 'function' ? loadAdminStorage() : null,
|
||||
extensions: () => UI.loadAdminExtensions(),
|
||||
health: () => UI.loadAdminHealth(),
|
||||
routing: () => UI.loadAdminRouting(),
|
||||
capabilities: () => UI.loadAdminCapabilities(),
|
||||
usage: () => UI.loadAdminUsage(),
|
||||
audit: () => UI.loadAuditLog(),
|
||||
stats: () => UI.loadAdminStats(),
|
||||
@@ -872,6 +877,281 @@ Object.assign(UI, {
|
||||
}
|
||||
},
|
||||
|
||||
// ── Provider Health Dashboard (v0.22.3) ──────
|
||||
async loadAdminHealth() {
|
||||
const el = document.getElementById('adminHealthContent');
|
||||
el.innerHTML = '<div class="loading">Loading health data...</div>';
|
||||
|
||||
document.getElementById('adminHealthRefreshBtn')?.addEventListener('click', () => UI.loadAdminHealth(), { once: true });
|
||||
|
||||
try {
|
||||
const data = await API.adminGetAllProviderHealth();
|
||||
const providers = data.data || [];
|
||||
|
||||
if (providers.length === 0) {
|
||||
el.innerHTML = '<div class="empty-hint">No health data yet — health metrics are collected after the first completion request.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
el.innerHTML = '<div class="admin-health-grid"></div>';
|
||||
const grid = el.querySelector('.admin-health-grid');
|
||||
|
||||
for (const p of providers) {
|
||||
const statusClass = p.status === 'healthy' ? 'badge-success' : p.status === 'degraded' ? 'badge-warning' : p.status === 'down' ? 'badge-danger' : '';
|
||||
const errorPct = p.error_rate !== undefined ? (p.error_rate * 100).toFixed(1) + '%' : '—';
|
||||
const avgLatency = p.avg_latency_ms !== undefined ? p.avg_latency_ms + 'ms' : '—';
|
||||
const configName = p.provider_config_id?.slice(0, 8) || '—';
|
||||
|
||||
grid.innerHTML += `
|
||||
<div class="admin-health-card">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
|
||||
<strong>${esc(p.provider_name || configName)}</strong>
|
||||
<span class="badge ${statusClass}">${esc(p.status || 'unknown')}</span>
|
||||
</div>
|
||||
<div class="admin-storage-grid" style="gap:4px">
|
||||
<div class="admin-storage-item">
|
||||
<span class="admin-storage-label">Requests</span>
|
||||
<span class="admin-storage-value">${p.request_count ?? 0}</span>
|
||||
</div>
|
||||
<div class="admin-storage-item">
|
||||
<span class="admin-storage-label">Error Rate</span>
|
||||
<span class="admin-storage-value" style="color:${p.error_rate > 0.05 ? 'var(--error)' : 'inherit'}">${errorPct}</span>
|
||||
</div>
|
||||
<div class="admin-storage-item">
|
||||
<span class="admin-storage-label">Avg Latency</span>
|
||||
<span class="admin-storage-value">${avgLatency}</span>
|
||||
</div>
|
||||
<div class="admin-storage-item">
|
||||
<span class="admin-storage-label">Errors</span>
|
||||
<span class="admin-storage-value">${p.error_count ?? 0}</span>
|
||||
</div>
|
||||
<div class="admin-storage-item">
|
||||
<span class="admin-storage-label">Timeouts</span>
|
||||
<span class="admin-storage-value">${p.timeout_count ?? 0}</span>
|
||||
</div>
|
||||
<div class="admin-storage-item">
|
||||
<span class="admin-storage-label">Max Latency</span>
|
||||
<span class="admin-storage-value">${p.max_latency_ms ?? 0}ms</span>
|
||||
</div>
|
||||
</div>
|
||||
${p.last_error ? `<div class="text-muted" style="font-size:11px;margin-top:6px;word-break:break-all">Last error: ${esc(p.last_error)}</div>` : ''}
|
||||
</div>`;
|
||||
}
|
||||
} catch (e) {
|
||||
el.innerHTML = `<div class="empty-hint">Failed to load health data: ${esc(e.message)}</div>`;
|
||||
}
|
||||
},
|
||||
|
||||
// ── Routing Policies (v0.22.3) ───────────────
|
||||
async loadAdminRouting() {
|
||||
const listEl = document.getElementById('adminRoutingPolicyList');
|
||||
const formEl = document.getElementById('adminRoutingForm');
|
||||
listEl.innerHTML = '<div class="loading">Loading policies...</div>';
|
||||
|
||||
// Wire add button
|
||||
const addBtn = document.getElementById('adminAddRoutingPolicyBtn');
|
||||
addBtn?.addEventListener('click', () => UI._showRoutingForm(null), { once: true });
|
||||
|
||||
// Wire test button
|
||||
const testBtn = document.getElementById('routingTestBtn');
|
||||
testBtn?.addEventListener('click', () => UI._runRoutingTest(), { once: true });
|
||||
|
||||
try {
|
||||
const data = await API.adminListRoutingPolicies();
|
||||
const policies = data.data || [];
|
||||
|
||||
if (policies.length === 0) {
|
||||
listEl.innerHTML = '<div class="empty-hint">No routing policies — requests go to the user\'s default provider config.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const typeLabels = { provider_prefer: 'Provider Prefer', team_route: 'Team Route', cost_limit: 'Cost Limit', model_alias: 'Model Alias' };
|
||||
|
||||
listEl.innerHTML = policies.map(p => `
|
||||
<div class="list-card" data-id="${esc(p.id)}">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center">
|
||||
<div>
|
||||
<strong>${esc(p.name)}</strong>
|
||||
<span class="badge" style="margin-left:6px">${esc(typeLabels[p.policy_type] || p.policy_type)}</span>
|
||||
<span class="badge ${p.is_active ? 'badge-success' : ''}" style="margin-left:4px">${p.is_active ? 'Active' : 'Inactive'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-muted" style="font-size:11px;margin-right:8px">Priority: ${p.priority}</span>
|
||||
<span class="badge">${p.scope}${p.team_id ? ' · ' + p.team_id.slice(0, 8) : ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;margin-top:8px">
|
||||
<button class="btn-small" onclick="UI._showRoutingForm('${esc(p.id)}')">Edit</button>
|
||||
<button class="btn-small" onclick="UI._toggleRoutingPolicy('${esc(p.id)}', ${!p.is_active})">${p.is_active ? 'Disable' : 'Enable'}</button>
|
||||
<button class="btn-small btn-danger" onclick="UI._deleteRoutingPolicy('${esc(p.id)}', '${esc(p.name)}')">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
} catch (e) {
|
||||
listEl.innerHTML = `<div class="empty-hint">Failed to load routing policies: ${esc(e.message)}</div>`;
|
||||
}
|
||||
},
|
||||
|
||||
async _showRoutingForm(policyId) {
|
||||
const formEl = document.getElementById('adminRoutingForm');
|
||||
const isEdit = !!policyId;
|
||||
let policy = { name: '', scope: 'global', priority: 100, policy_type: 'provider_prefer', is_active: true, config: {} };
|
||||
|
||||
if (isEdit) {
|
||||
try { policy = await API.adminGetRoutingPolicy(policyId); } catch (e) { UI.toast(e.message, 'error'); return; }
|
||||
}
|
||||
|
||||
const cfg = policy.config || {};
|
||||
formEl.style.display = '';
|
||||
formEl.innerHTML = `
|
||||
<h4 style="margin:0 0 12px">${isEdit ? 'Edit' : 'New'} Routing Policy</h4>
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label>Name</label><input type="text" id="rpName" value="${esc(policy.name)}"></div>
|
||||
<div class="form-group"><label>Priority</label><input type="number" id="rpPriority" value="${policy.priority}" min="0" max="999"></div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label>Type</label>
|
||||
<select id="rpType" ${isEdit ? 'disabled' : ''}>
|
||||
<option value="provider_prefer" ${policy.policy_type === 'provider_prefer' ? 'selected' : ''}>Provider Prefer</option>
|
||||
<option value="team_route" ${policy.policy_type === 'team_route' ? 'selected' : ''}>Team Route</option>
|
||||
<option value="cost_limit" ${policy.policy_type === 'cost_limit' ? 'selected' : ''}>Cost Limit</option>
|
||||
<option value="model_alias" ${policy.policy_type === 'model_alias' ? 'selected' : ''}>Model Alias</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group"><label>Scope</label>
|
||||
<select id="rpScope">
|
||||
<option value="global" ${policy.scope === 'global' ? 'selected' : ''}>Global</option>
|
||||
<option value="team" ${policy.scope === 'team' ? 'selected' : ''}>Team</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" id="rpTeamGroup" style="display:${policy.scope === 'team' ? '' : 'none'}">
|
||||
<label>Team ID</label><input type="text" id="rpTeamId" value="${esc(policy.team_id || '')}">
|
||||
</div>
|
||||
<div class="form-group"><label>Config (JSON)</label>
|
||||
<textarea id="rpConfig" rows="4" style="font-family:monospace;font-size:12px">${esc(JSON.stringify(cfg, null, 2))}</textarea>
|
||||
</div>
|
||||
<label class="checkbox-label"><input type="checkbox" id="rpActive" ${policy.is_active ? 'checked' : ''}> Active</label>
|
||||
<div class="form-row" style="margin-top:12px">
|
||||
<button class="btn-small btn-primary" id="rpSaveBtn">${isEdit ? 'Update' : 'Create'}</button>
|
||||
<button class="btn-small" id="rpCancelBtn">Cancel</button>
|
||||
</div>`;
|
||||
|
||||
document.getElementById('rpScope').addEventListener('change', (e) => {
|
||||
document.getElementById('rpTeamGroup').style.display = e.target.value === 'team' ? '' : 'none';
|
||||
});
|
||||
document.getElementById('rpCancelBtn').addEventListener('click', () => { formEl.style.display = 'none'; });
|
||||
document.getElementById('rpSaveBtn').addEventListener('click', async () => {
|
||||
let config;
|
||||
try { config = JSON.parse(document.getElementById('rpConfig').value || '{}'); }
|
||||
catch (e) { UI.toast('Invalid JSON in config field', 'error'); return; }
|
||||
|
||||
const payload = {
|
||||
name: document.getElementById('rpName').value,
|
||||
priority: parseInt(document.getElementById('rpPriority').value) || 100,
|
||||
policy_type: document.getElementById('rpType').value,
|
||||
scope: document.getElementById('rpScope').value,
|
||||
team_id: document.getElementById('rpScope').value === 'team' ? document.getElementById('rpTeamId').value : null,
|
||||
is_active: document.getElementById('rpActive').checked,
|
||||
config,
|
||||
};
|
||||
try {
|
||||
if (isEdit) { await API.adminUpdateRoutingPolicy(policyId, payload); }
|
||||
else { await API.adminCreateRoutingPolicy(payload); }
|
||||
UI.toast(isEdit ? 'Policy updated' : 'Policy created', 'success');
|
||||
formEl.style.display = 'none';
|
||||
UI.loadAdminRouting();
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
});
|
||||
},
|
||||
|
||||
async _toggleRoutingPolicy(id, active) {
|
||||
try {
|
||||
const policy = await API.adminGetRoutingPolicy(id);
|
||||
policy.is_active = active;
|
||||
await API.adminUpdateRoutingPolicy(id, policy);
|
||||
UI.toast(active ? 'Policy enabled' : 'Policy disabled', 'success');
|
||||
UI.loadAdminRouting();
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
},
|
||||
|
||||
async _deleteRoutingPolicy(id, name) {
|
||||
if (!await showConfirm(`Delete routing policy "${name}"?`)) return;
|
||||
try {
|
||||
await API.adminDeleteRoutingPolicy(id);
|
||||
UI.toast('Policy deleted', 'success');
|
||||
UI.loadAdminRouting();
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
},
|
||||
|
||||
async _runRoutingTest() {
|
||||
const model = document.getElementById('routingTestModel').value;
|
||||
const userId = document.getElementById('routingTestUser').value || API.user?.id;
|
||||
const resultEl = document.getElementById('routingTestResult');
|
||||
if (!model) { UI.toast('Model is required', 'warning'); return; }
|
||||
|
||||
resultEl.style.display = '';
|
||||
resultEl.innerHTML = '<div class="loading">Evaluating...</div>';
|
||||
try {
|
||||
const data = await API.adminTestRouting(model, userId);
|
||||
const dec = data.decision;
|
||||
const candidates = data.candidates || [];
|
||||
|
||||
resultEl.innerHTML = `
|
||||
<div style="margin-top:8px">
|
||||
<strong>Decision:</strong> ${dec ? `${esc(dec.policy_name || 'no policy')} → ${esc(dec.selected?.slice(0, 8) || '—')}` : 'No routing applied'}
|
||||
<span class="text-muted" style="margin-left:8px">(${candidates.length} candidates, ${data.policies || 0} policies evaluated)</span>
|
||||
</div>
|
||||
<div style="margin-top:8px;font-size:12px">
|
||||
${candidates.map((c, i) => `
|
||||
<div style="padding:4px 0;border-bottom:1px solid var(--border)">
|
||||
${i + 1}. <strong>${esc(c.provider_id)}</strong> / ${esc(c.config_id?.slice(0, 8) || '—')}
|
||||
<span class="badge ${c.status === 'healthy' ? 'badge-success' : c.status === 'down' ? 'badge-danger' : ''}" style="margin-left:4px">${esc(c.status || 'unknown')}</span>
|
||||
${c.reason ? `<span class="text-muted" style="margin-left:4px">${esc(c.reason)}</span>` : ''}
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>`;
|
||||
} catch (e) {
|
||||
resultEl.innerHTML = `<div class="empty-hint">Test failed: ${esc(e.message)}</div>`;
|
||||
}
|
||||
},
|
||||
|
||||
// ── Capability Overrides (v0.22.3) ───────────
|
||||
async loadAdminCapabilities() {
|
||||
const el = document.getElementById('adminCapabilityList');
|
||||
el.innerHTML = '<div class="loading">Loading overrides...</div>';
|
||||
try {
|
||||
const data = await API.adminListCapabilityOverrides();
|
||||
const overrides = data.data || [];
|
||||
|
||||
if (overrides.length === 0) {
|
||||
el.innerHTML = '<div class="empty-hint">No capability overrides — all models use auto-detected capabilities from catalog sync or heuristic inference.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
el.innerHTML = `<table class="admin-table" style="width:100%;font-size:13px">
|
||||
<thead><tr><th>Model</th><th>Provider Config</th><th>Field</th><th>Value</th><th></th></tr></thead>
|
||||
<tbody>${overrides.map(o => `<tr>
|
||||
<td><code>${esc(o.model_id)}</code></td>
|
||||
<td>${esc(o.provider_config_id?.slice(0, 8) || 'any')}</td>
|
||||
<td>${esc(o.field)}</td>
|
||||
<td><strong>${esc(String(o.value))}</strong></td>
|
||||
<td><button class="btn-small btn-danger" onclick="UI._deleteCapOverride('${esc(o.model_id)}', '${esc(o.id)}')">×</button></td>
|
||||
</tr>`).join('')}</tbody>
|
||||
</table>`;
|
||||
} catch (e) {
|
||||
el.innerHTML = `<div class="empty-hint">Failed to load overrides: ${esc(e.message)}</div>`;
|
||||
}
|
||||
},
|
||||
|
||||
async _deleteCapOverride(modelId, overrideId) {
|
||||
try {
|
||||
await API.adminDeleteModelCapability(modelId, overrideId);
|
||||
UI.toast('Override deleted', 'success');
|
||||
UI.loadAdminCapabilities();
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
},
|
||||
|
||||
async loadAdminSettings() {
|
||||
try {
|
||||
const data = await API.adminGetSettings();
|
||||
|
||||
Reference in New Issue
Block a user