This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/src/js/sw/surfaces/admin/packages.js
Jeffrey Smith 2abf406db8
All checks were successful
CI/CD / detect-changes (push) Successful in 4s
CI/CD / test-frontend (push) Successful in 5s
CI/CD / test-go-pg (push) Successful in 2m36s
CI/CD / test-sqlite (push) Successful in 2m43s
CI/CD / build-and-deploy (push) Successful in 1m19s
Feat v0.5.0 realtime dialog (#30)
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
2026-03-30 11:07:27 +00:00

368 lines
19 KiB
JavaScript

/**
* Admin > System > Packages
*
* Full package lifecycle: list, install (upload + registry), enable/disable,
* settings, export, delete. Uses /api/v1/admin/packages endpoints.
*/
const { html } = window;
const { useState, useEffect, useCallback } = hooks;
const TYPE_OPTIONS = ['all', 'surface', 'extension', 'full', 'workflow'];
const CORE_IDS = new Set(['admin']);
function typeBadge(type) {
const cls = type === 'surface' ? 'badge-active'
: type === 'extension' ? 'badge-pending' : '';
return html`<span class="badge ${cls}">${type || '?'}</span>`;
}
function tierBadge(tier) {
const cls = tier === 'browser' ? 'badge-active' : tier === 'starlark' ? 'badge-pending' : '';
return html`<span class="badge ${cls}">${tier || 'unknown'}</span>`;
}
function sourceBadge(source) {
if (!source || source === 'extension') return null;
const cls = source === 'core' ? 'badge-active' : source === 'registry' ? 'badge-pending' : '';
return html`<span class="badge ${cls}">${source}</span>`;
}
function statusBadge(status) {
if (status === 'dormant') return html`<span class="badge" style="opacity:0.6;">dormant</span>`;
if (status === 'pending_review') return html`<span class="badge" style="background:var(--warning,#e6a817);color:#000;">review</span>`;
if (status === 'suspended') return html`<span class="badge" style="background:var(--danger,#d33);color:#fff;">suspended</span>`;
return null;
}
export default function PackagesSection() {
const [packages, setPackages] = useState([]);
const [loading, setLoading] = useState(true);
const [typeFilter, setTypeFilter] = useState('all');
const [expandedId, setExpandedId] = useState(null);
const [pkgSettings, setPkgSettings] = useState(null);
const [settingsDraft, setSettingsDraft] = useState({});
const [registryOpen, setRegistryOpen] = useState(false);
const [registryPkgs, setRegistryPkgs] = useState([]);
const [registryLoading, setRegistryLoading] = useState(false);
const [installing, setInstalling] = useState(false);
const [permsId, setPermsId] = useState(null); // v0.5.0: permissions drawer
const [perms, setPerms] = useState([]);
const BASE = window.__BASE__ || '';
// ── Load packages ───────────────────────────
const load = useCallback(async () => {
try {
const opts = typeFilter !== 'all' ? { type: typeFilter } : undefined;
const data = await sw.api.admin.packages.list(opts);
setPackages(data || []);
} catch (e) { sw.toast(e.message, 'error'); }
finally { setLoading(false); }
}, [typeFilter]);
useEffect(() => { setLoading(true); load(); }, [typeFilter]);
// ── Actions ─────────────────────────────────
async function toggleEnabled(pkg) {
if (CORE_IDS.has(pkg.id)) return;
if (pkg.status === 'dormant') {
sw.toast('This package requires chat — it cannot be enabled yet', 'error');
return;
}
try {
if (pkg.enabled) {
await sw.api.admin.packages.disable(pkg.id);
sw.toast(`${pkg.title || pkg.id} disabled`, 'success');
} else {
await sw.api.admin.packages.enable(pkg.id);
sw.toast(`${pkg.title || pkg.id} enabled`, 'success');
}
load();
} catch (e) { sw.toast(e.message, 'error'); }
}
async function deletePkg(pkg) {
if (pkg.source === 'core') return;
const ok = await sw.confirm(`Delete "${pkg.title || pkg.id}"? This cannot be undone.`, true);
if (!ok) return;
try {
await sw.api.admin.packages.del(pkg.id);
sw.toast('Package deleted', 'success');
if (expandedId === pkg.id) { setExpandedId(null); setPkgSettings(null); }
load();
} catch (e) { sw.toast(e.message, 'error'); }
}
function uploadPackage() {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.pkg,.surface,.zip';
input.onchange = async () => {
const file = input.files[0];
if (!file) return;
setInstalling(true);
try {
await sw.api.admin.packages.install(file);
sw.toast('Package installed', 'success');
load();
} catch (e) { sw.toast(e.message, 'error'); }
finally { setInstalling(false); }
};
input.click();
}
function exportPkg(id) {
window.open(`${BASE}/api/v1/admin/packages/${id}/export`, '_blank');
}
// ── Settings drawer ─────────────────────────
async function toggleSettings(pkgId) {
if (expandedId === pkgId) {
setExpandedId(null); setPkgSettings(null); setSettingsDraft({});
return;
}
try {
const data = await sw.api.admin.packages.settings(pkgId);
const settings = data?.data || {};
setPkgSettings(settings);
setSettingsDraft({ ...settings });
setExpandedId(pkgId);
} catch (e) {
sw.toast(e.message, 'error');
}
}
async function saveSettings(pkgId) {
try {
await sw.api.admin.packages.updateSettings(pkgId, settingsDraft);
sw.toast('Settings saved', 'success');
setPkgSettings({ ...settingsDraft });
} catch (e) { sw.toast(e.message, 'error'); }
}
// ── Permissions drawer (v0.5.0) ────────────
async function togglePerms(pkgId) {
if (permsId === pkgId) { setPermsId(null); setPerms([]); return; }
try {
const data = await sw.api.admin.packages.permissions(pkgId);
setPerms((data && data.data) || data || []);
setPermsId(pkgId);
// Close settings if open
if (expandedId === pkgId) { setExpandedId(null); setPkgSettings(null); }
} catch (e) { sw.toast(e.message, 'error'); }
}
async function grantPerm(pkgId, perm) {
try {
await sw.api.admin.packages.grantPerm(pkgId, perm);
const data = await sw.api.admin.packages.permissions(pkgId);
setPerms((data && data.data) || data || []);
load();
} catch (e) { sw.toast(e.message, 'error'); }
}
async function revokePerm(pkgId, perm) {
try {
await sw.api.admin.packages.revokePerm(pkgId, perm);
const data = await sw.api.admin.packages.permissions(pkgId);
setPerms((data && data.data) || data || []);
load();
} catch (e) { sw.toast(e.message, 'error'); }
}
async function grantAllPerms(pkgId) {
try {
await sw.api.admin.packages.grantAllPerms(pkgId);
const data = await sw.api.admin.packages.permissions(pkgId);
setPerms((data && data.data) || data || []);
sw.toast('All permissions granted', 'success');
load();
} catch (e) { sw.toast(e.message, 'error'); }
}
// ── Registry ────────────────────────────────
async function toggleRegistry() {
if (registryOpen) { setRegistryOpen(false); return; }
setRegistryOpen(true);
setRegistryLoading(true);
try {
const data = await sw.api.admin.packages.registry();
setRegistryPkgs(data?.data || []);
} catch (e) {
sw.toast('Registry unavailable: ' + e.message, 'error');
setRegistryPkgs([]);
}
finally { setRegistryLoading(false); }
}
async function installFromRegistry(url) {
try {
await sw.api.admin.packages.registryInstall(url);
sw.toast('Package installed from registry', 'success');
load();
} catch (e) { sw.toast(e.message, 'error'); }
}
// ── Render ───────────────────────────────────
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
const enabled = packages.filter(p => p.enabled).length;
const dormant = packages.filter(p => p.status === 'dormant').length;
const hasManifestSettings = (pkg) =>
pkg.manifest?.settings || pkg.package_settings;
const hasDeclaredPerms = (pkg) =>
pkg.manifest?.permissions && pkg.manifest.permissions.length > 0;
return html`
<div>
${/* ── Stat cards ─────────────── */``}
<div class="stat-cards-grid" style="margin-bottom:16px;">
<div class="stat-card"><div class="stat-value">${packages.length}</div><div class="stat-label">Total</div></div>
<div class="stat-card"><div class="stat-value">${enabled}</div><div class="stat-label">Enabled</div></div>
${dormant > 0 && html`<div class="stat-card"><div class="stat-value">${dormant}</div><div class="stat-label">Dormant</div></div>`}
</div>
${/* ── Action bar ─────────────── */``}
<div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap;margin-bottom:14px;">
${TYPE_OPTIONS.map(t => html`
<button key=${t}
class="btn-small ${typeFilter === t ? 'btn-primary' : ''}"
onClick=${() => setTypeFilter(t)}
style="text-transform:capitalize;">${t}</button>
`)}
<span style="flex:1;" />
<button class="btn-small" onClick=${uploadPackage} disabled=${installing}>
${installing ? 'Installing\u2026' : 'Install Package'}
</button>
<button class="btn-small" onClick=${toggleRegistry}>
${registryOpen ? 'Close Registry' : 'Browse Registry'}
</button>
</div>
${/* ── Package list ────────────── */``}
<div class="admin-list">
${packages.length === 0 && html`<div class="empty-hint">No packages found</div>`}
${packages.map(pkg => html`
<div key=${pkg.id}>
<div class="admin-surface-row">
<div style="flex:1;min-width:0;">
<strong>${pkg.title || pkg.id}</strong>
<span class="text-muted" style="margin-left:6px;font-size:11px;">v${pkg.version || '?'}</span>
${' '}${typeBadge(pkg.type)}
${' '}${tierBadge(pkg.tier)}
${sourceBadge(pkg.source)}
${statusBadge(pkg.status)}
${pkg.is_system && html`<span class="badge" style="margin-left:4px;">system</span>`}
</div>
<div class="admin-actions-cell">
<button class="btn-small"
onClick=${() => toggleEnabled(pkg)}
disabled=${CORE_IDS.has(pkg.id) || pkg.status === 'dormant' || pkg.status === 'pending_review'}
title=${pkg.status === 'dormant' ? 'Requires chat' : pkg.status === 'pending_review' ? 'Grant permissions to activate' : ''}>
${pkg.enabled ? 'Disable' : 'Enable'}
</button>
${hasManifestSettings(pkg) && html`
<button class="btn-small" onClick=${() => toggleSettings(pkg.id)}>
${expandedId === pkg.id ? 'Close' : 'Settings'}
</button>
`}
${hasDeclaredPerms(pkg) && html`
<button class="btn-small" onClick=${() => togglePerms(pkg.id)}>
${permsId === pkg.id ? 'Close' : 'Permissions'}
</button>
`}
<button class="btn-small" onClick=${() => exportPkg(pkg.id)}>Export</button>
${pkg.source !== 'core' && !pkg.is_system && html`
<button class="btn-small btn-danger" onClick=${() => deletePkg(pkg)}>Delete</button>
`}
</div>
</div>
${/* ── Inline settings drawer ── */``}
${expandedId === pkg.id && pkgSettings != null && html`
<div style="padding:8px 12px 12px 24px;background:var(--bg-2);border-bottom:1px solid var(--border);">
${Object.keys(pkgSettings).length === 0
? html`<span class="text-muted" style="font-size:12px;">No configurable settings</span>`
: html`
${Object.entries(settingsDraft).map(([k, v]) => html`
<div key=${k} style="display:flex;gap:8px;align-items:center;margin-bottom:6px;">
<label style="min-width:120px;font-size:12px;color:var(--text-muted);">${k}</label>
<input type="text"
value=${v ?? ''}
onInput=${e => setSettingsDraft(prev => ({ ...prev, [k]: e.target.value }))}
style="flex:1;padding:4px 8px;background:var(--bg-1,var(--bg));color:var(--text);border:1px solid var(--border);border-radius:4px;font-size:12px;" />
</div>
`)}
<button class="btn-small" style="margin-top:4px;" onClick=${() => saveSettings(pkg.id)}>Save</button>
`
}
</div>
`}
${/* ── Inline permissions drawer (v0.5.0) ── */``}
${permsId === pkg.id && html`
<div style="padding:8px 12px 12px 24px;background:var(--bg-2);border-bottom:1px solid var(--border);">
${perms.length === 0
? html`<span class="text-muted" style="font-size:12px;">No permissions declared</span>`
: html`
<div style="margin-bottom:8px;">
${perms.map(p => html`
<div key=${p.permission} style="display:flex;gap:8px;align-items:center;margin-bottom:4px;">
<code style="min-width:140px;font-size:12px;">${p.permission}</code>
<button class="btn-small ${p.granted ? 'btn-danger' : 'btn-primary'}"
style="min-width:64px;font-size:11px;"
onClick=${() => p.granted ? revokePerm(pkg.id, p.permission) : grantPerm(pkg.id, p.permission)}>
${p.granted ? 'Revoke' : 'Grant'}
</button>
${p.granted && p.granted_by && html`
<span class="text-muted" style="font-size:11px;">by ${p.granted_by}</span>
`}
</div>
`)}
</div>
${perms.some(p => !p.granted) && html`
<button class="btn-small btn-primary" onClick=${() => grantAllPerms(pkg.id)}>Grant All</button>
`}
`
}
</div>
`}
</div>
`)}
</div>
${/* ── Registry panel ──────────── */``}
${registryOpen && html`
<div class="admin-section" style="margin-top:20px;padding:12px;border:1px solid var(--border);border-radius:6px;">
<h4 style="margin:0 0 8px;">Package Registry</h4>
${registryLoading
? html`<div class="settings-placeholder">Loading registry\u2026</div>`
: registryPkgs.length === 0
? html`<div class="empty-hint">No packages available in registry</div>`
: html`
<div class="admin-list">
${registryPkgs.map(rp => html`
<div class="admin-surface-row" key=${rp.id}>
<div style="flex:1;min-width:0;">
<strong>${rp.title || rp.id}</strong>
<span class="text-muted" style="margin-left:6px;font-size:11px;">v${rp.version || '?'}</span>
${rp.description && html`
<div class="text-muted" style="font-size:11px;margin-top:2px;">${rp.description}</div>
`}
</div>
<div class="admin-actions-cell">
${typeBadge(rp.type)}
<button class="btn-small btn-primary"
onClick=${() => installFromRegistry(rp.download_url)}>Install</button>
</div>
</div>
`)}
</div>
`
}
</div>
`}
</div>
`;
}