730 lines
27 KiB
HTML
730 lines
27 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>
|
|
<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>
|
|
</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>
|
|
|
|
<!-- 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="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('&','&')
|
|
.replaceAll('<','<')
|
|
.replaceAll('>','>')
|
|
.replaceAll('"','"')
|
|
.replaceAll("'","'");
|
|
}
|
|
|
|
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 {
|
|
// simpele ping: pods ophalen
|
|
await api('/pods-dashboard', '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;
|
|
const apiStat = document.getElementById('dashboardApiState');
|
|
if (apiStat) apiStat.textContent = ok ? 'OK' : '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);
|
|
}
|
|
setApiState(true, 'API: OK');
|
|
setLastRefreshNow();
|
|
} catch (e) {
|
|
setApiState(false, '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="https://cdnjs.cloudflare.com/ajax/libs/d3/7.9.0/d3.min.js"></script>
|
|
<script src="assets/js/tabs/networks.js"></script>
|
|
<script src="assets/js/tabs/images.js"></script>
|
|
</body>
|
|
</html>
|