/* * SysDeck — 3rd-Party Modules Panel (v0.0.46) * Author: Jeremy Anderson (https://dcos.net) * * Catalog-driven installer for third-party Cockpit modules. * * DESIGN (per v0.0.46 directive): the license, developer/author, * 3rd-party module name, and a homepage link are rendered INLINE * in every catalog row. The Install button is a TRUE 1-click install * — clicking it is the operator's acceptance of the inline-displayed * license. No modal, no extra confirmation step. * * The bridge helper (modules3p.py) still refuses silent installs: * install() requires --accept-license=1. The JS always passes that * flag on every click, because the license is shown inline next to * the button — clicking Install IS the acceptance gesture. * * Flow: * 1. bridge.modules3p.status() → renders rows * 2. operator clicks Install on a row * 3. bridge.modules3p.install(id, true) → 1 click, runs as root via polkit * 4. bridge appends an audit record to /etc/cockpit/MODULE_LICENSES.log * 5. JS re-renders the catalog * * Uninstall follows the same pattern: 1 click, runs uninstall, audit. */ const LICENSE_SHORT_NAMES = { "MIT": { cls: "success", note: "permissive — retains copyright + notice" }, "LGPL-2.1": { cls: "info", note: "weak copyleft — derivatives of the library must stay LGPL" }, "LGPL-2.1+": { cls: "info", note: "weak copyleft — or-later" }, "GPL-2.0": { cls: "warn", note: "copyleft — derivative works must be GPL-2.0+" }, "GPL-2.0+": { cls: "warn", note: "copyleft — or-later" }, "GPL-3.0": { cls: "warn", note: "copyleft — derivative works must be GPL-3.0+" }, "AGPL-3.0": { cls: "warn", note: "strong copyleft — network use triggers source disclosure" }, "Apache-2.0": { cls: "success", note: "permissive — retains NOTICE file + patent grant" }, "BSD-2-Clause": { cls: "success", note: "permissive — retains copyright + notice" }, "BSD-3-Clause": { cls: "success", note: "permissive — retains copyright + notice + no endorsement" }, }; function licenseBadge(lic) { const meta = LICENSE_SHORT_NAMES[lic] || { cls: "info", note: "see upstream for terms" }; return `${esc(lic)}`; } function esc(s) { return String(s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'", })[c]); } export async function mount(panel, { bridge, EventBus }) { panel.innerHTML = renderSkeleton(); let catalog = []; try { catalog = await bridge.modules3p.status(); } catch (err) { panel.innerHTML = renderError("Catalog unavailable", err); return; } panel.innerHTML = renderShell(catalog); wireUp(panel, catalog, bridge); } function renderShell(catalog) { const categories = unique(catalog.map((m) => m.category)).sort(); const total = catalog.length; const installed = catalog.filter((m) => m.installed).length; const missingDeps = catalog.filter((m) => m.missing_deps?.length > 0).length; return `

3rd-Party Cockpit Modules

Optional add-ons. License, developer, source, and homepage are shown inline next to each Install button — clicking Install is your acceptance of the displayed license.

${installed}/${total} installed · ${missingDeps} with missing deps
${catalog.map((m) => renderRow(m)).join("")}

License Audit Log

Append-only record at /etc/cockpit/MODULE_LICENSES.log. Every install / uninstall writes one JSON line with module id, license, author, source URL, action, and UTC timestamp.

Click Refresh to load.

Compliance Notes

SysDeck (MIT) invokes every upstream tool as a separate process via cockpit.spawn — no third-party code is bundled into the SysDeck tarball. Each module's license applies only to the module itself, not to SysDeck. For GPL-family modules (45Drives Navigator / File Sharing / ZFS Manager, cockpit-pacman) the invocation boundary is a subprocess call; you remain responsible for complying with each upstream license when distributing the combined system. See THIRD_PARTY.md for the full license compatibility matrix.

`; } function renderRow(m) { const installLabel = m.installed ? "Reinstall" : "Install"; const installBtn = ``; const uninstallBtn = m.installed ? `` : ""; const missingWarn = (m.missing_deps?.length > 0) ? `${m.missing_deps.length} missing dep${m.missing_deps.length > 1 ? "s" : ""}` : ""; const installedBadge = m.installed ? `installed` : `not installed`; const depList = m.depends?.length ? `runtime deps: ${m.depends.map(esc).join(", ")}` : ""; // The license agreement, developer, source, and homepage are all // rendered INLINE in this row — visible right next to the Install // button. No modal is needed. return `

${esc(m.name)} (${esc(m.id)})

${installedBadge} ${licenseBadge(m.license)} ${missingWarn}

${esc(m.blurb)}

developer: ${esc(m.author)} license: ${esc(m.license)} source: ${esc(m.source)} homepage: visit ↗ category: ${esc(m.category)} ${depList}
${installBtn} ${uninstallBtn}
`; } function unique(arr) { return Array.from(new Set(arr)); } function renderSkeleton() { return `
`; } function renderError(title, err) { return `

${esc(title)}

${esc(err.message || err)}.

Run python3 /usr/lib/sysdeck/bridge/modules3p.py status on the host to debug.

`; } function wireUp(panel, catalog, bridge) { const grid = panel.querySelector("#modules-grid"); const filterCat = panel.querySelector("#modules-filter-category"); const filterSearch = panel.querySelector("#modules-filter-search"); function applyFilter() { const cat = filterCat.value; const q = filterSearch.value.trim().toLowerCase(); grid.querySelectorAll(".modules-row").forEach((row) => { const m = catalog.find((x) => x.id === row.dataset.id); if (!m) return; const catOk = !cat || m.category === cat; const qOk = !q || [m.name, m.author, m.license, m.id, m.blurb] .some((s) => String(s).toLowerCase().includes(q)); row.style.display = (catOk && qOk) ? "" : "none"; }); } filterCat.addEventListener("change", applyFilter); filterSearch.addEventListener("input", applyFilter); // 1-click install / uninstall — license was shown inline in the row, // so the click IS the operator's acceptance. grid.addEventListener("click", async (ev) => { const btn = ev.target.closest("button[data-action]"); if (!btn) return; const id = btn.dataset.id; const action = btn.dataset.action; const row = btn.closest(".modules-row"); const flashBox = row?.querySelector("[data-row-flash]"); const originalLabel = btn.textContent; if (action === "install") { btn.disabled = true; btn.textContent = "Installing…"; showRowFlash(flashBox, `Pulling ${id} — see audit log. License shown inline above.`, "info"); try { const r = await bridge.modules3p.install(id, /*acceptLicense=*/ true); if (r.ok) { showRowFlash(flashBox, `Installed ${r.name} — ${r.license} · ${r.author} · ${r.source}. Audit record appended.`, "success"); await refresh(panel, bridge); } else { showRowFlash(flashBox, `Install failed: ${r.error || r.message || "unknown error"}`, "danger"); btn.disabled = false; btn.textContent = originalLabel; } } catch (e) { showRowFlash(flashBox, `Install error: ${e.message || e}`, "danger"); btn.disabled = false; btn.textContent = originalLabel; } } else if (action === "uninstall") { btn.disabled = true; btn.textContent = "Removing…"; showRowFlash(flashBox, `Removing ${id}…`, "info"); try { const r = await bridge.modules3p.uninstall(id); if (r.ok) { showRowFlash(flashBox, `Removed ${id}. Audit record appended.`, "success"); await refresh(panel, bridge); } else { showRowFlash(flashBox, `Uninstall failed: ${r.error || r.message || "unknown error"}`, "danger"); btn.disabled = false; btn.textContent = originalLabel; } } catch (e) { showRowFlash(flashBox, `Uninstall error: ${e.message || e}`, "danger"); btn.disabled = false; btn.textContent = originalLabel; } } }); panel.querySelector("#modules-audit-refresh")?.addEventListener("click", async () => { const body = panel.querySelector("#modules-audit-body"); body.innerHTML = `

Loading…

`; try { const r = await bridge.modules3p.audit(); body.innerHTML = renderAudit(r.records || []); } catch (e) { body.innerHTML = `

Failed: ${esc(e.message || e)}

`; } }); } function showRowFlash(flashBox, msg, kind) { if (!flashBox) return; flashBox.innerHTML = `${esc(msg)}`; } function renderAudit(records) { if (!records.length) { return `

No records yet — installs and uninstalls will appear here.

`; } return ` ${records.map((r) => r.raw ? `` : `` ).join("")}
Time (UTC)ModuleLicenseAuthorActionDetail
${esc(r.raw)}
${esc(r.ts || "")} ${esc(r.module || r.id || "")} ${esc(r.license || "")} ${esc(r.author || "")} ${esc(r.action || "")} ${esc(r.detail || "")}
`; } function actionClass(action) { if (!action) return ""; if (action.endsWith("-ok")) return "success"; if (action.endsWith("-failed")) return "danger"; return ""; } async function refresh(panel, bridge) { let catalog = []; try { catalog = await bridge.modules3p.status(); } catch (e) { showRowFlash(panel, `Refresh failed: ${e.message || e}`, "danger"); return; } panel.innerHTML = renderShell(catalog); wireUp(panel, catalog, bridge); }