Changeset 0.37.14 (#226)

Co-authored-by: gobha <jasafpro@gmail.com>
Co-committed-by: gobha <jasafpro@gmail.com>
This commit is contained in:
2026-03-23 16:47:48 +00:00
committed by xcaliber
parent fcb998bff9
commit b7746c3004
164 changed files with 6972 additions and 3527 deletions

View File

@@ -1,69 +1,274 @@
/**
* Admin > System > Packages (Extensions)
* 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;
export default function PackagesSection() {
const [extensions, setExtensions] = useState([]);
const [loading, setLoading] = useState(true);
const TYPE_OPTIONS = ['all', 'surface', 'extension', 'full', 'workflow'];
const CORE_IDS = new Set(['chat', '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>`;
}
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 BASE = window.__BASE__ || '';
// ── Load packages ───────────────────────────
const load = useCallback(async () => {
try {
const data = await sw.api.admin.extensions.list();
setExtensions(Array.isArray(data) ? data : data.data || []);
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(() => { load(); }, []);
useEffect(() => { setLoading(true); load(); }, [typeFilter]);
async function toggleEnabled(ext) {
// ── Actions ─────────────────────────────────
async function toggleEnabled(pkg) {
if (CORE_IDS.has(pkg.id)) return;
try {
await sw.api.admin.extensions.update(ext.id, { is_enabled: !ext.is_enabled });
sw.toast(`Extension ${ext.is_enabled ? 'disabled' : 'enabled'}`, 'success');
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 deleteExt(id) {
const ok = await sw.confirm('Delete this extension?', true);
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.extensions.del(id);
sw.toast('Extension deleted', 'success');
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 tierBadge(tier) {
const cls = tier === 'browser' ? 'badge-active' : tier === 'starlark' ? 'badge-pending' : '';
return html`<span class="badge ${cls}">${tier || 'unknown'}</span>`;
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'); }
}
// ── 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 hasManifestSettings = (pkg) =>
pkg.manifest?.settings || pkg.package_settings;
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>
</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">
${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>`}
${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)}
${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.enabled ? 'Disable' : 'Enable'}
</button>
${hasManifestSettings(pkg) && html`
<button class="btn-small" onClick=${() => toggleSettings(pkg.id)}>
${expandedId === pkg.id ? 'Close' : 'Settings'}
</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>
`}
</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>
`;
}