Changeset 0.37.6 (#218)

Co-authored-by: gobha <jasafpro@gmail.com>
Co-committed-by: gobha <jasafpro@gmail.com>
This commit is contained in:
2026-03-21 16:21:59 +00:00
committed by xcaliber
parent 74f3cb84e9
commit 5f1c733002
30 changed files with 2959 additions and 131 deletions

View File

@@ -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`
<div>
<div class="form-row" style="margin-bottom:12px;">
<div class="form-group"><label>Action</label>
<select value=${filterAction} onChange=${e => { setFilterAction(e.target.value); setPage(1); }}>
<option value="">All</option>
${actions.map(a => html`<option key=${a} value=${a}>${a}</option>`)}
</select>
</div>
<div class="form-group"><label>Resource</label>
<select value=${filterResource} onChange=${e => { setFilterResource(e.target.value); setPage(1); }}>
<option value="">All</option>
${resourceTypes.map(r => html`<option key=${r} value=${r}>${r}</option>`)}
</select>
</div>
</div>
${loading
? html`<div class="settings-placeholder">Loading\u2026</div>`
: html`
<div class="admin-list">
${entries.length === 0 && html`<div class="empty-hint">No audit entries</div>`}
${entries.map(e => html`
<div class="admin-surface-row" key=${e.id} style="font-size:12px;">
<span style="width:140px;">${fmtDate(e.created_at)}</span>
<span style="width:100px;"><strong>${e.actor_name || '\u2014'}</strong></span>
<span class="badge" style="margin:0 4px;">${e.action}</span>
<span style="flex:1;color:var(--text-3);">${e.resource_type}/${e.resource_id?.substring(0, 8) || '\u2014'}</span>
<span class="text-muted" style="width:100px;">${e.ip_address || ''}</span>
</div>
`)}
</div>
<div style="margin-top:12px;display:flex;align-items:center;gap:8px;">
<button class="btn-small" disabled=${page <= 1} onClick=${() => setPage(p => p - 1)}>\u2190 Prev</button>
<span class="text-muted" style="font-size:12px;">Page ${page} of ${totalPages} (${total} total)</span>
<button class="btn-small" disabled=${page >= totalPages} onClick=${() => setPage(p => p + 1)}>Next \u2192</button>
</div>
`
}
</div>
`;
}

View File

@@ -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`
<div>
<p class="text-muted" style="font-size:12px;margin-bottom:12px;">Send a system announcement to all active users.</p>
<div class="form-group"><label>Title</label>
<input value=${title} onInput=${e => setTitle(e.target.value)} placeholder="Scheduled Maintenance" />
</div>
<div class="form-group"><label>Message</label>
<textarea rows="4" value=${message} onInput=${e => setMessage(e.target.value)} placeholder="Servers will be down from 2-4 AM EST."></textarea>
</div>
<div class="form-group"><label>Level</label>
<select value=${level} onChange=${e => setLevel(e.target.value)}>
<option value="info">Info</option>
<option value="warning">Warning</option>
<option value="critical">Critical</option>
</select>
</div>
<button class="btn-md btn-primary" onClick=${send} disabled=${sending}>${sending ? 'Sending\u2026' : 'Send Broadcast'}</button>
</div>
`;
}

View File

@@ -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`<div class="settings-placeholder">Loading\u2026</div>`;
return html`
<div>
<p class="text-muted" style="font-size:12px;margin-bottom:12px;">
Override auto-detected model capabilities. ${overrides.length} active overrides.
</p>
<div class="admin-list">
${overrides.length === 0 && html`<div class="empty-hint">No capability overrides</div>`}
${overrides.map(o => html`
<div class="admin-surface-row" key=${o.id} style="flex-direction:column;align-items:stretch;">
<div style="display:flex;align-items:center;gap:8px;">
<strong style="flex:1;">${o.model_id || o.display_name || o.id}</strong>
<button class="btn-small btn-danger" onClick=${() => deleteOverride(o.model_id, o.id)}>Remove</button>
</div>
<div class="text-muted" style="font-size:12px;margin-top:4px;">
${Object.entries(o).filter(([k]) => CAP_FIELDS.includes(k) && o[k] != null).map(([k, v]) =>
html`<span key=${k} class="badge" style="margin-right:4px;">${k}: ${String(v)}</span>`
)}
</div>
</div>
`)}
</div>
<div class="settings-section" style="margin-top:24px;">
<h4>Add Override</h4>
<div class="form-row">
<div class="form-group"><label>Model</label>
<select value=${editing || ''} onChange=${e => setEditing(e.target.value || null)}>
<option value="">Select model\u2026</option>
${models.map(m => html`<option key=${m.id} value=${m.model_id || m.id}>${m.display_name || m.model_id || m.id}</option>`)}
</select>
</div>
</div>
${editing && html`
<div style="margin-top:8px;">
${CAP_FIELDS.map(field => {
const isBool = field.startsWith('supports_');
return html`
<div key=${field} class="form-row" style="margin-bottom:4px;align-items:center;">
<span style="width:180px;font-size:12px;">${field}</span>
${isBool
? html`
<button class="btn-small btn-primary" onClick=${() => saveOverride(editing, field, true)}>true</button>
<button class="btn-small" onClick=${() => saveOverride(editing, field, false)}>false</button>
`
: html`
<input type="number" style="width:120px;" placeholder="value"
onKeyDown=${e => { if (e.key === 'Enter') saveOverride(editing, field, parseInt(e.target.value)); }} />
`
}
</div>
`;
})}
</div>
`}
</div>
</div>
`;
}

View File

@@ -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`<div class="settings-placeholder">Loading\u2026</div>`;
return html`
<div>
<div class="stat-cards-grid" style="margin-bottom:16px;">
<div class="stat-card"><div class="stat-value">${total}</div><div class="stat-label">Archived</div></div>
<div class="stat-card"><div class="stat-value">${retentionMode || '\u2014'}</div><div class="stat-label">Retention Mode</div></div>
</div>
<div class="admin-list">
${channels.length === 0 && html`<div class="empty-hint">No archived channels</div>`}
${channels.map(c => html`
<div class="admin-surface-row" key=${c.id}>
<strong style="flex:1;">${c.title || c.id}</strong>
<span class="text-muted" style="font-size:11px;">${c.archived_at ? new Date(c.archived_at).toLocaleDateString() : ''}</span>
<button class="btn-small btn-danger" onClick=${() => purge(c.id, c.title)}>Purge</button>
</div>
`)}
</div>
</div>
`;
}

View File

@@ -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`<div class="settings-placeholder">Loading\u2026</div>`;
if (!data) return html`<div class="empty-hint">No dashboard data</div>`;
return html`
<div>
<p class="text-muted" style="font-size:12px;margin-bottom:12px;">Auto-refreshes every 10s</p>
<div class="stat-cards-grid" style="margin-bottom:16px;">
<div class="stat-card"><div class="stat-value">${formatUptime(data.uptime_seconds)}</div><div class="stat-label">Uptime</div></div>
<div class="stat-card"><div class="stat-value">${data.active_connections ?? '\u2014'}</div><div class="stat-label">Connections</div></div>
<div class="stat-card"><div class="stat-value">${data.goroutines ?? '\u2014'}</div><div class="stat-label">Goroutines</div></div>
<div class="stat-card"><div class="stat-value">${formatBytes(data.heap_alloc)}</div><div class="stat-label">Heap</div></div>
</div>
${data.db_pool && html`
<div class="stat-cards-grid" style="margin-bottom:16px;">
<div class="stat-card"><div class="stat-value">${data.db_pool.open || 0}</div><div class="stat-label">DB Open</div></div>
<div class="stat-card"><div class="stat-value">${data.db_pool.in_use || 0}</div><div class="stat-label">DB In Use</div></div>
<div class="stat-card"><div class="stat-value">${data.db_pool.idle || 0}</div><div class="stat-label">DB Idle</div></div>
</div>
`}
${data.providers && data.providers.length > 0 && html`
<h4 style="margin:16px 0 8px;">Provider Health</h4>
<div class="stat-cards-grid">
${data.providers.map(p => html`
<div class="dashboard-provider-card" key=${p.name || p.id}>
<div style="display:flex;align-items:center;gap:6px;margin-bottom:4px;">
<strong style="font-size:13px;">${p.name || p.id}</strong>
<span class="badge ${p.status === 'healthy' ? 'badge-active' : p.status === 'degraded' ? 'badge-pending' : 'badge-inactive'}">${p.status}</span>
</div>
<div style="font-size:11px;color:var(--text-3);">${Math.round(p.avg_latency_ms || 0)}ms avg</div>
</div>
`)}
</div>
`}
${data.recent_errors && data.recent_errors.length > 0 && html`
<h4 style="margin:16px 0 8px;">Recent Errors</h4>
<div class="admin-list">
${data.recent_errors.map((e, i) => html`
<div class="admin-surface-row" key=${i} style="font-size:12px;color:var(--danger);">${e}</div>
`)}
</div>
`}
</div>
`;
}

View File

@@ -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`<div class="settings-placeholder">Loading\u2026</div>`;
if (detail) return html`
<div>
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
<button class="btn-small" onClick=${() => setDetail(null)}>\u2190 Back</button>
<h4 style="margin:0;">${detail.name}</h4>
<span class="text-muted" style="font-size:12px;">${detail.scope}</span>
<div style="flex:1"></div>
<button class="btn-small btn-danger" onClick=${deleteGroup}>Delete</button>
</div>
<div class="settings-section" style="margin-bottom:16px;">
<h5 style="margin:0 0 8px;">Permissions</h5>
<div class="admin-permissions-grid">
${allPerms.map(p => html`
<label class="toggle-label" key=${p}>
<input type="checkbox" checked=${groupData.permissions.includes(p)}
onChange=${() => togglePerm(p)} />
<span class="toggle-track"></span>
<span>${p}</span>
</label>
`)}
</div>
</div>
<div class="settings-section" style="margin-bottom:16px;">
<h5 style="margin:0 0 8px;">Token Budgets</h5>
<div class="form-row" style="gap:12px;">
<div class="form-group" style="flex:1;"><label>Daily limit</label>
<input type="number" placeholder="Unlimited" min="0" value=${groupData.token_budget_daily}
onInput=${e => setGroupData(p => ({ ...p, token_budget_daily: e.target.value ? Number(e.target.value) : '' }))} />
</div>
<div class="form-group" style="flex:1;"><label>Monthly limit</label>
<input type="number" placeholder="Unlimited" min="0" value=${groupData.token_budget_monthly}
onInput=${e => setGroupData(p => ({ ...p, token_budget_monthly: e.target.value ? Number(e.target.value) : '' }))} />
</div>
</div>
</div>
<div class="settings-section" style="margin-bottom:16px;">
<h5 style="margin:0 0 8px;">Allowed Models</h5>
<p class="text-muted" style="font-size:12px;margin:0 0 8px;">Leave empty for unrestricted.</p>
<div class="admin-models-grid">
${allModels.map(m => html`
<label class="toggle-label" key=${m.id || m.model_id}>
<input type="checkbox" checked=${groupData.allowed_models.includes(m.model_id || m.id)}
onChange=${() => toggleModel(m.model_id || m.id)} />
<span class="toggle-track"></span>
<span>${m.display_name || m.model_id || m.id}</span>
</label>
`)}
</div>
</div>
<div style="margin-bottom:16px;">
<button class="btn-small btn-primary" onClick=${savePermsAndBudgets}>Save Permissions & Budgets</button>
</div>
<hr style="border:none;border-top:1px solid var(--border);margin:16px 0;" />
<h5 style="margin:0 0 8px;">Members</h5>
<div class="admin-list">
${members.map(m => html`
<div class="admin-surface-row" key=${m.id || m.user_id}>
<span style="flex:1;">${m.username || m.user_id}</span>
<button class="btn-small btn-danger" onClick=${() => removeMember(m.user_id || m.id)}>Remove</button>
</div>
`)}
</div>
${showAddMember
? html`
<form class="admin-inline-form" style="margin-top:8px;" onSubmit=${addMember}>
<div class="form-row">
<div class="form-group"><label>User</label>
<select name="userId">
<option value="">Select\u2026</option>
${allUsers.map(u => html`<option key=${u.id} value=${u.id}>${u.username}</option>`)}
</select>
</div>
</div>
<div class="form-row" style="margin-top:8px;gap:8px;">
<button type="submit" class="btn-small btn-primary">Add</button>
<button type="button" class="btn-small" onClick=${() => setShowAddMember(false)}>Cancel</button>
</div>
</form>
`
: html`<button class="btn-small" style="margin-top:8px;" onClick=${() => setShowAddMember(true)}>+ Add Member</button>`
}
</div>
`;
return html`
<div>
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
<div style="flex:1"></div>
<button class="btn-md btn-primary" onClick=${() => setShowCreate(true)}>+ Add</button>
</div>
${showCreate && html`
<form class="admin-inline-form" style="margin-bottom:16px;" onSubmit=${createGroup}>
<div class="form-row">
<div class="form-group"><label>Name</label><input name="name" placeholder="Group name" /></div>
<div class="form-group"><label>Description</label><input name="desc" placeholder="Optional" /></div>
<div class="form-group"><label>Scope</label>
<select name="scope"><option value="global">Global</option><option value="team">Team</option></select>
</div>
</div>
<div class="form-row" style="margin-top:8px;gap:8px;">
<button type="submit" class="btn-small btn-primary">Create</button>
<button type="button" class="btn-small" onClick=${() => setShowCreate(false)}>Cancel</button>
</div>
</form>
`}
<div class="admin-list">
${groups.length === 0 && html`<div class="empty-hint">No groups</div>`}
${groups.map(g => html`
<div class="admin-surface-row" key=${g.id} style="cursor:pointer;" onClick=${() => openDetail(g)}>
<strong style="flex:1;">${g.name}</strong>
<span class="text-muted" style="font-size:12px;">${g.scope}</span>
</div>
`)}
</div>
</div>
`;
}

View File

@@ -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`<div class="settings-placeholder">Loading\u2026</div>`;
return html`
<div>
<p class="text-muted" style="font-size:12px;margin-bottom:12px;">Auto-refreshes every 30s</p>
<div class="stat-cards-grid">
${providers.length === 0 && html`<div class="empty-hint">No providers</div>`}
${providers.map(p => html`
<div class="dashboard-provider-card" key=${p.provider_config_id}>
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
<strong>${p.provider_config_name || p.provider_config_id}</strong>
<span class="badge ${statusClass(p.status)}">${p.status}</span>
</div>
<div style="font-size:12px;color:var(--text-2);">
<div>Error rate: ${((p.error_rate || 0) * 100).toFixed(1)}%</div>
<div>Avg latency: ${Math.round(p.avg_latency_ms || 0)}ms</div>
<div>Timeout rate: ${((p.timeout_rate || 0) * 100).toFixed(1)}%</div>
<div>Rate limits: ${p.rate_limit_count || 0}</div>
${p.last_error && html`<div style="color:var(--danger);margin-top:4px;font-size:11px;">Last error: ${p.last_error}</div>`}
</div>
</div>
`)}
</div>
</div>
`;
}

View File

@@ -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`<circle cx=${cx} cy=${cy} r=${r} key=${i} />`; }
if (t === 'L') { const [x1, y1, x2, y2] = d.split(' '); return html`<line x1=${x1} y1=${y1} x2=${x2} y2=${y2} key=${i} />`; }
if (t === 'P') { return html`<polyline points=${d} key=${i} />`; }
if (t === 'R') { const [x, y, w, h, rx] = d.split(' '); return html`<rect x=${x} y=${y} width=${w} height=${h} rx=${rx} key=${i} />`; }
if (t === 'G') { return html`<polygon points=${d} key=${i} />`; }
if (t === 'M') { return html`<path d=${d} key=${i} />`; }
return html`<path d=${p} key=${i} />`;
});
return html`<svg width="13" height="13" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${els}</svg>`;
}
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`
<div class="surface-admin">
${/* Top Bar */``}
<div class="admin-topbar">
<a href="${BASE}/" class="admin-topbar-back" onClick=${backClick}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="19" y1="12" x2="5" y2="12"/>
<polyline points="12 19 5 12 12 5"/>
</svg>
Back
</a>
<div class="admin-topbar-sep"></div>
<span class="admin-topbar-title">
<img src="${BASE}/favicon.svg" alt="" width="18" height="18" style="vertical-align:-3px;" />
Administration
</span>
<div class="admin-category-tabs">
${Object.entries(CATEGORY_META).map(([cat, meta]) => html`
<a key=${cat} href="${BASE}/admin/${meta.first}"
class="admin-cat-btn ${activeCat === cat ? 'active' : ''}"
onClick=${navClick}>
<${CatIcon} paths=${meta.icon} />
${meta.label}
</a>
`)}
</div>
</div>
<div class="admin-body">
${/* Left Nav */``}
<div class="admin-nav">
${sidebarSections.map(key => html`
<a key=${key} href="${BASE}/admin/${key}"
class="admin-nav-link ${section === key ? 'active' : ''}"
onClick=${navClick}>
${ADMIN_LABELS[key] || key}
</a>
`)}
</div>
${/* Content */``}
<div class="admin-content">
<div class="admin-content-header">
<h2>${title}</h2>
</div>
<div class="admin-content-body" id="adminMain">
${SectionComponent
? html`<${SectionComponent} />`
: html`<div class="settings-placeholder">Loading\u2026</div>`
}
</div>
</div>
</div>
</div>
<${ToastContainer} />
<${DialogStack} />
`;
}
// ── Mount ────────────────────────────────────
const mount = document.getElementById('admin-mount');
if (mount) {
render(html`<${AdminSurface} />`, mount);
console.log('[admin] Surface mounted');
}
export { AdminSurface };

View File

@@ -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`<span class="badge ${cls}">${status}</span>`;
}
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`<div class="settings-placeholder">Loading\u2026</div>`;
if (detail) return html`
<div>
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
<button class="btn-small" onClick=${() => setDetail(null)}>\u2190 Back</button>
<h4 style="margin:0;">${detail.name}</h4>
${statusBadge(detail.status || 'active')}
<div style="flex:1"></div>
<button class="btn-small" onClick=${uploadDoc}>Upload Document</button>
<button class="btn-small btn-danger" onClick=${() => deleteKb(detail.id)}>Delete KB</button>
</div>
<p class="text-muted" style="font-size:12px;margin-bottom:12px;">
${detail.document_count || 0} docs \u2022 ${detail.chunk_count || 0} chunks \u2022 ${formatBytes(detail.total_bytes)}
\u2022 scope: ${detail.scope || 'personal'}
</p>
<div class="admin-list">
${docs.length === 0 && html`<div class="empty-hint">No documents</div>`}
${docs.map(d => html`
<div class="admin-surface-row" key=${d.id}>
<div style="flex:1;min-width:0;">
<strong>${d.filename}</strong>
<span class="text-muted" style="margin-left:8px;font-size:11px;">${formatBytes(d.size_bytes)}</span>
${' '}${statusBadge(d.status || 'pending')}
</div>
<button class="btn-small btn-danger" onClick=${() => deleteDoc(d.id)}>Delete</button>
</div>
`)}
</div>
</div>
`;
return html`
<div>
<div class="admin-list">
${kbs.length === 0 && html`<div class="empty-hint">No knowledge bases</div>`}
${kbs.map(k => html`
<div class="admin-surface-row" key=${k.id} style="cursor:pointer;" onClick=${() => openDetail(k)}>
<div style="flex:1;min-width:0;">
<strong>${k.name}</strong>
<span class="text-muted" style="margin-left:8px;font-size:12px;">${k.document_count || 0} docs</span>
</div>
<span class="badge">${k.scope || 'personal'}</span>
${statusBadge(k.status || 'active')}
</div>
`)}
</div>
</div>
`;
}

View File

@@ -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`<div class="settings-placeholder">Loading\u2026</div>`;
return html`
<div>
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
<span class="text-muted" style="font-size:12px;">${pending.length} pending</span>
<div style="flex:1"></div>
${pending.length > 0 && html`<button class="btn-small btn-primary" onClick=${bulkApprove}>Approve All</button>`}
</div>
<div class="admin-list">
${pending.length === 0 && html`<div class="empty-hint">No pending memories</div>`}
${pending.map(m => html`
<div class="admin-surface-row" key=${m.id} style="flex-direction:column;align-items:stretch;">
<div style="display:flex;align-items:center;gap:8px;">
<strong style="flex:1;">${m.key}</strong>
<span class="badge">${m.scope || 'user'}</span>
<span class="text-muted" style="font-size:11px;">conf: ${(m.confidence || 0).toFixed(2)}</span>
</div>
<div class="text-muted" style="font-size:12px;margin:4px 0;">${m.value}</div>
<div style="display:flex;gap:6px;">
<button class="btn-small btn-primary" onClick=${() => approveSingle(m.id)}>Approve</button>
<button class="btn-small btn-danger" onClick=${() => rejectSingle(m.id)}>Reject</button>
</div>
</div>
`)}
</div>
</div>
`;
}

View File

@@ -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`<div class="settings-placeholder">Loading\u2026</div>`;
return html`
<div>
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
<button class="btn-small btn-primary" onClick=${fetchModels} disabled=${fetching}>
${fetching ? 'Fetching\u2026' : 'Fetch Models'}
</button>
<span class="text-muted" style="font-size:12px;">${models.length} models</span>
<div style="flex:1"></div>
<button class="btn-small" onClick=${() => bulkSetVisibility('enabled')}>Enable All</button>
<button class="btn-small" onClick=${() => bulkSetVisibility('disabled')}>Disable All</button>
</div>
<div class="admin-search" style="margin-bottom:12px;">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="color:var(--text-3);flex-shrink:0;"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input placeholder="Search models\u2026" value=${search} onInput=${e => setSearch(e.target.value)} autocomplete="off" />
</div>
<div class="admin-list">
${filtered.length === 0 && html`<div class="empty-hint">No models found</div>`}
${filtered.map(m => html`
<div class="admin-surface-row" key=${m.id}>
<div style="flex:1;min-width:0;">
<strong>${m.display_name || m.model_id || m.id}</strong>
${m.display_name && m.model_id && html`<span class="text-muted" style="margin-left:8px;font-size:11px;">${m.model_id}</span>`}
${m.provider_name && html`<span class="badge" style="margin-left:6px;">${m.provider_name}</span>`}
</div>
<select value=${m.visibility || 'disabled'} onChange=${e => setVisibility(m.id, e.target.value)}
style="font-size:11px;padding:2px 4px;">
<option value="enabled">enabled</option>
<option value="disabled">disabled</option>
<option value="team">team only</option>
</select>
</div>
`)}
</div>
</div>
`;
}

View File

@@ -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`<span class="badge ${cls}">${tier || 'unknown'}</span>`;
}
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
return html`
<div>
<div class="admin-list">
${extensions.length === 0 && html`<div class="empty-hint">No extensions installed</div>`}
${extensions.map(ext => html`
<div class="admin-surface-row" key=${ext.id}>
<div style="flex:1;min-width:0;">
<strong>${ext.name}</strong>
<span class="text-muted" style="margin-left:6px;font-size:11px;">v${ext.version || '?'}</span>
${' '}${tierBadge(ext.tier)}
${ext.is_system && html`<span class="badge" style="margin-left:4px;">system</span>`}
</div>
<div class="admin-actions-cell">
<button class="btn-small" onClick=${() => toggleEnabled(ext)}>
${ext.is_enabled ? 'Disable' : 'Enable'}
</button>
${!ext.is_system && html`<button class="btn-small btn-danger" onClick=${() => deleteExt(ext.id)}>Delete</button>`}
</div>
</div>
`)}
</div>
</div>
`;
}

View File

@@ -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`<div class="settings-placeholder">Loading\u2026</div>`;
if (editing) {
const isNew = editing === 'new';
const p = isNew ? {} : editing;
return html`
<form onSubmit=${savePersona}>
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
<button type="button" class="btn-small" onClick=${() => setEditing(null)}>\u2190 Back</button>
<h4 style="margin:0;">${isNew ? 'Create Persona' : `Edit: ${p.name}`}</h4>
</div>
<div class="form-row">
<div class="form-group"><label>Name</label><input name="pname" value=${p.name || ''} placeholder="Code Reviewer" /></div>
<div class="form-group"><label>Handle</label><input name="handle" value=${p.handle || ''} placeholder="code-reviewer" /></div>
<div class="form-group" style="width:60px;"><label>Icon</label><input name="icon" value=${p.icon || ''} placeholder="\ud83d\udd0d" /></div>
</div>
<div class="form-group"><label>Description</label><input name="description" value=${p.description || ''} placeholder="Optional description" /></div>
<div class="form-group"><label>System Prompt</label>
<textarea name="system_prompt" rows="4" placeholder="System prompt\u2026">${p.system_prompt || ''}</textarea>
</div>
<div class="form-row">
<div class="form-group"><label>Model</label>
<select name="model">
<option value="">Default</option>
${models.map(m => html`<option key=${m.id} value=${m.model_id || m.id} selected=${p.base_model_id === (m.model_id || m.id)}>${m.display_name || m.model_id || m.id}</option>`)}
</select>
</div>
<div class="form-group" style="width:100px;"><label>Temperature</label>
<input name="temperature" type="number" step="0.1" min="0" max="2" value=${p.temperature ?? ''} placeholder="0.7" />
</div>
<div class="form-group" style="width:100px;"><label>Max Tokens</label>
<input name="max_tokens" type="number" min="1" value=${p.max_tokens ?? ''} placeholder="4096" />
</div>
</div>
<label class="toggle-label" style="margin:8px 0;">
<input type="checkbox" name="memory_enabled" checked=${!!p.memory_enabled} />
<span class="toggle-track"></span>
<span>Memory enabled</span>
</label>
${!isNew && html`
<div style="margin:12px 0;">
<button type="button" class="btn-small" onClick=${() => uploadAvatar(p.id)}>Upload Avatar</button>
${p.avatar && html`<button type="button" class="btn-small btn-danger" style="margin-left:6px;"
onClick=${async () => { await sw.api.admin.personas.delAvatar(p.id); sw.toast('Avatar removed', 'success'); load(); }}>Remove Avatar</button>`}
</div>
`}
<div class="settings-section" style="margin:12px 0;">
<h5 style="margin:0 0 8px;">Knowledge Base Bindings</h5>
${kbs.length === 0
? html`<span class="text-muted" style="font-size:12px;">No knowledge bases available</span>`
: kbs.map(k => html`
<label class="toggle-label" key=${k.id}>
<input type="checkbox" checked=${editKbs.includes(k.id)} onChange=${() => toggleKb(k.id)} />
<span class="toggle-track"></span>
<span>${k.name}</span>
</label>
`)
}
</div>
<div class="form-row" style="margin-top:16px;gap:8px;">
<button type="submit" class="btn-small btn-primary">${isNew ? 'Create' : 'Save'}</button>
<button type="button" class="btn-small" onClick=${() => setEditing(null)}>Cancel</button>
</div>
</form>
`;
}
return html`
<div>
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
<div class="admin-search">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="color:var(--text-3);flex-shrink:0;"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input placeholder="Search personas\u2026" value=${search} onInput=${e => setSearch(e.target.value)} autocomplete="off" />
</div>
<div style="flex:1"></div>
<button class="btn-md btn-primary" onClick=${() => startEdit('new')}>+ Add</button>
</div>
<div class="admin-list">
${filtered.length === 0 && html`<div class="empty-hint">No personas</div>`}
${filtered.map(p => html`
<div class="admin-surface-row" key=${p.id}>
<span style="font-size:18px;margin-right:6px;">${p.icon || '\ud83e\udd16'}</span>
<div style="flex:1;min-width:0;">
<strong>${p.name}</strong>
<span class="text-muted" style="margin-left:6px;font-size:11px;">@${p.handle || '?'}</span>
${p.scope && html`<span class="badge" style="margin-left:6px;">${p.scope}</span>`}
</div>
<div class="admin-actions-cell">
<button class="btn-small" onClick=${() => startEdit(p)}>Edit</button>
<button class="btn-small btn-danger" onClick=${() => deletePersona(p.id)}>Delete</button>
</div>
</div>
`)}
</div>
</div>
`;
}

View File

@@ -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`<div class="settings-placeholder">Loading\u2026</div>`;
return html`
<div>
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
<div style="flex:1"></div>
<button class="btn-md btn-primary" onClick=${() => setEditing('new')}>+ Add</button>
</div>
${editing && html`
<form class="admin-inline-form" style="margin-bottom:16px;padding:12px;border:1px solid var(--border);border-radius:var(--radius);" onSubmit=${saveProvider}>
<h4 style="margin:0 0 12px;">${editing === 'new' ? 'Add Provider' : 'Edit Provider'}</h4>
<div class="form-row">
<div class="form-group"><label>Name</label>
<input name="pname" placeholder="My OpenAI" value=${editing !== 'new' ? editing.name || '' : ''} />
</div>
<div class="form-group"><label>Type</label>
<select name="provider">
${providerTypes.map(t => html`<option key=${t.name} value=${t.name} selected=${editing !== 'new' && editing.provider === t.name}>${t.display_name || t.name}</option>`)}
</select>
</div>
</div>
<div class="form-row">
<div class="form-group" style="flex:2;"><label>Endpoint</label>
<input name="endpoint" placeholder="https://api.openai.com/v1" value=${editing !== 'new' ? editing.endpoint || '' : ''} />
</div>
<div class="form-group" style="flex:1;"><label>API Key</label>
<input name="api_key" type="password" placeholder="${editing !== 'new' ? '(unchanged)' : 'sk-...'}" autocomplete="off" />
</div>
</div>
<label class="toggle-label" style="margin:8px 0;">
<input type="checkbox" name="is_private" checked=${editing !== 'new' && editing.is_private} />
<span class="toggle-track"></span>
<span>Private (admin personas only)</span>
</label>
<div class="form-row" style="margin-top:12px;gap:8px;">
<button type="submit" class="btn-small btn-primary">${editing === 'new' ? 'Create' : 'Save'}</button>
<button type="button" class="btn-small" onClick=${() => setEditing(null)}>Cancel</button>
</div>
</form>
`}
<div class="provider-cards">
${configs.length === 0 && html`<div class="empty-hint">No provider configs</div>`}
${configs.map(c => html`
<div class="provider-card" key=${c.id}>
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
<strong>${c.name}</strong>
<span class="badge">${c.provider}</span>
${c.is_private && html`<span class="badge badge-inactive">private</span>`}
${c.has_key ? html`<span class="badge badge-active">key set</span>` : html`<span class="badge badge-pending">no key</span>`}
</div>
<div class="text-muted" style="font-size:12px;margin-bottom:8px;word-break:break-all;">${c.endpoint || '(default)'}</div>
<div style="display:flex;gap:6px;">
<button class="btn-small" onClick=${() => setEditing(c)}>Edit</button>
<button class="btn-small" onClick=${() => syncModels(c.id)}>Sync Models</button>
<button class="btn-small btn-danger" onClick=${() => deleteProvider(c.id)}>Delete</button>
</div>
</div>
`)}
</div>
</div>
`;
}

View File

@@ -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`<div class="settings-placeholder">Loading\u2026</div>`;
return html`
<div>
${roles.length === 0 && html`<div class="empty-hint">No roles configured</div>`}
${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`
<div class="settings-section" key=${name} style="margin-bottom:16px;padding:12px;border:1px solid var(--border);border-radius:var(--radius);">
<h4 style="margin:0 0 8px;">${name}</h4>
<div class="form-row">
<div class="form-group"><label>Primary Model</label>
<select value=${primary} onChange=${e => updateRole(name, 'primary_model', e.target.value)}>
<option value="">-- None --</option>
${models.map(m => html`<option key=${m.id} value=${m.model_id || m.id}>${m.display_name || m.model_id || m.id}</option>`)}
</select>
</div>
<div class="form-group"><label>Fallback Model</label>
<select value=${fallback} onChange=${e => updateRole(name, 'fallback_model', e.target.value)}>
<option value="">-- None --</option>
${models.map(m => html`<option key=${m.id} value=${m.model_id || m.id}>${m.display_name || m.model_id || m.id}</option>`)}
</select>
</div>
</div>
${dirty[name] && html`<button class="btn-small btn-primary" style="margin-top:8px;" onClick=${() => saveRole(name)}>Save</button>`}
</div>
`;
})}
</div>
`;
}

View File

@@ -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`<div class="settings-placeholder">Loading\u2026</div>`;
if (editing) {
const isNew = editing === 'new';
const p = isNew ? {} : editing;
return html`
<form onSubmit=${savePolicy}>
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
<button type="button" class="btn-small" onClick=${() => setEditing(null)}>\u2190 Back</button>
<h4 style="margin:0;">${isNew ? 'New Policy' : `Edit: ${p.name}`}</h4>
</div>
<div class="form-row">
<div class="form-group"><label>Name</label><input name="pname" value=${p.name || ''} placeholder="Prefer Anthropic" /></div>
<div class="form-group" style="width:100px;"><label>Priority</label>
<input name="priority" type="number" value=${p.priority ?? 10} />
</div>
</div>
<div class="form-row">
<div class="form-group"><label>Scope</label>
<select name="scope">
<option value="global" selected=${p.scope !== 'team'}>global</option>
<option value="team" selected=${p.scope === 'team'}>team</option>
</select>
</div>
<div class="form-group"><label>Type</label>
<select name="policy_type">
${POLICY_TYPES.map(t => html`<option key=${t} value=${t} selected=${p.policy_type === t}>${t}</option>`)}
</select>
</div>
</div>
<div class="form-group"><label>Config (JSON)</label>
<textarea name="config" rows="4" style="font-family:monospace;font-size:12px;">${JSON.stringify(p.config || {}, null, 2)}</textarea>
</div>
<label class="toggle-label" style="margin:8px 0;">
<input type="checkbox" name="is_active" checked=${p.is_active !== false} />
<span class="toggle-track"></span><span>Active</span>
</label>
<div class="form-row" style="margin-top:12px;gap:8px;">
<button type="submit" class="btn-small btn-primary">${isNew ? 'Create' : 'Save'}</button>
<button type="button" class="btn-small" onClick=${() => setEditing(null)}>Cancel</button>
</div>
</form>
`;
}
return html`
<div>
<div style="margin-bottom:12px;">
<button class="btn-md btn-primary" onClick=${() => setEditing('new')}>+ New Policy</button>
</div>
<div class="admin-list">
${policies.length === 0 && html`<div class="empty-hint">No routing policies</div>`}
${policies.map(p => html`
<div class="admin-surface-row" key=${p.id}>
<div style="flex:1;min-width:0;">
<strong>${p.name}</strong>
<span class="badge" style="margin-left:6px;">${p.policy_type}</span>
<span class="text-muted" style="margin-left:6px;font-size:11px;">priority: ${p.priority}</span>
</div>
<span class="badge">${p.scope}</span>
<span class="badge ${p.is_active ? 'badge-active' : 'badge-inactive'}">${p.is_active ? 'active' : 'off'}</span>
<div class="admin-actions-cell">
<button class="btn-small" onClick=${() => setEditing(p)}>Edit</button>
<button class="btn-small btn-danger" onClick=${() => deletePolicy(p.id)}>Delete</button>
</div>
</div>
`)}
</div>
<div class="settings-section" style="margin-top:24px;">
<h4>Test Routing</h4>
<div class="form-row">
<div class="form-group"><label>Model</label>
<input value=${testModel} onInput=${e => setTestModel(e.target.value)} placeholder="gpt-4o" />
</div>
<div class="form-group"><label>User ID</label>
<input value=${testUser} onInput=${e => setTestUser(e.target.value)} placeholder="optional" />
</div>
<button class="btn-small btn-primary" style="align-self:flex-end;" onClick=${runTest}>Test</button>
</div>
${testResult && html`
<pre class="code-block" style="margin-top:8px;font-size:12px;max-height:200px;overflow:auto;">${JSON.stringify(testResult, null, 2)}</pre>
`}
</div>
</div>
`;
}

View File

@@ -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`<div class="settings-placeholder">Loading\u2026</div>`;
return html`
<div class="admin-settings-form">
<div class="settings-section"><h3>Registration</h3>
<label class="toggle-label"><input type="checkbox" checked=${cfg.allow_registration} onChange=${e => set('allow_registration', e.target.checked)} /><span class="toggle-track"></span><span>Allow new user registration</span></label>
<div class="form-group" style="margin-top:8px;"><label>Default state</label>
<select value=${cfg.registration_default_state} onChange=${e => set('registration_default_state', e.target.value)}>
<option value="pending">Pending (require approval)</option>
<option value="active">Active (auto-approve)</option>
</select>
</div>
</div>
<div class="settings-section"><h3>System Prompt</h3>
<textarea rows="4" placeholder="Global system prompt\u2026" value=${cfg.system_prompt} onInput=${e => set('system_prompt', e.target.value)}></textarea>
</div>
<div class="settings-section"><h3>Default Model</h3>
<select value=${cfg.default_model} onChange=${e => set('default_model', e.target.value)}>
<option value="">-- None (first visible) --</option>
${models.map(m => html`<option key=${m.id} value=${m.model_id || m.id}>${m.display_name || m.model_id || m.id}</option>`)}
</select>
</div>
<div class="settings-section"><h3>Policies</h3>
<label class="toggle-label"><input type="checkbox" checked=${cfg.allow_user_byok} onChange=${e => set('allow_user_byok', e.target.checked)} /><span class="toggle-track"></span><span>Allow BYOK (user API keys)</span></label>
<label class="toggle-label"><input type="checkbox" checked=${cfg.allow_user_personas} onChange=${e => set('allow_user_personas', e.target.checked)} /><span class="toggle-track"></span><span>Allow user personas</span></label>
<label class="toggle-label"><input type="checkbox" checked=${cfg.allow_kb_direct_access} onChange=${e => set('allow_kb_direct_access', e.target.checked)} /><span class="toggle-track"></span><span>Allow direct KB access</span></label>
</div>
<div class="settings-section"><h3>Environment Banner</h3>
<label class="toggle-label"><input type="checkbox" checked=${cfg.banner_enabled} onChange=${e => set('banner_enabled', e.target.checked)} /><span class="toggle-track"></span><span>Show environment banner</span></label>
${cfg.banner_enabled && html`
<div style="margin-top:8px;">
<div class="form-group"><label>Text</label><input value=${cfg.banner_text} onInput=${e => set('banner_text', e.target.value)} placeholder="DEVELOPMENT" /></div>
<div class="form-row">
<div class="form-group"><label>Background</label><input type="color" value=${cfg.banner_bg} onInput=${e => set('banner_bg', e.target.value)} /></div>
<div class="form-group"><label>Foreground</label><input type="color" value=${cfg.banner_fg} onInput=${e => set('banner_fg', e.target.value)} /></div>
<div class="form-group"><label>Position</label>
<select value=${cfg.banner_position} onChange=${e => set('banner_position', e.target.value)}>
<option value="both">Both</option><option value="top">Top</option><option value="bottom">Bottom</option>
</select>
</div>
</div>
<div class="banner-preview" style="margin-top:8px;padding:4px 12px;text-align:center;font-size:12px;font-weight:600;border-radius:4px;background:${cfg.banner_bg};color:${cfg.banner_fg};">${cfg.banner_text || 'PREVIEW'}</div>
</div>
`}
</div>
<div class="settings-section"><h3>Web Search</h3>
<div class="form-group"><label>Provider</label>
<select value=${cfg.search_provider} onChange=${e => set('search_provider', e.target.value)}>
<option value="duckduckgo">DuckDuckGo</option><option value="searxng">SearXNG</option>
</select>
</div>
${cfg.search_provider === 'searxng' && html`
<div class="form-group"><label>Endpoint</label><input value=${cfg.search_endpoint} onInput=${e => set('search_endpoint', e.target.value)} placeholder="https://searxng.example.com" /></div>
<div class="form-group"><label>API Key</label><input value=${cfg.search_api_key} onInput=${e => set('search_api_key', e.target.value)} placeholder="Optional" /></div>
`}
<div class="form-group"><label>Max Results</label><input type="number" min="1" max="20" value=${cfg.search_max_results} onInput=${e => set('search_max_results', parseInt(e.target.value) || 5)} /></div>
</div>
<div class="settings-section"><h3>Auto-Compaction</h3>
<label class="toggle-label"><input type="checkbox" checked=${cfg.compaction_enabled} onChange=${e => set('compaction_enabled', e.target.checked)} /><span class="toggle-track"></span><span>Enable auto-compaction</span></label>
${cfg.compaction_enabled && html`
<div class="form-row" style="margin-top:8px;">
<div class="form-group"><label>Threshold (%)</label><input type="number" min="50" max="95" value=${cfg.compaction_threshold} onInput=${e => set('compaction_threshold', parseInt(e.target.value))} /></div>
<div class="form-group"><label>Cooldown (min)</label><input type="number" min="5" max="1440" value=${cfg.compaction_cooldown} onInput=${e => set('compaction_cooldown', parseInt(e.target.value))} /></div>
</div>
`}
</div>
<div class="settings-section"><h3>Memory Extraction</h3>
<label class="toggle-label"><input type="checkbox" checked=${cfg.memory_extraction_enabled} onChange=${e => set('memory_extraction_enabled', e.target.checked)} /><span class="toggle-track"></span><span>Enable memory extraction</span></label>
${cfg.memory_extraction_enabled && html`
<label class="toggle-label" style="margin-top:8px;"><input type="checkbox" checked=${cfg.memory_auto_approve} onChange=${e => set('memory_auto_approve', e.target.checked)} /><span class="toggle-track"></span><span>Auto-approve extracted memories</span></label>
`}
</div>
<div class="settings-section"><h3>Encryption Vault</h3>
${vault
? html`<div class="text-muted" style="font-size:12px;">Key set: ${vault.encryption_key_set ? 'Yes' : 'No'} \u2022 User vaults: ${vault.user_vaults_count ?? '?'}</div>`
: html`<span class="text-muted" style="font-size:12px;">Could not load vault status</span>`
}
</div>
<div class="settings-section"><h3>Email</h3>
<button class="btn-small" onClick=${testEmail}>Send Test Email</button>
</div>
<div style="display:flex;gap:8px;align-items:center;margin-top:16px;">
<button class="btn-md btn-primary" onClick=${save} disabled=${saving}>${saving ? 'Saving\u2026' : 'Save Settings'}</button>
</div>
</div>
`;
}

View File

@@ -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`<div class="settings-placeholder">Loading\u2026</div>`;
if (!stats) return html`<div class="empty-hint">No stats available</div>`;
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`
<div class="stat-cards-grid">
${cards.map(c => html`
<div class="stat-card" key=${c.label}>
<div class="stat-value">${c.value != null ? c.value.toLocaleString() : '\u2014'}</div>
<div class="stat-label">${c.label}</div>
</div>
`)}
</div>
`;
}

View File

@@ -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`<div class="settings-placeholder">Loading\u2026</div>`;
return html`
<div>
${status && html`
<div class="stat-cards-grid" style="margin-bottom:16px;">
<div class="stat-card"><div class="stat-value">${status.backend || '\u2014'}</div><div class="stat-label">Backend</div></div>
<div class="stat-card"><div class="stat-value"><span class="badge ${status.healthy ? 'badge-active' : 'badge-inactive'}">${status.healthy ? 'Healthy' : 'Unhealthy'}</span></div><div class="stat-label">Status</div></div>
<div class="stat-card"><div class="stat-value">${status.file_count ?? '\u2014'}</div><div class="stat-label">Files</div></div>
<div class="stat-card"><div class="stat-value">${formatBytes(status.total_bytes)}</div><div class="stat-label">Total Size</div></div>
</div>
`}
${orphans && html`
<div class="settings-section" style="margin-bottom:16px;">
<h4>Orphaned Blobs</h4>
<p class="text-muted" style="font-size:12px;">${orphans.count || 0} orphans found</p>
${(orphans.count || 0) > 0 && html`<button class="btn-small btn-primary" onClick=${cleanup}>Clean Up</button>`}
</div>
`}
${extraction && html`
<div class="settings-section">
<h4>Extraction Queue</h4>
<pre class="code-block" style="font-size:12px;max-height:200px;overflow:auto;">${JSON.stringify(extraction, null, 2)}</pre>
</div>
`}
</div>
`;
}

View File

@@ -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`<div class="settings-placeholder">Loading\u2026</div>`;
return html`
<div>
<div class="admin-list">
${tasks.length === 0 && html`<div class="empty-hint">No tasks</div>`}
${tasks.map(t => html`
<div key=${t.id}>
<div class="admin-surface-row">
<div style="flex:1;min-width:0;">
<strong>${t.name}</strong>
<span class="text-muted" style="margin-left:8px;font-size:11px;">${t.scope || 'personal'}</span>
${t.schedule && html`<span class="badge" style="margin-left:6px;">${t.schedule}</span>`}
</div>
<span class="text-muted" style="font-size:11px;">
last: ${fmtDate(t.last_run_at)} \u2022 next: ${fmtDate(t.next_run_at)}
</span>
<span class="badge ${t.is_active ? 'badge-active' : 'badge-inactive'}">${t.is_active ? 'active' : 'paused'}</span>
<div class="admin-actions-cell">
<button class="btn-small" onClick=${() => toggleRuns(t.id)}>${expandedRuns[t.id] ? 'Hide Runs' : 'Runs'}</button>
<button class="btn-small btn-primary" onClick=${() => runTask(t.id)}>Run</button>
<button class="btn-small" onClick=${() => killTask(t.id)}>Kill</button>
<button class="btn-small btn-danger" onClick=${() => deleteTask(t.id)}>Delete</button>
</div>
</div>
${expandedRuns[t.id] && html`
<div style="padding:4px 0 8px 24px;">
${expandedRuns[t.id].length === 0 && html`<span class="text-muted" style="font-size:12px;">No runs</span>`}
${expandedRuns[t.id].map(r => html`
<div key=${r.id} style="font-size:12px;display:flex;gap:8px;padding:2px 0;">
<span class="badge ${r.status === 'completed' ? 'badge-active' : r.status === 'failed' ? 'badge-inactive' : 'badge-pending'}">${r.status}</span>
<span>${fmtDate(r.started_at)}</span>
<span class="text-muted">${r.tokens_used || 0} tokens</span>
${r.error && html`<span style="color:var(--danger);">${r.error}</span>`}
</div>
`)}
</div>
`}
</div>
`)}
</div>
</div>
`;
}

View File

@@ -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`<div class="settings-placeholder">Loading\u2026</div>`;
// Detail view
if (detail) return html`
<div>
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
<button class="btn-small" onClick=${() => setDetail(null)}>\u2190 Back</button>
<h4 style="margin:0;">${detail.name}</h4>
<div style="flex:1"></div>
<button class="btn-small btn-danger" onClick=${() => deleteTeam(detail.id)}>Delete Team</button>
</div>
${detail.description && html`<p class="text-muted" style="font-size:12px;margin-bottom:12px;">${detail.description}</p>`}
<h5 style="margin:16px 0 8px;">Members (${members.length})</h5>
<div class="admin-list">
${members.map(m => html`
<div class="admin-surface-row" key=${m.id}>
<span style="flex:1;">${m.username || m.user_id}</span>
<select value=${m.role} onChange=${e => updateMemberRole(m.id, e.target.value)}
style="font-size:11px;padding:2px 4px;">
<option value="member">member</option>
<option value="admin">admin</option>
</select>
<button class="btn-small btn-danger" onClick=${() => removeMember(m.id)}>Remove</button>
</div>
`)}
</div>
${showAddMember
? html`
<form class="admin-inline-form" style="margin-top:8px;" onSubmit=${addMember}>
<div class="form-row">
<div class="form-group"><label>User</label>
<select name="userId">
<option value="">Select user\u2026</option>
${allUsers.map(u => html`<option key=${u.id} value=${u.id}>${u.username}</option>`)}
</select>
</div>
<div class="form-group"><label>Role</label>
<select name="role"><option value="member">Member</option><option value="admin">Admin</option></select>
</div>
</div>
<div class="form-row" style="margin-top:8px;gap:8px;">
<button type="submit" class="btn-small btn-primary">Add</button>
<button type="button" class="btn-small" onClick=${() => setShowAddMember(false)}>Cancel</button>
</div>
</form>
`
: html`<button class="btn-small" style="margin-top:8px;" onClick=${() => setShowAddMember(true)}>+ Add Member</button>`
}
</div>
`;
// List view
return html`
<div>
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
<div style="flex:1"></div>
<button class="btn-md btn-primary" onClick=${() => setShowCreate(true)}>+ Add</button>
</div>
${showCreate && html`
<form class="admin-inline-form" style="margin-bottom:16px;" onSubmit=${createTeam}>
<div class="form-row">
<div class="form-group"><label>Name</label><input name="name" placeholder="Team name" /></div>
<div class="form-group"><label>Description</label><input name="desc" placeholder="Optional" /></div>
</div>
<div class="form-row" style="margin-top:8px;gap:8px;">
<button type="submit" class="btn-small btn-primary">Create</button>
<button type="button" class="btn-small" onClick=${() => setShowCreate(false)}>Cancel</button>
</div>
</form>
`}
<div class="admin-list">
${teams.length === 0 && html`<div class="empty-hint">No teams</div>`}
${teams.map(t => html`
<div class="admin-surface-row" key=${t.id} style="cursor:pointer;" onClick=${() => openDetail(t)}>
<strong style="flex:1;">${t.name}</strong>
<span class="text-muted" style="font-size:12px;">${t.member_count ?? '?'} members</span>
<span class="badge ${t.is_active ? 'badge-active' : 'badge-inactive'}">${t.is_active ? 'active' : 'inactive'}</span>
</div>
`)}
</div>
</div>
`;
}

View File

@@ -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`<div class="settings-placeholder">Loading\u2026</div>`;
return html`
<div>
${usage?.totals && html`
<div class="stat-cards-grid" style="margin-bottom:16px;">
<div class="stat-card"><div class="stat-value">${fmtNum(usage.totals.total_tokens)}</div><div class="stat-label">Total Tokens</div></div>
<div class="stat-card"><div class="stat-value">${fmtNum(usage.totals.total_requests)}</div><div class="stat-label">Total Requests</div></div>
<div class="stat-card"><div class="stat-value">$${(usage.totals.total_cost || 0).toFixed(2)}</div><div class="stat-label">Total Cost</div></div>
</div>
`}
${usage?.results && usage.results.length > 0 && html`
<h4 style="margin:16px 0 8px;">Usage by Day</h4>
<div class="admin-list">
${usage.results.map((r, i) => html`
<div class="admin-surface-row" key=${i}>
<span style="width:100px;">${r.date || r.day || '\u2014'}</span>
<span style="flex:1;">${fmtNum(r.tokens)} tokens</span>
<span class="text-muted" style="font-size:12px;">${r.requests || 0} requests</span>
<span class="text-muted" style="font-size:12px;">$${(r.cost || 0).toFixed(2)}</span>
</div>
`)}
</div>
`}
<h4 style="margin:24px 0 8px;">Pricing Table</h4>
${pricing.length === 0
? html`<div class="empty-hint">No custom pricing entries</div>`
: html`
<div class="admin-list">
<div class="admin-surface-row" style="font-weight:600;font-size:12px;">
<span style="flex:1;">Model</span>
<span style="width:100px;">Input/M</span>
<span style="width:100px;">Output/M</span>
<span style="width:80px;">Currency</span>
<span style="width:60px;"></span>
</div>
${pricing.map(p => html`
<div class="admin-surface-row" key=${p.model_id + p.provider_config_id}>
<span style="flex:1;">${p.model_id}</span>
<span style="width:100px;">$${(p.input_per_m || 0).toFixed(2)}</span>
<span style="width:100px;">$${(p.output_per_m || 0).toFixed(2)}</span>
<span style="width:80px;">${p.currency || 'USD'}</span>
<button class="btn-small btn-danger" style="width:60px;" onClick=${() => deletePricing(p.provider_config_id, p.model_id)}>Del</button>
</div>
`)}
</div>
`
}
</div>
`;
}

View File

@@ -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`<span class="badge badge-pending">pending</span>`;
if (!u.is_active) return html`<span class="badge badge-inactive">inactive</span>`;
return html`<span class="badge badge-active">active</span>`;
}
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
return html`
<div>
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
<div class="admin-search">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="color:var(--text-3);flex-shrink:0;"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input placeholder="Search users\u2026" value=${search} onInput=${e => setSearch(e.target.value)} autocomplete="off" />
</div>
<div style="flex:1"></div>
<button class="btn-md btn-primary" onClick=${() => setShowCreate(true)}>+ Add</button>
</div>
${showCreate && html`
<form class="admin-inline-form" style="margin-bottom:16px;" onSubmit=${createUser}>
<div class="form-row">
<div class="form-group"><label>Username</label><input name="username" placeholder="username" autocomplete="off" /></div>
<div class="form-group"><label>Email</label><input name="email" placeholder="email@example.com" autocomplete="off" /></div>
<div class="form-group"><label>Password</label><input name="password" type="password" placeholder="min 8 chars" autocomplete="new-password" /></div>
<div class="form-group"><label>Role</label>
<select name="role"><option value="user">User</option><option value="admin">Admin</option></select>
</div>
</div>
<div class="form-row" style="margin-top:8px;gap:8px;">
<button type="submit" class="btn-small btn-primary">Create</button>
<button type="button" class="btn-small" onClick=${() => setShowCreate(false)}>Cancel</button>
</div>
</form>
`}
<div class="admin-list">
${filtered.length === 0 && html`<div class="empty-hint">No users found</div>`}
${filtered.map(u => html`
<div class="admin-surface-row" key=${u.id}>
<div style="flex:1;min-width:0;">
<strong>${u.username}</strong>
<span class="text-muted" style="margin-left:8px;font-size:12px;">${u.email || ''}</span>
${' '}${statusBadge(u)}
${' '}<span class="badge ${u.role === 'admin' ? 'badge-admin' : ''}">${u.role}</span>
</div>
<div class="admin-actions-cell">
${u.status === 'pending' && html`
<button class="btn-small btn-primary" onClick=${() => approveUser(u.id)}>Approve</button>
`}
<select value=${u.role} onChange=${e => changeRole(u.id, e.target.value)}
style="font-size:11px;padding:2px 4px;">
<option value="user">user</option>
<option value="admin">admin</option>
</select>
${u.is_active
? html`<button class="btn-small" onClick=${() => toggleActive(u.id, false)}>Disable</button>`
: html`<button class="btn-small" onClick=${() => toggleActive(u.id, true)}>Enable</button>`
}
<button class="btn-small" onClick=${() => resetPassword(u.id, u.username)}>Reset PW</button>
<button class="btn-small btn-danger" onClick=${() => deleteUser(u.id, u.username)}>Delete</button>
</div>
</div>
`)}
</div>
</div>
`;
}

View File

@@ -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`<div class="settings-placeholder">Loading\u2026</div>`;
if (editing) return html`
<form onSubmit=${updateWorkflow}>
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
<button type="button" class="btn-small" onClick=${() => setEditing(null)}>\u2190 Back</button>
<h4 style="margin:0;">Edit: ${editing.name}</h4>
<div style="flex:1"></div>
<button type="button" class="btn-small btn-danger" onClick=${() => deleteWorkflow(editing.id)}>Delete</button>
</div>
<div class="form-row">
<div class="form-group"><label>Name</label><input name="name" value=${editing.name || ''} /></div>
<div class="form-group"><label>Entry Mode</label>
<select name="entry_mode">
${ENTRY_MODES.map(m => html`<option key=${m} value=${m} selected=${editing.entry_mode === m}>${m}</option>`)}
</select>
</div>
</div>
<div class="form-group"><label>Description</label><input name="description" value=${editing.description || ''} /></div>
<label class="toggle-label" style="margin:8px 0;">
<input type="checkbox" name="is_active" checked=${editing.is_active !== false} />
<span class="toggle-track"></span><span>Active</span>
</label>
<h5 style="margin:16px 0 8px;">Stages (${stages.length})</h5>
<div class="admin-list">
${stages.map((s, i) => html`
<div class="admin-surface-row" key=${s.id}>
<span class="text-muted" style="font-size:11px;width:24px;">#${i + 1}</span>
<strong style="flex:1;">${s.name || `Stage ${i + 1}`}</strong>
<span class="badge">${s.stage_mode}</span>
${s.persona_id && html`<span class="badge badge-active">persona</span>`}
</div>
`)}
${stages.length === 0 && html`<div class="empty-hint">No stages defined</div>`}
</div>
<div class="form-row" style="margin-top:16px;gap:8px;">
<button type="submit" class="btn-small btn-primary">Save</button>
</div>
</form>
`;
return html`
<div>
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
<div style="flex:1"></div>
<button class="btn-md btn-primary" onClick=${() => setShowCreate(true)}>+ Add</button>
</div>
${showCreate && html`
<form class="admin-inline-form" style="margin-bottom:16px;" onSubmit=${createWorkflow}>
<div class="form-row">
<div class="form-group"><label>Name</label><input name="name" placeholder="Customer Intake" /></div>
<div class="form-group"><label>Slug</label><input name="slug" placeholder="customer-intake" /></div>
<div class="form-group"><label>Entry Mode</label>
<select name="entry_mode">
${ENTRY_MODES.map(m => html`<option key=${m} value=${m}>${m}</option>`)}
</select>
</div>
</div>
<div class="form-group"><label>Description</label><input name="description" placeholder="Optional" /></div>
<div class="form-row" style="margin-top:8px;gap:8px;">
<button type="submit" class="btn-small btn-primary">Create</button>
<button type="button" class="btn-small" onClick=${() => setShowCreate(false)}>Cancel</button>
</div>
</form>
`}
<div class="admin-list">
${workflows.length === 0 && html`<div class="empty-hint">No workflows</div>`}
${workflows.map(w => html`
<div class="admin-surface-row" key=${w.id} style="cursor:pointer;" onClick=${() => openEdit(w)}>
<div style="flex:1;min-width:0;">
<strong>${w.name}</strong>
<span class="text-muted" style="margin-left:8px;font-size:11px;">/${w.slug}</span>
</div>
<span class="badge">${w.entry_mode}</span>
<span class="badge ${w.is_active ? 'badge-active' : 'badge-inactive'}">${w.is_active ? 'active' : 'inactive'}</span>
<span class="text-muted" style="font-size:11px;">v${w.version || 0}</span>
</div>
`)}
</div>
</div>
`;
}