@@ -206,67 +146,26 @@
{{/* Scripts */}}
{{define "scripts-admin"}}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+{{/* Inject section name for Preact routing */}}
- const backBtn = document.getElementById('adminBackBtn');
- if (backBtn) {
- backBtn.addEventListener('click', (e) => {
- e.preventDefault();
- const returnURL = sessionStorage.getItem(RETURN_KEY);
- sessionStorage.removeItem(RETURN_KEY);
- location.href = returnURL || (window.__BASE__ || '') + '/';
- });
- }
+{{/* Vendor: Preact + htm → globals, then SDK boot, then admin surface */}}
+
{{end}}
diff --git a/server/version.go b/server/version.go
index 3632383..60c4824 100644
--- a/server/version.go
+++ b/server/version.go
@@ -1,3 +1,3 @@
package main
-const Version = "0.37.5"
+const Version = "0.37.6"
diff --git a/src/js/admin-scaffold.js b/src/js/admin-scaffold.js
index bbe5814..bf5c7fc 100644
--- a/src/js/admin-scaffold.js
+++ b/src/js/admin-scaffold.js
@@ -292,6 +292,8 @@
// === Init on DOMContentLoaded ========================================
document.addEventListener('DOMContentLoaded', function() {
+ // When the Preact admin surface is active, skip scaffold init entirely.
+ if (document.getElementById('admin-mount')) return;
var el = document.getElementById('adminContentBody');
if (!el) return;
var section = el.dataset.section;
diff --git a/src/js/sw/sdk/api-domains.js b/src/js/sw/sdk/api-domains.js
index a430880..a73a891 100644
--- a/src/js/sw/sdk/api-domains.js
+++ b/src/js/sw/sdk/api-domains.js
@@ -274,6 +274,7 @@ export function createDomains(restClient) {
updateRole: (id, role) => rc.put(`/api/v1/admin/users/${id}/role`, { role }),
toggleActive: (id, active, opts) => rc.put(`/api/v1/admin/users/${id}/active`, { is_active: active, ...opts }),
permissions: (id) => rc.get(`/api/v1/admin/users/${id}/permissions`),
+ resetVault: (id) => rc.post(`/api/v1/admin/users/${id}/vault/reset`, {}),
},
settings: {
@@ -310,6 +311,8 @@ export function createDomains(restClient) {
delAvatar: (id) => rc.del(`/api/v1/admin/personas/${id}/avatar`),
kbs: (id) => rc.get(`/api/v1/admin/personas/${id}/knowledge-bases`),
setKbs: (id, data) => rc.put(`/api/v1/admin/personas/${id}/knowledge-bases`, data),
+ toolGrants: (id) => rc.get(`/api/v1/admin/personas/${id}/tool-grants`),
+ setToolGrants: (id, data) => rc.put(`/api/v1/admin/personas/${id}/tool-grants`, data),
},
teams: {
@@ -406,7 +409,36 @@ export function createDomains(restClient) {
},
notifications: {
- testEmail: () => rc.post('/api/v1/admin/notifications/test-email', {}),
+ testEmail: () => rc.post('/api/v1/admin/notifications/test-email', {}),
+ broadcast: (data) => rc.post('/api/v1/admin/notifications/broadcast', data),
+ },
+
+ channels: {
+ archived: (opts) => rc.get('/api/v1/admin/channels/archived' + _qs(opts)),
+ purge: (id) => rc.del(`/api/v1/admin/channels/${id}/purge`),
+ },
+
+ extensions: {
+ list: () => rc.get('/api/v1/admin/extensions'),
+ create: (data) => rc.post('/api/v1/admin/extensions', data),
+ update: (id, data) => rc.put(`/api/v1/admin/extensions/${id}`, data),
+ del: (id) => rc.del(`/api/v1/admin/extensions/${id}`),
+ },
+
+ tasks: {
+ list: () => rc.get('/api/v1/admin/tasks'),
+ run: (id) => rc.post(`/api/v1/admin/tasks/${id}/run`, {}),
+ kill: (id) => rc.post(`/api/v1/admin/tasks/${id}/kill`, {}),
+ del: (id) => rc.del(`/api/v1/admin/tasks/${id}`),
+ },
+
+ surfaces: {
+ list: () => rc.get('/api/v1/admin/surfaces'),
+ get: (id) => rc.get(`/api/v1/admin/surfaces/${id}`),
+ install: (file) => rc.upload('/api/v1/admin/surfaces/install', file),
+ enable: (id) => rc.put(`/api/v1/admin/surfaces/${id}/enable`, {}),
+ disable: (id) => rc.put(`/api/v1/admin/surfaces/${id}/disable`, {}),
+ del: (id) => rc.del(`/api/v1/admin/surfaces/${id}`),
},
},
diff --git a/src/js/sw/sdk/index.js b/src/js/sw/sdk/index.js
index ee1a081..8f29610 100644
--- a/src/js/sw/sdk/index.js
+++ b/src/js/sw/sdk/index.js
@@ -117,7 +117,7 @@ export async function boot() {
};
// Marker for idempotency
- sw._sdk = '0.37.5';
+ sw._sdk = '0.37.6';
// 8. Expose globally
window.sw = sw;
diff --git a/src/js/sw/surfaces/admin/audit.js b/src/js/sw/surfaces/admin/audit.js
new file mode 100644
index 0000000..7196921
--- /dev/null
+++ b/src/js/sw/surfaces/admin/audit.js
@@ -0,0 +1,88 @@
+/**
+ * Admin > Monitoring > Audit
+ */
+const { html } = window;
+const { useState, useEffect, useCallback } = hooks;
+
+export default function AuditSection() {
+ const [entries, setEntries] = useState([]);
+ const [actions, setActions] = useState([]);
+ const [total, setTotal] = useState(0);
+ const [page, setPage] = useState(1);
+ const [filterAction, setFilterAction] = useState('');
+ const [filterResource, setFilterResource] = useState('');
+ const [loading, setLoading] = useState(true);
+ const perPage = 50;
+
+ const load = useCallback(async () => {
+ setLoading(true);
+ try {
+ const opts = { page, per_page: perPage };
+ if (filterAction) opts.action = filterAction;
+ if (filterResource) opts.resource_type = filterResource;
+ const data = await sw.api.admin.audit.list(opts);
+ setEntries(data.data || []);
+ setTotal(data.total || 0);
+ } catch (e) { sw.toast(e.message, 'error'); }
+ finally { setLoading(false); }
+ }, [page, filterAction, filterResource]);
+
+ useEffect(() => { load(); }, [load]);
+
+ useEffect(() => {
+ sw.api.admin.audit.actions().then(d => setActions(d.actions || [])).catch(() => {});
+ }, []);
+
+ const totalPages = Math.ceil(total / perPage) || 1;
+
+ function fmtDate(d) {
+ if (!d) return '\u2014';
+ return new Date(d).toLocaleString(undefined, { dateStyle: 'short', timeStyle: 'short' });
+ }
+
+ // Extract unique resource types from actions (action format: resource.verb)
+ const resourceTypes = [...new Set(actions.map(a => a.split('.')[0]).filter(Boolean))];
+
+ return html`
+
+
+
+ ${loading
+ ? html`
Loading\u2026
`
+ : html`
+
+ ${entries.length === 0 && html`
No audit entries
`}
+ ${entries.map(e => html`
+
+ ${fmtDate(e.created_at)}
+ ${e.actor_name || '\u2014'}
+ ${e.action}
+ ${e.resource_type}/${e.resource_id?.substring(0, 8) || '\u2014'}
+ ${e.ip_address || ''}
+
+ `)}
+
+
+
+ setPage(p => p - 1)}>\u2190 Prev
+ Page ${page} of ${totalPages} (${total} total)
+ = totalPages} onClick=${() => setPage(p => p + 1)}>Next \u2192
+
+ `
+ }
+
+ `;
+}
diff --git a/src/js/sw/surfaces/admin/broadcast.js b/src/js/sw/surfaces/admin/broadcast.js
new file mode 100644
index 0000000..b161fbc
--- /dev/null
+++ b/src/js/sw/surfaces/admin/broadcast.js
@@ -0,0 +1,44 @@
+/**
+ * Admin > System > Broadcast
+ */
+const { html } = window;
+const { useState } = hooks;
+
+export default function BroadcastSection() {
+ const [title, setTitle] = useState('');
+ const [message, setMessage] = useState('');
+ const [level, setLevel] = useState('info');
+ const [sending, setSending] = useState(false);
+
+ async function send() {
+ if (!title.trim() || !message.trim()) return sw.toast('Title and message are required', 'error');
+ setSending(true);
+ try {
+ const result = await sw.api.admin.notifications.broadcast({ title: title.trim(), message: message.trim(), level });
+ sw.toast(`Broadcast sent to ${result.count || '?'} users`, 'success');
+ setTitle('');
+ setMessage('');
+ } catch (e) { sw.toast(e.message, 'error'); }
+ finally { setSending(false); }
+ }
+
+ return html`
+
+
Send a system announcement to all active users.
+
Title
+ setTitle(e.target.value)} placeholder="Scheduled Maintenance" />
+
+
Message
+
+
+
Level
+ setLevel(e.target.value)}>
+ Info
+ Warning
+ Critical
+
+
+
${sending ? 'Sending\u2026' : 'Send Broadcast'}
+
+ `;
+}
diff --git a/src/js/sw/surfaces/admin/capabilities.js b/src/js/sw/surfaces/admin/capabilities.js
new file mode 100644
index 0000000..df235c6
--- /dev/null
+++ b/src/js/sw/surfaces/admin/capabilities.js
@@ -0,0 +1,105 @@
+/**
+ * Admin > Routing > Capabilities
+ */
+const { html } = window;
+const { useState, useEffect, useCallback } = hooks;
+
+const CAP_FIELDS = ['supports_vision', 'supports_tools', 'supports_thinking', 'context_window', 'max_output_tokens'];
+
+export default function CapabilitiesSection() {
+ const [overrides, setOverrides] = useState([]);
+ const [models, setModels] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [editing, setEditing] = useState(null); // model_id string or null
+
+ const load = useCallback(async () => {
+ try {
+ const [o, m] = await Promise.all([
+ sw.api.admin.models.overrides(),
+ sw.api.admin.models.list().catch(() => []),
+ ]);
+ setOverrides(Array.isArray(o) ? o : o.data || []);
+ setModels(Array.isArray(m) ? m : m.models || m.data || []);
+ } catch (e) { sw.toast(e.message, 'error'); }
+ finally { setLoading(false); }
+ }, []);
+
+ useEffect(() => { load(); }, []);
+
+ async function saveOverride(modelId, field, value) {
+ try {
+ await sw.api.admin.models.setCapability(modelId, { [field]: value });
+ sw.toast('Override saved', 'success');
+ load();
+ } catch (e) { sw.toast(e.message, 'error'); }
+ }
+
+ async function deleteOverride(modelId, overrideId) {
+ try {
+ await sw.api.admin.models.delCapability(modelId, overrideId);
+ sw.toast('Override removed', 'success');
+ load();
+ } catch (e) { sw.toast(e.message, 'error'); }
+ }
+
+ if (loading) return html`
Loading\u2026
`;
+
+ return html`
+
+
+ Override auto-detected model capabilities. ${overrides.length} active overrides.
+
+
+
+ ${overrides.length === 0 && html`
No capability overrides
`}
+ ${overrides.map(o => html`
+
+
+ ${o.model_id || o.display_name || o.id}
+ deleteOverride(o.model_id, o.id)}>Remove
+
+
+ ${Object.entries(o).filter(([k]) => CAP_FIELDS.includes(k) && o[k] != null).map(([k, v]) =>
+ html`${k}: ${String(v)} `
+ )}
+
+
+ `)}
+
+
+
+
Add Override
+
+ ${editing && html`
+
+ ${CAP_FIELDS.map(field => {
+ const isBool = field.startsWith('supports_');
+ return html`
+
+ ${field}
+ ${isBool
+ ? html`
+ saveOverride(editing, field, true)}>true
+ saveOverride(editing, field, false)}>false
+ `
+ : html`
+ { if (e.key === 'Enter') saveOverride(editing, field, parseInt(e.target.value)); }} />
+ `
+ }
+
+ `;
+ })}
+
+ `}
+
+
+ `;
+}
diff --git a/src/js/sw/surfaces/admin/channels.js b/src/js/sw/surfaces/admin/channels.js
new file mode 100644
index 0000000..66cc8d3
--- /dev/null
+++ b/src/js/sw/surfaces/admin/channels.js
@@ -0,0 +1,56 @@
+/**
+ * Admin > System > Channels (Archived)
+ */
+const { html } = window;
+const { useState, useEffect, useCallback } = hooks;
+
+export default function ChannelsSection() {
+ const [channels, setChannels] = useState([]);
+ const [total, setTotal] = useState(0);
+ const [retentionMode, setRetentionMode] = useState('');
+ const [loading, setLoading] = useState(true);
+
+ const load = useCallback(async () => {
+ try {
+ const data = await sw.api.admin.channels.archived();
+ setChannels(data.channels || data.data || []);
+ setTotal(data.total || 0);
+ setRetentionMode(data.retention_mode || '');
+ } catch (e) { sw.toast(e.message, 'error'); }
+ finally { setLoading(false); }
+ }, []);
+
+ useEffect(() => { load(); }, []);
+
+ async function purge(id, title) {
+ const ok = await sw.confirm(`Permanently delete "${title || id}"? This cannot be undone.`, true);
+ if (!ok) return;
+ try {
+ await sw.api.admin.channels.purge(id);
+ sw.toast('Channel purged', 'success');
+ load();
+ } catch (e) { sw.toast(e.message, 'error'); }
+ }
+
+ if (loading) return html`
Loading\u2026
`;
+
+ return html`
+
+
+
+
${retentionMode || '\u2014'}
Retention Mode
+
+
+
+ ${channels.length === 0 && html`
No archived channels
`}
+ ${channels.map(c => html`
+
+ ${c.title || c.id}
+ ${c.archived_at ? new Date(c.archived_at).toLocaleDateString() : ''}
+ purge(c.id, c.title)}>Purge
+
+ `)}
+
+
+ `;
+}
diff --git a/src/js/sw/surfaces/admin/dashboard.js b/src/js/sw/surfaces/admin/dashboard.js
new file mode 100644
index 0000000..d6981f5
--- /dev/null
+++ b/src/js/sw/surfaces/admin/dashboard.js
@@ -0,0 +1,90 @@
+/**
+ * Admin > Monitoring > Dashboard
+ */
+const { html } = window;
+const { useState, useEffect, useCallback, useRef } = hooks;
+
+export default function DashboardSection() {
+ const [data, setData] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const timerRef = useRef(null);
+
+ const load = useCallback(async () => {
+ try {
+ const d = await sw.api.admin.dashboard();
+ setData(d);
+ } catch (e) { sw.toast(e.message, 'error'); }
+ finally { setLoading(false); }
+ }, []);
+
+ useEffect(() => {
+ load();
+ timerRef.current = setInterval(load, 10000);
+ return () => clearInterval(timerRef.current);
+ }, []);
+
+ function formatUptime(seconds) {
+ if (!seconds) return '\u2014';
+ const d = Math.floor(seconds / 86400);
+ const h = Math.floor((seconds % 86400) / 3600);
+ const m = Math.floor((seconds % 3600) / 60);
+ if (d > 0) return `${d}d ${h}h`;
+ if (h > 0) return `${h}h ${m}m`;
+ return `${m}m`;
+ }
+
+ function formatBytes(b) {
+ if (!b) return '0 B';
+ if (b < 1048576) return (b / 1024).toFixed(0) + ' KB';
+ if (b < 1073741824) return (b / 1048576).toFixed(1) + ' MB';
+ return (b / 1073741824).toFixed(2) + ' GB';
+ }
+
+ if (loading) return html`
Loading\u2026
`;
+ if (!data) return html`
No dashboard data
`;
+
+ return html`
+
+
Auto-refreshes every 10s
+
+
+
${formatUptime(data.uptime_seconds)}
Uptime
+
${data.active_connections ?? '\u2014'}
Connections
+
${data.goroutines ?? '\u2014'}
Goroutines
+
${formatBytes(data.heap_alloc)}
Heap
+
+
+ ${data.db_pool && html`
+
+
${data.db_pool.open || 0}
DB Open
+
${data.db_pool.in_use || 0}
DB In Use
+
${data.db_pool.idle || 0}
DB Idle
+
+ `}
+
+ ${data.providers && data.providers.length > 0 && html`
+
Provider Health
+
+ ${data.providers.map(p => html`
+
+
+ ${p.name || p.id}
+ ${p.status}
+
+
${Math.round(p.avg_latency_ms || 0)}ms avg
+
+ `)}
+
+ `}
+
+ ${data.recent_errors && data.recent_errors.length > 0 && html`
+
Recent Errors
+
+ ${data.recent_errors.map((e, i) => html`
+
${e}
+ `)}
+
+ `}
+
+ `;
+}
diff --git a/src/js/sw/surfaces/admin/groups.js b/src/js/sw/surfaces/admin/groups.js
new file mode 100644
index 0000000..14eb147
--- /dev/null
+++ b/src/js/sw/surfaces/admin/groups.js
@@ -0,0 +1,254 @@
+/**
+ * Admin > People > Groups
+ */
+const { html } = window;
+const { useState, useEffect, useCallback } = hooks;
+
+export default function GroupsSection() {
+ const [groups, setGroups] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [detail, setDetail] = useState(null);
+ const [members, setMembers] = useState([]);
+ const [allPerms, setAllPerms] = useState([]);
+ const [allUsers, setAllUsers] = useState([]);
+ const [allModels, setAllModels] = useState([]);
+ const [groupData, setGroupData] = useState({});
+ const [showCreate, setShowCreate] = useState(false);
+ const [showAddMember, setShowAddMember] = useState(false);
+
+ const loadGroups = useCallback(async () => {
+ try {
+ const data = await sw.api.admin.groups.list();
+ setGroups(Array.isArray(data) ? data : data.data || []);
+ } catch (e) { sw.toast(e.message, 'error'); }
+ finally { setLoading(false); }
+ }, []);
+
+ useEffect(() => { loadGroups(); }, []);
+
+ async function openDetail(group) {
+ setDetail(group);
+ setGroupData({
+ permissions: group.permissions || [],
+ token_budget_daily: group.token_budget_daily ?? '',
+ token_budget_monthly: group.token_budget_monthly ?? '',
+ allowed_models: group.allowed_models || [],
+ });
+ try {
+ const [m, p, u, models] = await Promise.all([
+ sw.api.admin.groups.members(group.id),
+ sw.api.admin.permissions.list(),
+ sw.api.admin.users.list(),
+ sw.api.admin.models.list().catch(() => []),
+ ]);
+ setMembers(Array.isArray(m) ? m : m.data || []);
+ setAllPerms(p.permissions || []);
+ setAllUsers(Array.isArray(u) ? u : u.users || []);
+ setAllModels(Array.isArray(models) ? models : models.models || models.data || []);
+ } catch (e) { sw.toast(e.message, 'error'); }
+ }
+
+ async function createGroup(e) {
+ e.preventDefault();
+ const form = e.target;
+ try {
+ await sw.api.admin.groups.create({
+ name: form.name.value.trim(),
+ description: form.desc.value.trim(),
+ scope: form.scope.value,
+ });
+ sw.toast('Group created', 'success');
+ setShowCreate(false);
+ loadGroups();
+ } catch (e) { sw.toast(e.message, 'error'); }
+ }
+
+ async function savePermsAndBudgets() {
+ try {
+ await sw.api.admin.groups.update(detail.id, {
+ permissions: groupData.permissions,
+ token_budget_daily: groupData.token_budget_daily || null,
+ token_budget_monthly: groupData.token_budget_monthly || null,
+ allowed_models: groupData.allowed_models.length ? groupData.allowed_models : null,
+ });
+ sw.toast('Saved', 'success');
+ } catch (e) { sw.toast(e.message, 'error'); }
+ }
+
+ function togglePerm(perm) {
+ setGroupData(prev => {
+ const perms = prev.permissions.includes(perm)
+ ? prev.permissions.filter(p => p !== perm)
+ : [...prev.permissions, perm];
+ return { ...prev, permissions: perms };
+ });
+ }
+
+ function toggleModel(modelId) {
+ setGroupData(prev => {
+ const list = prev.allowed_models.includes(modelId)
+ ? prev.allowed_models.filter(m => m !== modelId)
+ : [...prev.allowed_models, modelId];
+ return { ...prev, allowed_models: list };
+ });
+ }
+
+ async function addMember(e) {
+ e.preventDefault();
+ const uid = e.target.userId.value;
+ if (!uid) return;
+ try {
+ await sw.api.admin.groups.addMember(detail.id, uid);
+ sw.toast('Member added', 'success');
+ setShowAddMember(false);
+ const m = await sw.api.admin.groups.members(detail.id);
+ setMembers(Array.isArray(m) ? m : m.data || []);
+ } catch (e) { sw.toast(e.message, 'error'); }
+ }
+
+ async function removeMember(uid) {
+ try {
+ await sw.api.admin.groups.removeMember(detail.id, uid);
+ sw.toast('Member removed', 'success');
+ setMembers(prev => prev.filter(m => (m.user_id || m.id) !== uid));
+ } catch (e) { sw.toast(e.message, 'error'); }
+ }
+
+ async function deleteGroup() {
+ const ok = await sw.confirm('Delete this group?', true);
+ if (!ok) return;
+ try {
+ await sw.api.admin.groups.del(detail.id);
+ sw.toast('Group deleted', 'success');
+ setDetail(null);
+ loadGroups();
+ } catch (e) { sw.toast(e.message, 'error'); }
+ }
+
+ if (loading) return html`
Loading\u2026
`;
+
+ if (detail) return html`
+
+
+
setDetail(null)}>\u2190 Back
+
${detail.name}
+
${detail.scope}
+
+
Delete
+
+
+
+
Permissions
+
+ ${allPerms.map(p => html`
+
+ togglePerm(p)} />
+
+ ${p}
+
+ `)}
+
+
+
+
+
+
+
Allowed Models
+
Leave empty for unrestricted.
+
+ ${allModels.map(m => html`
+
+ toggleModel(m.model_id || m.id)} />
+
+ ${m.display_name || m.model_id || m.id}
+
+ `)}
+
+
+
+
+ Save Permissions & Budgets
+
+
+
+
+
Members
+
+ ${members.map(m => html`
+
+ ${m.username || m.user_id}
+ removeMember(m.user_id || m.id)}>Remove
+
+ `)}
+
+ ${showAddMember
+ ? html`
+
+ `
+ : html`
setShowAddMember(true)}>+ Add Member `
+ }
+
+ `;
+
+ return html`
+
+
+
+
setShowCreate(true)}>+ Add
+
+
+ ${showCreate && html`
+
+ `}
+
+
+ ${groups.length === 0 && html`
No groups
`}
+ ${groups.map(g => html`
+
openDetail(g)}>
+ ${g.name}
+ ${g.scope}
+
+ `)}
+
+
+ `;
+}
diff --git a/src/js/sw/surfaces/admin/health.js b/src/js/sw/surfaces/admin/health.js
new file mode 100644
index 0000000..c395646
--- /dev/null
+++ b/src/js/sw/surfaces/admin/health.js
@@ -0,0 +1,57 @@
+/**
+ * Admin > Routing > Health
+ */
+const { html } = window;
+const { useState, useEffect, useCallback, useRef } = hooks;
+
+export default function HealthSection() {
+ const [providers, setProviders] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const timerRef = useRef(null);
+
+ const load = useCallback(async () => {
+ try {
+ const data = await sw.api.admin.providers.health();
+ setProviders(Array.isArray(data) ? data : data.data || []);
+ } catch (e) { sw.toast(e.message, 'error'); }
+ finally { setLoading(false); }
+ }, []);
+
+ useEffect(() => {
+ load();
+ timerRef.current = setInterval(load, 30000);
+ return () => clearInterval(timerRef.current);
+ }, []);
+
+ function statusClass(s) {
+ if (s === 'healthy') return 'badge-active';
+ if (s === 'degraded') return 'badge-pending';
+ return 'badge-inactive';
+ }
+
+ if (loading) return html`
Loading\u2026
`;
+
+ return html`
+
+
Auto-refreshes every 30s
+
+ ${providers.length === 0 && html`
No providers
`}
+ ${providers.map(p => html`
+
+
+ ${p.provider_config_name || p.provider_config_id}
+ ${p.status}
+
+
+
Error rate: ${((p.error_rate || 0) * 100).toFixed(1)}%
+
Avg latency: ${Math.round(p.avg_latency_ms || 0)}ms
+
Timeout rate: ${((p.timeout_rate || 0) * 100).toFixed(1)}%
+
Rate limits: ${p.rate_limit_count || 0}
+ ${p.last_error && html`
Last error: ${p.last_error}
`}
+
+
+ `)}
+
+
+ `;
+}
diff --git a/src/js/sw/surfaces/admin/index.js b/src/js/sw/surfaces/admin/index.js
new file mode 100644
index 0000000..6492d1b
--- /dev/null
+++ b/src/js/sw/surfaces/admin/index.js
@@ -0,0 +1,218 @@
+/**
+ * AdminSurface — root admin surface
+ *
+ * Reads globals:
+ * __SECTION__ — active section name (string)
+ * __BASE__ — base path
+ *
+ * Layout: topbar (back + category tabs) + body (sidebar nav + content area).
+ * All 24 sections are native Preact components loaded lazily.
+ */
+const { html } = window;
+const { useState, useEffect, useCallback, useMemo } = hooks;
+const { render } = preact;
+
+import { ToastContainer } from '../../primitives/toast.js';
+import { DialogStack } from '../../shell/dialog-stack.js';
+
+// ── Category → Section mapping ──────────────
+const ADMIN_SECTIONS = {
+ people: ['users', 'teams', 'groups'],
+ ai: ['providers', 'models', 'personas', 'roles', 'knowledgeBases', 'memory'],
+ workflows: ['workflows', 'tasks'],
+ routing: ['health', 'routing', 'capabilities'],
+ system: ['settings', 'storage', 'packages', 'channels', 'broadcast'],
+ monitoring: ['dashboard', 'usage', 'audit', 'stats'],
+};
+
+const ADMIN_LABELS = {
+ users: 'Users', teams: 'Teams', groups: 'Groups',
+ providers: 'Providers', models: 'Models', personas: 'Personas',
+ roles: 'Roles', knowledgeBases: 'Knowledge', memory: 'Memory',
+ health: 'Health', routing: 'Routing', capabilities: 'Capabilities',
+ workflows: 'Workflows', tasks: 'Tasks',
+ settings: 'Settings', storage: 'Storage', packages: 'Packages',
+ channels: 'Channels', broadcast: 'Broadcast',
+ dashboard: 'Dashboard', usage: 'Usage', audit: 'Audit', stats: 'Stats',
+};
+
+const CATEGORY_META = {
+ people: { label: 'People', first: 'users', icon: 'M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2|C9 7 4' },
+ ai: { label: 'AI', first: 'providers', icon: 'R4 4 16 16 2|L9 9 9.01 9|L15 9 15.01 9|M8 14s1.5 2 4 2 4-2 4-2' },
+ workflows: { label: 'Workflows', first: 'workflows', icon: 'P16 3 21 3 21 8|L4 20 21 3|P21 16 21 21 16 21|L15 15 21 21|L4 4 9 9' },
+ routing: { label: 'Routing', first: 'health', icon: 'G13 2 3 14 12 14 11 22 21 10 12 10 13 2' },
+ system: { label: 'System', first: 'settings', icon: 'C12 12 3|M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09' },
+ monitoring: { label: 'Monitoring', first: 'dashboard', icon: 'L18 20 18 10|L12 20 12 4|L6 20 6 14' },
+};
+
+// ── Lazy section imports ────────────────────
+const sectionModules = {
+ users: () => import('./users.js'),
+ teams: () => import('./teams.js'),
+ groups: () => import('./groups.js'),
+ providers: () => import('./providers.js'),
+ models: () => import('./models.js'),
+ personas: () => import('./personas.js'),
+ roles: () => import('./roles.js'),
+ knowledgeBases: () => import('./knowledge.js'),
+ memory: () => import('./memory.js'),
+ workflows: () => import('./workflows.js'),
+ tasks: () => import('./tasks.js'),
+ health: () => import('./health.js'),
+ routing: () => import('./routing.js'),
+ capabilities: () => import('./capabilities.js'),
+ settings: () => import('./settings.js'),
+ storage: () => import('./storage.js'),
+ packages: () => import('./packages.js'),
+ channels: () => import('./channels.js'),
+ broadcast: () => import('./broadcast.js'),
+ dashboard: () => import('./dashboard.js'),
+ usage: () => import('./usage.js'),
+ audit: () => import('./audit.js'),
+ stats: () => import('./stats.js'),
+};
+
+// ── Derive category from section ────────────
+function catForSection(section) {
+ for (const [cat, sections] of Object.entries(ADMIN_SECTIONS)) {
+ if (sections.includes(section)) return cat;
+ }
+ return 'people';
+}
+
+// ── Simple SVG icon renderer ────────────────
+function CatIcon({ paths }) {
+ // paths is a compact format: pipe-separated draw commands
+ // C = circle, L = line, P = polyline, R = rect, G = polygon, M = path
+ const els = paths.split('|').map((p, i) => {
+ const t = p[0], d = p.slice(1).trim();
+ if (t === 'C') { const [cx, cy, r] = d.split(' '); return html`
`; }
+ if (t === 'L') { const [x1, y1, x2, y2] = d.split(' '); return html`
`; }
+ if (t === 'P') { return html`
`; }
+ if (t === 'R') { const [x, y, w, h, rx] = d.split(' '); return html`
`; }
+ if (t === 'G') { return html`
`; }
+ if (t === 'M') { return html`
`; }
+ return html`
`;
+ });
+ return html`
${els} `;
+}
+
+function AdminSurface() {
+ const BASE = window.__BASE__ || '';
+ const section = window.__SECTION__ || 'users';
+ const activeCat = catForSection(section);
+
+ const [SectionComponent, setSectionComponent] = useState(null);
+
+ // Load section component
+ useEffect(() => {
+ setSectionComponent(null);
+ const loader = sectionModules[section];
+ if (loader) {
+ loader().then(mod => {
+ const Comp = mod.default || Object.values(mod)[0];
+ setSectionComponent(() => Comp);
+ }).catch(e => console.error('[admin] Failed to load section:', section, e));
+ }
+ }, [section]);
+
+ // Back button with return URL stash
+ useEffect(() => {
+ const RETURN_KEY = 'sb_admin_return';
+ if (!sessionStorage.getItem(RETURN_KEY)) {
+ const ref = document.referrer;
+ if (ref && ref.startsWith(location.origin) && !ref.includes('/admin')) {
+ sessionStorage.setItem(RETURN_KEY, ref);
+ } else {
+ sessionStorage.setItem(RETURN_KEY, location.origin + BASE + '/');
+ }
+ }
+ }, []);
+
+ const backClick = useCallback((e) => {
+ e.preventDefault();
+ const RETURN_KEY = 'sb_admin_return';
+ const returnURL = sessionStorage.getItem(RETURN_KEY);
+ sessionStorage.removeItem(RETURN_KEY);
+ location.href = returnURL || BASE + '/';
+ }, []);
+
+ const navClick = useCallback((e) => {
+ e.preventDefault();
+ const href = e.currentTarget.getAttribute('href');
+ if (href) location.replace(href);
+ }, []);
+
+ const title = ADMIN_LABELS[section] || 'Admin';
+ const sidebarSections = ADMIN_SECTIONS[activeCat] || [];
+
+ return html`
+
+ ${/* Top Bar */``}
+
+
+
+ ${/* Left Nav */``}
+
+
+ ${/* Content */``}
+
+
+
+ ${SectionComponent
+ ? html`<${SectionComponent} />`
+ : html`
Loading\u2026
`
+ }
+
+
+
+
+ <${ToastContainer} />
+ <${DialogStack} />
+ `;
+}
+
+// ── Mount ────────────────────────────────────
+const mount = document.getElementById('admin-mount');
+if (mount) {
+ render(html`<${AdminSurface} />`, mount);
+ console.log('[admin] Surface mounted');
+}
+
+export { AdminSurface };
diff --git a/src/js/sw/surfaces/admin/knowledge.js b/src/js/sw/surfaces/admin/knowledge.js
new file mode 100644
index 0000000..647743f
--- /dev/null
+++ b/src/js/sw/surfaces/admin/knowledge.js
@@ -0,0 +1,132 @@
+/**
+ * Admin > AI > Knowledge Bases
+ */
+const { html } = window;
+const { useState, useEffect, useCallback } = hooks;
+
+export default function KnowledgeSection() {
+ const [kbs, setKbs] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [detail, setDetail] = useState(null);
+ const [docs, setDocs] = useState([]);
+
+ const load = useCallback(async () => {
+ try {
+ const data = await sw.api.knowledge.list();
+ setKbs(Array.isArray(data) ? data : data.data || []);
+ } catch (e) { sw.toast(e.message, 'error'); }
+ finally { setLoading(false); }
+ }, []);
+
+ useEffect(() => { load(); }, []);
+
+ async function openDetail(kb) {
+ setDetail(kb);
+ try {
+ const d = await sw.api.knowledge.documents(kb.id);
+ setDocs(Array.isArray(d) ? d : d.data || []);
+ } catch (e) { sw.toast(e.message, 'error'); }
+ }
+
+ async function uploadDoc() {
+ const input = document.createElement('input');
+ input.type = 'file';
+ input.accept = '.txt,.md,.csv,.html';
+ input.onchange = async () => {
+ const file = input.files[0];
+ if (!file) return;
+ try {
+ await sw.api.knowledge.upload(detail.id, file);
+ sw.toast('Document uploaded', 'success');
+ const d = await sw.api.knowledge.documents(detail.id);
+ setDocs(Array.isArray(d) ? d : d.data || []);
+ } catch (e) { sw.toast(e.message, 'error'); }
+ };
+ input.click();
+ }
+
+ async function deleteDoc(docId) {
+ const ok = await sw.confirm('Delete this document?', true);
+ if (!ok) return;
+ try {
+ await sw.api.knowledge.delDoc(detail.id, docId);
+ sw.toast('Document deleted', 'success');
+ setDocs(prev => prev.filter(d => d.id !== docId));
+ } catch (e) { sw.toast(e.message, 'error'); }
+ }
+
+ async function deleteKb(id) {
+ const ok = await sw.confirm('Delete this knowledge base and all documents?', true);
+ if (!ok) return;
+ try {
+ await sw.api.knowledge.del(id);
+ sw.toast('Knowledge base deleted', 'success');
+ setDetail(null);
+ load();
+ } catch (e) { sw.toast(e.message, 'error'); }
+ }
+
+ function statusBadge(status) {
+ const cls = status === 'ready' ? 'badge-active' : status === 'error' ? 'badge-inactive' : 'badge-pending';
+ return html`
${status} `;
+ }
+
+ function formatBytes(b) {
+ if (!b) return '0 B';
+ if (b < 1024) return b + ' B';
+ if (b < 1048576) return (b / 1024).toFixed(1) + ' KB';
+ return (b / 1048576).toFixed(1) + ' MB';
+ }
+
+ if (loading) return html`
Loading\u2026
`;
+
+ if (detail) return html`
+
+
+
setDetail(null)}>\u2190 Back
+
${detail.name}
+ ${statusBadge(detail.status || 'active')}
+
+
Upload Document
+
deleteKb(detail.id)}>Delete KB
+
+
+
+ ${detail.document_count || 0} docs \u2022 ${detail.chunk_count || 0} chunks \u2022 ${formatBytes(detail.total_bytes)}
+ \u2022 scope: ${detail.scope || 'personal'}
+
+
+
+ ${docs.length === 0 && html`
No documents
`}
+ ${docs.map(d => html`
+
+
+ ${d.filename}
+ ${formatBytes(d.size_bytes)}
+ ${' '}${statusBadge(d.status || 'pending')}
+
+
deleteDoc(d.id)}>Delete
+
+ `)}
+
+
+ `;
+
+ return html`
+
+
+ ${kbs.length === 0 && html`
No knowledge bases
`}
+ ${kbs.map(k => html`
+
openDetail(k)}>
+
+ ${k.name}
+ ${k.document_count || 0} docs
+
+
${k.scope || 'personal'}
+ ${statusBadge(k.status || 'active')}
+
+ `)}
+
+
+ `;
+}
diff --git a/src/js/sw/surfaces/admin/memory.js b/src/js/sw/surfaces/admin/memory.js
new file mode 100644
index 0000000..4a1c6eb
--- /dev/null
+++ b/src/js/sw/surfaces/admin/memory.js
@@ -0,0 +1,76 @@
+/**
+ * Admin > AI > Memory
+ */
+const { html } = window;
+const { useState, useEffect, useCallback } = hooks;
+
+export default function MemorySection() {
+ const [pending, setPending] = useState([]);
+ const [loading, setLoading] = useState(true);
+
+ const load = useCallback(async () => {
+ try {
+ const data = await sw.api.admin.memories.pending();
+ setPending(Array.isArray(data) ? data : data.data || []);
+ } catch (e) { sw.toast(e.message, 'error'); }
+ finally { setLoading(false); }
+ }, []);
+
+ useEffect(() => { load(); }, []);
+
+ async function bulkApprove() {
+ const ids = pending.map(m => m.id);
+ if (!ids.length) return;
+ try {
+ const result = await sw.api.admin.memories.bulkApprove(ids);
+ sw.toast(`Approved ${result.count || ids.length} memories`, 'success');
+ load();
+ } catch (e) { sw.toast(e.message, 'error'); }
+ }
+
+ async function approveSingle(id) {
+ try {
+ await sw.api.memory.approve(id);
+ sw.toast('Memory approved', 'success');
+ setPending(prev => prev.filter(m => m.id !== id));
+ } catch (e) { sw.toast(e.message, 'error'); }
+ }
+
+ async function rejectSingle(id) {
+ try {
+ await sw.api.memory.reject(id);
+ sw.toast('Memory rejected', 'success');
+ setPending(prev => prev.filter(m => m.id !== id));
+ } catch (e) { sw.toast(e.message, 'error'); }
+ }
+
+ if (loading) return html`
Loading\u2026
`;
+
+ return html`
+
+
+
${pending.length} pending
+
+ ${pending.length > 0 && html`
Approve All `}
+
+
+
+ ${pending.length === 0 && html`
No pending memories
`}
+ ${pending.map(m => html`
+
+
+ ${m.key}
+ ${m.scope || 'user'}
+ conf: ${(m.confidence || 0).toFixed(2)}
+
+
${m.value}
+
+ approveSingle(m.id)}>Approve
+ rejectSingle(m.id)}>Reject
+
+
+ `)}
+
+
+ `;
+}
diff --git a/src/js/sw/surfaces/admin/models.js b/src/js/sw/surfaces/admin/models.js
new file mode 100644
index 0000000..9ea5c48
--- /dev/null
+++ b/src/js/sw/surfaces/admin/models.js
@@ -0,0 +1,95 @@
+/**
+ * Admin > AI > Models
+ */
+const { html } = window;
+const { useState, useEffect, useCallback } = hooks;
+
+export default function ModelsSection() {
+ const [models, setModels] = useState([]);
+ const [search, setSearch] = useState('');
+ const [loading, setLoading] = useState(true);
+ const [fetching, setFetching] = useState(false);
+
+ const load = useCallback(async () => {
+ try {
+ const data = await sw.api.admin.models.list();
+ setModels(Array.isArray(data) ? data : data.models || data.data || []);
+ } catch (e) { sw.toast(e.message, 'error'); }
+ finally { setLoading(false); }
+ }, []);
+
+ useEffect(() => { load(); }, []);
+
+ async function fetchModels() {
+ setFetching(true);
+ try {
+ const result = await sw.api.admin.models.fetch();
+ sw.toast(`Synced: ${result.added || 0} added, ${result.updated || 0} updated, ${result.total || 0} total`, 'success');
+ load();
+ } catch (e) { sw.toast(e.message, 'error'); }
+ finally { setFetching(false); }
+ }
+
+ async function setVisibility(id, visibility) {
+ try {
+ await sw.api.admin.models.update(id, { visibility });
+ setModels(prev => prev.map(m => m.id === id ? { ...m, visibility } : m));
+ } catch (e) { sw.toast(e.message, 'error'); }
+ }
+
+ async function bulkSetVisibility(visibility) {
+ try {
+ await sw.api.admin.models.bulkUpdate(visibility);
+ sw.toast(`All models set to ${visibility}`, 'success');
+ load();
+ } catch (e) { sw.toast(e.message, 'error'); }
+ }
+
+ const filtered = models.filter(m => {
+ if (!search) return true;
+ const q = search.toLowerCase();
+ return (m.model_id || m.id || '').toLowerCase().includes(q) ||
+ (m.display_name || '').toLowerCase().includes(q) ||
+ (m.provider_name || '').toLowerCase().includes(q);
+ });
+
+ if (loading) return html`
Loading\u2026
`;
+
+ return html`
+
+
+
+ ${fetching ? 'Fetching\u2026' : 'Fetch Models'}
+
+
${models.length} models
+
+
bulkSetVisibility('enabled')}>Enable All
+
bulkSetVisibility('disabled')}>Disable All
+
+
+
+
+ setSearch(e.target.value)} autocomplete="off" />
+
+
+
+ ${filtered.length === 0 && html`
No models found
`}
+ ${filtered.map(m => html`
+
+
+ ${m.display_name || m.model_id || m.id}
+ ${m.display_name && m.model_id && html`${m.model_id} `}
+ ${m.provider_name && html`${m.provider_name} `}
+
+
setVisibility(m.id, e.target.value)}
+ style="font-size:11px;padding:2px 4px;">
+ enabled
+ disabled
+ team only
+
+
+ `)}
+
+
+ `;
+}
diff --git a/src/js/sw/surfaces/admin/packages.js b/src/js/sw/surfaces/admin/packages.js
new file mode 100644
index 0000000..5322ce8
--- /dev/null
+++ b/src/js/sw/surfaces/admin/packages.js
@@ -0,0 +1,69 @@
+/**
+ * Admin > System > Packages (Extensions)
+ */
+const { html } = window;
+const { useState, useEffect, useCallback } = hooks;
+
+export default function PackagesSection() {
+ const [extensions, setExtensions] = useState([]);
+ const [loading, setLoading] = useState(true);
+
+ const load = useCallback(async () => {
+ try {
+ const data = await sw.api.admin.extensions.list();
+ setExtensions(Array.isArray(data) ? data : data.data || []);
+ } catch (e) { sw.toast(e.message, 'error'); }
+ finally { setLoading(false); }
+ }, []);
+
+ useEffect(() => { load(); }, []);
+
+ async function toggleEnabled(ext) {
+ try {
+ await sw.api.admin.extensions.update(ext.id, { is_enabled: !ext.is_enabled });
+ sw.toast(`Extension ${ext.is_enabled ? 'disabled' : 'enabled'}`, 'success');
+ load();
+ } catch (e) { sw.toast(e.message, 'error'); }
+ }
+
+ async function deleteExt(id) {
+ const ok = await sw.confirm('Delete this extension?', true);
+ if (!ok) return;
+ try {
+ await sw.api.admin.extensions.del(id);
+ sw.toast('Extension deleted', 'success');
+ load();
+ } catch (e) { sw.toast(e.message, 'error'); }
+ }
+
+ function tierBadge(tier) {
+ const cls = tier === 'browser' ? 'badge-active' : tier === 'starlark' ? 'badge-pending' : '';
+ return html`
${tier || 'unknown'} `;
+ }
+
+ if (loading) return html`
Loading\u2026
`;
+
+ return html`
+
+
+ ${extensions.length === 0 && html`
No extensions installed
`}
+ ${extensions.map(ext => html`
+
+
+ ${ext.name}
+ v${ext.version || '?'}
+ ${' '}${tierBadge(ext.tier)}
+ ${ext.is_system && html`system `}
+
+
+ toggleEnabled(ext)}>
+ ${ext.is_enabled ? 'Disable' : 'Enable'}
+
+ ${!ext.is_system && html` deleteExt(ext.id)}>Delete `}
+
+
+ `)}
+
+
+ `;
+}
diff --git a/src/js/sw/surfaces/admin/personas.js b/src/js/sw/surfaces/admin/personas.js
new file mode 100644
index 0000000..18de419
--- /dev/null
+++ b/src/js/sw/surfaces/admin/personas.js
@@ -0,0 +1,225 @@
+/**
+ * Admin > AI > Personas
+ */
+const { html } = window;
+const { useState, useEffect, useCallback } = hooks;
+
+export default function PersonasSection() {
+ const [personas, setPersonas] = useState([]);
+ const [models, setModels] = useState([]);
+ const [kbs, setKbs] = useState([]);
+ const [search, setSearch] = useState('');
+ const [loading, setLoading] = useState(true);
+ const [editing, setEditing] = useState(null); // null | 'new' | persona object
+ const [editKbs, setEditKbs] = useState([]);
+ const [editToolGrants, setEditToolGrants] = useState([]);
+
+ const load = useCallback(async () => {
+ try {
+ const [p, m, k] = await Promise.all([
+ sw.api.admin.personas.list(),
+ sw.api.admin.models.list().catch(() => []),
+ sw.api.knowledge.list().catch(() => []),
+ ]);
+ setPersonas(Array.isArray(p) ? p : p.data || []);
+ setModels(Array.isArray(m) ? m : m.models || m.data || []);
+ setKbs(Array.isArray(k) ? k : k.data || []);
+ } catch (e) { sw.toast(e.message, 'error'); }
+ finally { setLoading(false); }
+ }, []);
+
+ useEffect(() => { load(); }, []);
+
+ async function startEdit(persona) {
+ setEditing(persona);
+ if (persona !== 'new') {
+ try {
+ const [kbData, tgData] = await Promise.all([
+ sw.api.admin.personas.kbs(persona.id).catch(() => []),
+ sw.api.admin.personas.toolGrants(persona.id).catch(() => ({ tool_names: [] })),
+ ]);
+ setEditKbs(Array.isArray(kbData) ? kbData.map(k => k.kb_id || k.id) : kbData.kb_ids || []);
+ setEditToolGrants(tgData.tool_names || []);
+ } catch { setEditKbs([]); setEditToolGrants([]); }
+ } else {
+ setEditKbs([]);
+ setEditToolGrants([]);
+ }
+ }
+
+ async function savePersona(e) {
+ e.preventDefault();
+ const form = e.target;
+ const data = {
+ name: form.pname.value.trim(),
+ handle: form.handle.value.trim(),
+ description: form.description.value.trim(),
+ icon: form.icon.value.trim(),
+ system_prompt: form.system_prompt.value,
+ base_model_id: form.model.value || null,
+ temperature: form.temperature.value ? parseFloat(form.temperature.value) : null,
+ max_tokens: form.max_tokens.value ? parseInt(form.max_tokens.value) : null,
+ memory_enabled: form.memory_enabled.checked,
+ };
+ try {
+ let id;
+ if (editing === 'new') {
+ const res = await sw.api.admin.personas.create(data);
+ id = res.id;
+ sw.toast('Persona created', 'success');
+ } else {
+ id = editing.id;
+ await sw.api.admin.personas.update(id, data);
+ sw.toast('Persona updated', 'success');
+ }
+ // Save KB bindings
+ if (id) {
+ await sw.api.admin.personas.setKbs(id, { kb_ids: editKbs }).catch(() => {});
+ await sw.api.admin.personas.setToolGrants(id, { tool_names: editToolGrants }).catch(() => {});
+ }
+ setEditing(null);
+ load();
+ } catch (e) { sw.toast(e.message, 'error'); }
+ }
+
+ async function deletePersona(id) {
+ const ok = await sw.confirm('Delete this persona?', true);
+ if (!ok) return;
+ try {
+ await sw.api.admin.personas.del(id);
+ sw.toast('Persona deleted', 'success');
+ load();
+ } catch (e) { sw.toast(e.message, 'error'); }
+ }
+
+ async function uploadAvatar(id) {
+ const input = document.createElement('input');
+ input.type = 'file';
+ input.accept = 'image/*';
+ input.onchange = async () => {
+ const file = input.files[0];
+ if (!file) return;
+ const reader = new FileReader();
+ reader.onload = async () => {
+ try {
+ await sw.api.admin.personas.avatar(id, reader.result);
+ sw.toast('Avatar uploaded', 'success');
+ load();
+ } catch (e) { sw.toast(e.message, 'error'); }
+ };
+ reader.readAsDataURL(file);
+ };
+ input.click();
+ }
+
+ function toggleKb(kbId) {
+ setEditKbs(prev => prev.includes(kbId) ? prev.filter(k => k !== kbId) : [...prev, kbId]);
+ }
+
+ const filtered = personas.filter(p => {
+ if (!search) return true;
+ const q = search.toLowerCase();
+ return (p.name || '').toLowerCase().includes(q) || (p.handle || '').toLowerCase().includes(q);
+ });
+
+ if (loading) return html`
Loading\u2026
`;
+
+ if (editing) {
+ const isNew = editing === 'new';
+ const p = isNew ? {} : editing;
+ return html`
+
+ `;
+ }
+
+ return html`
+
+
+
+
+ setSearch(e.target.value)} autocomplete="off" />
+
+
+
startEdit('new')}>+ Add
+
+
+
+ ${filtered.length === 0 && html`
No personas
`}
+ ${filtered.map(p => html`
+
+
${p.icon || '\ud83e\udd16'}
+
+ ${p.name}
+ @${p.handle || '?'}
+ ${p.scope && html`${p.scope} `}
+
+
+ startEdit(p)}>Edit
+ deletePersona(p.id)}>Delete
+
+
+ `)}
+
+
+ `;
+}
diff --git a/src/js/sw/surfaces/admin/providers.js b/src/js/sw/surfaces/admin/providers.js
new file mode 100644
index 0000000..5a64498
--- /dev/null
+++ b/src/js/sw/surfaces/admin/providers.js
@@ -0,0 +1,130 @@
+/**
+ * Admin > AI > Providers
+ */
+const { html } = window;
+const { useState, useEffect, useCallback } = hooks;
+
+export default function ProvidersSection() {
+ const [configs, setConfigs] = useState([]);
+ const [providerTypes, setProviderTypes] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [editing, setEditing] = useState(null); // null | 'new' | config object
+
+ const load = useCallback(async () => {
+ try {
+ const [c, t] = await Promise.all([
+ sw.api.admin.configs.list(),
+ sw.api.admin.providers.types(),
+ ]);
+ setConfigs(Array.isArray(c) ? c : c.configs || c.data || []);
+ setProviderTypes(t.types || t.data || []);
+ } catch (e) { sw.toast(e.message, 'error'); }
+ finally { setLoading(false); }
+ }, []);
+
+ useEffect(() => { load(); }, []);
+
+ async function saveProvider(e) {
+ e.preventDefault();
+ const form = e.target;
+ const data = {
+ name: form.pname.value.trim(),
+ provider: form.provider.value,
+ endpoint: form.endpoint.value.trim(),
+ api_key: form.api_key.value || undefined,
+ is_private: form.is_private.checked,
+ };
+ try {
+ if (editing === 'new') {
+ await sw.api.admin.configs.create(data);
+ sw.toast('Provider created', 'success');
+ } else {
+ await sw.api.admin.configs.update(editing.id, data);
+ sw.toast('Provider updated', 'success');
+ }
+ setEditing(null);
+ load();
+ } catch (e) { sw.toast(e.message, 'error'); }
+ }
+
+ async function syncModels(id) {
+ try {
+ const result = await sw.api.admin.models.fetch();
+ sw.toast(`Synced: ${result.added || 0} added, ${result.updated || 0} updated`, 'success');
+ } catch (e) { sw.toast(e.message, 'error'); }
+ }
+
+ async function deleteProvider(id) {
+ const ok = await sw.confirm('Delete this provider config?', true);
+ if (!ok) return;
+ try {
+ await sw.api.admin.configs.del(id);
+ sw.toast('Provider deleted', 'success');
+ load();
+ } catch (e) { sw.toast(e.message, 'error'); }
+ }
+
+ if (loading) return html`
Loading\u2026
`;
+
+ return html`
+
+
+
+
setEditing('new')}>+ Add
+
+
+ ${editing && html`
+
+ ${editing === 'new' ? 'Add Provider' : 'Edit Provider'}
+
+
+
+
+
+ Private (admin personas only)
+
+
+ ${editing === 'new' ? 'Create' : 'Save'}
+ setEditing(null)}>Cancel
+
+
+ `}
+
+
+ ${configs.length === 0 && html`
No provider configs
`}
+ ${configs.map(c => html`
+
+
+ ${c.name}
+ ${c.provider}
+ ${c.is_private && html`private `}
+ ${c.has_key ? html`key set ` : html`no key `}
+
+
${c.endpoint || '(default)'}
+
+ setEditing(c)}>Edit
+ syncModels(c.id)}>Sync Models
+ deleteProvider(c.id)}>Delete
+
+
+ `)}
+
+
+ `;
+}
diff --git a/src/js/sw/surfaces/admin/roles.js b/src/js/sw/surfaces/admin/roles.js
new file mode 100644
index 0000000..7841148
--- /dev/null
+++ b/src/js/sw/surfaces/admin/roles.js
@@ -0,0 +1,83 @@
+/**
+ * Admin > AI > Roles
+ */
+const { html } = window;
+const { useState, useEffect, useCallback } = hooks;
+
+export default function RolesSection() {
+ const [roles, setRoles] = useState([]);
+ const [models, setModels] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [dirty, setDirty] = useState({});
+
+ const load = useCallback(async () => {
+ try {
+ const [r, m] = await Promise.all([
+ sw.api.admin.roles.list(),
+ sw.api.admin.models.list().catch(() => []),
+ ]);
+ setRoles(r.roles || r.data || (Array.isArray(r) ? r : []));
+ setModels(Array.isArray(m) ? m : m.models || m.data || []);
+ } catch (e) { sw.toast(e.message, 'error'); }
+ finally { setLoading(false); }
+ }, []);
+
+ useEffect(() => { load(); }, []);
+
+ function updateRole(roleName, field, value) {
+ setDirty(prev => ({
+ ...prev,
+ [roleName]: { ...(prev[roleName] || {}), [field]: value },
+ }));
+ }
+
+ async function saveRole(roleName) {
+ const changes = dirty[roleName];
+ if (!changes) return;
+ try {
+ // Get current role config and merge changes
+ const current = await sw.api.admin.roles.get(roleName);
+ const updated = { ...current, ...changes };
+ await sw.api.admin.roles.update(roleName, updated);
+ sw.toast(`Role "${roleName}" saved`, 'success');
+ setDirty(prev => { const n = { ...prev }; delete n[roleName]; return n; });
+ load();
+ } catch (e) { sw.toast(e.message, 'error'); }
+ }
+
+ if (loading) return html`
Loading\u2026
`;
+
+ return html`
+
+ ${roles.length === 0 && html`
No roles configured
`}
+ ${roles.map(r => {
+ const name = typeof r === 'string' ? r : r.name || r.role;
+ const config = typeof r === 'object' ? r : {};
+ const changes = dirty[name] || {};
+ const primary = changes.primary_model ?? config.primary_model ?? '';
+ const fallback = changes.fallback_model ?? config.fallback_model ?? '';
+
+ return html`
+
+
${name}
+
+ ${dirty[name] && html`
saveRole(name)}>Save `}
+
+ `;
+ })}
+
+ `;
+}
diff --git a/src/js/sw/surfaces/admin/routing.js b/src/js/sw/surfaces/admin/routing.js
new file mode 100644
index 0000000..4c86562
--- /dev/null
+++ b/src/js/sw/surfaces/admin/routing.js
@@ -0,0 +1,159 @@
+/**
+ * Admin > Routing > Routing Policies
+ */
+const { html } = window;
+const { useState, useEffect, useCallback } = hooks;
+
+const POLICY_TYPES = ['provider_prefer', 'team_route', 'cost_limit', 'model_alias', 'capability_match'];
+
+export default function RoutingSection() {
+ const [policies, setPolicies] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [editing, setEditing] = useState(null); // null | 'new' | policy
+ const [testModel, setTestModel] = useState('');
+ const [testUser, setTestUser] = useState('');
+ const [testResult, setTestResult] = useState(null);
+
+ const load = useCallback(async () => {
+ try {
+ const data = await sw.api.admin.routing.policies();
+ setPolicies(Array.isArray(data) ? data : data.data || []);
+ } catch (e) { sw.toast(e.message, 'error'); }
+ finally { setLoading(false); }
+ }, []);
+
+ useEffect(() => { load(); }, []);
+
+ async function savePolicy(e) {
+ e.preventDefault();
+ const form = e.target;
+ const data = {
+ name: form.pname.value.trim(),
+ scope: form.scope.value,
+ priority: parseInt(form.priority.value) || 0,
+ policy_type: form.policy_type.value,
+ config: JSON.parse(form.config.value || '{}'),
+ is_active: form.is_active.checked,
+ };
+ try {
+ if (editing === 'new') {
+ await sw.api.admin.routing.createPolicy(data);
+ sw.toast('Policy created', 'success');
+ } else {
+ await sw.api.admin.routing.updatePolicy(editing.id, data);
+ sw.toast('Policy updated', 'success');
+ }
+ setEditing(null);
+ load();
+ } catch (e) { sw.toast(e.message, 'error'); }
+ }
+
+ async function deletePolicy(id) {
+ const ok = await sw.confirm('Delete this routing policy?', true);
+ if (!ok) return;
+ try {
+ await sw.api.admin.routing.deletePolicy(id);
+ sw.toast('Policy deleted', 'success');
+ load();
+ } catch (e) { sw.toast(e.message, 'error'); }
+ }
+
+ async function runTest() {
+ if (!testModel) return sw.toast('Model is required', 'error');
+ try {
+ const result = await sw.api.admin.routing.test({
+ model: testModel,
+ user_id: testUser || undefined,
+ });
+ setTestResult(result);
+ } catch (e) { sw.toast(e.message, 'error'); }
+ }
+
+ if (loading) return html`
Loading\u2026
`;
+
+ if (editing) {
+ const isNew = editing === 'new';
+ const p = isNew ? {} : editing;
+ return html`
+
+
+ setEditing(null)}>\u2190 Back
+
${isNew ? 'New Policy' : `Edit: ${p.name}`}
+
+
+
+ Config (JSON)
+ ${JSON.stringify(p.config || {}, null, 2)}
+
+
+
+ Active
+
+
+ ${isNew ? 'Create' : 'Save'}
+ setEditing(null)}>Cancel
+
+
+ `;
+ }
+
+ return html`
+
+
+ setEditing('new')}>+ New Policy
+
+
+
+ ${policies.length === 0 && html`
No routing policies
`}
+ ${policies.map(p => html`
+
+
+ ${p.name}
+ ${p.policy_type}
+ priority: ${p.priority}
+
+
${p.scope}
+
${p.is_active ? 'active' : 'off'}
+
+ setEditing(p)}>Edit
+ deletePolicy(p.id)}>Delete
+
+
+ `)}
+
+
+
+
Test Routing
+
+ ${testResult && html`
+
${JSON.stringify(testResult, null, 2)}
+ `}
+
+
+ `;
+}
diff --git a/src/js/sw/surfaces/admin/settings.js b/src/js/sw/surfaces/admin/settings.js
new file mode 100644
index 0000000..1c77d46
--- /dev/null
+++ b/src/js/sw/surfaces/admin/settings.js
@@ -0,0 +1,203 @@
+/**
+ * Admin > System > Settings
+ */
+const { html } = window;
+const { useState, useEffect, useCallback } = hooks;
+
+export default function SettingsSection() {
+ const [cfg, setCfg] = useState({});
+ const [models, setModels] = useState([]);
+ const [vault, setVault] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [saving, setSaving] = useState(false);
+
+ const load = useCallback(async () => {
+ try {
+ const [s, m, v] = await Promise.all([
+ sw.api.admin.settings.get(),
+ sw.api.admin.models.list().catch(() => []),
+ sw.api.admin.vault.status().catch(() => null),
+ ]);
+ const settings = s || {};
+ setCfg({
+ allow_registration: settings.policies?.allow_registration === 'true',
+ registration_default_state: settings.registration_default_state || 'pending',
+ system_prompt: settings.system_prompt || '',
+ default_model: settings.default_model || '',
+ allow_user_byok: settings.policies?.allow_user_byok === 'true',
+ allow_user_personas: settings.policies?.allow_user_personas === 'true',
+ allow_kb_direct_access: settings.policies?.allow_kb_direct_access === 'true',
+ banner_enabled: !!settings.banner?.enabled,
+ banner_text: settings.banner?.text || '',
+ banner_bg: settings.banner?.bg || '#007a33',
+ banner_fg: settings.banner?.fg || '#ffffff',
+ banner_position: settings.banner?.position || 'both',
+ search_provider: settings.search_provider || 'duckduckgo',
+ search_endpoint: settings.search_endpoint || '',
+ search_api_key: settings.search_api_key || '',
+ search_max_results: settings.search_max_results || 5,
+ compaction_enabled: !!settings.auto_compaction_enabled,
+ compaction_threshold: settings.compaction_threshold || 70,
+ compaction_cooldown: settings.compaction_cooldown || 30,
+ memory_extraction_enabled: !!settings.memory_extraction_enabled,
+ memory_auto_approve: !!settings.memory_auto_approve,
+ });
+ setModels(Array.isArray(m) ? m : m.models || m.data || []);
+ setVault(v);
+ } catch (e) { sw.toast(e.message, 'error'); }
+ finally { setLoading(false); }
+ }, []);
+
+ useEffect(() => { load(); }, []);
+
+ function set(key, val) { setCfg(prev => ({ ...prev, [key]: val })); }
+
+ async function save() {
+ setSaving(true);
+ try {
+ // Policies
+ await sw.api.admin.settings.update('allow_registration', { value: String(cfg.allow_registration) });
+ await sw.api.admin.settings.update('registration_default_state', { value: cfg.registration_default_state });
+ await sw.api.admin.settings.update('allow_user_byok', { value: String(cfg.allow_user_byok) });
+ await sw.api.admin.settings.update('allow_user_personas', { value: String(cfg.allow_user_personas) });
+ await sw.api.admin.settings.update('allow_kb_direct_access', { value: String(cfg.allow_kb_direct_access) });
+
+ // System prompt & default model
+ await sw.api.admin.settings.update('system_prompt', { value: cfg.system_prompt });
+ if (cfg.default_model) await sw.api.admin.settings.update('default_model', { value: cfg.default_model });
+
+ // Banner
+ await sw.api.admin.settings.update('banner', { value: {
+ enabled: cfg.banner_enabled,
+ text: cfg.banner_text,
+ bg: cfg.banner_bg,
+ fg: cfg.banner_fg,
+ position: cfg.banner_position,
+ }});
+
+ // Search
+ await sw.api.admin.settings.update('search_provider', { value: cfg.search_provider });
+ if (cfg.search_provider === 'searxng') {
+ await sw.api.admin.settings.update('search_endpoint', { value: cfg.search_endpoint });
+ await sw.api.admin.settings.update('search_api_key', { value: cfg.search_api_key });
+ }
+ await sw.api.admin.settings.update('search_max_results', { value: cfg.search_max_results });
+
+ // Compaction
+ await sw.api.admin.settings.update('auto_compaction_enabled', { value: String(cfg.compaction_enabled) });
+ await sw.api.admin.settings.update('compaction_threshold', { value: cfg.compaction_threshold });
+ await sw.api.admin.settings.update('compaction_cooldown', { value: cfg.compaction_cooldown });
+
+ // Memory
+ await sw.api.admin.settings.update('memory_extraction_enabled', { value: String(cfg.memory_extraction_enabled) });
+ await sw.api.admin.settings.update('memory_auto_approve', { value: String(cfg.memory_auto_approve) });
+
+ sw.toast('Settings saved', 'success');
+ } catch (e) { sw.toast(e.message, 'error'); }
+ finally { setSaving(false); }
+ }
+
+ async function testEmail() {
+ try {
+ await sw.api.admin.notifications.testEmail();
+ sw.toast('Test email sent', 'success');
+ } catch (e) { sw.toast(e.message, 'error'); }
+ }
+
+ if (loading) return html`
Loading\u2026
`;
+
+ return html`
+
+ `;
+}
diff --git a/src/js/sw/surfaces/admin/stats.js b/src/js/sw/surfaces/admin/stats.js
new file mode 100644
index 0000000..11f61d4
--- /dev/null
+++ b/src/js/sw/surfaces/admin/stats.js
@@ -0,0 +1,40 @@
+/**
+ * Admin > Monitoring > Stats
+ */
+const { html } = window;
+const { useState, useEffect } = hooks;
+
+export default function StatsSection() {
+ const [stats, setStats] = useState(null);
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ sw.api.admin.stats()
+ .then(d => setStats(d))
+ .catch(e => sw.toast(e.message, 'error'))
+ .finally(() => setLoading(false));
+ }, []);
+
+ if (loading) return html`
Loading\u2026
`;
+ if (!stats) return html`
No stats available
`;
+
+ const cards = [
+ { label: 'Users', value: stats.users },
+ { label: 'Channels', value: stats.channels },
+ { label: 'Messages', value: stats.messages },
+ { label: 'Models', value: stats.models },
+ { label: 'Providers', value: stats.providers },
+ { label: 'Personas', value: stats.personas },
+ ];
+
+ return html`
+
+ ${cards.map(c => html`
+
+
${c.value != null ? c.value.toLocaleString() : '\u2014'}
+
${c.label}
+
+ `)}
+
+ `;
+}
diff --git a/src/js/sw/surfaces/admin/storage.js b/src/js/sw/surfaces/admin/storage.js
new file mode 100644
index 0000000..4ed27ab
--- /dev/null
+++ b/src/js/sw/surfaces/admin/storage.js
@@ -0,0 +1,76 @@
+/**
+ * Admin > System > Storage
+ */
+const { html } = window;
+const { useState, useEffect, useCallback } = hooks;
+
+export default function StorageSection() {
+ const [status, setStatus] = useState(null);
+ const [orphans, setOrphans] = useState(null);
+ const [extraction, setExtraction] = useState(null);
+ const [loading, setLoading] = useState(true);
+
+ const load = useCallback(async () => {
+ try {
+ const [s, o, e] = await Promise.all([
+ sw.api.admin.storage.status(),
+ sw.api.admin.storage.orphans(),
+ sw.api.admin.storage.extraction().catch(() => null),
+ ]);
+ setStatus(s);
+ setOrphans(o);
+ setExtraction(e);
+ } catch (e) { sw.toast(e.message, 'error'); }
+ finally { setLoading(false); }
+ }, []);
+
+ useEffect(() => { load(); }, []);
+
+ async function cleanup() {
+ const ok = await sw.confirm('Remove orphaned blobs?');
+ if (!ok) return;
+ try {
+ await sw.api.admin.storage.cleanup();
+ sw.toast('Cleanup complete', 'success');
+ load();
+ } catch (e) { sw.toast(e.message, 'error'); }
+ }
+
+ function formatBytes(b) {
+ if (!b) return '0 B';
+ if (b < 1024) return b + ' B';
+ if (b < 1048576) return (b / 1024).toFixed(1) + ' KB';
+ if (b < 1073741824) return (b / 1048576).toFixed(1) + ' MB';
+ return (b / 1073741824).toFixed(2) + ' GB';
+ }
+
+ if (loading) return html`
Loading\u2026
`;
+
+ return html`
+
+ ${status && html`
+
+
${status.backend || '\u2014'}
Backend
+
${status.healthy ? 'Healthy' : 'Unhealthy'}
Status
+
${status.file_count ?? '\u2014'}
Files
+
${formatBytes(status.total_bytes)}
Total Size
+
+ `}
+
+ ${orphans && html`
+
+
Orphaned Blobs
+
${orphans.count || 0} orphans found
+ ${(orphans.count || 0) > 0 && html`
Clean Up `}
+
+ `}
+
+ ${extraction && html`
+
+
Extraction Queue
+
${JSON.stringify(extraction, null, 2)}
+
+ `}
+
+ `;
+}
diff --git a/src/js/sw/surfaces/admin/tasks.js b/src/js/sw/surfaces/admin/tasks.js
new file mode 100644
index 0000000..dec0eac
--- /dev/null
+++ b/src/js/sw/surfaces/admin/tasks.js
@@ -0,0 +1,106 @@
+/**
+ * Admin > Workflows > Tasks
+ */
+const { html } = window;
+const { useState, useEffect, useCallback } = hooks;
+
+export default function TasksSection() {
+ const [tasks, setTasks] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [expandedRuns, setExpandedRuns] = useState({}); // taskId → runs[]
+
+ const load = useCallback(async () => {
+ try {
+ const data = await sw.api.admin.tasks.list();
+ setTasks(Array.isArray(data) ? data : data.data || []);
+ } catch (e) { sw.toast(e.message, 'error'); }
+ finally { setLoading(false); }
+ }, []);
+
+ useEffect(() => { load(); }, []);
+
+ async function runTask(id) {
+ try {
+ await sw.api.admin.tasks.run(id);
+ sw.toast('Task triggered', 'success');
+ } catch (e) { sw.toast(e.message, 'error'); }
+ }
+
+ async function killTask(id) {
+ try {
+ await sw.api.admin.tasks.kill(id);
+ sw.toast('Task cancelled', 'success');
+ } catch (e) { sw.toast(e.message, 'error'); }
+ }
+
+ async function deleteTask(id) {
+ const ok = await sw.confirm('Delete this task?', true);
+ if (!ok) return;
+ try {
+ await sw.api.admin.tasks.del(id);
+ sw.toast('Task deleted', 'success');
+ load();
+ } catch (e) { sw.toast(e.message, 'error'); }
+ }
+
+ async function toggleRuns(taskId) {
+ if (expandedRuns[taskId]) {
+ setExpandedRuns(prev => { const n = { ...prev }; delete n[taskId]; return n; });
+ return;
+ }
+ try {
+ const data = await sw.api.tasks.runs(taskId);
+ const runs = Array.isArray(data) ? data : data.data || [];
+ setExpandedRuns(prev => ({ ...prev, [taskId]: runs }));
+ } catch (e) { sw.toast(e.message, 'error'); }
+ }
+
+ function fmtDate(d) {
+ if (!d) return '\u2014';
+ return new Date(d).toLocaleString(undefined, { dateStyle: 'short', timeStyle: 'short' });
+ }
+
+ if (loading) return html`
Loading\u2026
`;
+
+ return html`
+
+
+ ${tasks.length === 0 && html`
No tasks
`}
+ ${tasks.map(t => html`
+
+
+
+ ${t.name}
+ ${t.scope || 'personal'}
+ ${t.schedule && html`${t.schedule} `}
+
+
+ last: ${fmtDate(t.last_run_at)} \u2022 next: ${fmtDate(t.next_run_at)}
+
+
${t.is_active ? 'active' : 'paused'}
+
+ toggleRuns(t.id)}>${expandedRuns[t.id] ? 'Hide Runs' : 'Runs'}
+ runTask(t.id)}>Run
+ killTask(t.id)}>Kill
+ deleteTask(t.id)}>Delete
+
+
+ ${expandedRuns[t.id] && html`
+
+ ${expandedRuns[t.id].length === 0 && html`
No runs `}
+ ${expandedRuns[t.id].map(r => html`
+
+ ${r.status}
+ ${fmtDate(r.started_at)}
+ ${r.tokens_used || 0} tokens
+ ${r.error && html`${r.error} `}
+
+ `)}
+
+ `}
+
+ `)}
+
+
+ `;
+}
diff --git a/src/js/sw/surfaces/admin/teams.js b/src/js/sw/surfaces/admin/teams.js
new file mode 100644
index 0000000..3802db9
--- /dev/null
+++ b/src/js/sw/surfaces/admin/teams.js
@@ -0,0 +1,176 @@
+/**
+ * Admin > People > Teams
+ */
+const { html } = window;
+const { useState, useEffect, useCallback } = hooks;
+
+export default function TeamsSection() {
+ const [teams, setTeams] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [detail, setDetail] = useState(null); // team object or null
+ const [members, setMembers] = useState([]);
+ const [allUsers, setAllUsers] = useState([]);
+ const [showCreate, setShowCreate] = useState(false);
+ const [showAddMember, setShowAddMember] = useState(false);
+
+ const loadTeams = useCallback(async () => {
+ try {
+ const data = await sw.api.admin.teams.list();
+ setTeams(Array.isArray(data) ? data : data.data || []);
+ } catch (e) { sw.toast(e.message, 'error'); }
+ finally { setLoading(false); }
+ }, []);
+
+ useEffect(() => { loadTeams(); }, []);
+
+ async function openDetail(team) {
+ setDetail(team);
+ try {
+ const [m, u] = await Promise.all([
+ sw.api.admin.teams.members(team.id),
+ sw.api.admin.users.list(),
+ ]);
+ setMembers(Array.isArray(m) ? m : m.data || []);
+ const uList = Array.isArray(u) ? u : u.users || [];
+ setAllUsers(uList);
+ } catch (e) { sw.toast(e.message, 'error'); }
+ }
+
+ async function createTeam(e) {
+ e.preventDefault();
+ const form = e.target;
+ try {
+ await sw.api.admin.teams.create({ name: form.name.value.trim(), description: form.desc.value.trim() });
+ sw.toast('Team created', 'success');
+ setShowCreate(false);
+ loadTeams();
+ } catch (e) { sw.toast(e.message, 'error'); }
+ }
+
+ async function deleteTeam(id) {
+ const ok = await sw.confirm('Delete this team? All members will be removed.', true);
+ if (!ok) return;
+ try {
+ await sw.api.admin.teams.del(id);
+ sw.toast('Team deleted', 'success');
+ setDetail(null);
+ loadTeams();
+ } catch (e) { sw.toast(e.message, 'error'); }
+ }
+
+ async function addMember(e) {
+ e.preventDefault();
+ const form = e.target;
+ try {
+ await sw.api.admin.teams.addMember(detail.id, form.userId.value, form.role.value);
+ sw.toast('Member added', 'success');
+ setShowAddMember(false);
+ const m = await sw.api.admin.teams.members(detail.id);
+ setMembers(Array.isArray(m) ? m : m.data || []);
+ } catch (e) { sw.toast(e.message, 'error'); }
+ }
+
+ async function removeMember(mid) {
+ try {
+ await sw.api.admin.teams.removeMember(detail.id, mid);
+ sw.toast('Member removed', 'success');
+ setMembers(prev => prev.filter(m => m.id !== mid));
+ } catch (e) { sw.toast(e.message, 'error'); }
+ }
+
+ async function updateMemberRole(mid, role) {
+ try {
+ await sw.api.admin.teams.updateMember(detail.id, mid, role);
+ sw.toast('Role updated', 'success');
+ setMembers(prev => prev.map(m => m.id === mid ? { ...m, role } : m));
+ } catch (e) { sw.toast(e.message, 'error'); }
+ }
+
+ if (loading) return html`
Loading\u2026
`;
+
+ // Detail view
+ if (detail) return html`
+
+
+
setDetail(null)}>\u2190 Back
+
${detail.name}
+
+
deleteTeam(detail.id)}>Delete Team
+
+
+ ${detail.description && html`
${detail.description}
`}
+
+
Members (${members.length})
+
+ ${members.map(m => html`
+
+ ${m.username || m.user_id}
+ updateMemberRole(m.id, e.target.value)}
+ style="font-size:11px;padding:2px 4px;">
+ member
+ admin
+
+ removeMember(m.id)}>Remove
+
+ `)}
+
+
+ ${showAddMember
+ ? html`
+
+
+
+ Add
+ setShowAddMember(false)}>Cancel
+
+
+ `
+ : html`
setShowAddMember(true)}>+ Add Member `
+ }
+
+ `;
+
+ // List view
+ return html`
+
+
+
+
setShowCreate(true)}>+ Add
+
+
+ ${showCreate && html`
+
+
+
+ Create
+ setShowCreate(false)}>Cancel
+
+
+ `}
+
+
+ ${teams.length === 0 && html`
No teams
`}
+ ${teams.map(t => html`
+
openDetail(t)}>
+ ${t.name}
+ ${t.member_count ?? '?'} members
+ ${t.is_active ? 'active' : 'inactive'}
+
+ `)}
+
+
+ `;
+}
diff --git a/src/js/sw/surfaces/admin/usage.js b/src/js/sw/surfaces/admin/usage.js
new file mode 100644
index 0000000..662ce2d
--- /dev/null
+++ b/src/js/sw/surfaces/admin/usage.js
@@ -0,0 +1,91 @@
+/**
+ * Admin > Monitoring > Usage
+ */
+const { html } = window;
+const { useState, useEffect, useCallback } = hooks;
+
+export default function UsageSection() {
+ const [usage, setUsage] = useState(null);
+ const [pricing, setPricing] = useState([]);
+ const [loading, setLoading] = useState(true);
+
+ const load = useCallback(async () => {
+ try {
+ const [u, p] = await Promise.all([
+ sw.api.admin.usage.get(),
+ sw.api.admin.pricing.list().catch(() => []),
+ ]);
+ setUsage(u);
+ setPricing(Array.isArray(p) ? p : p.data || []);
+ } catch (e) { sw.toast(e.message, 'error'); }
+ finally { setLoading(false); }
+ }, []);
+
+ useEffect(() => { load(); }, []);
+
+ async function deletePricing(provId, modelId) {
+ try {
+ await sw.api.admin.pricing.del(provId, modelId);
+ sw.toast('Pricing entry removed', 'success');
+ load();
+ } catch (e) { sw.toast(e.message, 'error'); }
+ }
+
+ function fmtNum(n) {
+ if (n == null) return '\u2014';
+ return n.toLocaleString();
+ }
+
+ if (loading) return html`
Loading\u2026
`;
+
+ return html`
+
+ ${usage?.totals && html`
+
+
${fmtNum(usage.totals.total_tokens)}
Total Tokens
+
${fmtNum(usage.totals.total_requests)}
Total Requests
+
$${(usage.totals.total_cost || 0).toFixed(2)}
Total Cost
+
+ `}
+
+ ${usage?.results && usage.results.length > 0 && html`
+
Usage by Day
+
+ ${usage.results.map((r, i) => html`
+
+ ${r.date || r.day || '\u2014'}
+ ${fmtNum(r.tokens)} tokens
+ ${r.requests || 0} requests
+ $${(r.cost || 0).toFixed(2)}
+
+ `)}
+
+ `}
+
+
Pricing Table
+ ${pricing.length === 0
+ ? html`
No custom pricing entries
`
+ : html`
+
+
+ Model
+ Input/M
+ Output/M
+ Currency
+
+
+ ${pricing.map(p => html`
+
+ ${p.model_id}
+ $${(p.input_per_m || 0).toFixed(2)}
+ $${(p.output_per_m || 0).toFixed(2)}
+ ${p.currency || 'USD'}
+ deletePricing(p.provider_config_id, p.model_id)}>Del
+
+ `)}
+
+ `
+ }
+
+ `;
+}
diff --git a/src/js/sw/surfaces/admin/users.js b/src/js/sw/surfaces/admin/users.js
new file mode 100644
index 0000000..43e9b86
--- /dev/null
+++ b/src/js/sw/surfaces/admin/users.js
@@ -0,0 +1,159 @@
+/**
+ * Admin > People > Users
+ */
+const { html } = window;
+const { useState, useEffect, useCallback } = hooks;
+
+function esc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
+
+export default function UsersSection() {
+ const [users, setUsers] = useState([]);
+ const [search, setSearch] = useState('');
+ const [loading, setLoading] = useState(true);
+ const [showCreate, setShowCreate] = useState(false);
+
+ const load = useCallback(async () => {
+ try {
+ const data = await sw.api.admin.users.list();
+ setUsers(Array.isArray(data) ? data : data.users || []);
+ } catch (e) { sw.toast(e.message, 'error'); }
+ finally { setLoading(false); }
+ }, []);
+
+ useEffect(() => { load(); }, []);
+
+ const filtered = users.filter(u => {
+ if (!search) return true;
+ const q = search.toLowerCase();
+ return u.username?.toLowerCase().includes(q) || u.email?.toLowerCase().includes(q);
+ });
+
+ async function createUser(e) {
+ e.preventDefault();
+ const form = e.target;
+ const data = {
+ username: form.username.value.trim(),
+ email: form.email.value.trim(),
+ password: form.password.value,
+ role: form.role.value,
+ };
+ if (!data.username || !data.password) return sw.toast('Username and password required', 'error');
+ try {
+ await sw.api.admin.users.create(data);
+ sw.toast('User created', 'success');
+ setShowCreate(false);
+ load();
+ } catch (e) { sw.toast(e.message, 'error'); }
+ }
+
+ async function toggleActive(id, active) {
+ try {
+ await sw.api.admin.users.toggleActive(id, active);
+ sw.toast(`User ${active ? 'activated' : 'deactivated'}`, 'success');
+ load();
+ } catch (e) { sw.toast(e.message, 'error'); }
+ }
+
+ async function changeRole(id, role) {
+ try {
+ await sw.api.admin.users.updateRole(id, role);
+ sw.toast(`Role updated to ${role}`, 'success');
+ load();
+ } catch (e) { sw.toast(e.message, 'error'); }
+ }
+
+ async function resetPassword(id, username) {
+ const pw = await sw.prompt(`New password for "${username}":`, '');
+ if (!pw) return;
+ try {
+ await sw.api.admin.users.resetPassword(id, pw);
+ sw.toast('Password reset', 'success');
+ } catch (e) { sw.toast(e.message, 'error'); }
+ }
+
+ async function approveUser(id) {
+ try {
+ await sw.api.admin.users.toggleActive(id, true);
+ sw.toast('User approved', 'success');
+ load();
+ } catch (e) { sw.toast(e.message, 'error'); }
+ }
+
+ async function deleteUser(id, username) {
+ const ok = await sw.confirm(`Delete user "${username}"? This is permanent and destroys their vault.`, true);
+ if (!ok) return;
+ try {
+ await sw.api.admin.users.del(id);
+ sw.toast('User deleted', 'success');
+ load();
+ } catch (e) { sw.toast(e.message, 'error'); }
+ }
+
+ function statusBadge(u) {
+ if (u.status === 'pending') return html`
pending `;
+ if (!u.is_active) return html`
inactive `;
+ return html`
active `;
+ }
+
+ if (loading) return html`
Loading\u2026
`;
+
+ return html`
+
+
+
+
+ setSearch(e.target.value)} autocomplete="off" />
+
+
+
setShowCreate(true)}>+ Add
+
+
+ ${showCreate && html`
+
+
+
+ Create
+ setShowCreate(false)}>Cancel
+
+
+ `}
+
+
+ ${filtered.length === 0 && html`
No users found
`}
+ ${filtered.map(u => html`
+
+
+ ${u.username}
+ ${u.email || ''}
+ ${' '}${statusBadge(u)}
+ ${' '}${u.role}
+
+
+ ${u.status === 'pending' && html`
+ approveUser(u.id)}>Approve
+ `}
+ changeRole(u.id, e.target.value)}
+ style="font-size:11px;padding:2px 4px;">
+ user
+ admin
+
+ ${u.is_active
+ ? html` toggleActive(u.id, false)}>Disable `
+ : html` toggleActive(u.id, true)}>Enable `
+ }
+ resetPassword(u.id, u.username)}>Reset PW
+ deleteUser(u.id, u.username)}>Delete
+
+
+ `)}
+
+
+ `;
+}
diff --git a/src/js/sw/surfaces/admin/workflows.js b/src/js/sw/surfaces/admin/workflows.js
new file mode 100644
index 0000000..da8960c
--- /dev/null
+++ b/src/js/sw/surfaces/admin/workflows.js
@@ -0,0 +1,163 @@
+/**
+ * Admin > Workflows > Workflows
+ */
+const { html } = window;
+const { useState, useEffect, useCallback } = hooks;
+
+const STAGE_MODES = ['chat_only', 'form_only', 'form_chat', 'review'];
+const ENTRY_MODES = ['public_link', 'team_only'];
+
+export default function WorkflowsSection() {
+ const [workflows, setWorkflows] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [editing, setEditing] = useState(null); // null | 'new' | workflow
+ const [stages, setStages] = useState([]);
+ const [showCreate, setShowCreate] = useState(false);
+
+ const load = useCallback(async () => {
+ try {
+ const data = await sw.api.workflows.list();
+ setWorkflows(Array.isArray(data) ? data : data.data || []);
+ } catch (e) { sw.toast(e.message, 'error'); }
+ finally { setLoading(false); }
+ }, []);
+
+ useEffect(() => { load(); }, []);
+
+ async function openEdit(wf) {
+ setEditing(wf);
+ try {
+ const detail = await sw.api.workflows.get(wf.id);
+ setStages(detail.stages || []);
+ } catch (e) { sw.toast(e.message, 'error'); setStages([]); }
+ }
+
+ async function createWorkflow(e) {
+ e.preventDefault();
+ const form = e.target;
+ try {
+ await sw.api.workflows.create({
+ name: form.name.value.trim(),
+ slug: form.slug.value.trim(),
+ entry_mode: form.entry_mode.value,
+ description: form.description.value.trim(),
+ });
+ sw.toast('Workflow created', 'success');
+ setShowCreate(false);
+ load();
+ } catch (e) { sw.toast(e.message, 'error'); }
+ }
+
+ async function updateWorkflow(e) {
+ e.preventDefault();
+ const form = e.target;
+ try {
+ await sw.api.workflows.update(editing.id, {
+ name: form.name.value.trim(),
+ description: form.description.value.trim(),
+ entry_mode: form.entry_mode.value,
+ is_active: form.is_active.checked,
+ });
+ sw.toast('Workflow updated', 'success');
+ setEditing(null);
+ load();
+ } catch (e) { sw.toast(e.message, 'error'); }
+ }
+
+ async function deleteWorkflow(id) {
+ const ok = await sw.confirm('Delete this workflow? All stages, versions, and instances will be deleted.', true);
+ if (!ok) return;
+ try {
+ await sw.api.workflows.del(id);
+ sw.toast('Workflow deleted', 'success');
+ setEditing(null);
+ load();
+ } catch (e) { sw.toast(e.message, 'error'); }
+ }
+
+ if (loading) return html`
Loading\u2026
`;
+
+ if (editing) return html`
+
+
+
setEditing(null)}>\u2190 Back
+
Edit: ${editing.name}
+
+
deleteWorkflow(editing.id)}>Delete
+
+
+ Description
+
+
+ Active
+
+
+ Stages (${stages.length})
+
+ ${stages.map((s, i) => html`
+
+ #${i + 1}
+ ${s.name || `Stage ${i + 1}`}
+ ${s.stage_mode}
+ ${s.persona_id && html`persona `}
+
+ `)}
+ ${stages.length === 0 && html`
No stages defined
`}
+
+
+
+ Save
+
+
+ `;
+
+ return html`
+
+
+
+
setShowCreate(true)}>+ Add
+
+
+ ${showCreate && html`
+
+
+ Description
+
+ Create
+ setShowCreate(false)}>Cancel
+
+
+ `}
+
+
+ ${workflows.length === 0 && html`
No workflows
`}
+ ${workflows.map(w => html`
+
openEdit(w)}>
+
+ ${w.name}
+ /${w.slug}
+
+
${w.entry_mode}
+
${w.is_active ? 'active' : 'inactive'}
+
v${w.version || 0}
+
+ `)}
+
+
+ `;
+}