Files
podman-mvp/webui/html/26_index.html
T

1018 lines
36 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>
<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)}
.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}
</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>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 enforcet 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('&','&amp;')
.replaceAll('<','&lt;')
.replaceAll('>','&gt;')
.replaceAll('"','&quot;')
.replaceAll("'","&#039;");
}
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();
}
refreshActive();
}
// ---- 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();
} catch (e) {
showModal(`Pod ${action} fout`, e.stack || e.message);
}
}
// ---- Containers ----
async function fetchContainers() {
const containers = await api('/containers-dashboard', 'GET');
const list = Array.isArray(containers) ? containers : (containers?.containers || []);
document.getElementById('countContainers').textContent = list.length;
const tbody = document.getElementById('containersTbody');
tbody.innerHTML = list.map(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.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>${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>
`;
}).join('');
}
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 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>