Files
podman-mvp/webui/html/index.html
T
kodi 5e7d1b887c feat (health): voeg helper socket check toe, drie visuele states
Backend (/api/health):
- Importeer HELPER_SOCKET uit common.py
- Voeg helper-check toe: connect() op /run/podman-helper.sock, timeout=2s
- ok blijft true als alleen de helper ontbreekt (waarschuwing, geen fout)
- Nieuwe response key: "helper": {"ok": bool}

Frontend (pingApi / setApiState):
- pingApi() roept nu /api/health aan i.p.v. /pods-dashboard
- setApiState(state, msg) accepteert 'ok' / 'warn' / 'error'
- Gele dot met --warn kleur als helper.ok=false maar core OK
- refreshActive() delegeert statusupdate aan pingApi()
- Detailbericht bij fout: toont welk component (podman/systemd) faalt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 08:06:38 +01:00

777 lines
30 KiB
HTML

<!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>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.css">
<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>
<button class="btn ghost" id="themeToggleBtn" title="Schakel light/dark mode">◐ Theme</button>
<span class="statusline headerMeta" id="lastRefreshHeader">Laatste refresh: -</span>
</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><span class="navCount" id="countNavContainers">-</span>
</div>
<div class="tab" id="tab-networks" onclick="setTab('networks')" title="Netwerk">
<span class="navIcon">🌐</span><span class="navLabel">Netwerk</span><span class="navCount" id="countNavNetworks">-</span>
</div>
<div class="tab" id="tab-images" onclick="setTab('images')" title="Images">
<span class="navIcon">📦</span><span class="navLabel">Images</span><span class="navCount" id="countNavImages">-</span>
</div>
<div class="tab" id="tab-files" onclick="setTab('files')" title="Files">
<span class="navIcon">📁</span><span class="navLabel">Files</span><span class="navCount" id="countNavFiles">-</span>
</div>
</div>
</aside>
<!-- Main -->
<main class="main">
<div class="wrap">
<div id="view-dashboard" class="grid">
<div class="card">
<div class="cardHeader">
<div class="cardTitle">Platform overzicht</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="dashboardKpiGrid">
<div class="statCard">
<div class="statValue" id="countPods">-</div>
<div class="statLabel">Pods</div>
</div>
<div class="statCard">
<div class="statValue" id="countContainers">-</div>
<div class="statLabel">Containers</div>
</div>
<div class="statCard">
<div class="statValue" id="dashboardApiState">-</div>
<div class="statLabel">API status</div>
</div>
<div class="statCard">
<div class="statValue" id="dashboardLastRefresh">-</div>
<div class="statLabel">Laatste refresh</div>
</div>
</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">Snel acties</div>
</div>
<div class="cardBody">
<div class="actionBar">
<button class="btn" onclick="setTab('containers')">Ga naar containers</button>
<button class="btn" onclick="setTab('networks')">Ga naar netwerken</button>
<button class="btn" onclick="setTab('images')">Ga naar images</button>
<button class="btn" onclick="setTab('files')">Ga naar files</button>
<a class="btn ghost" href="/docs/" target="_blank">API docs ↗</a>
</div>
<div class="hint">Gebruik de zijbalk voor detailbeheer; deze acties geven snelle toegang tot de hoofdsecties.</div>
</div>
</div>
</div>
<div id="view-containers" class="grid" style="display:none">
<div class="card card--menu-overflow" 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-networks" style="display:none;">
<div class="card">
<div class="cardHeader">
<h2>Netwerken</h2>
<div class="row gap">
<button class="btn small" id="networksRefreshBtn">Vernieuwen</button>
</div>
</div>
<div id="networksSummary" class="statGrid" style="margin:12px 14px 0 14px;"></div>
<div id="networksStatus" class="muted" style="margin:8px 0;"></div>
<div id="networksToolbar" class="toolbar" style="margin:10px 14px 0 14px;">
<div class="row gap" style="flex-wrap:wrap; align-items:center;">
<input id="networksSearch" class="input" type="search" placeholder="Zoek netwerk, subnet of driver…" style="min-width:260px; flex:1;" />
<div class="segToggle" id="networksViewToggle" title="Weergave">
<button class="seg active" type="button" data-view="table">Tabel</button>
<button class="seg" type="button" data-view="map">Kaart</button>
</div>
<label class="chip">
<input id="networksFilterConnected" type="checkbox" />
Alleen verbonden
</label>
<label class="chip">
<input id="networksFilterHideDefaults" type="checkbox" checked />
Verberg standaard
</label>
<label class="chip">
<input id="networksFilterShared" type="checkbox" />
Shared netns
</label>
<select id="networksSort" class="select">
<option value="name_asc">Sorteer: Naam (A→Z)</option>
<option value="containers_desc">Sorteer: Containers (hoog→laag)</option>
<option value="driver_asc">Sorteer: Driver (A→Z)</option>
</select>
</div>
<!-- Kaartweergave (STAP 3A-1: alleen UI/placeholder, geen D3) -->
<div id="networksMapWrap" style="display:none; margin:10px 14px 0 14px;">
<div class="toolbar" style="margin:0 0 10px 0;">
<div class="row gap" style="flex-wrap:wrap; align-items:center;">
<!-- Zoekveld is hetzelfde als tabel (networksSearch) -->
<button class="btn small" type="button" id="networksMapResetBtn">Reset view</button>
<button class="btn small" type="button" id="networksMapLayoutBtn">Auto-layout</button>
<button class="btn small ghost" type="button" id="networksMapLegendBtn">Legenda</button>
<label class="chk" style="display:flex; align-items:center; gap:8px; margin-left:10px;">
<input type="checkbox" id="networksMapShowModes" checked>
<span class="muted">Toon modes</span>
</label>
<label class="chk" style="display:flex; align-items:center; gap:8px; margin-left:10px;">
<input type="checkbox" id="networksMapConnectedOnly">
<span class="muted">Alleen verbonden</span>
</label>
<span class="muted" id="networksMapStatus" style="margin-left:auto;">Kaartweergave (placeholder)</span>
</div>
</div>
<div class="mapSplit">
<div class="mapMain">
<div id="networksMapHost" class="mapHost">
</div>
</div>
<div class="mapSide">
<div id="networksDetailPanel" class="mapDetail" style="display:none;">
<div class="mapDetailHeader">
<button class="btn small ghost" type="button" id="networksMapBackBtn">← Terug</button>
<div class="mapDetailTitle" id="networksDetailTitle">Netwerk</div>
</div>
<div class="mapDetailBody" id="networksDetailBody"></div>
</div>
</div>
</div>
<div id="networksMapLegend" class="mapLegend" style="display:none;">
<div class="legendTitle">Legenda</div>
<div class="legendRow"><span class="legendSwatch net"></span> Netwerk</div>
<div class="legendRow"><span class="legendSwatch ctr"></span> Container</div>
<div class="legendRow"><span class="legendSwatch link"></span> Verbinding</div>
<div class="legendRow"><span class="legendSwatch shared"></span> Shared netns (stippellijn)</div>
</div>
</div>
</div>
<table class="table" id="networksTable">
<thead>
<tr>
<th style="width:42px;"></th>
<th>Naam</th>
<th style="width:120px;">Driver</th>
<th>Subnets</th>
<th style="width:140px; text-align:right;">Containers</th>
<th style="width:220px;">Flags</th>
</tr>
</thead>
<tbody id="networksTbody"></tbody>
</table>
</div>
<div class="card" id="networksRelationsCard" style="margin-top:12px;">
<div class="cardHeader">
<h2>Relaties</h2>
</div>
<div id="networksRelations"></div>
</div>
</div>
<div id="view-images" class="grid" style="display:none">
<div class="card" style="grid-column: 1 / -1;">
<div class="cardHeader">
<div class="cardTitle">Images</div>
<div class="flex">
<button class="btn" onclick="loadImages()">Ververs</button>
<button class="btn bad" onclick="removeSelectedImages()">Remove selected</button>
<button class="btn warn" onclick="pruneUnusedImages()">Prune unused</button>
<button class="btn ok" onclick="openBuildModal()">Build image</button>
</div>
</div>
<div class="cardBody">
<table>
<thead>
<tr>
<th style="width:30px;">
<input type="checkbox" id="imagesSelectAll" onclick="toggleSelectAllImages(this)">
</th>
<th class="sortable" onclick="sortImages('repo')">
Repo / Tag <span class="sort-indicator" id="sort-repo"></span>
</th>
<th class="sortable" onclick="sortImages('id')">
ID <span class="sort-indicator" id="sort-id"></span>
</th>
<th class="sortable" onclick="sortImages('size')">
Size <span class="sort-indicator" id="sort-size"></span>
</th>
<th class="sortable" onclick="sortImages('created')">
Created <span class="sort-indicator" id="sort-created"></span>
</th>
<th class="sortable" onclick="sortImages('containers')">
Containers <span class="sort-indicator" id="sort-containers"></span>
</th>
<th>Status</th>
<th style="width:100px;">Acties</th>
</tr>
</thead>
<tbody id="images-tbody"></tbody>
</table>
</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 id="filesEditorStatus" class="filesEditorStatus muted">Geen bestand geselecteerd</div>
<div class="hint">
Na wijzigen van <span class="mono">*.container</span> moet je meestal <span class="mono">daemon-reload</span> doen (via de 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>
<!-- Exec Terminal Modal -->
<div class="modalBack" id="execModalBack" style="display:none;" onclick="closeExecModal(event)">
<div class="modal modalExec" id="execTerminalModal" onclick="event.stopPropagation()">
<div class="modalHeader">
<div class="modalTitle" id="execModalTitle">Exec</div>
<div class="flex">
<span class="statusline" id="execTerminalStatus">Disconnected</span>
<button class="btn small ghost" id="execRawModeBtn" onclick="containerExecToggleRawMode()">Raw keys: Off</button>
<button class="btn small ghost" onclick="containerExecClose()">Sluiten</button>
</div>
</div>
<div class="modalBody execBody">
<div id="execTerminalHost" class="execTerminalHost"></div>
<pre id="execTerminalOutput" class="execOutput"></pre>
<div class="execInputRow" id="execInputRow">
<span class="statusline" id="execTerminalBufferInfo"></span>
<input
class="input mono"
id="execTerminalInput"
placeholder="Type command and press Enter..."
onkeydown="containerExecHandleInputKey(event)"
onpaste="containerExecHandleInputPaste(event)"
onblur="containerExecHandleInputBlur(event)"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
/>
<button class="btn" id="execTerminalSendBtn" onclick="containerExecSendInput()">Send</button>
</div>
</div>
</div>
</div>
<!-- Build Modal -->
<div class="modalBack" id="buildModalBack" style="display:none;" onclick="closeBuildModal(event)">
<div class="modal" onclick="event.stopPropagation()" style="width:700px;">
<div class="modalHeader">
<div class="modalTitle">Build image</div>
<button class="btn small ghost" onclick="hideBuildModal()">Sluiten</button>
</div>
<div class="modalBody">
<div class="formRow">
<label>Context directory</label>
<input class="input" id="buildContext" placeholder="systemd/buildtests/hello">
</div>
<div class="formRow">
<label>Dockerfile/Containerfile</label>
<div class="row gap">
<input class="input" id="buildDockerfile" value="" style="flex:1;">
<button class="btn" type="button" onclick="openDockerfilePicker()">Kies...</button>
</div>
<div class="muted" style="margin-top:6px;">
Kiest een Dockerfile/Containerfile onder <span class="mono">systemd/</span>.
</div>
</div>
<div class="formRow">
<label>Tag</label>
<input class="input" id="buildTag" placeholder="localhost/myimage:latest">
</div>
<div class="formRow">
<label><input type="checkbox" id="buildPull"> Pull latest base image</label>
</div>
<div class="formRow">
<label><input type="checkbox" id="buildNoCache"> No cache</label>
</div>
<div style="margin-top:15px;">
<button class="btn ok" onclick="buildImage()">Start build</button>
</div>
<div style="margin-top:15px;">
<textarea id="buildOutput" class="textarea mono" style="height:200px;" readonly></textarea>
</div>
</div>
</div>
</div>
<!-- Dockerfile Picker Modal -->
<div class="modalBack" id="dfPickerBack" style="display:none;" onclick="closeDockerfilePicker(event)">
<div class="modal" onclick="event.stopPropagation()" style="width:760px;">
<div class="modalHeader">
<div class="modalTitle">Kies Dockerfile/Containerfile</div>
<button class="btn small ghost" onclick="hideDockerfilePicker()">Sluiten</button>
</div>
<div class="modalBody">
<div class="row gap" style="margin-bottom:10px;">
<input class="input" id="dfPickerSearch" placeholder="Zoek... (bijv. traefik, hello, Dockerfile)" style="flex:1;">
<button class="btn" type="button" onclick="refreshDockerfilePicker()">Ververs</button>
</div>
<div class="hint" style="margin-bottom:10px;">
Alleen bestanden onder <span class="mono">systemd/</span> die <span class="mono">Dockerfile</span>,
<span class="mono">Containerfile</span> of eindigen op <span class="mono">.dockerfile</span>/<span class="mono">.containerfile</span>.
</div>
<div class="input" style="padding:10px; max-height:360px; overflow:auto;">
<div id="dfPickerList" class="mono muted">Laden...</div>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js"></script>
<script src="assets/js/tabs/containers.js"></script>
<script src="assets/js/tabs/files.js"></script>
<script>
// ---- 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;");
}
async function apiGet(path) {
const r = await fetch('/api' + path, { headers: { 'Accept': 'application/json' } });
if (!r.ok) {
const t = await r.text().catch(()=> '');
throw new Error(`HTTP ${r.status} ${path}: ${t}`);
}
return r.json();
}
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">${esc(s || 'unknown')}</span>`;
}
function updateNavCount(id, value) {
const el = document.getElementById(id);
if (!el) return;
el.textContent = Number.isFinite(Number(value)) ? String(value) : '-';
}
function renderStateBox(type, title, message) {
const t = type === 'error' ? 'error' : (type === 'empty' ? 'empty' : 'info');
return `
<div class="stateBox ${t}">
<div class="stateTitle">${esc(title || 'Status')}</div>
<div class="stateText">${esc(message || '')}</div>
</div>
`;
}
// ---- 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');
const view = document.getElementById('view-' + tab);
view.style.display = '';
view.classList.remove('viewAnim');
requestAnimationFrame(() => view.classList.add('viewAnim'));
if (tab === 'files') {
filesRefresh();
}
if (tab === 'networks') {
window.mvpNetworks?.bindUiOnce?.();
window.mvpNetworks?.refresh?.();
}
if (tab === "images") {
loadImages();
}
// Start/stop live stats alleen in Containers tab (polling via /containers-dashboard)
if (tab === 'containers') startContainersDashboardStatsPoll();
else stopContainersDashboardStatsPoll();
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") {
stopContainersDashboardStatsPoll();
} else if (currentTab === "containers") {
startContainersDashboardStatsPoll();
}
});
// ---- Health / Ping ----
async function pingApi() {
try {
const h = await api('/health', 'GET');
const helperOk = h?.helper?.ok === true;
if (!h?.ok) {
const detail = !h?.podman?.ok ? 'podman' : !h?.systemd_user?.reachable ? 'systemd' : 'onbekend';
setApiState('error', `API: fout (${detail})`);
} else if (!helperOk) {
setApiState('warn', 'API: OK | ⚠️ helper');
} else {
setApiState('ok', 'API: OK');
}
} catch (e) {
setApiState('error', 'API: fout (' + e.message + ')');
showModal('API fout', e.stack || e.message);
}
}
function setApiState(state, msg) {
const dot = document.getElementById('apiDot');
const ok = state === 'ok';
const warn = state === 'warn';
dot.style.background = ok ? 'var(--ok)' : warn ? 'var(--warn, #f59e0b)' : 'var(--bad)';
dot.style.boxShadow = ok ? '0 0 0 6px rgba(45,212,191,.15)' : warn ? '0 0 0 6px rgba(245,158,11,.15)' : '0 0 0 6px rgba(251,113,133,.15)';
document.getElementById('statusLine').textContent = msg;
const apiStat = document.getElementById('dashboardApiState');
if (apiStat) apiStat.textContent = ok ? 'OK' : warn ? 'Waarschuwing' : 'Fout';
}
function currentClockText() {
return new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
function setLastRefreshNow() {
const now = currentClockText();
const hdr = document.getElementById('lastRefreshHeader');
if (hdr) hdr.textContent = 'Laatste refresh: ' + now;
const dash = document.getElementById('dashboardLastRefresh');
if (dash) dash.textContent = now;
}
// ---- Dashboard refresh ----
async function refreshActive() {
try {
if (currentTab === 'containers') await fetchContainers();
else {
const [pods, containers, networks] = await Promise.all([
api('/pods-dashboard','GET'),
api('/containers-dashboard','GET'),
api('/networks','GET').catch(() => ({ networks: [] }))
]);
document.getElementById('countPods').textContent = (pods || []).length;
const list = Array.isArray(containers) ? containers : (containers?.containers || []);
const cCount = list.length;
document.getElementById('countContainers').textContent = cCount;
updateNavCount('countNavContainers', cCount);
const nCount = Array.isArray(networks?.networks) ? networks.networks.length : 0;
updateNavCount('countNavNetworks', nCount);
}
setLastRefreshNow();
pingApi();
} catch (e) {
setApiState('error', 'API: fout (' + e.message + ')');
}
}
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);
}
}
// ---- Theme (light/dark) ----
const THEME_KEY = 'mvp_theme_v1';
function getSystemTheme() {
return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
}
function getStoredTheme() {
const t = localStorage.getItem(THEME_KEY);
return (t === 'dark' || t === 'light') ? t : '';
}
function getInitialTheme() {
return getStoredTheme() || getSystemTheme();
}
function updateThemeToggleUi(theme) {
const btn = document.getElementById('themeToggleBtn');
if (!btn) return;
const next = theme === 'dark' ? 'light' : 'dark';
btn.textContent = `Theme: ${theme === 'dark' ? 'Dark' : 'Light'}`;
btn.title = `Schakel naar ${next === 'dark' ? 'dark' : 'light'} mode`;
}
function applyTheme(theme, persist = false) {
const t = (theme === 'light') ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', t);
updateThemeToggleUi(t);
if (persist) localStorage.setItem(THEME_KEY, t);
if (typeof window.filesSetEditorTheme === 'function') {
window.filesSetEditorTheme(t);
}
}
function toggleTheme() {
const current = document.documentElement.getAttribute('data-theme') || getInitialTheme();
const next = current === 'dark' ? 'light' : 'dark';
applyTheme(next, true);
}
// ---- Init ----
(async function init(){
applyTheme(getInitialTheme(), false);
const themeBtn = document.getElementById('themeToggleBtn');
if (themeBtn) themeBtn.onclick = toggleTheme;
if (window.matchMedia) {
const mq = window.matchMedia('(prefers-color-scheme: dark)');
const onSystemThemeChange = (ev) => {
if (getStoredTheme()) return;
applyTheme(ev.matches ? 'dark' : 'light', false);
};
if (mq.addEventListener) mq.addEventListener('change', onSystemThemeChange);
else if (mq.addListener) mq.addListener(onSystemThemeChange);
}
applySidebarState();
const t = document.getElementById('sidebarToggle');
if (t) t.onclick = toggleSidebar;
// first refresh
await refreshActive();
// periodic refresh (light): ping every 20s
setInterval(() => { pingApi(); }, 20000);
})();
</script>
<script src="assets/js/d3.min.js"></script>
<script src="assets/js/tabs/networks.js"></script>
<script src="assets/js/tabs/images.js"></script>
</body>
</html>