1261 lines
43 KiB
HTML
1261 lines
43 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="podman.io-favicon.ico">
|
||
<style>
|
||
:root{
|
||
--bg: #0b1220;
|
||
--panel: #111a2e;
|
||
--panel2: #0e1730;
|
||
--text: #e8eefc;
|
||
--muted:#9bb0da;
|
||
--border:#24345f;
|
||
--ok:#2dd4bf;
|
||
--warn:#fbbf24;
|
||
--bad:#fb7185;
|
||
--btn:#1b2a55;
|
||
--btn2:#223564;
|
||
--accent:#60a5fa;
|
||
--shadow: 0 10px 30px rgba(0,0,0,.35);
|
||
--radius: 14px;
|
||
--mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||
--sans: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji","Segoe UI Emoji";
|
||
}
|
||
*{box-sizing:border-box}
|
||
body{
|
||
margin:0;
|
||
font-family: var(--sans);
|
||
background: radial-gradient(1200px 600px at 20% 0%, #18244a 0%, var(--bg) 55%);
|
||
color: var(--text);
|
||
}
|
||
header{
|
||
position: sticky; top:0; z-index:10;
|
||
background: rgba(11,18,32,.7);
|
||
backdrop-filter: blur(10px);
|
||
border-bottom: 1px solid rgba(36,52,95,.7);
|
||
}
|
||
.wrap{max-width:1700px;margin:0 auto;padding:16px}
|
||
.topbar{
|
||
display:flex; gap:12px; align-items:center; justify-content:space-between;
|
||
}
|
||
.brand{
|
||
display:flex; gap:12px; align-items:center;
|
||
font-weight:700; letter-spacing:.2px;
|
||
}
|
||
.brandLogo{
|
||
width: 34px;
|
||
height: 34px;
|
||
display:block;
|
||
}
|
||
.dot{
|
||
width:12px;height:12px;border-radius:50%;
|
||
background: var(--ok);
|
||
box-shadow: 0 0 0 6px rgba(45,212,191,.15);
|
||
}
|
||
.statusline{color:var(--muted); font-size:13px}
|
||
.row{display:flex; gap:14px; flex-wrap:wrap}
|
||
.tabs{
|
||
display:flex; gap:8px; flex-wrap:wrap;
|
||
margin-top:12px;
|
||
}
|
||
.tab{
|
||
border:1px solid var(--border);
|
||
background: rgba(17,26,46,.6);
|
||
color: var(--text);
|
||
padding:10px 12px;
|
||
border-radius: 999px;
|
||
cursor:pointer;
|
||
user-select:none;
|
||
font-size:14px;
|
||
}
|
||
.tab.active{
|
||
background: linear-gradient(135deg, rgba(96,165,250,.25), rgba(17,26,46,.6));
|
||
border-color: rgba(96,165,250,.5);
|
||
}
|
||
.grid{
|
||
display:grid;
|
||
grid-template-columns: 1fr;
|
||
gap:14px;
|
||
padding:16px 0 26px;
|
||
}
|
||
@media (min-width: 980px){
|
||
.grid{grid-template-columns: 1fr 1fr}
|
||
}
|
||
.card{
|
||
background: linear-gradient(180deg, rgba(17,26,46,.85), rgba(14,23,48,.85));
|
||
border: 1px solid rgba(36,52,95,.9);
|
||
border-radius: var(--radius);
|
||
box-shadow: var(--shadow);
|
||
overflow:hidden;
|
||
}
|
||
.card.half{min-height: 240px;}
|
||
.cardHeader{
|
||
display:flex; align-items:center; justify-content:space-between;
|
||
padding:14px 14px;
|
||
border-bottom:1px solid rgba(36,52,95,.7);
|
||
}
|
||
.cardTitle{
|
||
font-weight:700;
|
||
display:flex; gap:10px; align-items:center;
|
||
}
|
||
.cardBody{padding:14px}
|
||
.btn{
|
||
border:1px solid rgba(36,52,95,.9);
|
||
background: var(--btn);
|
||
color: var(--text);
|
||
padding:9px 10px;
|
||
border-radius: 12px;
|
||
cursor:pointer;
|
||
font-size:13px;
|
||
}
|
||
.btn:hover{background: var(--btn2)}
|
||
.btn.small{padding:7px 9px; border-radius: 10px}
|
||
.btn.ghost{background: transparent}
|
||
.btn.ok{border-color: rgba(45,212,191,.6)}
|
||
.btn.bad{border-color: rgba(251,113,133,.6)}
|
||
.btn.warn{border-color: rgba(251,191,36,.6)}
|
||
.pill{
|
||
display:inline-flex; align-items:center; gap:8px;
|
||
padding:6px 10px;
|
||
border-radius:999px;
|
||
border:1px solid rgba(36,52,95,.9);
|
||
color: var(--muted);
|
||
font-size:12px;
|
||
}
|
||
.pill .b{color: var(--text); font-weight:600}
|
||
table{
|
||
width:100%;
|
||
border-collapse: collapse;
|
||
font-size: 13px;
|
||
}
|
||
th,td{
|
||
padding:10px 8px;
|
||
border-bottom:1px solid rgba(36,52,95,.6);
|
||
text-align:left;
|
||
vertical-align: top;
|
||
}
|
||
th{color: var(--muted); font-weight:600}
|
||
tr:hover td{background: rgba(96,165,250,.06)}
|
||
.badge{
|
||
display:inline-flex;
|
||
align-items:center;
|
||
border:1px solid rgba(36,52,95,.9);
|
||
padding:4px 8px;
|
||
border-radius:999px;
|
||
font-size:12px;
|
||
color: var(--muted);
|
||
}
|
||
.badge.ok{border-color: rgba(45,212,191,.6); color: var(--ok)}
|
||
.badge.bad{border-color: rgba(251,113,133,.6); color: var(--bad)}
|
||
.badge.warn{border-color: rgba(251,191,36,.6); color: var(--warn)}
|
||
.mono{font-family: var(--mono)}
|
||
.muted{color:var(--muted)}
|
||
.num{
|
||
text-align:right;
|
||
white-space:nowrap;
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
.flex{display:flex; gap:8px; flex-wrap:wrap; align-items:center}
|
||
.input, .textarea{
|
||
width: 100%;
|
||
background: rgba(8,12,25,.6);
|
||
border:1px solid rgba(36,52,95,.9);
|
||
color: var(--text);
|
||
border-radius: 12px;
|
||
padding:10px 12px;
|
||
outline:none;
|
||
font-size: 13px;
|
||
}
|
||
.textarea{min-height: 120px; font-family: var(--mono)}
|
||
.split{
|
||
display:grid;
|
||
grid-template-columns: 1fr;
|
||
gap:12px;
|
||
}
|
||
@media (min-width: 980px){
|
||
.split{grid-template-columns: 1fr 1fr}
|
||
}
|
||
|
||
/* Modal */
|
||
.modalBack{
|
||
position: fixed; inset:0;
|
||
background: rgba(0,0,0,.55);
|
||
display:none; align-items:center; justify-content:center;
|
||
padding:18px; z-index: 99;
|
||
}
|
||
.modal{
|
||
width:min(980px, 100%);
|
||
background: linear-gradient(180deg, rgba(17,26,46,.95), rgba(14,23,48,.95));
|
||
border:1px solid rgba(36,52,95,.9);
|
||
border-radius: 18px;
|
||
box-shadow: var(--shadow);
|
||
overflow:hidden;
|
||
}
|
||
.modalHeader{
|
||
padding:12px 14px;
|
||
display:flex; align-items:center; justify-content:space-between;
|
||
border-bottom:1px solid rgba(36,52,95,.7);
|
||
}
|
||
.modalTitle{font-weight:700}
|
||
.modalBody{padding:14px}
|
||
pre{
|
||
margin:0;
|
||
white-space: pre-wrap;
|
||
word-break: break-word;
|
||
font-family: var(--mono);
|
||
font-size: 12.5px;
|
||
color: #d9e6ff;
|
||
background: rgba(0,0,0,.35);
|
||
border:1px solid rgba(36,52,95,.7);
|
||
border-radius: 14px;
|
||
padding: 12px;
|
||
max-height: 60vh;
|
||
overflow:auto;
|
||
}
|
||
.hint{font-size:12px;color:var(--muted);margin-top:8px;line-height:1.35}
|
||
.pod-group-row td {
|
||
background: rgba(255,255,255,0.04);
|
||
border-top: 1px solid rgba(255,255,255,0.08);
|
||
font-weight: 600;
|
||
}
|
||
.pod-group-row:hover td {
|
||
background: rgba(255,255,255,0.07);
|
||
}
|
||
</style>
|
||
</head>
|
||
|
||
<body>
|
||
<header>
|
||
<div class="wrap">
|
||
<div class="topbar">
|
||
<div class="brand">
|
||
<img class="brandLogo" src="podman-logo.png" alt="Podman" />
|
||
<span class="dot" id="apiDot"></span>
|
||
<div>
|
||
MVP Control UI
|
||
<div class="statusline" id="statusLine">API: onbekend</div>
|
||
</div>
|
||
</div>
|
||
<div class="flex">
|
||
<button class="btn ghost" onclick="pingApi()">Ping</button>
|
||
<button class="btn" onclick="refreshActive()">Ververs</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="tabs">
|
||
<div class="tab active" id="tab-dashboard" onclick="setTab('dashboard')">Dashboard</div>
|
||
<div class="tab" id="tab-containers" onclick="setTab('containers')">Containers</div>
|
||
<div class="tab" id="tab-pods" onclick="setTab('pods')">Pods</div>
|
||
<div class="tab" id="tab-systemd" onclick="setTab('systemd')">Systemd</div>
|
||
<div class="tab" id="tab-files" onclick="setTab('files')">Files</div>
|
||
</div>
|
||
</div>
|
||
</header>
|
||
|
||
<div class="wrap">
|
||
<div id="view-dashboard" class="grid">
|
||
<div class="card half">
|
||
<div class="cardHeader">
|
||
<div class="cardTitle">Snel acties</div>
|
||
<div class="flex">
|
||
<button class="btn ok" onclick="daemonReload()">daemon-reload</button>
|
||
<button class="btn" onclick="refreshActive()">Ververs alles</button>
|
||
</div>
|
||
</div>
|
||
<div class="cardBody">
|
||
<div class="flex">
|
||
<span class="pill"><span class="b" id="countPods">-</span> pods</span>
|
||
<span class="pill"><span class="b" id="countContainers">-</span> containers</span>
|
||
<span class="pill"><span class="b" id="countSystemd">-</span> units (UI)</span>
|
||
</div>
|
||
<div class="hint">
|
||
Deze UI gebruikt jouw API endpoints onder <span class="mono">/api</span> (same origin).
|
||
Containers/pods komen uit Podman; systemd acties gebruiken jouw <span class="mono">systemctl --user</span> endpoints.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card half">
|
||
<div class="cardHeader">
|
||
<div class="cardTitle">Systemd units (uit UI lijst)</div>
|
||
<div class="flex">
|
||
<button class="btn" onclick="systemdRefresh()">Ververs status</button>
|
||
</div>
|
||
</div>
|
||
<div class="cardBody">
|
||
<div id="systemdMini" class="muted">Nog geen data.</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="view-containers" class="grid" style="display:none">
|
||
<div class="card" style="grid-column: 1 / -1;">
|
||
<div class="cardHeader">
|
||
<div class="cardTitle">Containers</div>
|
||
<div class="flex">
|
||
<button class="btn" onclick="fetchContainers()">Ververs</button>
|
||
</div>
|
||
</div>
|
||
<div class="cardBody">
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Naam</th>
|
||
<th>Status</th>
|
||
<th>Pod</th>
|
||
<th>Image</th>
|
||
<th>Managed</th>
|
||
<th>CPU</th>
|
||
<th>MEM</th>
|
||
<th>Published port</th>
|
||
<th>Acties</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="containersTbody"></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="view-pods" class="grid" style="display:none">
|
||
<div class="card" style="grid-column: 1 / -1;">
|
||
<div class="cardHeader">
|
||
<div class="cardTitle">Pods</div>
|
||
<div class="flex">
|
||
<button class="btn" onclick="fetchPods()">Ververs</button>
|
||
</div>
|
||
</div>
|
||
<div class="cardBody">
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Naam</th>
|
||
<th>Status</th>
|
||
<th>Containers</th>
|
||
<th>Acties</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="podsTbody"></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="view-systemd" class="grid" style="display:none">
|
||
<div class="card" style="grid-column: 1 / -1;">
|
||
<div class="cardHeader">
|
||
<div class="cardTitle">Systemd (allowlist via UI)</div>
|
||
<div class="flex">
|
||
<button class="btn ok" onclick="daemonReload()">daemon-reload</button>
|
||
<button class="btn" onclick="systemdRefresh()">Ververs status</button>
|
||
</div>
|
||
</div>
|
||
<div class="cardBody">
|
||
<div class="split">
|
||
<div>
|
||
<div class="muted" style="margin-bottom:8px">
|
||
Units (één per regel). Deze lijst wordt opgeslagen in je browser (localStorage).
|
||
</div>
|
||
<textarea id="systemdUnits" class="textarea" spellcheck="false"></textarea>
|
||
<div class="flex" style="margin-top:10px">
|
||
<button class="btn" onclick="saveSystemdUnits()">Opslaan</button>
|
||
<button class="btn ghost" onclick="loadDefaultUnits()">Standaard</button>
|
||
<span class="pill">Gebruik allowlist op server om te beperken.</span>
|
||
</div>
|
||
<div class="hint">
|
||
De server enforce’t jouw allowlist. Als je hier een unit invult die niet toegestaan is, krijg je 403.
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<div class="muted" style="margin-bottom:8px">
|
||
Snelle actie op één unit:
|
||
</div>
|
||
<input id="systemdOne" class="input mono" placeholder="bijv. sonarr.service" />
|
||
<div class="flex" style="margin-top:10px">
|
||
<button class="btn" onclick="systemdActionSingle('status')">Status</button>
|
||
<button class="btn ok" onclick="systemdActionSingle('start')">Start</button>
|
||
<button class="btn warn" onclick="systemdActionSingle('restart')">Restart</button>
|
||
<button class="btn bad" onclick="systemdActionSingle('stop')">Stop</button>
|
||
</div>
|
||
<div class="hint">
|
||
Tip: gebruik <span class="mono">demo1.service</span>, <span class="mono">demo2.service</span>, <span class="mono">sonarr.service</span> om te testen.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div style="margin-top:16px">
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Unit</th>
|
||
<th>Laatste status (API output)</th>
|
||
<th>Acties</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="systemdTbody"></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="view-files" class="grid" style="display:none">
|
||
<div class="card" style="grid-column: 1 / -1;">
|
||
<div class="cardHeader">
|
||
<div class="cardTitle">Files (systemd)</div>
|
||
<div class="flex">
|
||
<button class="btn" onclick="filesRefresh()">Ververs</button>
|
||
<button class="btn" onclick="filesNewFolder()">Nieuwe map</button>
|
||
<button class="btn ok" onclick="filesNewFile()">Nieuw bestand</button>
|
||
<button class="btn ok" onclick="filesSave()">Opslaan</button>
|
||
<button class="btn bad" onclick="filesDelete()">Verwijderen</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="cardBody">
|
||
<div class="split">
|
||
<!-- Links: tree -->
|
||
<div>
|
||
<div class="muted" style="margin-bottom:8px">
|
||
Alleen onder <span class="mono">~/.config/containers/systemd</span> (systemd wordt niet getoond in paden).
|
||
</div>
|
||
<div id="filesTree" class="input" style="min-height:360px; overflow:auto; padding:12px"></div>
|
||
<div class="hint">Klik op een bestand om te openen.</div>
|
||
</div>
|
||
|
||
<!-- Rechts: editor -->
|
||
<div>
|
||
<div class="muted" style="margin-bottom:8px">
|
||
Huidig bestand: <span class="mono" id="filesCurrent">-</span>
|
||
</div>
|
||
<textarea id="filesEditor" class="textarea mono" spellcheck="false" placeholder="Selecteer links een bestand..."></textarea>
|
||
<div class="hint">
|
||
Na wijzigen van <span class="mono">*.container</span> moet je meestal <span class="mono">daemon-reload</span> doen (kan via Systemd-tab of dashboard-knop).
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Modal -->
|
||
<div class="modalBack" id="modalBack" onclick="closeModal(event)">
|
||
<div class="modal" onclick="event.stopPropagation()">
|
||
<div class="modalHeader">
|
||
<div class="modalTitle" id="modalTitle">Details</div>
|
||
<button class="btn small ghost" onclick="hideModal()">Sluiten</button>
|
||
</div>
|
||
<div class="modalBody">
|
||
<pre id="modalPre"></pre>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/codemirror.min.js"></script>
|
||
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/yaml/yaml.min.js"></script>
|
||
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/properties/properties.min.js"></script>
|
||
<script>
|
||
let cmEditor = null;
|
||
// ---- API helper ----
|
||
async function api(path, method = 'GET', body = null) {
|
||
const opts = { method, headers: {} };
|
||
if (body !== null) {
|
||
opts.headers['Content-Type'] = 'application/json';
|
||
opts.body = JSON.stringify(body);
|
||
}
|
||
const res = await fetch('/api' + path, opts);
|
||
const ct = res.headers.get('content-type') || '';
|
||
let data;
|
||
if (ct.includes('application/json')) {
|
||
data = await res.json();
|
||
} else {
|
||
data = { text: await res.text() };
|
||
}
|
||
if (!res.ok) {
|
||
const msg = data?.detail || data?.error || data?.text || ('HTTP ' + res.status);
|
||
throw new Error(msg);
|
||
}
|
||
return data;
|
||
}
|
||
|
||
function esc(s) {
|
||
return String(s)
|
||
.replaceAll('&','&')
|
||
.replaceAll('<','<')
|
||
.replaceAll('>','>')
|
||
.replaceAll('"','"')
|
||
.replaceAll("'","'");
|
||
}
|
||
|
||
function badgeFromStatus(s) {
|
||
const t = (s || '').toLowerCase();
|
||
if (t.includes('running') || t === 'running' || t === 'active') return `<span class="badge ok">${esc(s)}</span>`;
|
||
if (t.includes('exited') || t.includes('dead') || t.includes('stopped') || t === 'inactive') return `<span class="badge bad">${esc(s)}</span>`;
|
||
return `<span class="badge warn">${esc(s || 'unknown')}</span>`;
|
||
}
|
||
|
||
// ---- Modal ----
|
||
function showModal(title, content) {
|
||
document.getElementById('modalTitle').textContent = title;
|
||
document.getElementById('modalPre').textContent = content;
|
||
document.getElementById('modalBack').style.display = 'flex';
|
||
}
|
||
function hideModal() { document.getElementById('modalBack').style.display = 'none'; }
|
||
function closeModal(e){ if(e.target.id === 'modalBack') hideModal(); }
|
||
|
||
// ---- Tabs ----
|
||
let currentTab = 'dashboard';
|
||
function setTab(tab) {
|
||
currentTab = tab;
|
||
document.querySelectorAll('.tab').forEach(x => x.classList.remove('active'));
|
||
document.getElementById('tab-' + tab).classList.add('active');
|
||
|
||
document.querySelectorAll('[id^="view-"]').forEach(v => v.style.display='none');
|
||
document.getElementById('view-' + tab).style.display = '';
|
||
if (tab === 'files') {
|
||
filesRefresh();
|
||
}
|
||
|
||
// Start/stop live stats alleen in Containers tab
|
||
if (tab === 'containers') startContainersStatsStream();
|
||
else stopContainersStatsStream();
|
||
|
||
refreshActive();
|
||
}
|
||
|
||
document.addEventListener("visibilitychange", () => {
|
||
if (document.visibilityState !== "visible") {
|
||
stopContainersStatsStream();
|
||
} else if (currentTab === "containers") {
|
||
startContainersStatsStream();
|
||
}
|
||
});
|
||
|
||
// ---- Health / Ping ----
|
||
async function pingApi() {
|
||
try {
|
||
// simpele ping: pods ophalen
|
||
await api('/pods', 'GET');
|
||
setApiState(true, 'API: OK');
|
||
} catch (e) {
|
||
setApiState(false, 'API: fout (' + e.message + ')');
|
||
showModal('API fout', e.stack || e.message);
|
||
}
|
||
}
|
||
function setApiState(ok, msg) {
|
||
const dot = document.getElementById('apiDot');
|
||
dot.style.background = ok ? 'var(--ok)' : 'var(--bad)';
|
||
dot.style.boxShadow = ok ? '0 0 0 6px rgba(45,212,191,.15)' : '0 0 0 6px rgba(251,113,133,.15)';
|
||
document.getElementById('statusLine').textContent = msg;
|
||
}
|
||
|
||
// ---- Dashboard refresh ----
|
||
async function refreshActive() {
|
||
try {
|
||
if (currentTab === 'containers') await fetchContainers();
|
||
else if (currentTab === 'pods') await fetchPods();
|
||
else if (currentTab === 'systemd') await systemdRefresh();
|
||
else {
|
||
// dashboard: haal in achtergrond counts + mini systemd
|
||
const [pods, containers] = await Promise.all([
|
||
api('/pods-dashboard','GET'),
|
||
api('/containers','GET')
|
||
]);
|
||
document.getElementById('countPods').textContent = (pods || []).length;
|
||
// containers list kan array of object zijn; jij gebruikt array
|
||
const cCount = Array.isArray(containers) ? containers.length : (containers?.length || 0);
|
||
document.getElementById('countContainers').textContent = cCount;
|
||
|
||
const units = await getSystemdUnitsFromServer();
|
||
document.getElementById('countSystemd').textContent = units.length;
|
||
|
||
await systemdMiniRefresh();
|
||
}
|
||
setApiState(true, 'API: OK');
|
||
} catch (e) {
|
||
setApiState(false, 'API: fout (' + e.message + ')');
|
||
}
|
||
}
|
||
|
||
// ---- Pods ----
|
||
async function fetchPods() {
|
||
const pods = await api('/pods-dashboard','GET');
|
||
document.getElementById('countPods').textContent = (pods || []).length;
|
||
const tbody = document.getElementById('podsTbody');
|
||
tbody.innerHTML = (pods || []).map(p => {
|
||
const name = p.Name || p.name || '';
|
||
const status = p.Status || p.status || '';
|
||
const containers = (Array.isArray(p.Containers) ? p.Containers : [])
|
||
.map(c => {
|
||
if (typeof c === 'string') return c; // jouw nieuwe API: list of strings
|
||
const n = c?.Names;
|
||
if (Array.isArray(n)) return n[0] || '';
|
||
if (typeof n === 'string') return n;
|
||
return c?.Name || c?.name || '';
|
||
})
|
||
.filter(Boolean)
|
||
.join(', ');
|
||
return `
|
||
<tr>
|
||
<td><strong>${esc(name)}</strong></td>
|
||
<td>${badgeFromStatus(status)}</td>
|
||
<td class="muted">${esc(containers || '')}</td>
|
||
<td>
|
||
<div class="flex">
|
||
<button class="btn small ok" onclick="podAction('start','${esc(name)}')">Start</button>
|
||
<button class="btn small warn" onclick="podAction('restart','${esc(name)}')">Restart</button>
|
||
<button class="btn small bad" onclick="podAction('stop','${esc(name)}')">Stop</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
`;
|
||
}).join('');
|
||
}
|
||
|
||
async function podAction(action, name) {
|
||
try {
|
||
const res = await api(`/pods/actions/${encodeURIComponent(action)}/${encodeURIComponent(name)}`, 'POST');
|
||
showModal(`Pod ${action}: ${name}`, JSON.stringify(res, null, 2));
|
||
await fetchPods();
|
||
if (currentTab === 'containers') {
|
||
await fetchContainers();
|
||
}
|
||
} catch (e) {
|
||
showModal(`Pod ${action} fout`, e.stack || e.message);
|
||
}
|
||
}
|
||
|
||
// ---- Containers ----
|
||
|
||
function _podKey(c) {
|
||
return (c.PodName && String(c.PodName).trim()) ? String(c.PodName).trim() : '(geen pod)';
|
||
}
|
||
|
||
function _cmpStr(a, b) {
|
||
return String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: 'base' });
|
||
}
|
||
|
||
function _isCollapsed(pod) {
|
||
return localStorage.getItem('pod_group_collapsed:' + pod) === '1';
|
||
}
|
||
|
||
function _setCollapsed(pod, v) {
|
||
localStorage.setItem('pod_group_collapsed:' + pod, v ? '1' : '0');
|
||
}
|
||
|
||
function renderContainerRow(c) {
|
||
const name = (c.Names && c.Names[0]) ? c.Names[0] : (c.Names || c.Name || c.name || '');
|
||
const status = c.Status || c.State || c.state || '';
|
||
const podName = c.PodName || '-';
|
||
const image = c.Image || c.image || '';
|
||
const managed = c._dashboard_source || 'podman';
|
||
const ports = (c._dashboard_published_ports || []).join(", ")
|
||
|| (c.Ports || []).map(p => `${p.host_port}:${p.container_port}`).join(", ");
|
||
|
||
return `
|
||
<tr>
|
||
<td><strong>${esc(name)}</strong></td>
|
||
<td>${badgeFromStatus(status)}</td>
|
||
<td>${podName}</td>
|
||
<td class="muted">${esc(image)}</td>
|
||
<td>${badgeFromStatus(managed)}</td>
|
||
<td class="muted num" id="cpu-${cssSafeId(normalizeContainerName(name))}">-</td>
|
||
<td class="muted num" id="mem-${cssSafeId(normalizeContainerName(name))}">-</td>
|
||
<td>${ports || '-'}</td>
|
||
<td>
|
||
<div class="flex">
|
||
<button class="btn small" onclick="containerInspect('${esc(name)}')">Inspect</button>
|
||
<button class="btn small" onclick="containerLogs('${esc(name)}')">Logs</button>
|
||
<button class="btn small ok" onclick="containerAction('start','${esc(name)}')">Start</button>
|
||
<button class="btn small warn" onclick="containerAction('restart','${esc(name)}')">Restart</button>
|
||
<button class="btn small bad" onclick="containerAction('stop','${esc(name)}')">Stop</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
`;
|
||
}
|
||
|
||
function renderContainersGrouped(list, tbody, podStatus) {
|
||
const groups = new Map();
|
||
for (const c of (list || [])) {
|
||
const k = _podKey(c);
|
||
if (!groups.has(k)) groups.set(k, []);
|
||
groups.get(k).push(c);
|
||
}
|
||
|
||
for (const podName of Object.keys(podStatus || {})) {
|
||
if (!groups.has(podName)) groups.set(podName, []);
|
||
}
|
||
|
||
const keys = Array.from(groups.keys()).sort((a, b) => {
|
||
if (a === '(geen pod)' && b !== '(geen pod)') return 1;
|
||
if (b === '(geen pod)' && a !== '(geen pod)') return -1;
|
||
return _cmpStr(a, b);
|
||
});
|
||
|
||
const table = tbody.closest('table');
|
||
const colCount = table ? table.querySelectorAll('thead th').length : 9;
|
||
|
||
let html = '';
|
||
|
||
for (const pod of keys) {
|
||
const items = groups.get(pod) || [];
|
||
items.sort((a, b) =>
|
||
_cmpStr(
|
||
(a.Names && a.Names[0]) ? a.Names[0] : (a.Name || ''),
|
||
(b.Names && b.Names[0]) ? b.Names[0] : (b.Name || '')
|
||
)
|
||
);
|
||
|
||
const collapsed = _isCollapsed(pod);
|
||
const isRealPod = (pod !== '(geen pod)');
|
||
const total = items.length;
|
||
|
||
let cls = 'muted';
|
||
let label = '-';
|
||
|
||
if (total > 0) {
|
||
const running = items.filter(x => {
|
||
const s = (x.Status || x.State || '').toLowerCase();
|
||
return s === 'running';
|
||
}).length;
|
||
|
||
label = `${running}/${total}`;
|
||
if (running === total) cls = 'ok';
|
||
else if (running === 0) cls = 'bad';
|
||
else cls = 'warn';
|
||
} else {
|
||
const ps = String((podStatus && podStatus[pod]) || '').toLowerCase();
|
||
if (ps === 'active') { cls = 'ok'; label = 'active'; }
|
||
else if (ps === 'inactive') { cls = 'bad'; label = 'inactive'; }
|
||
else { cls = 'muted'; label = ps || 'unknown'; }
|
||
}
|
||
|
||
html += `
|
||
<tr class="pod-group-row">
|
||
<td>
|
||
<span class="pod-toggle" data-pod="${pod}" style="cursor:pointer; user-select:none;">
|
||
${collapsed ? '▶' : '▼'} <b>${esc(pod)}</b>
|
||
</span>
|
||
</td>
|
||
|
||
<td>
|
||
<span class="btn small ${cls}">
|
||
${label}
|
||
</span>
|
||
</td>
|
||
|
||
<td class="muted">-</td>
|
||
<td class="muted">-</td>
|
||
<td>${isRealPod ? '<span class="btn small muted">pod</span>' : '<span class="muted">-</span>'}</td>
|
||
<td class="muted num">-</td>
|
||
<td class="muted num">-</td>
|
||
<td class="muted">-</td>
|
||
<td>
|
||
${isRealPod ? `
|
||
<div class="flex">
|
||
<button class="btn small" disabled style="visibility:hidden;">Inspect</button>
|
||
<button class="btn small" disabled style="visibility:hidden;">Logs</button>
|
||
|
||
<button class="btn small ok" onclick="podAction('start','${esc(pod)}')">Start</button>
|
||
<button class="btn small warn" onclick="podAction('restart','${esc(pod)}')">Restart</button>
|
||
<button class="btn small bad" onclick="podAction('stop','${esc(pod)}')">Stop</button>
|
||
</div>
|
||
` : '<span class="muted">-</span>'}
|
||
</td>
|
||
</tr>
|
||
`;
|
||
|
||
for (const c of items) {
|
||
const row = renderContainerRow(c).replace(
|
||
'<tr',
|
||
`<tr class="pod-item-row" data-pod="${pod}"${collapsed ? ' style="display:none;"' : ''}`
|
||
);
|
||
html += row;
|
||
}
|
||
}
|
||
|
||
tbody.innerHTML = html;
|
||
|
||
tbody.onclick = (ev) => {
|
||
const t = ev.target.closest('.pod-toggle');
|
||
if (!t) return;
|
||
|
||
const pod = t.getAttribute('data-pod');
|
||
const isNowCollapsed = !_isCollapsed(pod);
|
||
_setCollapsed(pod, isNowCollapsed);
|
||
|
||
tbody.querySelectorAll(`.pod-item-row[data-pod="${CSS.escape(pod)}"]`).forEach(r => {
|
||
r.style.display = isNowCollapsed ? 'none' : '';
|
||
});
|
||
|
||
t.innerHTML =
|
||
`${isNowCollapsed ? '▶' : '▼'} <b>${pod}</b> ` +
|
||
`<span style="opacity:.7;">(${tbody.querySelectorAll(`.pod-item-row[data-pod="${CSS.escape(pod)}"]`).length})</span>`;
|
||
};
|
||
}
|
||
|
||
async function fetchContainers() {
|
||
|
||
const [containers, pods] = await Promise.all([
|
||
api('/containers-dashboard', 'GET'),
|
||
api('/pods-dashboard', 'GET')
|
||
]);
|
||
|
||
const list = Array.isArray(containers) ? containers : (containers?.containers || []);
|
||
document.getElementById('countContainers').textContent = list.length;
|
||
|
||
const podsList = Array.isArray(pods) ? pods : [];
|
||
const podStatus = {};
|
||
for (const p of podsList) {
|
||
const n = p?.Name || p?.name;
|
||
if (!n) continue;
|
||
podStatus[n] = (p?.Status || p?.status || '').toLowerCase(); // "active"/"inactive"/...
|
||
}
|
||
|
||
const tbody = document.getElementById('containersTbody');
|
||
renderContainersGrouped(list, tbody, podStatus);
|
||
}
|
||
|
||
let containersStatsES = null;
|
||
|
||
function startContainersStatsStream() {
|
||
if (containersStatsES) return;
|
||
|
||
containersStatsES = new EventSource("/api/containers/stats/stream?interval=1");
|
||
|
||
containersStatsES.addEventListener("stats", (ev) => {
|
||
const payload = JSON.parse(ev.data);
|
||
const statsList = payload?.data?.Stats || [];
|
||
|
||
for (const st of statsList) {
|
||
const cname = normalizeContainerName(st?.Name);
|
||
if (!cname) continue;
|
||
|
||
const key = cssSafeId(cname);
|
||
|
||
// CPU: jouw API geeft CPU als fractie (0.00384 -> 0.384%)
|
||
const cpuPct = Number(st?.CPUPerc ?? st?.CPU ?? st?.AvgCPU ?? 0);
|
||
const cpuEl = document.getElementById(`cpu-${key}`);
|
||
if (cpuEl) cpuEl.textContent = cpuPct.toFixed(2) + "%";
|
||
|
||
// MEM: bytes + percentage
|
||
const memEl = document.getElementById(`mem-${key}`);
|
||
if (memEl) {
|
||
const mem = formatBytes(st?.MemUsage);
|
||
const memp = Number(st?.MemPerc ?? 0);
|
||
memEl.textContent = `${mem} (${memp.toFixed(1)}%)`;
|
||
}
|
||
}
|
||
});
|
||
|
||
containersStatsES.onerror = () => {
|
||
// EventSource reconnect automatisch; geen actie nodig
|
||
};
|
||
}
|
||
|
||
function stopContainersStatsStream() {
|
||
if (!containersStatsES) return;
|
||
containersStatsES.close();
|
||
containersStatsES = null;
|
||
}
|
||
|
||
async function containerInspect(name) {
|
||
try {
|
||
const res = await api(`/containers/inspect/${encodeURIComponent(name)}`, 'GET');
|
||
showModal(`Inspect: ${name}`, JSON.stringify(res, null, 2));
|
||
} catch (e) {
|
||
showModal(`Inspect fout: ${name}`, e.stack || e.message);
|
||
}
|
||
}
|
||
|
||
async function containerLogs(name) {
|
||
try {
|
||
const res = await api(`/containers/logs/${encodeURIComponent(name)}`, 'GET');
|
||
const logs = res.logs ?? JSON.stringify(res, null, 2);
|
||
showModal(`Logs: ${name}`, logs);
|
||
} catch (e) {
|
||
showModal(`Logs fout: ${name}`, e.stack || e.message);
|
||
}
|
||
}
|
||
|
||
async function containerAction(action, name) {
|
||
try {
|
||
const res = await api(`/containers/${encodeURIComponent(action)}/${encodeURIComponent(name)}`, 'POST');
|
||
showModal(`Container ${action}: ${name}`, JSON.stringify(res, null, 2));
|
||
await fetchContainers();
|
||
} catch (e) {
|
||
showModal(`Container ${action} fout`, e.stack || e.message);
|
||
}
|
||
}
|
||
|
||
// ---- Systemd UI storage ----
|
||
const LS_KEY = 'mvp_systemd_units_v1';
|
||
function loadDefaultUnits() {
|
||
const defaults = ["demo1.service","demo2.service","sonarr.service"];
|
||
document.getElementById('systemdUnits').value = defaults.join("\n");
|
||
saveSystemdUnits();
|
||
}
|
||
function saveSystemdUnits() {
|
||
const raw = document.getElementById('systemdUnits').value || '';
|
||
const units = raw.split('\n').map(x => x.trim()).filter(Boolean);
|
||
localStorage.setItem(LS_KEY, JSON.stringify(units));
|
||
systemdRenderRows(units);
|
||
refreshActive();
|
||
}
|
||
async function getSystemdUnitsFromServer() {
|
||
const data = await api('/systemd/allowlist', 'GET');
|
||
const units = Array.isArray(data.units) ? data.units : [];
|
||
// vul textarea ook
|
||
const ta = document.getElementById('systemdUnits');
|
||
if (ta) ta.value = units.join("\n");
|
||
return units;
|
||
}
|
||
function systemdRenderRows(units) {
|
||
const tbody = document.getElementById('systemdTbody');
|
||
tbody.innerHTML = units.map(u => `
|
||
<tr>
|
||
<td><strong class="mono">${esc(u)}</strong></td>
|
||
<td class="muted mono" id="sys-out-${cssSafeId(u)}">-</td>
|
||
<td>
|
||
<div class="flex">
|
||
<button class="btn small" onclick="systemdAction('status','${esc(u)}')">Status</button>
|
||
<button class="btn small ok" onclick="systemdAction('start','${esc(u)}')">Start</button>
|
||
<button class="btn small warn" onclick="systemdAction('restart','${esc(u)}')">Restart</button>
|
||
<button class="btn small bad" onclick="systemdAction('stop','${esc(u)}')">Stop</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
`).join('');
|
||
}
|
||
|
||
function cssSafeId(s){
|
||
// simpele safe id: base64-ish
|
||
return btoa(unescape(encodeURIComponent(s))).replaceAll('=','').replaceAll('+','-').replaceAll('/','_');
|
||
}
|
||
|
||
function normalizeContainerName(n) {
|
||
if (!n) return '';
|
||
// Podman kan soms "/name" geven, we strippen de leading slash
|
||
return ('' + n).replace(/^\//, '');
|
||
}
|
||
|
||
function formatBytes(b) {
|
||
const n = Number(b || 0);
|
||
if (n >= 1024**3) return (n / 1024**3).toFixed(1) + "GiB";
|
||
if (n >= 1024**2) return (n / 1024**2).toFixed(0) + "MiB";
|
||
if (n >= 1024) return (n / 1024).toFixed(0) + "KiB";
|
||
return n + "B";
|
||
}
|
||
|
||
function encodeUnit(unit) {
|
||
// encodeURIComponent is genoeg voor @ en .
|
||
return encodeURIComponent(unit);
|
||
}
|
||
|
||
async function daemonReload() {
|
||
try {
|
||
const res = await api('/daemon-reload','POST');
|
||
showModal('daemon-reload', JSON.stringify(res, null, 2));
|
||
} catch (e) {
|
||
showModal('daemon-reload fout', e.stack || e.message);
|
||
}
|
||
}
|
||
|
||
async function systemdAction(action, unit) {
|
||
try {
|
||
const res = await api(`/${encodeURIComponent(action)}/${encodeUnit(unit)}`, 'POST');
|
||
// res.output kan lang zijn
|
||
showModal(`systemctl ${action} ${unit}`, (res.output ?? JSON.stringify(res, null, 2)));
|
||
// update inline status cell
|
||
const cell = document.getElementById('sys-out-' + cssSafeId(unit));
|
||
if (cell) {
|
||
const summary = (res.output || '').split('\n').slice(0,3).join(' / ') || '(geen output)';
|
||
cell.textContent = summary;
|
||
}
|
||
} catch (e) {
|
||
showModal(`systemctl ${action} fout`, e.stack || e.message);
|
||
}
|
||
}
|
||
|
||
async function systemdActionSingle(action) {
|
||
const unit = (document.getElementById('systemdOne').value || '').trim();
|
||
if (!unit) return showModal('Systemd', 'Vul eerst een unit in (bijv. sonarr.service).');
|
||
await systemdAction(action, unit);
|
||
}
|
||
|
||
async function systemdRefresh() {
|
||
const units = await getSystemdUnitsFromServer();
|
||
systemdRenderRows(units);
|
||
document.getElementById('countSystemd').textContent = units.length;
|
||
|
||
for (const u of units) {
|
||
try {
|
||
const res = await api(`/status/${encodeUnit(u)}`, 'POST');
|
||
const cell = document.getElementById('sys-out-' + cssSafeId(u));
|
||
if (cell) {
|
||
const first = (res.output || '').split('\n')[0] || '';
|
||
const activeLine = (res.output || '').split('\n').find(x => x.trim().startsWith('Active:')) || '';
|
||
cell.textContent = (first + ' | ' + activeLine).trim();
|
||
}
|
||
} catch (e) {
|
||
const cell = document.getElementById('sys-out-' + cssSafeId(u));
|
||
if (cell) cell.textContent = 'ERROR: ' + e.message;
|
||
}
|
||
}
|
||
}
|
||
|
||
async function systemdMiniRefresh() {
|
||
const units = await getSystemdUnitsFromServer();
|
||
const mini = document.getElementById('systemdMini');
|
||
if (!mini) return;
|
||
|
||
const lines = [];
|
||
for (const u of units.slice(0, 6)) {
|
||
try {
|
||
const res = await api(`/status/${encodeUnit(u)}`, 'POST');
|
||
const activeLine = (res.output || '').split('\n').find(x => x.trim().startsWith('Active:')) || '';
|
||
lines.push(`${u}: ${activeLine.replace('Active:','').trim() || 'unknown'}`);
|
||
} catch (e) {
|
||
lines.push(`${u}: ERROR (${e.message})`);
|
||
}
|
||
}
|
||
mini.innerHTML = `<pre>${esc(lines.join('\n'))}</pre>`;
|
||
}
|
||
|
||
// =========================
|
||
// Files tab (systemd subtree)
|
||
// =========================
|
||
const FILES_ROOT = 'systemd'; // API-root binnen WORKLOADS_DIR
|
||
let filesCurrentUiPath = ''; // zonder "systemd/"
|
||
let filesCurrentApiPath = ''; // met "systemd/"
|
||
|
||
function cmModeForPath(uiPath) {
|
||
const p = (uiPath || '').toLowerCase();
|
||
if (p.endsWith('.yaml') || p.endsWith('.yml') || p.endsWith('.kube') || p.endsWith('.container')) return 'yaml';
|
||
if (p.endsWith('.json')) return 'application/json';
|
||
if (p.endsWith('.js')) return 'javascript';
|
||
return 'text/plain';
|
||
}
|
||
|
||
function filesToApiPath(uiPath) {
|
||
let p = (uiPath || '').trim().replace(/^\/+/, '');
|
||
if (!p) return FILES_ROOT;
|
||
if (p === FILES_ROOT || p.startsWith(FILES_ROOT + '/')) return p;
|
||
return `${FILES_ROOT}/${p}`;
|
||
}
|
||
|
||
function filesToUiPath(apiPath) {
|
||
const p = (apiPath || '').trim().replace(/^\/+/, '');
|
||
return p.replace(new RegExp('^' + FILES_ROOT + '/?'), '');
|
||
}
|
||
|
||
function filesSetCurrent(uiPath) {
|
||
filesCurrentUiPath = (uiPath || '').trim().replace(/^\/+/, '');
|
||
filesCurrentApiPath = filesToApiPath(filesCurrentUiPath);
|
||
document.getElementById('filesCurrent').textContent = filesCurrentUiPath || '-';
|
||
}
|
||
|
||
async function filesRefresh() {
|
||
const treeEl = document.getElementById('filesTree');
|
||
treeEl.textContent = 'Laden...';
|
||
|
||
const data = await api('/files/tree', 'GET');
|
||
|
||
// Filter alleen systemd subtree
|
||
const scoped = (data || []).filter(folder => {
|
||
const p = (folder.path || '').replace(/^\/+/, '');
|
||
return p === FILES_ROOT || p.startsWith(FILES_ROOT + '/');
|
||
});
|
||
|
||
if (!scoped.length) {
|
||
treeEl.textContent = 'Geen bestanden gevonden onder systemd.';
|
||
return;
|
||
}
|
||
|
||
// Render simpel: folders met files eronder
|
||
const parts = [];
|
||
for (const folder of scoped) {
|
||
const apiFolderPath = (folder.path || '').replace(/^\/+/, '');
|
||
const uiFolderPath = filesToUiPath(apiFolderPath); // zonder systemd/
|
||
const folderLabel = uiFolderPath || 'root';
|
||
|
||
parts.push(`
|
||
<div class="mono" style="margin:8px 0 6px 0; font-weight:600; display:flex; align-items:center; justify-content:space-between; gap:10px;">
|
||
<span>📂 ${esc(folderLabel)}</span>
|
||
<span class="flex">
|
||
<button class="btn small ok" title="Nieuw bestand in ${esc(folderLabel)}" onclick="filesNewFileInFolder('${esc(uiFolderPath)}')">+</button>
|
||
<button class="btn small bad" title="Verwijder map (alleen als leeg)" onclick="filesDeleteFolder('${esc(uiFolderPath)}')">🗑️</button>
|
||
</span>
|
||
</div>
|
||
`);
|
||
|
||
const files = folder.files || [];
|
||
if (!files.length) {
|
||
parts.push(`<div class="muted" style="margin-left:18px;">(leeg)</div>`);
|
||
continue;
|
||
}
|
||
|
||
for (const f of files) {
|
||
const fullUi = uiFolderPath ? `${uiFolderPath}/${f}` : f;
|
||
parts.push(`
|
||
<div style="display:flex;align-items:center;justify-content:space-between;gap:10px;margin-left:10px;padding:4px 0;border-bottom:1px dashed rgba(36,52,95,.35)">
|
||
<span class="mono" style="cursor:pointer" onclick="filesOpen('${esc(fullUi)}')">📄 ${esc(f)}</span>
|
||
</div>
|
||
`);
|
||
}
|
||
}
|
||
|
||
treeEl.innerHTML = parts.join('');
|
||
}
|
||
|
||
async function filesOpen(uiPath) {
|
||
filesSetCurrent(uiPath);
|
||
|
||
const res = await api(`/files/read?path=${encodeURIComponent(filesCurrentApiPath)}`, 'GET');
|
||
|
||
const text = res.content || '';
|
||
if (cmEditor) {
|
||
cmEditor.setOption('mode', cmModeForPath(uiPath));
|
||
cmEditor.setValue(text);
|
||
cmEditor.refresh();
|
||
} else {
|
||
document.getElementById('filesEditor').value = text;
|
||
}
|
||
}
|
||
|
||
async function filesSave() {
|
||
if (!filesCurrentApiPath || filesCurrentApiPath === FILES_ROOT) {
|
||
return showModal('Files', 'Selecteer eerst een bestand.');
|
||
}
|
||
|
||
const content = cmEditor
|
||
? cmEditor.getValue()
|
||
: document.getElementById('filesEditor').value;
|
||
|
||
const res = await api(
|
||
`/files/save?path=${encodeURIComponent(filesCurrentApiPath)}`,
|
||
'POST',
|
||
{ content }
|
||
);
|
||
|
||
showModal('Opgeslagen', JSON.stringify(res, null, 2));
|
||
await filesRefresh();
|
||
}
|
||
|
||
async function filesDelete() {
|
||
if (!filesCurrentApiPath || filesCurrentApiPath === FILES_ROOT) {
|
||
return showModal('Files', 'Selecteer eerst een bestand om te verwijderen.');
|
||
}
|
||
if (!confirm(`Verwijderen: ${filesCurrentUiPath}?`)) return;
|
||
|
||
const res = await api(`/files/delete?path=${encodeURIComponent(filesCurrentApiPath)}`, 'DELETE');
|
||
showModal('Verwijderd', JSON.stringify(res, null, 2));
|
||
|
||
// reset current
|
||
filesSetCurrent('');
|
||
document.getElementById('filesEditor').value = '';
|
||
await filesRefresh();
|
||
}
|
||
|
||
async function filesNewFolder() {
|
||
const ui = prompt('Nieuwe map (onder systemd):\nVoorbeeld: mediaserver', '');
|
||
if (!ui) return;
|
||
|
||
const apiPath = filesToApiPath(ui);
|
||
const res = await api(`/files/mkdir?path=${encodeURIComponent(apiPath)}`, 'POST');
|
||
showModal('Map aangemaakt', JSON.stringify(res, null, 2));
|
||
await filesRefresh();
|
||
}
|
||
|
||
async function filesNewFile() {
|
||
const ui = prompt('Nieuw bestand (onder systemd):\nVoorbeeld: demo-web/demo-web.container', '');
|
||
if (!ui) return;
|
||
|
||
const apiPath = filesToApiPath(ui);
|
||
|
||
// altijd leeg (jouw keuze) -> leeg bestand
|
||
const res = await api(`/files/save?path=${encodeURIComponent(apiPath)}`, 'POST', { content: "" });
|
||
showModal('Bestand aangemaakt', JSON.stringify(res, null, 2));
|
||
|
||
// Open direct
|
||
filesSetCurrent(ui);
|
||
const editorEl = document.getElementById('filesEditor');
|
||
if (editorEl) editorEl.value = "";
|
||
await filesRefresh();
|
||
await filesOpen(ui);
|
||
} // <- BELANGRIJK: deze } miste bij jou
|
||
|
||
|
||
async function filesNewFileInFolder(uiFolderPath) {
|
||
const base = (uiFolderPath || '').trim().replace(/^\/+/, '');
|
||
const name = prompt(`Nieuw bestand in "${base || 'root'}"\nBijv: test.yaml of demo.container`, '');
|
||
if (!name) return;
|
||
|
||
const uiFull = base ? `${base}/${name}` : name;
|
||
const apiPath = filesToApiPath(uiFull);
|
||
|
||
// altijd leeg (jouw keuze)
|
||
const res = await api(`/files/save?path=${encodeURIComponent(apiPath)}`, 'POST', { content: "" });
|
||
showModal('Bestand aangemaakt', JSON.stringify(res, null, 2));
|
||
|
||
await filesRefresh();
|
||
await filesOpen(uiFull);
|
||
}
|
||
|
||
async function filesDeleteFolder(uiFolderPath) {
|
||
const base = (uiFolderPath || '').trim().replace(/^\/+/, '');
|
||
if (!base) {
|
||
return showModal('Files', 'Root map verwijderen mag niet.');
|
||
}
|
||
if (!confirm(`Map verwijderen (alleen als leeg): ${base}?`)) return;
|
||
|
||
const apiPath = filesToApiPath(base);
|
||
|
||
try {
|
||
const res = await api(`/files/rmdir?path=${encodeURIComponent(apiPath)}`, 'DELETE');
|
||
showModal('Map verwijderd', JSON.stringify(res, null, 2));
|
||
await filesRefresh();
|
||
} catch (e) {
|
||
showModal('Kan map niet verwijderen', e.message);
|
||
}
|
||
}
|
||
|
||
// ---- Init ----
|
||
(async function init(){
|
||
// preload systemd units UI
|
||
const units = await getSystemdUnitsFromServer();
|
||
systemdRenderRows(units);
|
||
document.getElementById('countSystemd').textContent = units.length;
|
||
|
||
// Files editor: CodeMirror init (alleen als textarea bestaat)
|
||
const taFiles = document.getElementById('filesEditor');
|
||
if (taFiles && window.CodeMirror) {
|
||
cmEditor = CodeMirror.fromTextArea(taFiles, {
|
||
lineNumbers: true,
|
||
lineWrapping: true,
|
||
mode: 'text/plain',
|
||
theme: 'material-darker'
|
||
});
|
||
cmEditor.setSize('100%', 360);
|
||
}
|
||
|
||
// first refresh
|
||
await refreshActive();
|
||
|
||
// periodic refresh (light): ping every 20s
|
||
setInterval(() => { pingApi(); }, 20000);
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|