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

602 lines
22 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>
</div>
</div>
</div>
</header>
<div class="layout">
<!-- Sidebar -->
<aside class="sidebar" id="sidebar">
<div class="sidebarTop">
<button class="btn small ghost sidebarToggle" id="sidebarToggle" title="Sidebar in/uitklappen"></button>
</div>
<div class="tabs">
<div class="tab active" id="tab-dashboard" onclick="setTab('dashboard')" title="Dashboard">
<span class="navIcon">🏠</span><span class="navLabel">Dashboard</span>
</div>
<div class="tab" id="tab-containers" onclick="setTab('containers')" title="Containers">
<span class="navIcon">📦</span><span class="navLabel">Containers</span>
</div>
<div class="tab" id="tab-networks" onclick="setTab('networks')" title="Netwerk">
<span class="navIcon">🌐</span><span class="navLabel">Netwerk</span>
</div>
<div class="tab" id="tab-images" onclick="setTab('images')" title="Images">
<span class="navIcon">📦</span><span class="navLabel">Images</span>
</div>
<div class="tab" id="tab-files" onclick="setTab('files')" title="Files">
<span class="navIcon">📁</span><span class="navLabel">Files</span>
</div>
</div>
</aside>
<!-- Main -->
<main class="main">
<div class="wrap">
<div id="view-dashboard" class="grid">
<div class="card half">
<div class="cardHeader">
<div class="cardTitle">Snel acties</div>
<div class="flex">
<button class="btn ok" onclick="daemonReload()">daemon-reload</button>
<button class="btn" onclick="refreshActive()">Ververs alles</button>
</div>
</div>
<div class="cardBody">
<div class="flex">
<span class="pill"><span class="b" id="countPods">-</span> pods</span>
<span class="pill"><span class="b" id="countContainers">-</span> containers</span>
</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>
<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 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</label>
<div class="row gap">
<input class="input" id="buildDockerfile" value="Dockerfile" 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</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('&','&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 warn">${esc(s || 'unknown')}</span>`;
}
// ---- Modal ----
function showModal(title, content) {
document.getElementById('modalTitle').textContent = title;
document.getElementById('modalPre').textContent = content;
document.getElementById('modalBack').style.display = 'flex';
}
function hideModal() { document.getElementById('modalBack').style.display = 'none'; }
function closeModal(e){ if(e.target.id === 'modalBack') hideModal(); }
function closeAllMenus() {
document.querySelectorAll('.menuPanel.open').forEach(p => p.classList.remove('open'));
}
function toggleMenu(menuId) {
const el = document.getElementById(menuId);
if (!el) return;
const willOpen = !el.classList.contains('open');
closeAllMenus();
if (willOpen) el.classList.add('open');
}
// klik buiten menu = sluiten
document.addEventListener('click', (e) => {
// als je op een menu knop klikt, laat toggleMenu het regelen
if (e.target.closest('.actions-menu')) return;
closeAllMenus();
});
// ---- Tabs ----
let currentTab = 'dashboard';
function setTab(tab) {
currentTab = tab;
document.querySelectorAll('.tab').forEach(x => x.classList.remove('active'));
document.getElementById('tab-' + tab).classList.add('active');
document.querySelectorAll('[id^="view-"]').forEach(v => v.style.display='none');
document.getElementById('view-' + tab).style.display = '';
if (tab === 'files') {
filesRefresh();
}
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;
}
// ---- Dashboard refresh ----
async function refreshActive() {
try {
if (currentTab === 'containers') await fetchContainers();
else {
const [pods, containers] = await Promise.all([
api('/pods-dashboard','GET'),
api('/containers-dashboard','GET')
]);
document.getElementById('countPods').textContent = (pods || []).length;
const list = Array.isArray(containers) ? containers : (containers?.containers || []);
const cCount = list.length;
document.getElementById('countContainers').textContent = cCount;
}
setApiState(true, 'API: OK');
} 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);
}
}
// ---- Init ----
(async function init(){
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>