Changeset 0.37.14 (#226)
Co-authored-by: gobha <jasafpro@gmail.com> Co-committed-by: gobha <jasafpro@gmail.com>
This commit is contained in:
@@ -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>
|
||||
`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user