Files
podman-mvp/webui/html/index.html
T
kodi b8ba0f08dc refactor(webui): introduceer assets-structuur en externe stylesheet
- CSS verplaatst naar assets/css/app.css
- Logo en favicon verplaatst naar assets/img en assets/icons
- index.html verwijst naar nieuwe paden
2026-02-20 12:05:28 +01:00

1220 lines
43 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="nl">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/codemirror.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/theme/material-darker.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/codemirror.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/yaml/yaml.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/javascript/javascript.min.js"></script>
<title>MVP Control UI</title>
<link rel="icon" type="image/x-icon" href="assets/icons/podman.io-favicon.ico">
<link rel="stylesheet" href="assets/css/app.css">
<style>
</style>
</head>
<body>
<header>
<div class="wrap">
<div class="topbar">
<div class="brand">
<img class="brandLogo" src="assets/img/podman-logo.png" alt="Podman" />
<span class="dot" id="apiDot"></span>
<div>
MVP Control UI
<div class="statusline" id="statusLine">API: onbekend</div>
</div>
</div>
<div class="flex">
<button class="btn ghost" onclick="pingApi()">Ping</button>
<button class="btn" onclick="refreshActive()">Ververs</button>
</div>
</div>
</div>
</header>
<div class="layout">
<!-- Sidebar -->
<aside class="sidebar" id="sidebar">
<div class="sidebarTop">
<button class="btn small ghost sidebarToggle" id="sidebarToggle" title="Sidebar in/uitklappen"></button>
</div>
<div class="tabs">
<div class="tab active" id="tab-dashboard" onclick="setTab('dashboard')" title="Dashboard">
<span class="navIcon">🏠</span><span class="navLabel">Dashboard</span>
</div>
<div class="tab" id="tab-containers" onclick="setTab('containers')" title="Containers">
<span class="navIcon">📦</span><span class="navLabel">Containers</span>
</div>
<div class="tab" id="tab-pods" onclick="setTab('pods')" title="Pods">
<span class="navIcon">🧩</span><span class="navLabel">Pods</span>
</div>
<div class="tab" id="tab-systemd" onclick="setTab('systemd')" title="Systemd">
<span class="navIcon">⚙️</span><span class="navLabel">Systemd</span>
</div>
<div class="tab" id="tab-files" onclick="setTab('files')" title="Files">
<span class="navIcon">📁</span><span class="navLabel">Files</span>
</div>
</div>
</aside>
<!-- Main -->
<main class="main">
<div class="wrap">
<div id="view-dashboard" class="grid">
<div class="card half">
<div class="cardHeader">
<div class="cardTitle">Snel acties</div>
<div class="flex">
<button class="btn ok" onclick="daemonReload()">daemon-reload</button>
<button class="btn" onclick="refreshActive()">Ververs alles</button>
</div>
</div>
<div class="cardBody">
<div class="flex">
<span class="pill"><span class="b" id="countPods">-</span> pods</span>
<span class="pill"><span class="b" id="countContainers">-</span> containers</span>
<span class="pill"><span class="b" id="countSystemd">-</span> units (UI)</span>
</div>
<div class="hint">
Deze UI gebruikt jouw API endpoints onder <span class="mono">/api</span> (same origin).
Containers/pods komen uit Podman; systemd acties gebruiken jouw <span class="mono">systemctl --user</span> endpoints.
</div>
</div>
</div>
<div class="card half">
<div class="cardHeader">
<div class="cardTitle">Systemd units (uit UI lijst)</div>
<div class="flex">
<button class="btn" onclick="systemdRefresh()">Ververs status</button>
</div>
</div>
<div class="cardBody">
<div id="systemdMini" class="muted">Nog geen data.</div>
</div>
</div>
</div>
<div id="view-containers" class="grid" style="display:none">
<div class="card" style="grid-column: 1 / -1;">
<div class="cardHeader">
<div class="cardTitle">Containers</div>
<div class="flex">
<button class="btn" onclick="fetchContainers()">Ververs</button>
</div>
</div>
<div class="cardBody">
<table>
<thead>
<tr>
<th>Naam</th>
<th>Status</th>
<th>Pod</th>
<th>Image</th>
<th>Managed</th>
<th>CPU</th>
<th>MEM</th>
<th>Published port</th>
<th>Acties</th>
</tr>
</thead>
<tbody id="containersTbody"></tbody>
</table>
</div>
</div>
</div>
<div id="view-pods" class="grid" style="display:none">
<div class="card" style="grid-column: 1 / -1;">
<div class="cardHeader">
<div class="cardTitle">Pods</div>
<div class="flex">
<button class="btn" onclick="fetchPods()">Ververs</button>
</div>
</div>
<div class="cardBody">
<table>
<thead>
<tr>
<th>Naam</th>
<th>Status</th>
<th>Containers</th>
<th>Acties</th>
</tr>
</thead>
<tbody id="podsTbody"></tbody>
</table>
</div>
</div>
</div>
<div id="view-systemd" class="grid" style="display:none">
<div class="card" style="grid-column: 1 / -1;">
<div class="cardHeader">
<div class="cardTitle">Systemd (allowlist via UI)</div>
<div class="flex">
<button class="btn ok" onclick="daemonReload()">daemon-reload</button>
<button class="btn" onclick="systemdRefresh()">Ververs status</button>
</div>
</div>
<div class="cardBody">
<div class="split">
<div>
<div class="muted" style="margin-bottom:8px">
Units (één per regel). Deze lijst wordt opgeslagen in je browser (localStorage).
</div>
<textarea id="systemdUnits" class="textarea" spellcheck="false"></textarea>
<div class="flex" style="margin-top:10px">
<button class="btn" onclick="saveSystemdUnits()">Opslaan</button>
<button class="btn ghost" onclick="loadDefaultUnits()">Standaard</button>
<span class="pill">Gebruik allowlist op server om te beperken.</span>
</div>
<div class="hint">
De server enforcet jouw allowlist. Als je hier een unit invult die niet toegestaan is, krijg je 403.
</div>
</div>
<div>
<div class="muted" style="margin-bottom:8px">
Snelle actie op één unit:
</div>
<input id="systemdOne" class="input mono" placeholder="bijv. sonarr.service" />
<div class="flex" style="margin-top:10px">
<button class="btn" onclick="systemdActionSingle('status')">Status</button>
<button class="btn ok" onclick="systemdActionSingle('start')">Start</button>
<button class="btn warn" onclick="systemdActionSingle('restart')">Restart</button>
<button class="btn bad" onclick="systemdActionSingle('stop')">Stop</button>
</div>
<div class="hint">
Tip: gebruik <span class="mono">demo1.service</span>, <span class="mono">demo2.service</span>, <span class="mono">sonarr.service</span> om te testen.
</div>
</div>
</div>
<div style="margin-top:16px">
<table>
<thead>
<tr>
<th>Unit</th>
<th>Laatste status (API output)</th>
<th>Acties</th>
</tr>
</thead>
<tbody id="systemdTbody"></tbody>
</table>
</div>
</div>
</div>
</div>
<div id="view-files" class="grid" style="display:none">
<div class="card" style="grid-column: 1 / -1;">
<div class="cardHeader">
<div class="cardTitle">Files (systemd)</div>
<div class="flex">
<button class="btn" onclick="filesRefresh()">Ververs</button>
<button class="btn" onclick="filesNewFolder()">Nieuwe map</button>
<button class="btn ok" onclick="filesNewFile()">Nieuw bestand</button>
<button class="btn ok" onclick="filesSave()">Opslaan</button>
<button class="btn bad" onclick="filesDelete()">Verwijderen</button>
</div>
</div>
<div class="cardBody">
<div class="split">
<!-- Links: tree -->
<div>
<div class="muted" style="margin-bottom:8px">
Alleen onder <span class="mono">~/.config/containers/systemd</span> (systemd wordt niet getoond in paden).
</div>
<div id="filesTree" class="input" style="min-height:360px; overflow:auto; padding:12px"></div>
<div class="hint">Klik op een bestand om te openen.</div>
</div>
<!-- Rechts: editor -->
<div>
<div class="muted" style="margin-bottom:8px">
Huidig bestand: <span class="mono" id="filesCurrent">-</span>
</div>
<textarea id="filesEditor" class="textarea mono" spellcheck="false" placeholder="Selecteer links een bestand..."></textarea>
<div class="hint">
Na wijzigen van <span class="mono">*.container</span> moet je meestal <span class="mono">daemon-reload</span> doen (kan via Systemd-tab of dashboard-knop).
</div>
</div>
</div>
</div>
</div>
</div>
</div> <!-- einde main wrap -->
</main>
</div> <!-- einde layout -->
<!-- Modal -->
<div class="modalBack" id="modalBack" onclick="closeModal(event)">
<div class="modal" onclick="event.stopPropagation()">
<div class="modalHeader">
<div class="modalTitle" id="modalTitle">Details</div>
<button class="btn small ghost" onclick="hideModal()">Sluiten</button>
</div>
<div class="modalBody">
<pre id="modalPre"></pre>
</div>
</div>
</div>
<script>
let cmEditor = null;
// ---- API helper ----
async function api(path, method = 'GET', body = null) {
const opts = { method, headers: {} };
if (body !== null) {
opts.headers['Content-Type'] = 'application/json';
opts.body = JSON.stringify(body);
}
const res = await fetch('/api' + path, opts);
const ct = res.headers.get('content-type') || '';
let data;
if (ct.includes('application/json')) {
data = await res.json();
} else {
data = { text: await res.text() };
}
if (!res.ok) {
const msg = data?.detail || data?.error || data?.text || ('HTTP ' + res.status);
throw new Error(msg);
}
return data;
}
function esc(s) {
return String(s)
.replaceAll('&','&amp;')
.replaceAll('<','&lt;')
.replaceAll('>','&gt;')
.replaceAll('"','&quot;')
.replaceAll("'","&#039;");
}
function badgeFromStatus(s) {
const t = (s || '').toLowerCase();
if (t.includes('running') || t === 'running' || t === 'active') return `<span class="badge ok">${esc(s)}</span>`;
if (t.includes('exited') || t.includes('dead') || t.includes('stopped') || t === 'inactive') return `<span class="badge bad">${esc(s)}</span>`;
return `<span class="badge warn">${esc(s || 'unknown')}</span>`;
}
// ---- Modal ----
function showModal(title, content) {
document.getElementById('modalTitle').textContent = title;
document.getElementById('modalPre').textContent = content;
document.getElementById('modalBack').style.display = 'flex';
}
function hideModal() { document.getElementById('modalBack').style.display = 'none'; }
function closeModal(e){ if(e.target.id === 'modalBack') hideModal(); }
function closeAllMenus() {
document.querySelectorAll('.menuPanel.open').forEach(p => p.classList.remove('open'));
}
function toggleMenu(menuId) {
const el = document.getElementById(menuId);
if (!el) return;
const willOpen = !el.classList.contains('open');
closeAllMenus();
if (willOpen) el.classList.add('open');
}
// klik buiten menu = sluiten
document.addEventListener('click', (e) => {
// als je op een menu knop klikt, laat toggleMenu het regelen
if (e.target.closest('.actions-menu')) return;
closeAllMenus();
});
// ---- Tabs ----
let currentTab = 'dashboard';
function setTab(tab) {
currentTab = tab;
document.querySelectorAll('.tab').forEach(x => x.classList.remove('active'));
document.getElementById('tab-' + tab).classList.add('active');
document.querySelectorAll('[id^="view-"]').forEach(v => v.style.display='none');
document.getElementById('view-' + tab).style.display = '';
if (tab === 'files') {
filesRefresh();
}
// Start/stop live stats alleen in Containers tab
if (tab === 'containers') startContainersStatsStream();
else stopContainersStatsStream();
refreshActive();
}
// ---- Sidebar collapse ----
const SIDEBAR_KEY = 'mvp_sidebar_collapsed_v1';
function applySidebarState() {
const sb = document.getElementById('sidebar');
if (!sb) return;
const collapsed = localStorage.getItem(SIDEBAR_KEY) === '1';
sb.classList.toggle('collapsed', collapsed);
}
function toggleSidebar() {
const collapsed = localStorage.getItem(SIDEBAR_KEY) === '1';
localStorage.setItem(SIDEBAR_KEY, collapsed ? '0' : '1');
applySidebarState();
}
document.addEventListener("visibilitychange", () => {
if (document.visibilityState !== "visible") {
stopContainersStatsStream();
} else if (currentTab === "containers") {
startContainersStatsStream();
}
});
// ---- Health / Ping ----
async function pingApi() {
try {
// simpele ping: pods ophalen
await api('/pods', 'GET');
setApiState(true, 'API: OK');
} catch (e) {
setApiState(false, 'API: fout (' + e.message + ')');
showModal('API fout', e.stack || e.message);
}
}
function setApiState(ok, msg) {
const dot = document.getElementById('apiDot');
dot.style.background = ok ? 'var(--ok)' : 'var(--bad)';
dot.style.boxShadow = ok ? '0 0 0 6px rgba(45,212,191,.15)' : '0 0 0 6px rgba(251,113,133,.15)';
document.getElementById('statusLine').textContent = msg;
}
// ---- Dashboard refresh ----
async function refreshActive() {
try {
if (currentTab === 'containers') await fetchContainers();
else if (currentTab === 'pods') await fetchPods();
else if (currentTab === 'systemd') await systemdRefresh();
else {
// dashboard: haal in achtergrond counts + mini systemd
const [pods, containers] = await Promise.all([
api('/pods-dashboard','GET'),
api('/containers','GET')
]);
document.getElementById('countPods').textContent = (pods || []).length;
// containers list kan array of object zijn; jij gebruikt array
const cCount = Array.isArray(containers) ? containers.length : (containers?.length || 0);
document.getElementById('countContainers').textContent = cCount;
const units = await getSystemdUnitsFromServer();
document.getElementById('countSystemd').textContent = units.length;
await systemdMiniRefresh();
}
setApiState(true, 'API: OK');
} catch (e) {
setApiState(false, 'API: fout (' + e.message + ')');
}
}
// ---- Pods ----
async function fetchPods() {
const pods = await api('/pods-dashboard','GET');
document.getElementById('countPods').textContent = (pods || []).length;
const tbody = document.getElementById('podsTbody');
tbody.innerHTML = (pods || []).map(p => {
const name = p.Name || p.name || '';
const status = p.Status || p.status || '';
const containers = (Array.isArray(p.Containers) ? p.Containers : [])
.map(c => {
if (typeof c === 'string') return c; // jouw nieuwe API: list of strings
const n = c?.Names;
if (Array.isArray(n)) return n[0] || '';
if (typeof n === 'string') return n;
return c?.Name || c?.name || '';
})
.filter(Boolean)
.join(', ');
return `
<tr>
<td><strong>${esc(name)}</strong></td>
<td>${badgeFromStatus(status)}</td>
<td class="muted">${esc(containers || '')}</td>
<td>
<div class="flex">
<button class="btn small ok" onclick="podAction('start','${esc(name)}')">Start</button>
<button class="btn small warn" onclick="podAction('restart','${esc(name)}')">Restart</button>
<button class="btn small bad" onclick="podAction('stop','${esc(name)}')">Stop</button>
</div>
</td>
</tr>
`;
}).join('');
}
async function podAction(action, name) {
try {
const res = await api(`/pods/actions/${encodeURIComponent(action)}/${encodeURIComponent(name)}`, 'POST');
showModal(`Pod ${action}: ${name}`, JSON.stringify(res, null, 2));
await fetchPods();
if (currentTab === 'containers') {
await fetchContainers();
}
} catch (e) {
showModal(`Pod ${action} fout`, e.stack || e.message);
}
}
// ---- Containers ----
function _podKey(c) {
return (c.PodName && String(c.PodName).trim()) ? String(c.PodName).trim() : '(geen pod)';
}
function _cmpStr(a, b) {
return String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: 'base' });
}
function _isCollapsed(pod) {
return localStorage.getItem('pod_group_collapsed:' + pod) === '1';
}
function _setCollapsed(pod, v) {
localStorage.setItem('pod_group_collapsed:' + pod, v ? '1' : '0');
}
function _isFolderCollapsed(folderKey) {
return localStorage.getItem('files_folder_collapsed:' + folderKey) !== '0';
// default collapsed = true
}
function _setFolderCollapsed(folderKey, v) {
localStorage.setItem('files_folder_collapsed:' + folderKey, v ? '1' : '0');
}
function renderActionsDropdown(menuId, actionFn, targetEsc) {
// actionFn is string: "containerAction" of "podAction"
// targetEsc is al esc(...) dus veilig in onclick
return `
<span class="actions-menu">
<button class="btn icon" title="Acties" onclick="toggleMenu('${menuId}')">⋮</button>
<div class="menuPanel" id="${menuId}">
<button class="menuItem ok" onclick="${actionFn}('start','${targetEsc}'); closeAllMenus();">Start</button>
<button class="menuItem warn" onclick="${actionFn}('restart','${targetEsc}'); closeAllMenus();">Restart</button>
<button class="menuItem bad" onclick="${actionFn}('stop','${targetEsc}'); closeAllMenus();">Stop</button>
</div>
</span>
`;
}
function renderContainerRow(c) {
const name = (c.Names && c.Names[0]) ? c.Names[0] : (c.Names || c.Name || c.name || '');
const status = c.Status || c.State || c.state || '';
const podName = c.PodName || '-';
const image = c.Image || c.image || '';
const managed = c._dashboard_source || 'podman';
const menuId = `menu-${cssSafeId('c:' + normalizeContainerName(name))}`;
const inPod = !!(c.PodName && String(c.PodName).trim());
const ports = inPod
? '' // verberg bij pod-containers
: ((c._dashboard_published_ports || []).join(", ")
|| (c.Ports || []).map(p => `${p.host_port}:${p.container_port}`).join(", "));
return `
<tr>
<td><strong>${esc(name)}</strong></td>
<td>${badgeFromStatus(status)}</td>
<td>${podName}</td>
<td class="muted">${esc(image)}</td>
<td>${badgeFromStatus(managed)}</td>
<td class="muted num" id="cpu-${cssSafeId(normalizeContainerName(name))}">-</td>
<td class="muted num" id="mem-${cssSafeId(normalizeContainerName(name))}">-</td>
<td>${ports || '-'}</td>
<td>
<div class="flex">
<button class="btn icon" title="Inspect" onclick="containerInspect('${esc(name)}')">🔍</button>
<button class="btn icon" title="Logs" onclick="containerLogs('${esc(name)}')">📄</button>
${renderActionsDropdown(menuId, 'containerAction', esc(name))}
</div>
</td>
</tr>
`;
}
function renderContainersGrouped(list, tbody, podStatus) {
const groups = new Map();
for (const c of (list || [])) {
const k = _podKey(c);
if (!groups.has(k)) groups.set(k, []);
groups.get(k).push(c);
}
for (const podName of Object.keys(podStatus || {})) {
if (!groups.has(podName)) groups.set(podName, []);
}
const keys = Array.from(groups.keys()).sort((a, b) => {
if (a === '(geen pod)' && b !== '(geen pod)') return 1;
if (b === '(geen pod)' && a !== '(geen pod)') return -1;
return _cmpStr(a, b);
});
const table = tbody.closest('table');
const colCount = table ? table.querySelectorAll('thead th').length : 9;
containersC2P = new Map();
let html = '';
for (const pod of keys) {
const items = groups.get(pod) || [];
items.sort((a, b) =>
_cmpStr(
(a.Names && a.Names[0]) ? a.Names[0] : (a.Name || ''),
(b.Names && b.Names[0]) ? b.Names[0] : (b.Name || '')
)
);
const collapsed = _isCollapsed(pod);
const isRealPod = (pod !== '(geen pod)');
const total = items.length;
const podMenuId = `menu-${cssSafeId('p:' + pod)}`;
let podPortsText = "-";
if (isRealPod) {
const podPorts = Array.from(new Set(
items.flatMap(c => (c._dashboard_published_ports || []))
)).sort((a, b) => _cmpStr(a, b));
podPortsText = podPorts.length ? podPorts.join(", ") : "-";
}
let cls = 'muted';
let label = '-';
if (total > 0) {
const running = items.filter(x => {
const s = (x.Status || x.State || '').toLowerCase();
return s === 'running';
}).length;
label = `${running}/${total}`;
if (running === total) cls = 'ok';
else if (running === 0) cls = 'bad';
else cls = 'warn';
} else {
const ps = String((podStatus && podStatus[pod]) || '').toLowerCase();
if (ps === 'active') { cls = 'ok'; label = 'active'; }
else if (ps === 'inactive') { cls = 'bad'; label = 'inactive'; }
else { cls = 'muted'; label = ps || 'unknown'; }
}
html += `
<tr class="pod-group-row">
<td>
<span class="pod-toggle" data-pod="${pod}" style="cursor:pointer; user-select:none;">
${collapsed ? '▶' : '▼'} <b>${esc(pod)}</b>
</span>
</td>
<td>
<span class="btn small ${cls}">
${label}
</span>
</td>
<td class="muted">-</td>
<td class="muted">-</td>
<td>${isRealPod ? '<span class="btn small muted">pod</span>' : '<span class="muted">-</span>'}</td>
<td class="muted num" id="podcpu-${cssSafeId(pod)}" data-podstatus="${esc((podStatus && podStatus[pod]) || '')}">-</td>
<td class="muted num" id="podmem-${cssSafeId(pod)}" data-podstatus="${esc((podStatus && podStatus[pod]) || '')}">-</td>
<td class="mono">${esc(podPortsText)}</td>
<td>
${isRealPod ? `
<div class="flex">
<!-- placeholders voor uitlijning -->
<button class="btn icon" disabled style="visibility:hidden;" aria-hidden="true" tabindex="-1">🔍</button>
<button class="btn icon" disabled style="visibility:hidden;" aria-hidden="true" tabindex="-1">📄</button>
${renderActionsDropdown(podMenuId, 'podAction', esc(pod))}
</div>
` : '<span class="muted">-</span>'}
</td>
</tr>
`;
for (const c of items) {
const cname = normalizeContainerName((c.Names && c.Names[0]) ? c.Names[0] : (c.Names || c.Name || c.name || ''));
if (cname) containersC2P.set(cname, pod);
const row = renderContainerRow(c).replace(
'<tr',
`<tr class="pod-item-row" data-pod="${pod}"${collapsed ? ' style="display:none;"' : ''}`
);
html += row;
}
}
tbody.innerHTML = html;
tbody.onclick = (ev) => {
const t = ev.target.closest('.pod-toggle');
if (!t) return;
const pod = t.getAttribute('data-pod');
const isNowCollapsed = !_isCollapsed(pod);
_setCollapsed(pod, isNowCollapsed);
tbody.querySelectorAll(`.pod-item-row[data-pod="${CSS.escape(pod)}"]`).forEach(r => {
r.style.display = isNowCollapsed ? 'none' : '';
});
t.innerHTML =
`${isNowCollapsed ? '▶' : '▼'} <b>${pod}</b> ` +
`<span style="opacity:.7;">(${tbody.querySelectorAll(`.pod-item-row[data-pod="${CSS.escape(pod)}"]`).length})</span>`;
};
}
async function fetchContainers() {
const [containers, pods] = await Promise.all([
api('/containers-dashboard', 'GET'),
api('/pods-dashboard', 'GET')
]);
const list = Array.isArray(containers) ? containers : (containers?.containers || []);
document.getElementById('countContainers').textContent = list.length;
const podsList = Array.isArray(pods) ? pods : [];
const podStatus = {};
for (const p of podsList) {
const n = p?.Name || p?.name;
if (!n) continue;
podStatus[n] = (p?.Status || p?.status || '').toLowerCase(); // "active"/"inactive"/...
}
const tbody = document.getElementById('containersTbody');
renderContainersGrouped(list, tbody, podStatus);
}
let containersStatsES = null;
let containersC2P = new Map();
function startContainersStatsStream() {
if (containersStatsES) return;
containersStatsES = new EventSource("/api/containers/stats/stream?interval=1");
containersStatsES.addEventListener("stats", (ev) => {
const payload = JSON.parse(ev.data);
const statsList = payload?.data?.Stats || [];
// totals per pod voor deze SSE tick
const podCpu = new Map(); // podName -> cpuPct sum
const podMem = new Map(); // podName -> memBytes sum
const podMemPct = new Map(); // podName -> memPct sum
for (const st of statsList) {
const cname = normalizeContainerName(st?.Name);
if (!cname) continue;
const key = cssSafeId(cname);
const cpuPct = Number(st?.CPUPerc ?? st?.CPU ?? st?.AvgCPU ?? 0);
const memBytes = Number(st?.MemUsage ?? 0);
const memPct = Number(st?.MemPerc ?? 0);
const pod = containersC2P.get(cname);
if (pod) {
podCpu.set(pod, (podCpu.get(pod) || 0) + cpuPct);
podMem.set(pod, (podMem.get(pod) || 0) + memBytes);
podMemPct.set(pod, (podMemPct.get(pod) || 0) + memPct);
}
// CPU: jouw API geeft CPU als fractie (0.00384 -> 0.384%)
const cpuEl = document.getElementById(`cpu-${key}`);
if (cpuEl) cpuEl.textContent = cpuPct.toFixed(2) + "%";
// MEM: bytes + percentage
const memEl = document.getElementById(`mem-${key}`);
if (memEl) {
const mem = formatBytes(memBytes);
memEl.textContent = `${mem} (${memPct.toFixed(1)}%)`;
}
}
// NA de container-loop: pod totals renderen
for (const [pod, cpuSum] of podCpu.entries()) {
const el = document.getElementById(`podcpu-${cssSafeId(pod)}`);
if (el) {
el.textContent = cpuSum.toFixed(2) + "%";
el.classList.remove('stale');
}
}
for (const [pod, memSum] of podMem.entries()) {
const el = document.getElementById(`podmem-${cssSafeId(pod)}`);
if (el) {
const mp = podMemPct.get(pod) || 0;
el.textContent = `${formatBytes(memSum)} (${mp.toFixed(1)}%)`;
el.classList.remove('stale');
}
}
});
containersStatsES.onerror = () => {
resetPodTotals();
};
}
function stopContainersStatsStream() {
if (!containersStatsES) return;
containersStatsES.close();
containersStatsES = null;
resetPodTotals();
}
function resetPodTotals() {
document.querySelectorAll('[id^="podcpu-"]').forEach(el => {
const ps = String(el.getAttribute('data-podstatus') || '').toLowerCase();
if (ps === 'inactive') {
el.textContent = '-';
el.classList.remove('stale');
} else {
el.textContent = '—';
el.classList.add('stale');
}
});
document.querySelectorAll('[id^="podmem-"]').forEach(el => {
const ps = String(el.getAttribute('data-podstatus') || '').toLowerCase();
if (ps === 'inactive') {
el.textContent = '-';
el.classList.remove('stale');
} else {
el.textContent = '—';
el.classList.add('stale');
}
});
}
async function containerInspect(name) {
try {
const res = await api(`/containers/inspect/${encodeURIComponent(name)}`, 'GET');
showModal(`Inspect: ${name}`, JSON.stringify(res, null, 2));
} catch (e) {
showModal(`Inspect fout: ${name}`, e.stack || e.message);
}
}
async function containerLogs(name) {
try {
const res = await api(`/containers/logs/${encodeURIComponent(name)}`, 'GET');
const logs = res.logs ?? JSON.stringify(res, null, 2);
showModal(`Logs: ${name}`, logs);
} catch (e) {
showModal(`Logs fout: ${name}`, e.stack || e.message);
}
}
async function containerAction(action, name) {
try {
const res = await api(`/containers/${encodeURIComponent(action)}/${encodeURIComponent(name)}`, 'POST');
showModal(`Container ${action}: ${name}`, JSON.stringify(res, null, 2));
await fetchContainers();
} catch (e) {
showModal(`Container ${action} fout`, e.stack || e.message);
}
}
// ---- Systemd UI storage ----
const LS_KEY = 'mvp_systemd_units_v1';
function loadDefaultUnits() {
const defaults = ["demo1.service","demo2.service","sonarr.service"];
document.getElementById('systemdUnits').value = defaults.join("\n");
saveSystemdUnits();
}
function saveSystemdUnits() {
const raw = document.getElementById('systemdUnits').value || '';
const units = raw.split('\n').map(x => x.trim()).filter(Boolean);
localStorage.setItem(LS_KEY, JSON.stringify(units));
systemdRenderRows(units);
refreshActive();
}
async function getSystemdUnitsFromServer() {
const data = await api('/systemd/allowlist', 'GET');
const units = Array.isArray(data.units) ? data.units : [];
// vul textarea ook
const ta = document.getElementById('systemdUnits');
if (ta) ta.value = units.join("\n");
return units;
}
function systemdRenderRows(units) {
const tbody = document.getElementById('systemdTbody');
tbody.innerHTML = units.map(u => `
<tr>
<td><strong class="mono">${esc(u)}</strong></td>
<td class="muted mono" id="sys-out-${cssSafeId(u)}">-</td>
<td>
<div class="flex">
<button class="btn small" onclick="systemdAction('status','${esc(u)}')">Status</button>
<button class="btn small ok" onclick="systemdAction('start','${esc(u)}')">Start</button>
<button class="btn small warn" onclick="systemdAction('restart','${esc(u)}')">Restart</button>
<button class="btn small bad" onclick="systemdAction('stop','${esc(u)}')">Stop</button>
</div>
</td>
</tr>
`).join('');
}
function cssSafeId(s){
// simpele safe id: base64-ish
return btoa(unescape(encodeURIComponent(s))).replaceAll('=','').replaceAll('+','-').replaceAll('/','_');
}
function normalizeContainerName(n) {
if (!n) return '';
// Podman kan soms "/name" geven, we strippen de leading slash
return ('' + n).replace(/^\//, '');
}
function formatBytes(b) {
const n = Number(b || 0);
if (n >= 1024**3) return (n / 1024**3).toFixed(1) + "GiB";
if (n >= 1024**2) return (n / 1024**2).toFixed(0) + "MiB";
if (n >= 1024) return (n / 1024).toFixed(0) + "KiB";
return n + "B";
}
function encodeUnit(unit) {
// encodeURIComponent is genoeg voor @ en .
return encodeURIComponent(unit);
}
async function daemonReload() {
try {
const res = await api('/daemon-reload','POST');
showModal('daemon-reload', JSON.stringify(res, null, 2));
} catch (e) {
showModal('daemon-reload fout', e.stack || e.message);
}
}
async function systemdAction(action, unit) {
try {
const res = await api(`/${encodeURIComponent(action)}/${encodeUnit(unit)}`, 'POST');
// res.output kan lang zijn
showModal(`systemctl ${action} ${unit}`, (res.output ?? JSON.stringify(res, null, 2)));
// update inline status cell
const cell = document.getElementById('sys-out-' + cssSafeId(unit));
if (cell) {
const summary = (res.output || '').split('\n').slice(0,3).join(' / ') || '(geen output)';
cell.textContent = summary;
}
} catch (e) {
showModal(`systemctl ${action} fout`, e.stack || e.message);
}
}
async function systemdActionSingle(action) {
const unit = (document.getElementById('systemdOne').value || '').trim();
if (!unit) return showModal('Systemd', 'Vul eerst een unit in (bijv. sonarr.service).');
await systemdAction(action, unit);
}
async function systemdRefresh() {
const units = await getSystemdUnitsFromServer();
systemdRenderRows(units);
document.getElementById('countSystemd').textContent = units.length;
for (const u of units) {
try {
const res = await api(`/status/${encodeUnit(u)}`, 'POST');
const cell = document.getElementById('sys-out-' + cssSafeId(u));
if (cell) {
const first = (res.output || '').split('\n')[0] || '';
const activeLine = (res.output || '').split('\n').find(x => x.trim().startsWith('Active:')) || '';
cell.textContent = (first + ' | ' + activeLine).trim();
}
} catch (e) {
const cell = document.getElementById('sys-out-' + cssSafeId(u));
if (cell) cell.textContent = 'ERROR: ' + e.message;
}
}
}
async function systemdMiniRefresh() {
const units = await getSystemdUnitsFromServer();
const mini = document.getElementById('systemdMini');
if (!mini) return;
const lines = [];
for (const u of units.slice(0, 6)) {
try {
const res = await api(`/status/${encodeUnit(u)}`, 'POST');
const activeLine = (res.output || '').split('\n').find(x => x.trim().startsWith('Active:')) || '';
lines.push(`${u}: ${activeLine.replace('Active:','').trim() || 'unknown'}`);
} catch (e) {
lines.push(`${u}: ERROR (${e.message})`);
}
}
mini.innerHTML = `<pre>${esc(lines.join('\n'))}</pre>`;
}
// =========================
// Files tab (systemd subtree)
// =========================
const FILES_ROOT = 'systemd'; // API-root binnen WORKLOADS_DIR
let filesCurrentUiPath = ''; // zonder "systemd/"
let filesCurrentApiPath = ''; // met "systemd/"
function cmModeForPath(uiPath) {
const p = (uiPath || '').toLowerCase();
if (p.endsWith('.yaml') || p.endsWith('.yml') || p.endsWith('.kube') || p.endsWith('.container')) return 'yaml';
if (p.endsWith('.json')) return 'application/json';
if (p.endsWith('.js')) return 'javascript';
return 'text/plain';
}
function filesToApiPath(uiPath) {
let p = (uiPath || '').trim().replace(/^\/+/, '');
if (!p) return FILES_ROOT;
if (p === FILES_ROOT || p.startsWith(FILES_ROOT + '/')) return p;
return `${FILES_ROOT}/${p}`;
}
function filesToUiPath(apiPath) {
const p = (apiPath || '').trim().replace(/^\/+/, '');
return p.replace(new RegExp('^' + FILES_ROOT + '/?'), '');
}
function filesSetCurrent(uiPath) {
filesCurrentUiPath = (uiPath || '').trim().replace(/^\/+/, '');
filesCurrentApiPath = filesToApiPath(filesCurrentUiPath);
document.getElementById('filesCurrent').textContent = filesCurrentUiPath || '-';
}
async function filesRefresh() {
const treeEl = document.getElementById('filesTree');
treeEl.textContent = 'Laden...';
const data = await api('/files/tree', 'GET');
// Filter alleen systemd subtree
const scoped = (data || []).filter(folder => {
const p = (folder.path || '').replace(/^\/+/, '');
return p === FILES_ROOT || p.startsWith(FILES_ROOT + '/');
});
if (!scoped.length) {
treeEl.textContent = 'Geen bestanden gevonden onder systemd.';
return;
}
// Render simpel: folders met files eronder
const parts = [];
for (const folder of scoped) {
const apiFolderPath = (folder.path || '').replace(/^\/+/, '');
const uiFolderPath = filesToUiPath(apiFolderPath); // zonder systemd/
const folderLabel = uiFolderPath || 'root';
const folderKey = apiFolderPath; // unieke key (met systemd/..)
const collapsed = _isFolderCollapsed(folderKey);
parts.push(`
<div class="mono file-folder-row" data-folder="${esc(folderKey)}" style="margin:8px 0 6px 0; font-weight:600;">
<span class="file-folder-left">
<span class="folder-toggle">${collapsed ? '▶' : '▼'}</span>
<span>📂 ${esc(folderLabel)}</span>
</span>
<span class="flex" onclick="event.stopPropagation();">
<button class="btn small ok" title="Nieuw bestand in ${esc(folderLabel)}" onclick="filesNewFileInFolder('${esc(uiFolderPath)}')">+</button>
<button class="btn small bad" title="Verwijder map (alleen als leeg)" onclick="filesDeleteFolder('${esc(uiFolderPath)}')">🗑️</button>
</span>
</div>
`);
const files = folder.files || [];
if (!files.length) {
parts.push(`<div class="muted" style="margin-left:18px;">(leeg)</div>`);
continue;
}
parts.push(`<div class="file-folder-files" data-folder-files="${esc(folderKey)}" style="${collapsed ? 'display:none;' : ''}">`);
for (const f of files) {
const fullUi = uiFolderPath ? `${uiFolderPath}/${f}` : f;
parts.push(`
<div style="display:flex;align-items:center;justify-content:space-between;gap:10px;padding:4px 0;border-bottom:1px dashed rgba(36,52,95,.35)">
<span class="mono" style="cursor:pointer" onclick="filesOpen('${esc(fullUi)}')">📄 ${esc(f)}</span>
</div>
`);
}
parts.push(`</div>`);
}
treeEl.innerHTML = parts.join('');
treeEl.onclick = (ev) => {
const row = ev.target.closest('.file-folder-row');
if (!row) return;
const folderKey = row.getAttribute('data-folder');
const isNowCollapsed = !_isFolderCollapsed(folderKey);
_setFolderCollapsed(folderKey, isNowCollapsed);
// pijltje updaten
const arrow = row.querySelector('.folder-toggle');
if (arrow) arrow.textContent = isNowCollapsed ? '▶' : '▼';
// files block tonen/verbergen
const filesBlock = treeEl.querySelector(`[data-folder-files="${CSS.escape(folderKey)}"]`);
if (filesBlock) filesBlock.style.display = isNowCollapsed ? 'none' : '';
};
}
async function filesOpen(uiPath) {
filesSetCurrent(uiPath);
const res = await api(`/files/read?path=${encodeURIComponent(filesCurrentApiPath)}`, 'GET');
const text = res.content || '';
if (cmEditor) {
cmEditor.setOption('mode', cmModeForPath(uiPath));
cmEditor.setValue(text);
cmEditor.refresh();
} else {
document.getElementById('filesEditor').value = text;
}
}
async function filesSave() {
if (!filesCurrentApiPath || filesCurrentApiPath === FILES_ROOT) {
return showModal('Files', 'Selecteer eerst een bestand.');
}
const content = cmEditor
? cmEditor.getValue()
: document.getElementById('filesEditor').value;
const res = await api(
`/files/save?path=${encodeURIComponent(filesCurrentApiPath)}`,
'POST',
{ content }
);
showModal('Opgeslagen', JSON.stringify(res, null, 2));
await filesRefresh();
}
async function filesDelete() {
if (!filesCurrentApiPath || filesCurrentApiPath === FILES_ROOT) {
return showModal('Files', 'Selecteer eerst een bestand om te verwijderen.');
}
if (!confirm(`Verwijderen: ${filesCurrentUiPath}?`)) return;
const res = await api(`/files/delete?path=${encodeURIComponent(filesCurrentApiPath)}`, 'DELETE');
showModal('Verwijderd', JSON.stringify(res, null, 2));
// reset current
filesSetCurrent('');
document.getElementById('filesEditor').value = '';
await filesRefresh();
}
async function filesNewFolder() {
const ui = prompt('Nieuwe map (onder systemd):\nVoorbeeld: mediaserver', '');
if (!ui) return;
const apiPath = filesToApiPath(ui);
const res = await api(`/files/mkdir?path=${encodeURIComponent(apiPath)}`, 'POST');
showModal('Map aangemaakt', JSON.stringify(res, null, 2));
await filesRefresh();
}
async function filesNewFile() {
const ui = prompt('Nieuw bestand (onder systemd):\nVoorbeeld: demo-web/demo-web.container', '');
if (!ui) return;
const apiPath = filesToApiPath(ui);
// altijd leeg (jouw keuze) -> leeg bestand
const res = await api(`/files/save?path=${encodeURIComponent(apiPath)}`, 'POST', { content: "" });
showModal('Bestand aangemaakt', JSON.stringify(res, null, 2));
// Open direct
filesSetCurrent(ui);
const editorEl = document.getElementById('filesEditor');
if (editorEl) editorEl.value = "";
await filesRefresh();
await filesOpen(ui);
} // <- BELANGRIJK: deze } miste bij jou
async function filesNewFileInFolder(uiFolderPath) {
const base = (uiFolderPath || '').trim().replace(/^\/+/, '');
const name = prompt(`Nieuw bestand in "${base || 'root'}"\nBijv: test.yaml of demo.container`, '');
if (!name) return;
const uiFull = base ? `${base}/${name}` : name;
const apiPath = filesToApiPath(uiFull);
// altijd leeg (jouw keuze)
const res = await api(`/files/save?path=${encodeURIComponent(apiPath)}`, 'POST', { content: "" });
showModal('Bestand aangemaakt', JSON.stringify(res, null, 2));
await filesRefresh();
await filesOpen(uiFull);
}
async function filesDeleteFolder(uiFolderPath) {
const base = (uiFolderPath || '').trim().replace(/^\/+/, '');
if (!base) {
return showModal('Files', 'Root map verwijderen mag niet.');
}
if (!confirm(`Map verwijderen (alleen als leeg): ${base}?`)) return;
const apiPath = filesToApiPath(base);
try {
const res = await api(`/files/rmdir?path=${encodeURIComponent(apiPath)}`, 'DELETE');
showModal('Map verwijderd', JSON.stringify(res, null, 2));
await filesRefresh();
} catch (e) {
showModal('Kan map niet verwijderen', e.message);
}
}
// ---- Init ----
(async function init(){
applySidebarState();
const t = document.getElementById('sidebarToggle');
if (t) t.onclick = toggleSidebar;
// preload systemd units UI
const units = await getSystemdUnitsFromServer();
systemdRenderRows(units);
document.getElementById('countSystemd').textContent = units.length;
// Files editor: CodeMirror init (alleen als textarea bestaat)
const taFiles = document.getElementById('filesEditor');
if (taFiles && window.CodeMirror) {
cmEditor = CodeMirror.fromTextArea(taFiles, {
lineNumbers: true,
lineWrapping: true,
mode: 'text/plain',
theme: 'material-darker'
});
cmEditor.setSize('100%', 360);
}
// first refresh
await refreshActive();
// periodic refresh (light): ping every 20s
setInterval(() => { pingApi(); }, 20000);
})();
</script>
</body>
</html>