Files
podman-mvp/webui/html/index.html
T

1315 lines
45 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="podman.io-favicon.ico">
<style>
:root{
--bg: #0b1220;
--panel: #111a2e;
--panel2: #0e1730;
--text: #e8eefc;
--muted:#9bb0da;
--border:#24345f;
--ok:#2dd4bf;
--warn:#fbbf24;
--bad:#fb7185;
--btn:#1b2a55;
--btn2:#223564;
--accent:#60a5fa;
--shadow: 0 10px 30px rgba(0,0,0,.35);
--radius: 14px;
--mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--sans: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji","Segoe UI Emoji";
}
*{box-sizing:border-box}
body{
margin:0;
font-family: var(--sans);
background: radial-gradient(1200px 600px at 20% 0%, #18244a 0%, var(--bg) 55%);
color: var(--text);
}
header{
position: sticky; top:0; z-index:10;
background: rgba(11,18,32,.7);
backdrop-filter: blur(10px);
border-bottom: 1px solid rgba(36,52,95,.7);
}
.wrap{max-width:1700px;margin:0 auto;padding:16px}
.topbar{
display:flex; gap:12px; align-items:center; justify-content:space-between;
}
.brand{
display:flex; gap:12px; align-items:center;
font-weight:700; letter-spacing:.2px;
}
.brandLogo{
width: 34px;
height: 34px;
display:block;
}
.dot{
width:12px;height:12px;border-radius:50%;
background: var(--ok);
box-shadow: 0 0 0 6px rgba(45,212,191,.15);
}
.statusline{color:var(--muted); font-size:13px}
.row{display:flex; gap:14px; flex-wrap:wrap}
.tabs{
display:flex; gap:8px; flex-wrap:wrap;
margin-top:12px;
}
.tab{
border:1px solid var(--border);
background: rgba(17,26,46,.6);
color: var(--text);
padding:10px 12px;
border-radius: 999px;
cursor:pointer;
user-select:none;
font-size:14px;
}
.tab.active{
background: linear-gradient(135deg, rgba(96,165,250,.25), rgba(17,26,46,.6));
border-color: rgba(96,165,250,.5);
}
.grid{
display:grid;
grid-template-columns: 1fr;
gap:14px;
padding:16px 0 26px;
}
@media (min-width: 980px){
.grid{grid-template-columns: 1fr 1fr}
}
.card{
background: linear-gradient(180deg, rgba(17,26,46,.85), rgba(14,23,48,.85));
border: 1px solid rgba(36,52,95,.9);
border-radius: var(--radius);
box-shadow: var(--shadow);
overflow:hidden;
}
.card.half{min-height: 240px;}
.cardHeader{
display:flex; align-items:center; justify-content:space-between;
padding:14px 14px;
border-bottom:1px solid rgba(36,52,95,.7);
}
.cardTitle{
font-weight:700;
display:flex; gap:10px; align-items:center;
}
.cardBody{padding:14px}
.btn{
border:1px solid rgba(36,52,95,.9);
background: var(--btn);
color: var(--text);
padding:9px 10px;
border-radius: 12px;
cursor:pointer;
font-size:13px;
}
.btn:hover{background: var(--btn2)}
.btn.small{padding:7px 9px; border-radius: 10px}
.btn.ghost{background: transparent}
.btn.ok{border-color: rgba(45,212,191,.6)}
.btn.bad{border-color: rgba(251,113,133,.6)}
.btn.warn{border-color: rgba(251,191,36,.6)}
.pill{
display:inline-flex; align-items:center; gap:8px;
padding:6px 10px;
border-radius:999px;
border:1px solid rgba(36,52,95,.9);
color: var(--muted);
font-size:12px;
}
.pill .b{color: var(--text); font-weight:600}
table{
width:100%;
border-collapse: collapse;
font-size: 13px;
}
th,td{
padding:10px 8px;
border-bottom:1px solid rgba(36,52,95,.6);
text-align:left;
vertical-align: top;
}
th{color: var(--muted); font-weight:600}
tr:hover td{background: rgba(96,165,250,.06)}
.badge{
display:inline-flex;
align-items:center;
border:1px solid rgba(36,52,95,.9);
padding:4px 8px;
border-radius:999px;
font-size:12px;
color: var(--muted);
}
.badge.ok{border-color: rgba(45,212,191,.6); color: var(--ok)}
.badge.bad{border-color: rgba(251,113,133,.6); color: var(--bad)}
.badge.warn{border-color: rgba(251,191,36,.6); color: var(--warn)}
.mono{font-family: var(--mono)}
.muted{color:var(--muted)}
.num{
text-align:right;
white-space:nowrap;
font-variant-numeric: tabular-nums;
}
.flex{display:flex; gap:8px; flex-wrap:wrap; align-items:center}
.input, .textarea{
width: 100%;
background: rgba(8,12,25,.6);
border:1px solid rgba(36,52,95,.9);
color: var(--text);
border-radius: 12px;
padding:10px 12px;
outline:none;
font-size: 13px;
}
.textarea{min-height: 120px; font-family: var(--mono)}
.split{
display:grid;
grid-template-columns: 1fr;
gap:12px;
}
@media (min-width: 980px){
.split{grid-template-columns: 1fr 1fr}
}
/* Modal */
.modalBack{
position: fixed; inset:0;
background: rgba(0,0,0,.55);
display:none; align-items:center; justify-content:center;
padding:18px; z-index: 99;
}
.modal{
width:min(980px, 100%);
background: linear-gradient(180deg, rgba(17,26,46,.95), rgba(14,23,48,.95));
border:1px solid rgba(36,52,95,.9);
border-radius: 18px;
box-shadow: var(--shadow);
overflow:hidden;
}
.modalHeader{
padding:12px 14px;
display:flex; align-items:center; justify-content:space-between;
border-bottom:1px solid rgba(36,52,95,.7);
}
.modalTitle{font-weight:700}
.modalBody{padding:14px}
pre{
margin:0;
white-space: pre-wrap;
word-break: break-word;
font-family: var(--mono);
font-size: 12.5px;
color: #d9e6ff;
background: rgba(0,0,0,.35);
border:1px solid rgba(36,52,95,.7);
border-radius: 14px;
padding: 12px;
max-height: 60vh;
overflow:auto;
}
.hint{font-size:12px;color:var(--muted);margin-top:8px;line-height:1.35}
.pod-group-row td {
background: rgba(255,255,255,0.04);
border-top: 1px solid rgba(255,255,255,0.08);
font-weight: 600;
}
.pod-group-row:hover td {
background: rgba(255,255,255,0.07);
}
</style>
</head>
<body>
<header>
<div class="wrap">
<div class="topbar">
<div class="brand">
<img class="brandLogo" src="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 class="tabs">
<div class="tab active" id="tab-dashboard" onclick="setTab('dashboard')">Dashboard</div>
<div class="tab" id="tab-containers" onclick="setTab('containers')">Containers</div>
<div class="tab" id="tab-pods" onclick="setTab('pods')">Pods</div>
<div class="tab" id="tab-systemd" onclick="setTab('systemd')">Systemd</div>
<div class="tab" id="tab-files" onclick="setTab('files')">Files</div>
</div>
</div>
</header>
<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>
<!-- 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 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/properties/properties.min.js"></script>
<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(); }
// ---- 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();
}
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 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 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 small" onclick="containerInspect('${esc(name)}')">Inspect</button>
<button class="btn small" onclick="containerLogs('${esc(name)}')">Logs</button>
<button class="btn small ok" onclick="containerAction('start','${esc(name)}')">Start</button>
<button class="btn small warn" onclick="containerAction('restart','${esc(name)}')">Restart</button>
<button class="btn small bad" onclick="containerAction('stop','${esc(name)}')">Stop</button>
</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;
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)}">-</td>
<td class="muted num" id="podmem-${cssSafeId(pod)}">-</td>
<td class="mono">${esc(podPortsText)}</td>
<td>
${isRealPod ? `
<div class="flex">
<button class="btn small" disabled style="visibility:hidden;">Inspect</button>
<button class="btn small" disabled style="visibility:hidden;">Logs</button>
<button class="btn small ok" onclick="podAction('start','${esc(pod)}')">Start</button>
<button class="btn small warn" onclick="podAction('restart','${esc(pod)}')">Restart</button>
<button class="btn small bad" onclick="podAction('stop','${esc(pod)}')">Stop</button>
</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 pod = containersC2P.get(cname);
if (pod) {
const cpuPct = Number(st?.CPUPerc ?? st?.CPU ?? st?.AvgCPU ?? 0);
const memBytes = Number(st?.MemUsage ?? 0);
const memPct = Number(st?.MemPerc ?? 0);
podCpu.set(pod, (podCpu.get(pod) || 0) + cpuPct);
podMem.set(pod, (podMem.get(pod) || 0) + memBytes);
podMemPct.set(pod, (podMemPct.get(pod) || 0) + memPct);
}
for (const [pod, cpuSum] of podCpu.entries()) {
const el = document.getElementById(`podcpu-${cssSafeId(pod)}`);
if (el) el.textContent = cpuSum.toFixed(2) + "%";
}
for (const [pod, memSum] of podMem.entries()) {
const el = document.getElementById(`podmem-${cssSafeId(pod)}`);
if (el) {
const memPct = podMemPct.get(pod) || 0;
el.textContent = `${formatBytes(memSum)} (${memPct.toFixed(1)}%)`;
}
}
// CPU: jouw API geeft CPU als fractie (0.00384 -> 0.384%)
const cpuPct = Number(st?.CPUPerc ?? st?.CPU ?? st?.AvgCPU ?? 0);
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(st?.MemUsage);
const memp = Number(st?.MemPerc ?? 0);
memEl.textContent = `${mem} (${memp.toFixed(1)}%)`;
}
}
});
containersStatsES.onerror = () => {
resetPodTotals();
};
}
function stopContainersStatsStream() {
if (!containersStatsES) return;
containersStatsES.close();
containersStatsES = null;
resetPodTotals();
}
function resetPodTotals() {
document.querySelectorAll('[id^="podcpu-"]').forEach(el => {
el.textContent = '-';
});
document.querySelectorAll('[id^="podmem-"]').forEach(el => {
el.textContent = '-';
});
}
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';
parts.push(`
<div class="mono" style="margin:8px 0 6px 0; font-weight:600; display:flex; align-items:center; justify-content:space-between; gap:10px;">
<span>📂 ${esc(folderLabel)}</span>
<span class="flex">
<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;
}
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;margin-left: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>
`);
}
}
treeEl.innerHTML = parts.join('');
}
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(){
// 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>