1348 lines
46 KiB
HTML
1348 lines
46 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" 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="networksStatus" class="muted" style="margin:8px 0;"></div>
|
|
|
|
<table class="table" id="networksTable">
|
|
<thead>
|
|
<tr>
|
|
<th style="width:40px;"></th>
|
|
<th>Netwerk</th>
|
|
<th># containers</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="networksTbody"></tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div class="card" 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>Repo / Tag</th>
|
|
<th>ID</th>
|
|
<th>Size</th>
|
|
<th>Created</th>
|
|
<th>Containers</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>
|
|
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('&','&')
|
|
.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();
|
|
}
|
|
|
|
async function fetchNetworksUsage() {
|
|
return apiGet('/networks/usage');
|
|
}
|
|
|
|
async function fetchNetworksList() {
|
|
// returns { networks: [...] }
|
|
return apiGet('/networks');
|
|
}
|
|
|
|
async function fetchNetworkInspect(name) {
|
|
return apiGet('/networks/' + encodeURIComponent(name));
|
|
}
|
|
|
|
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') {
|
|
networksRefresh();
|
|
}
|
|
if (tab === "images") {
|
|
loadImages();
|
|
}
|
|
// Start/stop live stats alleen in Containers tab
|
|
if (tab === 'containers') startContainersStatsStream();
|
|
else stopContainersStatsStream();
|
|
|
|
refreshActive();
|
|
}
|
|
|
|
// ---- Sidebar collapse ----
|
|
const SIDEBAR_KEY = 'mvp_sidebar_collapsed_v1';
|
|
|
|
function applySidebarState() {
|
|
const sb = document.getElementById('sidebar');
|
|
if (!sb) return;
|
|
const collapsed = localStorage.getItem(SIDEBAR_KEY) === '1';
|
|
sb.classList.toggle('collapsed', collapsed);
|
|
}
|
|
|
|
function toggleSidebar() {
|
|
const collapsed = localStorage.getItem(SIDEBAR_KEY) === '1';
|
|
localStorage.setItem(SIDEBAR_KEY, collapsed ? '0' : '1');
|
|
applySidebarState();
|
|
}
|
|
|
|
document.addEventListener("visibilitychange", () => {
|
|
if (document.visibilityState !== "visible") {
|
|
stopContainersStatsStream();
|
|
} else if (currentTab === "containers") {
|
|
startContainersStatsStream();
|
|
}
|
|
});
|
|
|
|
// ---- Health / Ping ----
|
|
async function pingApi() {
|
|
try {
|
|
// simpele ping: pods ophalen
|
|
await api('/pods-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 + ')');
|
|
}
|
|
}
|
|
|
|
// ---- Pods ----
|
|
|
|
|
|
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 fetchContainers(); // refresh pods + containers view in één keer
|
|
} catch (e) {
|
|
showModal(`Pod ${action} fout`, e.stack || e.message);
|
|
}
|
|
}
|
|
|
|
// ---- Containers ----
|
|
|
|
function _podKey(c) {
|
|
return (c.PodName && String(c.PodName).trim()) ? String(c.PodName).trim() : '(geen pod)';
|
|
}
|
|
|
|
function _cmpStr(a, b) {
|
|
return String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: 'base' });
|
|
}
|
|
|
|
function _isCollapsed(pod) {
|
|
return localStorage.getItem('pod_group_collapsed:' + pod) === '1';
|
|
}
|
|
|
|
function _setCollapsed(pod, v) {
|
|
localStorage.setItem('pod_group_collapsed:' + pod, v ? '1' : '0');
|
|
}
|
|
|
|
function _isFolderCollapsed(folderKey) {
|
|
return localStorage.getItem('files_folder_collapsed:' + folderKey) !== '0';
|
|
// default collapsed = true
|
|
}
|
|
|
|
function _setFolderCollapsed(folderKey, v) {
|
|
localStorage.setItem('files_folder_collapsed:' + folderKey, v ? '1' : '0');
|
|
}
|
|
|
|
function renderActionsDropdown(menuId, actionFn, targetEsc) {
|
|
// actionFn is string: "containerAction" of "podAction"
|
|
// targetEsc is al esc(...) dus veilig in onclick
|
|
return `
|
|
<span class="actions-menu">
|
|
<button class="btn icon" title="Acties" onclick="toggleMenu('${menuId}')">⋮</button>
|
|
<div class="menuPanel" id="${menuId}">
|
|
<button class="menuItem ok" onclick="${actionFn}('start','${targetEsc}'); closeAllMenus();">Start</button>
|
|
<button class="menuItem warn" onclick="${actionFn}('restart','${targetEsc}'); closeAllMenus();">Restart</button>
|
|
<button class="menuItem bad" onclick="${actionFn}('stop','${targetEsc}'); closeAllMenus();">Stop</button>
|
|
</div>
|
|
</span>
|
|
`;
|
|
}
|
|
|
|
function renderContainerRow(c) {
|
|
const name = (c.Names && c.Names[0]) ? c.Names[0] : (c.Names || c.Name || c.name || '');
|
|
const status = c.Status || c.State || c.state || '';
|
|
const podName = c.PodName || '-';
|
|
const image = c.Image || c.image || '';
|
|
const managed = c._dashboard_source || 'podman';
|
|
const menuId = `menu-${cssSafeId('c:' + normalizeContainerName(name))}`;
|
|
const inPod = !!(c.PodName && String(c.PodName).trim());
|
|
const ports = inPod
|
|
? '' // verberg bij pod-containers
|
|
: ((c._dashboard_published_ports || []).join(", ")
|
|
|| (c.Ports || []).map(p => `${p.host_port}:${p.container_port}`).join(", "));
|
|
|
|
return `
|
|
<tr>
|
|
<td><strong>${esc(name)}</strong></td>
|
|
<td>${badgeFromStatus(status)}</td>
|
|
<td>${podName}</td>
|
|
<td class="muted">${esc(image)}</td>
|
|
<td>${badgeFromStatus(managed)}</td>
|
|
<td class="muted num" id="cpu-${cssSafeId(normalizeContainerName(name))}">-</td>
|
|
<td class="muted num" id="mem-${cssSafeId(normalizeContainerName(name))}">-</td>
|
|
<td>${ports || '-'}</td>
|
|
<td>
|
|
<div class="flex">
|
|
<button class="btn icon" title="Inspect" onclick="containerInspect(decodeURIComponent('${encodeURIComponent(name)}'))">🔍</button>
|
|
<button class="btn icon" title="Logs" onclick="containerLogs(decodeURIComponent('${encodeURIComponent(name)}'))">📄</button>
|
|
${renderActionsDropdown(menuId, 'containerAction', esc(name))}
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
`;
|
|
}
|
|
let containersC2P = new Map();
|
|
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);
|
|
});
|
|
|
|
containersC2P = new Map();
|
|
|
|
let html = '';
|
|
|
|
for (const pod of keys) {
|
|
const items = groups.get(pod) || [];
|
|
items.sort((a, b) =>
|
|
_cmpStr(
|
|
(a.Names && a.Names[0]) ? a.Names[0] : (a.Name || ''),
|
|
(b.Names && b.Names[0]) ? b.Names[0] : (b.Name || '')
|
|
)
|
|
);
|
|
|
|
const collapsed = _isCollapsed(pod);
|
|
const isRealPod = (pod !== '(geen pod)');
|
|
const total = items.length;
|
|
const podMenuId = `menu-${cssSafeId('p:' + pod)}`;
|
|
|
|
let podPortsText = "-";
|
|
if (isRealPod) {
|
|
const podPorts = Array.from(new Set(
|
|
items.flatMap(c => (c._dashboard_published_ports || []))
|
|
)).sort((a, b) => _cmpStr(a, b));
|
|
podPortsText = podPorts.length ? podPorts.join(", ") : "-";
|
|
}
|
|
|
|
let cls = 'muted';
|
|
let label = '-';
|
|
|
|
if (total > 0) {
|
|
const running = items.filter(x => {
|
|
const s = (x.Status || x.State || '').toLowerCase();
|
|
return s === 'running';
|
|
}).length;
|
|
|
|
label = `${running}/${total}`;
|
|
if (running === total) cls = 'ok';
|
|
else if (running === 0) cls = 'bad';
|
|
else cls = 'warn';
|
|
} else {
|
|
const ps = String((podStatus && podStatus[pod]) || '').toLowerCase();
|
|
if (ps === 'active') { cls = 'ok'; label = 'active'; }
|
|
else if (ps === 'inactive') { cls = 'bad'; label = 'inactive'; }
|
|
else { cls = 'muted'; label = ps || 'unknown'; }
|
|
}
|
|
|
|
html += `
|
|
<tr class="pod-group-row">
|
|
<td>
|
|
<span class="pod-toggle" data-pod="${encodeURIComponent(pod)}" style="cursor:pointer; user-select:none;">
|
|
${collapsed ? '▶' : '▼'} <b>${esc(pod)}</b>
|
|
</span>
|
|
</td>
|
|
|
|
<td>
|
|
<span class="btn small ${cls}">
|
|
${label}
|
|
</span>
|
|
</td>
|
|
|
|
<td class="muted">-</td>
|
|
<td class="muted">-</td>
|
|
<td>${isRealPod ? '<span class="btn small muted">pod</span>' : '<span class="muted">-</span>'}</td>
|
|
|
|
<td class="muted num" id="podcpu-${cssSafeId(pod)}" data-podstatus="${esc((podStatus && podStatus[pod]) || '')}">-</td>
|
|
<td class="muted num" id="podmem-${cssSafeId(pod)}" data-podstatus="${esc((podStatus && podStatus[pod]) || '')}">-</td>
|
|
<td class="mono">${esc(podPortsText)}</td>
|
|
<td>
|
|
${isRealPod ? `
|
|
<div class="flex">
|
|
<!-- placeholders voor uitlijning -->
|
|
<button class="btn icon" disabled style="visibility:hidden;" aria-hidden="true" tabindex="-1">🔍</button>
|
|
<button class="btn icon" disabled style="visibility:hidden;" aria-hidden="true" tabindex="-1">📄</button>
|
|
|
|
${renderActionsDropdown(podMenuId, 'podAction', esc(pod))}
|
|
</div>
|
|
` : '<span class="muted">-</span>'}
|
|
</td>
|
|
</tr>
|
|
`;
|
|
|
|
for (const c of items) {
|
|
const cname = normalizeContainerName((c.Names && c.Names[0]) ? c.Names[0] : (c.Names || c.Name || c.name || ''));
|
|
if (cname) containersC2P.set(cname, pod);
|
|
const row = renderContainerRow(c).replace(
|
|
'<tr',
|
|
`<tr class="pod-item-row" data-pod="${encodeURIComponent(pod)}"${collapsed ? ' style="display:none;"' : ''}`
|
|
);
|
|
html += row;
|
|
}
|
|
}
|
|
|
|
tbody.innerHTML = html;
|
|
|
|
tbody.onclick = (ev) => {
|
|
const t = ev.target.closest('.pod-toggle');
|
|
if (!t) return;
|
|
|
|
const podEnc = t.getAttribute('data-pod') || '';
|
|
const pod = decodeURIComponent(podEnc);
|
|
const isNowCollapsed = !_isCollapsed(pod);
|
|
_setCollapsed(pod, isNowCollapsed);
|
|
|
|
tbody.querySelectorAll(`.pod-item-row[data-pod="${CSS.escape(podEnc)}"]`).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(podEnc)}"]`).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');
|
|
if (!tbody) return;
|
|
renderContainersGrouped(list, tbody, podStatus);
|
|
}
|
|
|
|
let containersStatsES = null;
|
|
|
|
|
|
function startContainersStatsStream() {
|
|
if (containersStatsES) return;
|
|
|
|
containersStatsES = new EventSource("/api/containers/stats/stream?interval=1");
|
|
|
|
containersStatsES.addEventListener("stats", (ev) => {
|
|
let payload;
|
|
try {
|
|
payload = JSON.parse(ev.data);
|
|
} catch (e) {
|
|
// Slechte SSE chunk -> negeren zodat de stream niet "stilvalt"
|
|
return;
|
|
}
|
|
const statsList = payload?.data?.Stats || [];
|
|
// totals per pod voor deze SSE tick
|
|
const podCpu = new Map(); // podName -> cpuPct sum
|
|
const podMem = new Map(); // podName -> memBytes sum
|
|
const podMemPct = new Map(); // podName -> memPct sum
|
|
|
|
for (const st of statsList) {
|
|
const cname = normalizeContainerName(st?.Name);
|
|
if (!cname) continue;
|
|
|
|
const key = cssSafeId(cname);
|
|
|
|
const cpuPct = Number(st?.CPUPerc ?? st?.CPU ?? st?.AvgCPU ?? 0);
|
|
const memBytes = Number(st?.MemUsage ?? 0);
|
|
const memPct = Number(st?.MemPerc ?? 0);
|
|
|
|
|
|
const pod = containersC2P.get(cname);
|
|
if (pod) {
|
|
podCpu.set(pod, (podCpu.get(pod) || 0) + cpuPct);
|
|
podMem.set(pod, (podMem.get(pod) || 0) + memBytes);
|
|
podMemPct.set(pod, (podMemPct.get(pod) || 0) + memPct);
|
|
}
|
|
|
|
// CPU: jouw API geeft CPU als fractie (0.00384 -> 0.384%)
|
|
const cpuEl = document.getElementById(`cpu-${key}`);
|
|
if (cpuEl) cpuEl.textContent = cpuPct.toFixed(2) + "%";
|
|
|
|
// MEM: bytes + percentage
|
|
const memEl = document.getElementById(`mem-${key}`);
|
|
if (memEl) {
|
|
const mem = formatBytes(memBytes);
|
|
memEl.textContent = `${mem} (${memPct.toFixed(1)}%)`;
|
|
}
|
|
}
|
|
// NA de container-loop: pod totals renderen
|
|
for (const [pod, cpuSum] of podCpu.entries()) {
|
|
const el = document.getElementById(`podcpu-${cssSafeId(pod)}`);
|
|
if (el) {
|
|
el.textContent = cpuSum.toFixed(2) + "%";
|
|
el.classList.remove('stale');
|
|
}
|
|
}
|
|
|
|
for (const [pod, memSum] of podMem.entries()) {
|
|
const el = document.getElementById(`podmem-${cssSafeId(pod)}`);
|
|
if (el) {
|
|
const mp = podMemPct.get(pod) || 0;
|
|
el.textContent = `${formatBytes(memSum)} (${mp.toFixed(1)}%)`;
|
|
el.classList.remove('stale');
|
|
}
|
|
}
|
|
});
|
|
|
|
containersStatsES.onerror = () => {
|
|
resetPodTotals();
|
|
};
|
|
}
|
|
|
|
function stopContainersStatsStream() {
|
|
if (!containersStatsES) return;
|
|
containersStatsES.close();
|
|
containersStatsES = null;
|
|
resetPodTotals();
|
|
}
|
|
|
|
function resetPodTotals() {
|
|
document.querySelectorAll('[id^="podcpu-"]').forEach(el => {
|
|
const ps = String(el.getAttribute('data-podstatus') || '').toLowerCase();
|
|
if (ps === 'inactive') {
|
|
el.textContent = '-';
|
|
el.classList.remove('stale');
|
|
} else {
|
|
el.textContent = '—';
|
|
el.classList.add('stale');
|
|
}
|
|
});
|
|
|
|
document.querySelectorAll('[id^="podmem-"]').forEach(el => {
|
|
const ps = String(el.getAttribute('data-podstatus') || '').toLowerCase();
|
|
if (ps === 'inactive') {
|
|
el.textContent = '-';
|
|
el.classList.remove('stale');
|
|
} else {
|
|
el.textContent = '—';
|
|
el.classList.add('stale');
|
|
}
|
|
});
|
|
}
|
|
|
|
async function containerInspect(name) {
|
|
try {
|
|
const res = await api(`/containers/inspect/${encodeURIComponent(name)}`, 'GET');
|
|
showModal(`Inspect: ${name}`, JSON.stringify(res, null, 2));
|
|
} catch (e) {
|
|
showModal(`Inspect fout: ${name}`, e.stack || e.message);
|
|
}
|
|
}
|
|
|
|
async function containerLogs(name) {
|
|
try {
|
|
const res = await api(`/containers/logs/${encodeURIComponent(name)}`, 'GET');
|
|
const logs = res.logs ?? JSON.stringify(res, null, 2);
|
|
showModal(`Logs: ${name}`, logs);
|
|
} catch (e) {
|
|
showModal(`Logs fout: ${name}`, e.stack || e.message);
|
|
}
|
|
}
|
|
|
|
async function containerAction(action, name) {
|
|
try {
|
|
const res = await api(`/containers/${encodeURIComponent(action)}/${encodeURIComponent(name)}`, 'POST');
|
|
showModal(`Container ${action}: ${name}`, JSON.stringify(res, null, 2));
|
|
await fetchContainers();
|
|
} catch (e) {
|
|
showModal(`Container ${action} fout`, e.stack || e.message);
|
|
}
|
|
}
|
|
|
|
function cssSafeId(s){
|
|
const bytes = new TextEncoder().encode(String(s));
|
|
let bin = '';
|
|
bytes.forEach(b => bin += String.fromCharCode(b));
|
|
return btoa(bin).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";
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
// =========================
|
|
// Files tab (systemd subtree)
|
|
// =========================
|
|
|
|
let networksState = {
|
|
expanded: new Set(), // welke networks staan open
|
|
usage: null,
|
|
list: null,
|
|
inspectCache: new Map(), // name -> inspect json
|
|
};
|
|
|
|
function toggleNetworkRow(name) {
|
|
if (networksState.expanded.has(name)) networksState.expanded.delete(name);
|
|
else networksState.expanded.add(name);
|
|
renderNetworks(); // re-render
|
|
}
|
|
|
|
async function networksRefresh() {
|
|
const statusEl = document.getElementById('networksStatus');
|
|
statusEl.textContent = 'Bezig met laden...';
|
|
|
|
try {
|
|
const [usage, list] = await Promise.all([
|
|
fetchNetworksUsage(),
|
|
fetchNetworksList(),
|
|
]);
|
|
networksState.usage = usage;
|
|
networksState.list = list;
|
|
statusEl.textContent = `Laatst geladen: ${new Date().toLocaleString()}`;
|
|
renderNetworks();
|
|
} catch (e) {
|
|
console.error(e);
|
|
statusEl.textContent = 'Fout: ' + (e?.message || e);
|
|
}
|
|
}
|
|
|
|
function renderNetworks() {
|
|
const tbody = document.getElementById('networksTbody');
|
|
const rel = document.getElementById('networksRelations');
|
|
tbody.innerHTML = '';
|
|
rel.innerHTML = '';
|
|
|
|
const usage = networksState.usage;
|
|
if (!usage || !usage.byNetwork) {
|
|
tbody.innerHTML = `<tr><td colspan="3" class="muted">Geen data. Klik op Vernieuwen.</td></tr>`;
|
|
rel.innerHTML = `<div class="muted">Geen data.</div>`;
|
|
return;
|
|
}
|
|
|
|
const byNetwork = usage.byNetwork;
|
|
|
|
// Sorteer op naam
|
|
const names = Object.keys(byNetwork).sort((a,b) => a.localeCompare(b));
|
|
|
|
// Build rows
|
|
for (const netName of names) {
|
|
const slot = byNetwork[netName] || {};
|
|
const containers = slot.containers || [];
|
|
const isOpen = networksState.expanded.has(netName);
|
|
|
|
const arrow = isOpen ? '▾' : '▸';
|
|
|
|
// hoofd-rij
|
|
const tr = document.createElement('tr');
|
|
tr.innerHTML = `
|
|
<td><button class="btn small ghost" title="Open/dicht">${arrow}</button></td>
|
|
<td>${esc(netName)}</td>
|
|
<td>${containers.length}</td>
|
|
`;
|
|
tr.querySelector('button').addEventListener('click', () => toggleNetworkRow(netName));
|
|
tbody.appendChild(tr);
|
|
|
|
// detail-rij (uitklap)
|
|
if (isOpen) {
|
|
const tr2 = document.createElement('tr');
|
|
tr2.innerHTML = `
|
|
<td></td>
|
|
<td colspan="2">
|
|
<div style="padding:8px 0;">
|
|
${renderNetworkUsersListHTML(netName, containers)}
|
|
<div class="row gap" style="margin-top:8px;">
|
|
<button class="btn small" data-inspect="${esc(netName)}">Inspect details</button>
|
|
<span class="muted" id="inspectStatus-${cssId(netName)}"></span>
|
|
</div>
|
|
<pre class="code" id="inspectBox-${cssId(netName)}" style="display:none; margin-top:8px; max-height:260px; overflow:auto;"></pre>
|
|
</div>
|
|
</td>
|
|
`;
|
|
tbody.appendChild(tr2);
|
|
|
|
// inspect button handler
|
|
const btn = tr2.querySelector('button[data-inspect]');
|
|
btn.addEventListener('click', async () => {
|
|
await onInspectNetwork(netName);
|
|
});
|
|
}
|
|
}
|
|
|
|
// Relaties sectie
|
|
rel.innerHTML = renderNetworksRelationsHTML(usage);
|
|
}
|
|
|
|
function renderNetworkUsersListHTML(netName, containers) {
|
|
if (!containers.length) {
|
|
return `<div class="muted">Geen containers gevonden op dit netwerk.</div>`;
|
|
}
|
|
|
|
// lijstje
|
|
const rows = containers.map(c => {
|
|
const cname = c.name || '?';
|
|
const pod = c.pod || '';
|
|
const shared = c.networkMode && String(c.networkMode).startsWith('container:');
|
|
const via = c.networkOwnerName ? `via ${c.networkOwnerName}` : (c.networkOwnerId ? `via ${c.networkOwnerId}` : '');
|
|
|
|
const badges = [];
|
|
if (pod) badges.push(`<span class="badge">pod: ${esc(pod)}</span>`);
|
|
if (shared) badges.push(`<span class="badge">shared netns</span>`);
|
|
if (via) badges.push(`<span class="badge">${esc(via)}</span>`);
|
|
|
|
return `<li>
|
|
<b>${esc(cname)}</b>
|
|
${badges.length ? `<div class="row gap" style="margin-top:4px; flex-wrap:wrap;">${badges.join('')}</div>` : ``}
|
|
</li>`;
|
|
}).join('');
|
|
|
|
return `<div>
|
|
<div class="muted" style="margin-bottom:6px;">Containers op <b>${esc(netName)}</b>:</div>
|
|
<ul style="margin:0; padding-left:18px;">${rows}</ul>
|
|
</div>`;
|
|
}
|
|
|
|
function renderNetworksRelationsHTML(usage) {
|
|
const byContainer = usage.byContainer || {};
|
|
const byMeta = usage.byContainerMeta || {};
|
|
|
|
// A) containers met meerdere netwerken
|
|
const multi = Object.entries(byContainer)
|
|
.filter(([_, nets]) => Array.isArray(nets) && nets.length > 1)
|
|
.sort((a,b) => a[0].localeCompare(b[0]));
|
|
|
|
// B) shared netns containers
|
|
const shared = Object.entries(byMeta)
|
|
.filter(([_, meta]) => meta && meta.networkMode && String(meta.networkMode).startsWith('container:'))
|
|
.sort((a,b) => a[0].localeCompare(b[0]));
|
|
|
|
let html = '';
|
|
|
|
html += `<h3 style="margin:0 0 6px 0;">Containers met meerdere netwerken</h3>`;
|
|
if (!multi.length) {
|
|
html += `<div class="muted" style="margin-bottom:12px;">Geen.</div>`;
|
|
} else {
|
|
html += `<ul style="margin:0 0 12px 0; padding-left:18px;">` + multi.map(([name, nets]) =>
|
|
`<li><b>${esc(name)}</b> → ${esc(nets.join(', '))}</li>`
|
|
).join('') + `</ul>`;
|
|
}
|
|
|
|
html += `<h3 style="margin:0 0 6px 0;">Shared network namespace</h3>`;
|
|
if (!shared.length) {
|
|
html += `<div class="muted">Geen.</div>`;
|
|
} else {
|
|
html += `<ul style="margin:0; padding-left:18px;">` + shared.map(([name, meta]) => {
|
|
const owner = meta.networkOwnerName || meta.networkOwnerId || '?';
|
|
return `<li><b>${esc(name)}</b> deelt netwerkstack met <b>${esc(owner)}</b></li>`;
|
|
}).join('') + `</ul>`;
|
|
}
|
|
|
|
return html;
|
|
}
|
|
|
|
// helper: maak safe id voor DOM
|
|
function cssId(s) {
|
|
return String(s).replaceAll(/[^a-zA-Z0-9_-]/g, '_');
|
|
}
|
|
|
|
async function onInspectNetwork(name) {
|
|
const key = name;
|
|
const box = document.getElementById('inspectBox-' + cssId(name));
|
|
const status = document.getElementById('inspectStatus-' + cssId(name));
|
|
|
|
status.textContent = 'Inspect laden...';
|
|
|
|
try {
|
|
let data = networksState.inspectCache.get(key);
|
|
if (!data) {
|
|
data = await fetchNetworkInspect(name);
|
|
networksState.inspectCache.set(key, data);
|
|
}
|
|
box.style.display = '';
|
|
box.textContent = JSON.stringify(data, null, 2);
|
|
status.textContent = '';
|
|
} catch (e) {
|
|
console.error(e);
|
|
status.textContent = 'Fout: ' + (e?.message || e);
|
|
}
|
|
}
|
|
|
|
// =========================
|
|
// Files tab (systemd subtree)
|
|
// =========================
|
|
const FILES_ROOT = 'systemd'; // API-root binnen WORKLOADS_DIR
|
|
let filesCurrentUiPath = ''; // zonder "systemd/"
|
|
let filesCurrentApiPath = ''; // met "systemd/"
|
|
|
|
function cmModeForPath(uiPath) {
|
|
const p = (uiPath || '').toLowerCase();
|
|
if (p.endsWith('.yaml') || p.endsWith('.yml') || p.endsWith('.kube') || p.endsWith('.container')) return 'yaml';
|
|
if (p.endsWith('.json')) return 'application/json';
|
|
if (p.endsWith('.js')) return 'javascript';
|
|
return 'text/plain';
|
|
}
|
|
|
|
function filesToApiPath(uiPath) {
|
|
let p = (uiPath || '').trim().replace(/^\/+/, '');
|
|
if (!p) return FILES_ROOT;
|
|
if (p === FILES_ROOT || p.startsWith(FILES_ROOT + '/')) return p;
|
|
return `${FILES_ROOT}/${p}`;
|
|
}
|
|
|
|
function filesToUiPath(apiPath) {
|
|
const p = (apiPath || '').trim().replace(/^\/+/, '');
|
|
return p.replace(new RegExp('^' + FILES_ROOT + '/?'), '');
|
|
}
|
|
|
|
function filesSetCurrent(uiPath) {
|
|
filesCurrentUiPath = (uiPath || '').trim().replace(/^\/+/, '');
|
|
filesCurrentApiPath = filesToApiPath(filesCurrentUiPath);
|
|
document.getElementById('filesCurrent').textContent = filesCurrentUiPath || '-';
|
|
}
|
|
|
|
async function filesRefresh() {
|
|
const treeEl = document.getElementById('filesTree');
|
|
treeEl.textContent = 'Laden...';
|
|
|
|
const data = await api('/files/tree', 'GET');
|
|
|
|
// Filter alleen systemd subtree
|
|
const scoped = (data || []).filter(folder => {
|
|
const p = (folder.path || '').replace(/^\/+/, '');
|
|
return p === FILES_ROOT || p.startsWith(FILES_ROOT + '/');
|
|
});
|
|
|
|
if (!scoped.length) {
|
|
treeEl.textContent = 'Geen bestanden gevonden onder systemd.';
|
|
return;
|
|
}
|
|
|
|
// Render simpel: folders met files eronder
|
|
const parts = [];
|
|
for (const folder of scoped) {
|
|
const apiFolderPath = (folder.path || '').replace(/^\/+/, '');
|
|
const uiFolderPath = filesToUiPath(apiFolderPath); // zonder systemd/
|
|
const folderLabel = uiFolderPath || 'root';
|
|
const folderKey = apiFolderPath; // unieke key (met systemd/..)
|
|
const collapsed = _isFolderCollapsed(folderKey);
|
|
|
|
parts.push(`
|
|
<div class="mono file-folder-row" data-folder="${esc(folderKey)}" style="margin:8px 0 6px 0; font-weight:600;">
|
|
<span class="file-folder-left">
|
|
<span class="folder-toggle">${collapsed ? '▶' : '▼'}</span>
|
|
<span>📂 ${esc(folderLabel)}</span>
|
|
</span>
|
|
<span class="flex" onclick="event.stopPropagation();">
|
|
<button class="btn small ok" title="Nieuw bestand in ${esc(folderLabel)}" onclick="filesNewFileInFolder(decodeURIComponent('${encodeURIComponent(uiFolderPath)}'))">+</button>
|
|
<button class="btn small bad" title="Verwijder map (alleen als leeg)" onclick="filesDeleteFolder(decodeURIComponent('${encodeURIComponent(uiFolderPath)}'))">🗑️</button>
|
|
</span>
|
|
</div>
|
|
`);
|
|
|
|
const files = folder.files || [];
|
|
if (!files.length) {
|
|
parts.push(`<div class="muted" style="margin-left:18px;">(leeg)</div>`);
|
|
continue;
|
|
}
|
|
|
|
parts.push(`<div class="file-folder-files" data-folder-files="${esc(folderKey)}" style="${collapsed ? 'display:none;' : ''}">`);
|
|
|
|
for (const f of files) {
|
|
const fullUi = uiFolderPath ? `${uiFolderPath}/${f}` : f;
|
|
parts.push(`
|
|
<div style="display:flex;align-items:center;justify-content:space-between;gap:10px;padding:4px 0;border-bottom:1px dashed rgba(36,52,95,.35)">
|
|
<span class="mono" style="cursor:pointer" onclick="filesOpen(decodeURIComponent('${encodeURIComponent(fullUi)}'))">📄 ${esc(f)}</span>
|
|
</div>
|
|
`);
|
|
}
|
|
|
|
parts.push(`</div>`);
|
|
}
|
|
|
|
treeEl.innerHTML = parts.join('');
|
|
treeEl.onclick = (ev) => {
|
|
const row = ev.target.closest('.file-folder-row');
|
|
if (!row) return;
|
|
|
|
const folderKey = row.getAttribute('data-folder');
|
|
const isNowCollapsed = !_isFolderCollapsed(folderKey);
|
|
_setFolderCollapsed(folderKey, isNowCollapsed);
|
|
|
|
// pijltje updaten
|
|
const arrow = row.querySelector('.folder-toggle');
|
|
if (arrow) arrow.textContent = isNowCollapsed ? '▶' : '▼';
|
|
|
|
// files block tonen/verbergen
|
|
const filesBlock = treeEl.querySelector(`[data-folder-files="${CSS.escape(folderKey)}"]`);
|
|
if (filesBlock) filesBlock.style.display = isNowCollapsed ? 'none' : '';
|
|
};
|
|
}
|
|
|
|
async function filesOpen(uiPath) {
|
|
filesSetCurrent(uiPath);
|
|
|
|
const res = await api(`/files/read?path=${encodeURIComponent(filesCurrentApiPath)}`, 'GET');
|
|
|
|
const text = res.content || '';
|
|
if (cmEditor) {
|
|
cmEditor.setOption('mode', cmModeForPath(uiPath));
|
|
cmEditor.setValue(text);
|
|
cmEditor.refresh();
|
|
} else {
|
|
document.getElementById('filesEditor').value = text;
|
|
}
|
|
}
|
|
|
|
async function filesSave() {
|
|
if (!filesCurrentApiPath || filesCurrentApiPath === FILES_ROOT) {
|
|
return showModal('Files', 'Selecteer eerst een bestand.');
|
|
}
|
|
|
|
const content = cmEditor
|
|
? cmEditor.getValue()
|
|
: document.getElementById('filesEditor').value;
|
|
|
|
const res = await api(
|
|
`/files/save?path=${encodeURIComponent(filesCurrentApiPath)}`,
|
|
'POST',
|
|
{ content }
|
|
);
|
|
|
|
showModal('Opgeslagen', JSON.stringify(res, null, 2));
|
|
await filesRefresh();
|
|
}
|
|
|
|
async function filesDelete() {
|
|
if (!filesCurrentApiPath || filesCurrentApiPath === FILES_ROOT) {
|
|
return showModal('Files', 'Selecteer eerst een bestand om te verwijderen.');
|
|
}
|
|
if (!confirm(`Verwijderen: ${filesCurrentUiPath}?`)) return;
|
|
|
|
const res = await api(`/files/delete?path=${encodeURIComponent(filesCurrentApiPath)}`, 'DELETE');
|
|
showModal('Verwijderd', JSON.stringify(res, null, 2));
|
|
|
|
// reset current
|
|
filesSetCurrent('');
|
|
document.getElementById('filesEditor').value = '';
|
|
await filesRefresh();
|
|
}
|
|
|
|
async function filesNewFolder() {
|
|
const ui = prompt('Nieuwe map (onder systemd):\nVoorbeeld: mediaserver', '');
|
|
if (!ui) return;
|
|
|
|
const apiPath = filesToApiPath(ui);
|
|
const res = await api(`/files/mkdir?path=${encodeURIComponent(apiPath)}`, 'POST');
|
|
showModal('Map aangemaakt', JSON.stringify(res, null, 2));
|
|
await filesRefresh();
|
|
}
|
|
|
|
async function filesNewFile() {
|
|
const ui = prompt('Nieuw bestand (onder systemd):\nVoorbeeld: demo-web/demo-web.container', '');
|
|
if (!ui) return;
|
|
|
|
const apiPath = filesToApiPath(ui);
|
|
|
|
// altijd leeg (jouw keuze) -> leeg bestand
|
|
const res = await api(`/files/save?path=${encodeURIComponent(apiPath)}`, 'POST', { content: "" });
|
|
showModal('Bestand aangemaakt', JSON.stringify(res, null, 2));
|
|
|
|
// Open direct
|
|
filesSetCurrent(ui);
|
|
const editorEl = document.getElementById('filesEditor');
|
|
if (editorEl) editorEl.value = "";
|
|
await filesRefresh();
|
|
await filesOpen(ui);
|
|
} // <- BELANGRIJK: deze } miste bij jou
|
|
|
|
|
|
async function filesNewFileInFolder(uiFolderPath) {
|
|
const base = (uiFolderPath || '').trim().replace(/^\/+/, '');
|
|
const name = prompt(`Nieuw bestand in "${base || 'root'}"\nBijv: test.yaml of demo.container`, '');
|
|
if (!name) return;
|
|
|
|
const uiFull = base ? `${base}/${name}` : name;
|
|
const apiPath = filesToApiPath(uiFull);
|
|
|
|
// altijd leeg (jouw keuze)
|
|
const res = await api(`/files/save?path=${encodeURIComponent(apiPath)}`, 'POST', { content: "" });
|
|
showModal('Bestand aangemaakt', JSON.stringify(res, null, 2));
|
|
|
|
await filesRefresh();
|
|
await filesOpen(uiFull);
|
|
}
|
|
|
|
async function filesDeleteFolder(uiFolderPath) {
|
|
const base = (uiFolderPath || '').trim().replace(/^\/+/, '');
|
|
if (!base) {
|
|
return showModal('Files', 'Root map verwijderen mag niet.');
|
|
}
|
|
if (!confirm(`Map verwijderen (alleen als leeg): ${base}?`)) return;
|
|
|
|
const apiPath = filesToApiPath(base);
|
|
|
|
try {
|
|
const res = await api(`/files/rmdir?path=${encodeURIComponent(apiPath)}`, 'DELETE');
|
|
showModal('Map verwijderd', JSON.stringify(res, null, 2));
|
|
await filesRefresh();
|
|
} catch (e) {
|
|
showModal('Kan map niet verwijderen', e.message);
|
|
}
|
|
}
|
|
|
|
// ---- Init ----
|
|
(async function init(){
|
|
applySidebarState();
|
|
const t = document.getElementById('sidebarToggle');
|
|
if (t) t.onclick = toggleSidebar;
|
|
|
|
// Networks refresh button: 1x listener (niet in setTab!)
|
|
document.getElementById('networksRefreshBtn')?.addEventListener('click', networksRefresh);
|
|
|
|
// 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>
|
|
<script src="assets/js/tabs/images.js"></script>
|
|
</body>
|
|
</html>
|