Initial commit - podman-mvp net na toevoegen cpu en mem kolommen
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
ServerName localhost
|
||||
Listen 8081
|
||||
PidFile /tmp/httpd.pid
|
||||
|
||||
LoadModule mpm_event_module modules/mod_mpm_event.so
|
||||
LoadModule unixd_module modules/mod_unixd.so
|
||||
LoadModule authz_core_module modules/mod_authz_core.so
|
||||
LoadModule dir_module modules/mod_dir.so
|
||||
LoadModule mime_module modules/mod_mime.so
|
||||
LoadModule log_config_module modules/mod_log_config.so
|
||||
|
||||
LoadModule proxy_module modules/mod_proxy.so
|
||||
LoadModule proxy_http_module modules/mod_proxy_http.so
|
||||
|
||||
DocumentRoot "/usr/local/apache2/htdocs"
|
||||
DirectoryIndex index.html
|
||||
|
||||
<Directory "/usr/local/apache2/htdocs">
|
||||
Require all granted
|
||||
</Directory>
|
||||
|
||||
ErrorLog /proc/self/fd/2
|
||||
CustomLog /proc/self/fd/1 combined
|
||||
|
||||
ProxyPreserveHost On
|
||||
ProxyPass "/api/" "http://127.0.0.1:8000/api/"
|
||||
ProxyPassReverse "/api/" "http://127.0.0.1:8000/api/"
|
||||
@@ -0,0 +1,733 @@
|
||||
<!doctype html>
|
||||
<html lang="nl">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<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:1200px;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;
|
||||
}
|
||||
.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">
|
||||
<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>
|
||||
</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>Image</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>
|
||||
|
||||
<!-- 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>
|
||||
// ---- 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 = '';
|
||||
|
||||
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','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 = getSystemdUnitsFromUI();
|
||||
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','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 = (p.Containers || []).map(c => c.Names || c.Names?.[0] || c.Names || c.Names).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(`/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', '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 image = c.Image || c.image || '';
|
||||
return `
|
||||
<tr>
|
||||
<td><strong>${esc(name)}</strong></td>
|
||||
<td>${badgeFromStatus(status)}</td>
|
||||
<td class="muted">${esc(image)}</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();
|
||||
}
|
||||
function getSystemdUnitsFromUI() {
|
||||
let units = [];
|
||||
try { units = JSON.parse(localStorage.getItem(LS_KEY) || '[]'); } catch {}
|
||||
if (!Array.isArray(units) || units.length === 0) units = ["demo1.service","demo2.service","sonarr.service"];
|
||||
// sync textarea
|
||||
const ta = document.getElementById('systemdUnits');
|
||||
if (ta && ta.value.trim().length === 0) 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 = getSystemdUnitsFromUI();
|
||||
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 = getSystemdUnitsFromUI();
|
||||
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>`;
|
||||
}
|
||||
|
||||
// ---- Init ----
|
||||
(function init(){
|
||||
// preload systemd units UI
|
||||
const units = getSystemdUnitsFromUI();
|
||||
const ta = document.getElementById('systemdUnits');
|
||||
if (ta) ta.value = units.join("\n");
|
||||
systemdRenderRows(units);
|
||||
document.getElementById('countSystemd').textContent = units.length;
|
||||
|
||||
// first refresh
|
||||
refreshActive();
|
||||
// periodic refresh (light): ping every 20s
|
||||
setInterval(() => { pingApi(); }, 20000);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,739 @@
|
||||
<!doctype html>
|
||||
<html lang="nl">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<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:1200px;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;
|
||||
}
|
||||
.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">
|
||||
<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>
|
||||
</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>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>
|
||||
|
||||
<!-- 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>
|
||||
// ---- 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 = '';
|
||||
|
||||
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','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','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 = (p.Containers || []).map(c => c.Names || c.Names?.[0] || c.Names || c.Names).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(`/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', '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 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>${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>`;
|
||||
}
|
||||
|
||||
// ---- Init ----
|
||||
(function init(){
|
||||
// preload systemd units UI
|
||||
const units = getSystemdUnitsFromServer();
|
||||
const ta = document.getElementById('systemdUnits');
|
||||
if (ta) ta.value = units.join("\n");
|
||||
systemdRenderRows(units);
|
||||
document.getElementById('countSystemd').textContent = units.length;
|
||||
|
||||
// first refresh
|
||||
refreshActive();
|
||||
// periodic refresh (light): ping every 20s
|
||||
setInterval(() => { pingApi(); }, 20000);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,738 @@
|
||||
<!doctype html>
|
||||
<html lang="nl">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<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:1200px;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;
|
||||
}
|
||||
.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">
|
||||
<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>
|
||||
</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>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>
|
||||
|
||||
<!-- 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>
|
||||
// ---- 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 = '';
|
||||
|
||||
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','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','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 = (p.Containers || []).map(c => c.Names || c.Names?.[0] || c.Names || c.Names).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', '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 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>${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>`;
|
||||
}
|
||||
|
||||
// ---- Init ----
|
||||
(async function init(){
|
||||
// preload systemd units UI
|
||||
const units = await getSystemdUnitsFromServer();
|
||||
systemdRenderRows(units);
|
||||
document.getElementById('countSystemd').textContent = units.length;
|
||||
|
||||
// first refresh
|
||||
await refreshActive();
|
||||
|
||||
// periodic refresh (light): ping every 20s
|
||||
setInterval(() => { pingApi(); }, 20000);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,738 @@
|
||||
<!doctype html>
|
||||
<html lang="nl">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<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:1200px;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;
|
||||
}
|
||||
.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">
|
||||
<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>
|
||||
</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>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>
|
||||
|
||||
<!-- 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>
|
||||
// ---- 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 = '';
|
||||
|
||||
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','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','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 = (p.Containers || []).map(c => c.Names || c.Names?.[0] || c.Names || c.Names).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', '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 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>${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>`;
|
||||
}
|
||||
|
||||
// ---- Init ----
|
||||
(async function init(){
|
||||
// preload systemd units UI
|
||||
const units = await getSystemdUnitsFromServer();
|
||||
systemdRenderRows(units);
|
||||
document.getElementById('countSystemd').textContent = units.length;
|
||||
|
||||
// first refresh
|
||||
await refreshActive();
|
||||
|
||||
// periodic refresh (light): ping every 20s
|
||||
setInterval(() => { pingApi(); }, 20000);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,738 @@
|
||||
<!doctype html>
|
||||
<html lang="nl">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<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:1200px;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;
|
||||
}
|
||||
.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">
|
||||
<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>
|
||||
</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>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>
|
||||
|
||||
<!-- 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>
|
||||
// ---- 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 = '';
|
||||
|
||||
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 = (p.Containers || []).map(c => c.Names || c.Names?.[0] || c.Names || c.Names).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 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>${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>`;
|
||||
}
|
||||
|
||||
// ---- Init ----
|
||||
(async function init(){
|
||||
// preload systemd units UI
|
||||
const units = await getSystemdUnitsFromServer();
|
||||
systemdRenderRows(units);
|
||||
document.getElementById('countSystemd').textContent = units.length;
|
||||
|
||||
// first refresh
|
||||
await refreshActive();
|
||||
|
||||
// periodic refresh (light): ping every 20s
|
||||
setInterval(() => { pingApi(); }, 20000);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,741 @@
|
||||
<!doctype html>
|
||||
<html lang="nl">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<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:1200px;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;
|
||||
}
|
||||
.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">
|
||||
<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>
|
||||
</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 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>
|
||||
|
||||
<!-- 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>
|
||||
// ---- 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 = '';
|
||||
|
||||
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 = (p.Containers || []).map(c => c.Names || c.Names?.[0] || c.Names || c.Names).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>`;
|
||||
}
|
||||
|
||||
// ---- Init ----
|
||||
(async function init(){
|
||||
// preload systemd units UI
|
||||
const units = await getSystemdUnitsFromServer();
|
||||
systemdRenderRows(units);
|
||||
document.getElementById('countSystemd').textContent = units.length;
|
||||
|
||||
// first refresh
|
||||
await refreshActive();
|
||||
|
||||
// periodic refresh (light): ping every 20s
|
||||
setInterval(() => { pingApi(); }, 20000);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,915 @@
|
||||
<!doctype html>
|
||||
<html lang="nl">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<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:1200px;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;
|
||||
}
|
||||
.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">
|
||||
<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 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>
|
||||
// ---- 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();
|
||||
}
|
||||
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 = (p.Containers || []).map(c => c.Names || c.Names?.[0] || c.Names || c.Names).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 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;">📂 ${esc(folderLabel)}</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) {
|
||||
const editor = document.getElementById('filesEditor');
|
||||
filesSetCurrent(uiPath);
|
||||
|
||||
const res = await api(`/files/read?path=${encodeURIComponent(filesCurrentApiPath)}`, 'GET');
|
||||
editor.value = res.content || '';
|
||||
}
|
||||
|
||||
async function filesSave() {
|
||||
if (!filesCurrentApiPath || filesCurrentApiPath === FILES_ROOT) {
|
||||
return showModal('Files', 'Selecteer eerst een bestand.');
|
||||
}
|
||||
const editor = document.getElementById('filesEditor');
|
||||
const content = editor.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);
|
||||
|
||||
// Maak het bestand aan met lege content (of minimale comment)
|
||||
const res = await api(`/files/save?path=${encodeURIComponent(apiPath)}`, 'POST', { content: "# nieuw bestand\n" });
|
||||
showModal('Bestand aangemaakt', JSON.stringify(res, null, 2));
|
||||
|
||||
// Open direct
|
||||
filesSetCurrent(ui);
|
||||
document.getElementById('filesEditor').value = "# nieuw bestand\n";
|
||||
await filesRefresh();
|
||||
}
|
||||
|
||||
|
||||
// ---- Init ----
|
||||
(async function init(){
|
||||
// preload systemd units UI
|
||||
const units = await getSystemdUnitsFromServer();
|
||||
systemdRenderRows(units);
|
||||
document.getElementById('countSystemd').textContent = units.length;
|
||||
|
||||
// first refresh
|
||||
await refreshActive();
|
||||
|
||||
// periodic refresh (light): ping every 20s
|
||||
setInterval(() => { pingApi(); }, 20000);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,329 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="nl">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Podman MVP | Dashboard V4.5</title>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.12/codemirror.min.css">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.12/theme/material-darker.min.css">
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.12/codemirror.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.12/mode/yaml/yaml.min.js"></script>
|
||||
<style>
|
||||
:root {
|
||||
--sidebar-width: 250px;
|
||||
--portainer-blue: #00b3e6;
|
||||
--bg-dark: #2c3e50;
|
||||
--bg-light: #f4f6f9;
|
||||
--border-color: #dce1e5;
|
||||
}
|
||||
body { font-family: 'Segoe UI', sans-serif; margin: 0; display: flex; background: var(--bg-light); height: 100vh; overflow: hidden; }
|
||||
.sidebar { width: var(--sidebar-width); background: var(--bg-dark); color: white; display: flex; flex-direction: column; }
|
||||
.sidebar-header { padding: 15px 20px; background: #1a252f; font-weight: bold; display: flex; align-items: center; gap: 10px; }
|
||||
.sidebar-header img { width: 30px; }
|
||||
.nav-item { padding: 15px 20px; cursor: pointer; border-left: 4px solid transparent; transition: 0.2s; }
|
||||
.nav-item:hover { background: #34495e; }
|
||||
.nav-item.active { background: #34495e; border-left-color: var(--portainer-blue); color: var(--portainer-blue); }
|
||||
.main-content { flex: 1; display: flex; flex-direction: column; overflow-y: auto; }
|
||||
.topbar { background: white; padding: 15px 30px; border-bottom: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: center; }
|
||||
|
||||
/* Navigatie logica */
|
||||
.view-container { padding: 25px; display: none; }
|
||||
.view-container.active { display: block; }
|
||||
|
||||
.card { background: white; border-radius: 4px; border: 1px solid var(--border-color); margin-bottom: 20px; }
|
||||
.card-header { padding: 12px 20px; border-bottom: 1px solid var(--border-color); font-weight: bold; background: #fafafa; display: flex; justify-content: space-between; align-items: center; }
|
||||
.card-body { padding: 15px; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th { text-align: left; padding: 12px; border-bottom: 2px solid var(--border-color); font-size: 0.8rem; text-transform: uppercase; color: #7f8c8d; }
|
||||
td { padding: 10px 12px; border-bottom: 1px solid #eee; font-size: 0.9rem; }
|
||||
.badge { padding: 4px 10px; border-radius: 12px; font-size: 0.75rem; font-weight: bold; }
|
||||
.badge-running { background: #d4edda; color: #155724; }
|
||||
.badge-stopped { background: #f8d7da; color: #721c24; }
|
||||
.btn { padding: 5px 10px; border-radius: 3px; border: 1px solid #ccc; cursor: pointer; font-size: 0.8rem; background: white; transition: 0.2s; }
|
||||
.btn:hover { background: #f8f9fa; border-color: #bbb; }
|
||||
.btn-primary { background: var(--portainer-blue); color: white; border: none; padding: 8px 15px; font-weight: bold; }
|
||||
|
||||
/* Modal Styles */
|
||||
.modal-overlay { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.7); z-index: 1000; justify-content: center; align-items: center; }
|
||||
.modal-content { background: white; width: 80%; max-height: 80%; border-radius: 8px; display: flex; flex-direction: column; position: relative; }
|
||||
.modal-header { padding: 15px 20px; border-bottom: 1px solid #ddd; font-weight: bold; display: flex; justify-content: space-between; align-items: center; }
|
||||
.modal-body { padding: 20px; overflow-y: auto; flex: 1; }
|
||||
.log-container { background: #1e1e1e; color: #d4d4d4; padding: 15px; font-family: 'Consolas', monospace; font-size: 0.85rem; border-radius: 4px; white-space: pre-wrap; }
|
||||
.inspect-container { background: #f8f9fa; padding: 15px; font-family: monospace; font-size: 0.85rem; border: 1px solid #ddd; border-radius: 4px; }
|
||||
|
||||
/* Studio Layout */
|
||||
.studio-layout { display: flex; gap: 20px; height: calc(100vh - 280px); }
|
||||
.file-tree { width: 280px; border-right: 1px solid #eee; overflow-y: auto; padding-right: 10px; }
|
||||
.editor-container { flex: 1; display: flex; flex-direction: column; }
|
||||
.CodeMirror { flex: 1; border: 1px solid #ddd; font-size: 13px; }
|
||||
.tree-item { display: flex; justify-content: space-between; align-items: center; padding: 5px 8px; border-radius: 4px; margin-bottom: 2px; }
|
||||
.tree-item:hover { background: #f0f2f5; }
|
||||
.tree-folder { font-weight: bold; color: #2c3e50; background: #eaeff2; margin-top: 10px; }
|
||||
.tree-file { cursor: pointer; color: #2980b9; }
|
||||
.icon-btn { cursor: pointer; opacity: 0.6; transition: 0.2s; font-style: normal; }
|
||||
.icon-btn:hover { opacity: 1; transform: scale(1.1); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<img src="https://raw.githubusercontent.com/containers/common/main/logos/podman-logo.svg" alt="Podman">
|
||||
<span>Podman MVP</span>
|
||||
</div>
|
||||
<div class="nav-item active" id="nav-dashboard" onclick="showView('dashboard')">Dashboard</div>
|
||||
<div class="nav-item" id="nav-containers" onclick="showView('containers')">Containers</div>
|
||||
<div class="nav-item" id="nav-pods" onclick="showView('pods')">Pods</div>
|
||||
<div class="nav-item" id="nav-workloads" onclick="showView('workloads')">Workload Studio</div>
|
||||
</div>
|
||||
|
||||
<div class="main-content">
|
||||
<div class="topbar">
|
||||
<span id="view-title" style="font-weight: bold; color: #34495e;">DASHBOARD</span>
|
||||
<button class="btn" onclick="refresh()">🔄 Ververs Data</button>
|
||||
</div>
|
||||
|
||||
<div id="dashboard-view" class="view-container active">
|
||||
<div class="card">
|
||||
<div class="card-header">Beheerde Workloads</div>
|
||||
<div class="card-body">
|
||||
<table>
|
||||
<thead><tr><th>Naam</th><th>Status</th><th>IP</th><th>Acties</th></tr></thead>
|
||||
<tbody id="dashboard-list"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="containers-view" class="view-container">
|
||||
<div class="card">
|
||||
<div class="card-header">Live Containers</div>
|
||||
<div class="card-body">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Naam</th>
|
||||
<th>Status</th>
|
||||
<th>Pod</th>
|
||||
<th>IP</th>
|
||||
<th>Poorten</th>
|
||||
<th>Quick Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="live-container-list"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="pods-view" class="view-container">
|
||||
<div class="card">
|
||||
<div class="card-header">Active Pods</div>
|
||||
<div class="card-body">
|
||||
<table>
|
||||
<thead><tr><th>Pod Naam</th><th>Status</th><th>Containers</th></tr></thead>
|
||||
<tbody id="pod-list"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="workloads-view" class="view-container">
|
||||
<div class="card">
|
||||
<div class="card-header"><span>Bestandsbeheer</span><button class="btn" onclick="createNewMap()">📁 Nieuwe Hoofdmap</button></div>
|
||||
<div class="card-body studio-layout">
|
||||
<div id="file-tree" class="file-tree">Laden...</div>
|
||||
<div class="editor-container">
|
||||
<div id="editor-header" style="padding: 10px; background: #34495e; color: white; font-family: monospace; font-size: 0.8rem; border-radius: 4px 4px 0 0;">Selecteer een bestand...</div>
|
||||
<textarea id="edit-content"></textarea>
|
||||
<div style="margin-top:15px; display: flex; justify-content: flex-end;"><button class="btn btn-primary" onclick="saveFile()">💾 Bestand Opslaan</button></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="modal" class="modal-overlay" onclick="closeModal()">
|
||||
<div class="modal-content" onclick="event.stopPropagation()">
|
||||
<div class="modal-header">
|
||||
<span id="modal-title">Info</span>
|
||||
<button class="btn" onclick="closeModal()">✖ Sluiten</button>
|
||||
</div>
|
||||
<div class="modal-body" id="modal-body"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let editor;
|
||||
let currentPath = '';
|
||||
|
||||
window.onload = () => {
|
||||
editor = CodeMirror.fromTextArea(document.getElementById("edit-content"), {
|
||||
mode: "yaml", theme: "material-darker", lineNumbers: true, tabSize: 2, indentWithTabs: false
|
||||
});
|
||||
refresh();
|
||||
};
|
||||
|
||||
async function api(path, method = 'GET', body = null) {
|
||||
const options = { method, headers: body ? {'Content-Type': 'application/json'} : {} };
|
||||
if (body) options.body = JSON.stringify(body);
|
||||
const res = await fetch('/api' + path, options);
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
// --- NAVIGATIE HERSTELD ---
|
||||
function showView(view) {
|
||||
document.querySelectorAll('.view-container').forEach(v => {
|
||||
v.style.display = 'none';
|
||||
v.classList.remove('active');
|
||||
});
|
||||
|
||||
const selected = document.getElementById(view + '-view');
|
||||
if (selected) {
|
||||
selected.style.display = 'block';
|
||||
selected.classList.add('active');
|
||||
}
|
||||
|
||||
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
|
||||
const nav = document.getElementById('nav-' + view);
|
||||
if (nav) nav.classList.add('active');
|
||||
|
||||
document.getElementById('view-title').innerText = view.toUpperCase();
|
||||
|
||||
if(view === 'workloads') fetchTree();
|
||||
}
|
||||
|
||||
async function fetchDashboard() {
|
||||
const data = await api('/dashboard');
|
||||
document.getElementById('dashboard-list').innerHTML = data.map(w => `
|
||||
<tr><td><strong>${w.name}</strong></td>
|
||||
<td><span class="badge ${w.status === 'running' ? 'badge-running' : 'badge-stopped'}">${w.status}</span></td>
|
||||
<td>${w.ip || '-'}</td>
|
||||
<td><button class="btn" onclick="runAction('start', '${w.name}')">▶</button> <button class="btn" onclick="runAction('stop', '${w.name}')">⏹</button></td></tr>`).join('');
|
||||
}
|
||||
|
||||
// --- CONTAINERS MET POORTEN EN ACTIONS ---
|
||||
async function fetchContainers() {
|
||||
const data = await api('/containers');
|
||||
const list = document.getElementById('live-container-list');
|
||||
if (!data) return;
|
||||
|
||||
list.innerHTML = '';
|
||||
const realContainers = data.filter(c => !c.IsInfra);
|
||||
|
||||
realContainers.forEach(c => {
|
||||
const name = c.Names[0].replace('/', '');
|
||||
const ip = c.Networks ? Object.values(c.Networks)[0]?.IPAddress || '-' : '-';
|
||||
|
||||
let portMapping = '-';
|
||||
if (c.Ports && c.Ports.length > 0) {
|
||||
portMapping = c.Ports.map(p => {
|
||||
const host = p.host_port;
|
||||
const container = p.container_port;
|
||||
return host ? `${host}:${container}` : `${container}/${p.protocol}`;
|
||||
}).join(', ');
|
||||
}
|
||||
|
||||
list.innerHTML += `
|
||||
<tr>
|
||||
<td><strong>${name}</strong></td>
|
||||
<td><span class="badge ${c.State==='running'?'badge-running':'badge-stopped'}">${c.State}</span></td>
|
||||
<td>${c.PodName || '-'}</td>
|
||||
<td>${ip}</td>
|
||||
<td style="font-family:monospace; font-size:0.8rem;">${portMapping}</td>
|
||||
<td>
|
||||
<button class="btn" title="Logs" onclick="showLogs('${name}')">📜</button>
|
||||
<button class="btn" title="Inspect" onclick="showInspect('${name}')">🔍</button>
|
||||
</td>
|
||||
</tr>`;
|
||||
});
|
||||
}
|
||||
|
||||
async function showLogs(name) {
|
||||
document.getElementById('modal-title').innerText = `Logs: ${name}`;
|
||||
document.getElementById('modal-body').innerHTML = '<div class="log-container">Laden...</div>';
|
||||
document.getElementById('modal').style.display = 'flex';
|
||||
const data = await api(`/containers/logs/${name}`);
|
||||
const cleanLogs = data.logs.replace(/[\u0000-\u0008]/g, "");
|
||||
document.getElementById('modal-body').innerHTML = `<div class="log-container">${cleanLogs || 'Geen logs gevonden.'}</div>`;
|
||||
}
|
||||
|
||||
async function showInspect(name) {
|
||||
document.getElementById('modal-title').innerText = `Inspect: ${name}`;
|
||||
document.getElementById('modal-body').innerHTML = '<div class="inspect-container">Laden...</div>';
|
||||
document.getElementById('modal').style.display = 'flex';
|
||||
const data = await api(`/containers/inspect/${name}`);
|
||||
document.getElementById('modal-body').innerHTML = `<pre class="inspect-container">${JSON.stringify(data, null, 2)}</pre>`;
|
||||
}
|
||||
|
||||
function closeModal() { document.getElementById('modal').style.display = 'none'; }
|
||||
|
||||
async function fetchTree() {
|
||||
const data = await api('/files/tree');
|
||||
const tree = document.getElementById('file-tree');
|
||||
tree.innerHTML = '';
|
||||
data.forEach(folder => {
|
||||
const folderName = folder.path || "root";
|
||||
tree.innerHTML += `<div class="tree-item tree-folder"><span>📂 ${folderName}</span><div style="display:flex;gap:8px;"><i class="icon-btn" onclick="createFileIn('${folder.path}')">➕</i><i class="icon-btn" style="color:red" onclick="deleteItem('${folder.path}')">🗑</i></div></div>`;
|
||||
folder.files.forEach(f => {
|
||||
const full = folder.path ? `${folder.path}/${f}` : f;
|
||||
tree.innerHTML += `<div class="tree-item ml-4"><span class="tree-file" onclick="loadFile('${full}')">📄 ${f}</span><i class="icon-btn" style="color:red" onclick="deleteItem('${full}')">🗑</i></div>`;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function loadFile(path) {
|
||||
currentPath = path;
|
||||
const data = await api(`/files/read?path=${encodeURIComponent(path)}`);
|
||||
editor.setValue(data.content);
|
||||
document.getElementById('editor-header').innerText = `BESTAND: ${path}`;
|
||||
}
|
||||
|
||||
async function saveFile() {
|
||||
if(!currentPath) return alert("Selecteer bestand!");
|
||||
await api(`/files/save?path=${encodeURIComponent(currentPath)}`, 'POST', {content: editor.getValue()});
|
||||
alert("Opgeslagen!");
|
||||
fetchTree();
|
||||
}
|
||||
|
||||
async function createFileIn(folderPath) {
|
||||
const name = prompt(`Naam in ${folderPath || 'root'}:`, "service.yaml");
|
||||
if(name) {
|
||||
const full = folderPath ? `${folderPath}/${name}` : name;
|
||||
const template = "version: '1.0'\nservices:\n ";
|
||||
await api(`/files/save?path=${encodeURIComponent(full)}`, 'POST', {content: template});
|
||||
fetchTree();
|
||||
}
|
||||
}
|
||||
|
||||
async function createNewMap() {
|
||||
const path = prompt("Nieuwe hoofdmap:");
|
||||
if(path) { await api(`/files/mkdir?path=${encodeURIComponent(path)}`, 'POST'); fetchTree(); }
|
||||
}
|
||||
|
||||
async function deleteItem(path) {
|
||||
if(!path || !confirm(`Verwijderen: ${path}?`)) return;
|
||||
const res = await api(`/files/delete?path=${encodeURIComponent(path)}`, 'DELETE');
|
||||
if(res.status === 'deleted') fetchTree();
|
||||
else if(res.detail) alert(res.detail);
|
||||
}
|
||||
|
||||
async function fetchPods() {
|
||||
const data = await api('/pods');
|
||||
document.getElementById('pod-list').innerHTML = data.map(p => `<tr><td>${p.Name}</td><td><span class="badge ${p.Status==='Running'?'badge-running':'badge-stopped'}">${p.Status}</span></td><td>${p.Containers.length}</td></tr>`).join('');
|
||||
}
|
||||
|
||||
async function runAction(action, name) {
|
||||
await api(`/actions/${action}/${name}`, 'POST');
|
||||
setTimeout(refresh, 1200);
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
fetchDashboard();
|
||||
fetchContainers();
|
||||
fetchPods();
|
||||
if(document.getElementById('workloads-view').classList.contains('active')) fetchTree();
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
|
After Width: | Height: | Size: 123 KiB |
Reference in New Issue
Block a user