refact (ui): 01
This commit is contained in:
@@ -0,0 +1,394 @@
|
||||
// ---- Pods ----
|
||||
|
||||
async function podAction(action, name) {
|
||||
try {
|
||||
const res = await api(`/pods/actions/${encodeURIComponent(action)}/${encodeURIComponent(name)}`, 'POST');
|
||||
showModal(`Pod ${action}: ${name}`, JSON.stringify(res, null, 2));
|
||||
|
||||
await fetchContainers(); // refresh pods + containers view in één keer
|
||||
} catch (e) {
|
||||
showModal(`Pod ${action} fout`, e.stack || e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Containers ----
|
||||
|
||||
function _podKey(c) {
|
||||
return (c.PodName && String(c.PodName).trim()) ? String(c.PodName).trim() : '(geen pod)';
|
||||
}
|
||||
|
||||
function _cmpStr(a, b) {
|
||||
return String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: 'base' });
|
||||
}
|
||||
|
||||
function _isCollapsed(pod) {
|
||||
return localStorage.getItem('pod_group_collapsed:' + pod) === '1';
|
||||
}
|
||||
|
||||
function _setCollapsed(pod, v) {
|
||||
localStorage.setItem('pod_group_collapsed:' + pod, v ? '1' : '0');
|
||||
}
|
||||
|
||||
function renderActionsDropdown(menuId, actionFn, targetEsc) {
|
||||
// actionFn is string: "containerAction" of "podAction"
|
||||
// targetEsc is al esc(...) dus veilig in onclick
|
||||
return `
|
||||
<span class="actions-menu">
|
||||
<button class="btn icon" title="Acties" onclick="toggleMenu('${menuId}')">⋮</button>
|
||||
<div class="menuPanel" id="${menuId}">
|
||||
<button class="menuItem ok" onclick="${actionFn}('start','${targetEsc}'); closeAllMenus();">Start</button>
|
||||
<button class="menuItem warn" onclick="${actionFn}('restart','${targetEsc}'); closeAllMenus();">Restart</button>
|
||||
<button class="menuItem bad" onclick="${actionFn}('stop','${targetEsc}'); closeAllMenus();">Stop</button>
|
||||
</div>
|
||||
</span>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderContainerRow(c) {
|
||||
const name = (c.Names && c.Names[0]) ? c.Names[0] : (c.Names || c.Name || c.name || '');
|
||||
const status = c.Status || c.State || c.state || '';
|
||||
const podName = c.PodName || '-';
|
||||
const image = c.Image || c.image || '';
|
||||
const managed = c._dashboard_source || 'podman';
|
||||
const menuId = `menu-${cssSafeId('c:' + normalizeContainerName(name))}`;
|
||||
const inPod = !!(c.PodName && String(c.PodName).trim());
|
||||
const ports = inPod
|
||||
? '' // verberg bij pod-containers
|
||||
: ((c._dashboard_published_ports || []).join(", ")
|
||||
|| (c.Ports || []).map(p => `${p.host_port}:${p.container_port}`).join(", "));
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td><strong>${esc(name)}</strong></td>
|
||||
<td>${badgeFromStatus(status)}</td>
|
||||
<td>${podName}</td>
|
||||
<td class="muted">${esc(image)}</td>
|
||||
<td>${badgeFromStatus(managed)}</td>
|
||||
<td class="muted num" id="cpu-${cssSafeId(normalizeContainerName(name))}">-</td>
|
||||
<td class="muted num" id="mem-${cssSafeId(normalizeContainerName(name))}">-</td>
|
||||
<td>${ports || '-'}</td>
|
||||
<td>
|
||||
<div class="flex">
|
||||
<button class="btn icon" title="Inspect" onclick="containerInspect(decodeURIComponent('${encodeURIComponent(name)}'))">🔍</button>
|
||||
<button class="btn icon" title="Logs" onclick="containerLogs(decodeURIComponent('${encodeURIComponent(name)}'))">📄</button>
|
||||
${renderActionsDropdown(menuId, 'containerAction', esc(name))}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
|
||||
let containersC2P = new Map();
|
||||
|
||||
function renderContainersGrouped(list, tbody, podStatus) {
|
||||
const groups = new Map();
|
||||
for (const c of (list || [])) {
|
||||
const k = _podKey(c);
|
||||
if (!groups.has(k)) groups.set(k, []);
|
||||
groups.get(k).push(c);
|
||||
}
|
||||
|
||||
for (const podName of Object.keys(podStatus || {})) {
|
||||
if (!groups.has(podName)) groups.set(podName, []);
|
||||
}
|
||||
|
||||
const keys = Array.from(groups.keys()).sort((a, b) => {
|
||||
if (a === '(geen pod)' && b !== '(geen pod)') return 1;
|
||||
if (b === '(geen pod)' && a !== '(geen pod)') return -1;
|
||||
return _cmpStr(a, b);
|
||||
});
|
||||
|
||||
containersC2P = new Map();
|
||||
|
||||
let html = '';
|
||||
|
||||
for (const pod of keys) {
|
||||
const items = groups.get(pod) || [];
|
||||
items.sort((a, b) =>
|
||||
_cmpStr(
|
||||
(a.Names && a.Names[0]) ? a.Names[0] : (a.Name || ''),
|
||||
(b.Names && b.Names[0]) ? b.Names[0] : (b.Name || '')
|
||||
)
|
||||
);
|
||||
|
||||
const collapsed = _isCollapsed(pod);
|
||||
const isRealPod = (pod !== '(geen pod)');
|
||||
const total = items.length;
|
||||
const podMenuId = `menu-${cssSafeId('p:' + pod)}`;
|
||||
|
||||
let podPortsText = "-";
|
||||
if (isRealPod) {
|
||||
const podPorts = Array.from(new Set(
|
||||
items.flatMap(c => (c._dashboard_published_ports || []))
|
||||
)).sort((a, b) => _cmpStr(a, b));
|
||||
podPortsText = podPorts.length ? podPorts.join(", ") : "-";
|
||||
}
|
||||
|
||||
let cls = 'muted';
|
||||
let label = '-';
|
||||
|
||||
if (total > 0) {
|
||||
const running = items.filter(x => {
|
||||
const s = (x.Status || x.State || '').toLowerCase();
|
||||
return s === 'running';
|
||||
}).length;
|
||||
|
||||
label = `${running}/${total}`;
|
||||
if (running === total) cls = 'ok';
|
||||
else if (running === 0) cls = 'bad';
|
||||
else cls = 'warn';
|
||||
} else {
|
||||
const ps = String((podStatus && podStatus[pod]) || '').toLowerCase();
|
||||
if (ps === 'active') { cls = 'ok'; label = 'active'; }
|
||||
else if (ps === 'inactive') { cls = 'bad'; label = 'inactive'; }
|
||||
else { cls = 'muted'; label = ps || 'unknown'; }
|
||||
}
|
||||
|
||||
html += `
|
||||
<tr class="pod-group-row">
|
||||
<td>
|
||||
<span class="pod-toggle" data-pod="${encodeURIComponent(pod)}" style="cursor:pointer; user-select:none;">
|
||||
${collapsed ? '▶' : '▼'} <b>${esc(pod)}</b>
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<span class="btn small ${cls}">
|
||||
${label}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<td class="muted">-</td>
|
||||
<td class="muted">-</td>
|
||||
<td>${isRealPod ? '<span class="btn small muted">pod</span>' : '<span class="muted">-</span>'}</td>
|
||||
|
||||
<td class="muted num" id="podcpu-${cssSafeId(pod)}" data-podstatus="${esc((podStatus && podStatus[pod]) || '')}">-</td>
|
||||
<td class="muted num" id="podmem-${cssSafeId(pod)}" data-podstatus="${esc((podStatus && podStatus[pod]) || '')}">-</td>
|
||||
<td class="mono">${esc(podPortsText)}</td>
|
||||
<td>
|
||||
${isRealPod ? `
|
||||
<div class="flex">
|
||||
<!-- placeholders voor uitlijning -->
|
||||
<button class="btn icon" disabled style="visibility:hidden;" aria-hidden="true" tabindex="-1">🔍</button>
|
||||
<button class="btn icon" disabled style="visibility:hidden;" aria-hidden="true" tabindex="-1">📄</button>
|
||||
|
||||
${renderActionsDropdown(podMenuId, 'podAction', esc(pod))}
|
||||
</div>
|
||||
` : '<span class="muted">-</span>'}
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
|
||||
for (const c of items) {
|
||||
const cname = normalizeContainerName((c.Names && c.Names[0]) ? c.Names[0] : (c.Names || c.Name || c.name || ''));
|
||||
if (cname) containersC2P.set(cname, pod);
|
||||
const row = renderContainerRow(c).replace(
|
||||
'<tr',
|
||||
`<tr class="pod-item-row" data-pod="${encodeURIComponent(pod)}"${collapsed ? ' style="display:none;"' : ''}`
|
||||
);
|
||||
html += row;
|
||||
}
|
||||
}
|
||||
|
||||
tbody.innerHTML = html;
|
||||
|
||||
tbody.onclick = (ev) => {
|
||||
const t = ev.target.closest('.pod-toggle');
|
||||
if (!t) return;
|
||||
|
||||
const podEnc = t.getAttribute('data-pod') || '';
|
||||
const pod = decodeURIComponent(podEnc);
|
||||
const isNowCollapsed = !_isCollapsed(pod);
|
||||
_setCollapsed(pod, isNowCollapsed);
|
||||
|
||||
tbody.querySelectorAll(`.pod-item-row[data-pod="${CSS.escape(podEnc)}"]`).forEach(r => {
|
||||
r.style.display = isNowCollapsed ? 'none' : '';
|
||||
});
|
||||
|
||||
t.innerHTML =
|
||||
`${isNowCollapsed ? '▶' : '▼'} <b>${pod}</b> ` +
|
||||
`<span style="opacity:.7;">(${tbody.querySelectorAll(`.pod-item-row[data-pod="${CSS.escape(podEnc)}"]`).length})</span>`;
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchContainers() {
|
||||
const [containers, pods] = await Promise.all([
|
||||
api('/containers-dashboard', 'GET'),
|
||||
api('/pods-dashboard', 'GET')
|
||||
]);
|
||||
|
||||
const list = Array.isArray(containers) ? containers : (containers?.containers || []);
|
||||
document.getElementById('countContainers').textContent = list.length;
|
||||
|
||||
const podsList = Array.isArray(pods) ? pods : [];
|
||||
const podStatus = {};
|
||||
for (const p of podsList) {
|
||||
const n = p?.Name || p?.name;
|
||||
if (!n) continue;
|
||||
podStatus[n] = (p?.Status || p?.status || '').toLowerCase(); // "active"/"inactive"/...
|
||||
}
|
||||
|
||||
const tbody = document.getElementById('containersTbody');
|
||||
if (!tbody) return;
|
||||
renderContainersGrouped(list, tbody, podStatus);
|
||||
}
|
||||
|
||||
let containersDashboardStatsTimer = null;
|
||||
let containersDashboardStatsInFlight = false;
|
||||
|
||||
function startContainersDashboardStatsPoll() {
|
||||
if (containersDashboardStatsTimer) return;
|
||||
// immediate tick, daarna elke 1s
|
||||
pollContainersDashboardStatsOnce();
|
||||
containersDashboardStatsTimer = setInterval(pollContainersDashboardStatsOnce, 1000);
|
||||
}
|
||||
|
||||
function stopContainersDashboardStatsPoll() {
|
||||
if (!containersDashboardStatsTimer) return;
|
||||
clearInterval(containersDashboardStatsTimer);
|
||||
containersDashboardStatsTimer = null;
|
||||
resetPodTotals();
|
||||
}
|
||||
|
||||
async function pollContainersDashboardStatsOnce() {
|
||||
if (containersDashboardStatsInFlight) return;
|
||||
containersDashboardStatsInFlight = true;
|
||||
try {
|
||||
const containers = await api('/containers-dashboard', 'GET');
|
||||
const list = Array.isArray(containers) ? containers : (containers?.containers || []);
|
||||
|
||||
// totals per pod voor deze poll tick
|
||||
const podCpu = new Map(); // podName -> cpuPct sum
|
||||
const podMem = new Map(); // podName -> memBytes sum
|
||||
const podMemPct = new Map(); // podName -> memPct sum
|
||||
|
||||
for (const c of (list || [])) {
|
||||
const cname = normalizeContainerName((c?.Names && c.Names[0]) ? c.Names[0] : (c?.Names || c?.Name || c?.name || ''));
|
||||
if (!cname) continue;
|
||||
|
||||
const key = cssSafeId(cname);
|
||||
|
||||
const cpuRaw = c?._dashboard_cpu;
|
||||
const memBytesRaw = c?._dashboard_mem_usage;
|
||||
const memPctRaw = c?._dashboard_mem_perc;
|
||||
|
||||
const cpuPct = Number(cpuRaw);
|
||||
const memBytes = Number(memBytesRaw);
|
||||
const memPct = Number(memPctRaw);
|
||||
|
||||
const pod = containersC2P.get(cname);
|
||||
if (pod) {
|
||||
if (Number.isFinite(cpuPct)) podCpu.set(pod, (podCpu.get(pod) || 0) + cpuPct);
|
||||
if (Number.isFinite(memBytes)) podMem.set(pod, (podMem.get(pod) || 0) + memBytes);
|
||||
if (Number.isFinite(memPct)) podMemPct.set(pod, (podMemPct.get(pod) || 0) + memPct);
|
||||
}
|
||||
|
||||
const cpuEl = document.getElementById(`cpu-${key}`);
|
||||
if (cpuEl) cpuEl.textContent = Number.isFinite(cpuPct) ? (cpuPct.toFixed(2) + "%") : "-";
|
||||
|
||||
const memEl = document.getElementById(`mem-${key}`);
|
||||
if (memEl) {
|
||||
if (Number.isFinite(memBytes) && Number.isFinite(memPct)) {
|
||||
memEl.textContent = `${formatBytes(memBytes)} (${memPct.toFixed(1)}%)`;
|
||||
} else {
|
||||
memEl.textContent = "-";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [pod, cpuSum] of podCpu.entries()) {
|
||||
const el = document.getElementById(`podcpu-${cssSafeId(pod)}`);
|
||||
if (el) {
|
||||
el.textContent = cpuSum.toFixed(2) + "%";
|
||||
el.classList.remove('stale');
|
||||
}
|
||||
}
|
||||
|
||||
for (const [pod, memSum] of podMem.entries()) {
|
||||
const el = document.getElementById(`podmem-${cssSafeId(pod)}`);
|
||||
if (el) {
|
||||
const mp = podMemPct.get(pod) || 0;
|
||||
el.textContent = `${formatBytes(memSum)} (${Number(mp).toFixed(1)}%)`;
|
||||
el.classList.remove('stale');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
resetPodTotals();
|
||||
} finally {
|
||||
containersDashboardStatsInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
function resetPodTotals() {
|
||||
document.querySelectorAll('[id^="podcpu-"]').forEach(el => {
|
||||
const ps = String(el.getAttribute('data-podstatus') || '').toLowerCase();
|
||||
if (ps === 'inactive') {
|
||||
el.textContent = '-';
|
||||
el.classList.remove('stale');
|
||||
} else {
|
||||
el.textContent = '—';
|
||||
el.classList.add('stale');
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelectorAll('[id^="podmem-"]').forEach(el => {
|
||||
const ps = String(el.getAttribute('data-podstatus') || '').toLowerCase();
|
||||
if (ps === 'inactive') {
|
||||
el.textContent = '-';
|
||||
el.classList.remove('stale');
|
||||
} else {
|
||||
el.textContent = '—';
|
||||
el.classList.add('stale');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function containerInspect(name) {
|
||||
try {
|
||||
const res = await api(`/containers/inspect/${encodeURIComponent(name)}`, 'GET');
|
||||
showModal(`Inspect: ${name}`, JSON.stringify(res, null, 2));
|
||||
} catch (e) {
|
||||
showModal(`Inspect fout: ${name}`, e.stack || e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function containerLogs(name) {
|
||||
try {
|
||||
const res = await api(`/containers/logs/${encodeURIComponent(name)}`, 'GET');
|
||||
const logs = res.logs ?? JSON.stringify(res, null, 2);
|
||||
showModal(`Logs: ${name}`, logs);
|
||||
} catch (e) {
|
||||
showModal(`Logs fout: ${name}`, e.stack || e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function containerAction(action, name) {
|
||||
try {
|
||||
const res = await api(`/containers/${encodeURIComponent(action)}/${encodeURIComponent(name)}`, 'POST');
|
||||
showModal(`Container ${action}: ${name}`, JSON.stringify(res, null, 2));
|
||||
await fetchContainers();
|
||||
} catch (e) {
|
||||
showModal(`Container ${action} fout`, e.stack || e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function cssSafeId(s) {
|
||||
const bytes = new TextEncoder().encode(String(s));
|
||||
let bin = '';
|
||||
bytes.forEach(b => bin += String.fromCharCode(b));
|
||||
return btoa(bin).replaceAll('=','').replaceAll('+','-').replaceAll('/','_');
|
||||
}
|
||||
|
||||
function normalizeContainerName(n) {
|
||||
if (!n) return '';
|
||||
// Podman kan soms "/name" geven, we strippen de leading slash
|
||||
return ('' + n).replace(/^\//, '');
|
||||
}
|
||||
|
||||
function formatBytes(b) {
|
||||
const n = Number(b || 0);
|
||||
if (n >= 1024**3) return (n / 1024**3).toFixed(1) + "GiB";
|
||||
if (n >= 1024**2) return (n / 1024**2).toFixed(0) + "MiB";
|
||||
if (n >= 1024) return (n / 1024).toFixed(0) + "KiB";
|
||||
return n + "B";
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
let cmEditor = null;
|
||||
|
||||
function _isFolderCollapsed(folderKey) {
|
||||
return localStorage.getItem('files_folder_collapsed:' + folderKey) !== '0';
|
||||
// default collapsed = true
|
||||
}
|
||||
|
||||
function _setFolderCollapsed(folderKey, v) {
|
||||
localStorage.setItem('files_folder_collapsed:' + folderKey, v ? '1' : '0');
|
||||
}
|
||||
|
||||
// =========================
|
||||
// Files tab (systemd subtree)
|
||||
// =========================
|
||||
const FILES_ROOT = 'systemd'; // API-root binnen WORKLOADS_DIR
|
||||
let filesCurrentUiPath = ''; // zonder "systemd/"
|
||||
let filesCurrentApiPath = ''; // met "systemd/"
|
||||
|
||||
function cmModeForPath(uiPath) {
|
||||
const p = (uiPath || '').toLowerCase();
|
||||
if (p.endsWith('.yaml') || p.endsWith('.yml') || p.endsWith('.kube') || p.endsWith('.container')) return 'yaml';
|
||||
if (p.endsWith('.json')) return 'application/json';
|
||||
if (p.endsWith('.js')) return 'javascript';
|
||||
return 'text/plain';
|
||||
}
|
||||
|
||||
function filesToApiPath(uiPath) {
|
||||
let p = (uiPath || '').trim().replace(/^\/+/, '');
|
||||
if (!p) return FILES_ROOT;
|
||||
if (p === FILES_ROOT || p.startsWith(FILES_ROOT + '/')) return p;
|
||||
return `${FILES_ROOT}/${p}`;
|
||||
}
|
||||
|
||||
function filesToUiPath(apiPath) {
|
||||
const p = (apiPath || '').trim().replace(/^\/+/, '');
|
||||
return p.replace(new RegExp('^' + FILES_ROOT + '/?'), '');
|
||||
}
|
||||
|
||||
function filesSetCurrent(uiPath) {
|
||||
filesCurrentUiPath = (uiPath || '').trim().replace(/^\/+/, '');
|
||||
filesCurrentApiPath = filesToApiPath(filesCurrentUiPath);
|
||||
document.getElementById('filesCurrent').textContent = filesCurrentUiPath || '-';
|
||||
}
|
||||
|
||||
async function filesRefresh() {
|
||||
// Files editor: CodeMirror init (alleen als textarea bestaat)
|
||||
if (!cmEditor) {
|
||||
const taFiles = document.getElementById('filesEditor');
|
||||
if (taFiles && window.CodeMirror) {
|
||||
cmEditor = CodeMirror.fromTextArea(taFiles, {
|
||||
lineNumbers: true,
|
||||
lineWrapping: true,
|
||||
mode: 'text/plain',
|
||||
theme: 'material-darker'
|
||||
});
|
||||
cmEditor.setSize('100%', 360);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// Bouw een geneste folder-tree uit de "platte" API response.
|
||||
const folderByPath = new Map();
|
||||
for (const f of scoped) {
|
||||
const apiPath = (f.path || '').replace(/^\/+/, '');
|
||||
folderByPath.set(apiPath, f);
|
||||
}
|
||||
|
||||
function getOrCreateChild(parent, name) {
|
||||
if (!parent.children.has(name)) {
|
||||
const apiPath = parent.apiPath ? `${parent.apiPath}/${name}` : name;
|
||||
parent.children.set(name, {
|
||||
name,
|
||||
apiPath,
|
||||
uiPath: filesToUiPath(apiPath),
|
||||
children: new Map(),
|
||||
});
|
||||
}
|
||||
return parent.children.get(name);
|
||||
}
|
||||
|
||||
const root = { name: FILES_ROOT, apiPath: FILES_ROOT, uiPath: '', children: new Map() };
|
||||
|
||||
// 1) Nodes aanmaken op basis van bekende folder paths
|
||||
for (const apiPath of folderByPath.keys()) {
|
||||
if (apiPath === FILES_ROOT) continue;
|
||||
if (!apiPath.startsWith(FILES_ROOT + '/')) continue;
|
||||
const rel = apiPath.slice((FILES_ROOT + '/').length);
|
||||
const segs = rel.split('/').filter(Boolean);
|
||||
let cur = root;
|
||||
for (const s of segs) cur = getOrCreateChild(cur, s);
|
||||
}
|
||||
|
||||
// 2) Nodes aanvullen op basis van dirs-lijsten (zodat lege tussenfolders ook verschijnen)
|
||||
for (const [apiPath, folder] of folderByPath.entries()) {
|
||||
if (apiPath !== FILES_ROOT && !apiPath.startsWith(FILES_ROOT + '/')) continue;
|
||||
|
||||
let base = root;
|
||||
if (apiPath !== FILES_ROOT) {
|
||||
const rel = apiPath.slice((FILES_ROOT + '/').length);
|
||||
const segs = rel.split('/').filter(Boolean);
|
||||
for (const s of segs) base = getOrCreateChild(base, s);
|
||||
}
|
||||
|
||||
for (const d of (folder.dirs || [])) getOrCreateChild(base, d);
|
||||
}
|
||||
|
||||
function renderNode(node, level) {
|
||||
const folderKey = node.apiPath;
|
||||
const collapsed = _isFolderCollapsed(folderKey);
|
||||
const label = node.uiPath || 'root';
|
||||
const indent = Math.max(0, level) * 14;
|
||||
|
||||
const folder = folderByPath.get(folderKey);
|
||||
const files = (folder && folder.files) ? folder.files : [];
|
||||
|
||||
// subfolders (NU AL beschikbaar voor de badges)
|
||||
const childNames = Array.from(node.children.keys()).sort((a,b) => a.localeCompare(b));
|
||||
|
||||
// files (NU AL beschikbaar voor de badges)
|
||||
const sortedFiles = (files || []).slice().sort((a,b) => a.localeCompare(b));
|
||||
|
||||
const out = [];
|
||||
out.push(`
|
||||
<div class="mono file-folder-row" data-folder="${esc(folderKey)}" style="margin:${level === 0 ? '8px' : '6px'} 0 6px 0; padding-left:${indent}px; font-weight:600;">
|
||||
<span class="file-folder-left">
|
||||
<span class="folder-toggle">${collapsed ? '▶' : '▼'}</span>
|
||||
<span>📂 ${esc(label)}</span>
|
||||
</span>
|
||||
<span class="file-folder-meta" onclick="event.stopPropagation();">
|
||||
<span class="file-badge" title="Subfolders in deze map">📁 ${childNames.length}</span>
|
||||
<span class="file-badge" title="Bestanden in deze map">📄 ${sortedFiles.length}</span>
|
||||
|
||||
<span class="flex file-folder-actions">
|
||||
<button class="btn small ok" title="Nieuw bestand in ${esc(label)}" onclick="filesNewFileInFolder(decodeURIComponent('${encodeURIComponent(node.uiPath)}'))">+</button>
|
||||
<button class="btn small bad" title="Verwijder map (alleen als leeg)" onclick="filesDeleteFolder(decodeURIComponent('${encodeURIComponent(node.uiPath)}'))">🗑️</button>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
`);
|
||||
|
||||
out.push(`<div class="file-folder-files" data-folder-files="${esc(folderKey)}" style="${collapsed ? 'display:none;' : ''}">`);
|
||||
|
||||
for (const name of childNames) {
|
||||
out.push(renderNode(node.children.get(name), level + 1));
|
||||
}
|
||||
|
||||
for (const f of sortedFiles) {
|
||||
const fullUi = node.uiPath ? `${node.uiPath}/${f}` : f;
|
||||
out.push(`
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;gap:10px;padding:4px 0 4px ${indent + 18}px;border-bottom:1px dashed rgba(36,52,95,.35)">
|
||||
<span class="mono" style="cursor:pointer" onclick="filesOpen(decodeURIComponent('${encodeURIComponent(fullUi)}'))">📄 ${esc(f)}</span>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
if (!childNames.length && !sortedFiles.length) {
|
||||
out.push(`<div class="muted" style="padding-left:${indent + 18}px;">(leeg)</div>`);
|
||||
}
|
||||
|
||||
out.push(`</div>`);
|
||||
return out.join('');
|
||||
}
|
||||
|
||||
const parts = [];
|
||||
const topNames = Array.from(root.children.keys()).sort((a,b) => a.localeCompare(b));
|
||||
for (const n of topNames) parts.push(renderNode(root.children.get(n), 0));
|
||||
|
||||
// Files direct onder "systemd/" (root) tonen bovenaan
|
||||
const rootFolder = folderByPath.get(FILES_ROOT);
|
||||
if (rootFolder && (rootFolder.files || []).length) {
|
||||
const folderKey = FILES_ROOT;
|
||||
const collapsed = _isFolderCollapsed(folderKey);
|
||||
parts.unshift(`
|
||||
<div class="mono file-folder-row" data-folder="${esc(folderKey)}" style="margin:8px 0 6px 0; font-weight:600;">
|
||||
<span class="file-folder-left">
|
||||
<span class="folder-toggle">${collapsed ? '▶' : '▼'}</span>
|
||||
<span>📂 root</span>
|
||||
</span>
|
||||
<span class="flex" onclick="event.stopPropagation();">
|
||||
<button class="btn small ok" title="Nieuw bestand in root" onclick="filesNewFileInFolder('')">+</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="file-folder-files" data-folder-files="${esc(folderKey)}" style="${collapsed ? 'display:none;' : ''}">
|
||||
${(rootFolder.files || []).slice().sort((a,b)=>a.localeCompare(b)).map(f => {
|
||||
const fullUi = f;
|
||||
return `
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;gap:10px;padding:4px 0 4px 18px;border-bottom:1px dashed rgba(36,52,95,.35)">
|
||||
<span class="mono" style="cursor:pointer" onclick="filesOpen(decodeURIComponent('${encodeURIComponent(fullUi)}'))">📄 ${esc(f)}</span>
|
||||
</div>
|
||||
`;
|
||||
}).join('')}
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
treeEl.innerHTML = parts.join('');
|
||||
treeEl.onclick = (ev) => {
|
||||
const row = ev.target.closest('.file-folder-row');
|
||||
if (!row) return;
|
||||
|
||||
const folderKey = row.getAttribute('data-folder');
|
||||
const isNowCollapsed = !_isFolderCollapsed(folderKey);
|
||||
_setFolderCollapsed(folderKey, isNowCollapsed);
|
||||
|
||||
// pijltje updaten
|
||||
const arrow = row.querySelector('.folder-toggle');
|
||||
if (arrow) arrow.textContent = isNowCollapsed ? '▶' : '▼';
|
||||
|
||||
// files block tonen/verbergen
|
||||
const filesBlock = treeEl.querySelector(`[data-folder-files="${CSS.escape(folderKey)}"]`);
|
||||
if (filesBlock) filesBlock.style.display = isNowCollapsed ? 'none' : '';
|
||||
};
|
||||
}
|
||||
|
||||
async function filesOpen(uiPath) {
|
||||
filesSetCurrent(uiPath);
|
||||
|
||||
const res = await api(`/files/read?path=${encodeURIComponent(filesCurrentApiPath)}`, 'GET');
|
||||
|
||||
const text = res.content || '';
|
||||
if (cmEditor) {
|
||||
cmEditor.setOption('mode', cmModeForPath(uiPath));
|
||||
cmEditor.setValue(text);
|
||||
cmEditor.refresh();
|
||||
} else {
|
||||
document.getElementById('filesEditor').value = text;
|
||||
}
|
||||
}
|
||||
|
||||
async function filesSave() {
|
||||
if (!filesCurrentApiPath || filesCurrentApiPath === FILES_ROOT) {
|
||||
return showModal('Files', 'Selecteer eerst een bestand.');
|
||||
}
|
||||
|
||||
const content = cmEditor
|
||||
? cmEditor.getValue()
|
||||
: document.getElementById('filesEditor').value;
|
||||
|
||||
const res = await api(
|
||||
`/files/save?path=${encodeURIComponent(filesCurrentApiPath)}`,
|
||||
'POST',
|
||||
{ content }
|
||||
);
|
||||
|
||||
showModal('Opgeslagen', JSON.stringify(res, null, 2));
|
||||
await filesRefresh();
|
||||
}
|
||||
|
||||
async function filesDelete() {
|
||||
if (!filesCurrentApiPath || filesCurrentApiPath === FILES_ROOT) {
|
||||
return showModal('Files', 'Selecteer eerst een bestand om te verwijderen.');
|
||||
}
|
||||
if (!confirm(`Verwijderen: ${filesCurrentUiPath}?`)) return;
|
||||
|
||||
const res = await api(`/files/delete?path=${encodeURIComponent(filesCurrentApiPath)}`, 'DELETE');
|
||||
showModal('Verwijderd', JSON.stringify(res, null, 2));
|
||||
|
||||
// reset current
|
||||
filesSetCurrent('');
|
||||
document.getElementById('filesEditor').value = '';
|
||||
await filesRefresh();
|
||||
}
|
||||
|
||||
async function filesNewFolder() {
|
||||
const ui = prompt('Nieuwe map (onder systemd):\nVoorbeeld: mediaserver', '');
|
||||
if (!ui) return;
|
||||
|
||||
const apiPath = filesToApiPath(ui);
|
||||
const res = await api(`/files/mkdir?path=${encodeURIComponent(apiPath)}`, 'POST');
|
||||
showModal('Map aangemaakt', JSON.stringify(res, null, 2));
|
||||
await filesRefresh();
|
||||
}
|
||||
|
||||
async function filesNewFile() {
|
||||
const ui = prompt('Nieuw bestand (onder systemd):\nVoorbeeld: demo-web/demo-web.container', '');
|
||||
if (!ui) return;
|
||||
|
||||
const apiPath = filesToApiPath(ui);
|
||||
|
||||
// altijd leeg (jouw keuze) -> leeg bestand
|
||||
const res = await api(`/files/save?path=${encodeURIComponent(apiPath)}`, 'POST', { content: "" });
|
||||
showModal('Bestand aangemaakt', JSON.stringify(res, null, 2));
|
||||
|
||||
// Open direct
|
||||
filesSetCurrent(ui);
|
||||
const editorEl = document.getElementById('filesEditor');
|
||||
if (editorEl) editorEl.value = "";
|
||||
await filesRefresh();
|
||||
await filesOpen(ui);
|
||||
}
|
||||
|
||||
async function filesNewFileInFolder(uiFolderPath) {
|
||||
const base = (uiFolderPath || '').trim().replace(/^\/+/, '');
|
||||
const name = prompt(`Nieuw bestand in "${base || 'root'}"\nBijv: test.yaml of demo.container`, '');
|
||||
if (!name) return;
|
||||
|
||||
const uiFull = base ? `${base}/${name}` : name;
|
||||
const apiPath = filesToApiPath(uiFull);
|
||||
|
||||
// altijd leeg (jouw keuze)
|
||||
const res = await api(`/files/save?path=${encodeURIComponent(apiPath)}`, 'POST', { content: "" });
|
||||
showModal('Bestand aangemaakt', JSON.stringify(res, null, 2));
|
||||
|
||||
await filesRefresh();
|
||||
await filesOpen(uiFull);
|
||||
}
|
||||
|
||||
async function filesDeleteFolder(uiFolderPath) {
|
||||
const base = (uiFolderPath || '').trim().replace(/^\/+/, '');
|
||||
if (!base) {
|
||||
return showModal('Files', 'Root map verwijderen mag niet.');
|
||||
}
|
||||
if (!confirm(`Map verwijderen (alleen als leeg): ${base}?`)) return;
|
||||
|
||||
const apiPath = filesToApiPath(base);
|
||||
|
||||
try {
|
||||
const res = await api(`/files/rmdir?path=${encodeURIComponent(apiPath)}`, 'DELETE');
|
||||
showModal('Map verwijderd', JSON.stringify(res, null, 2));
|
||||
await filesRefresh();
|
||||
} catch (e) {
|
||||
showModal('Kan map niet verwijderen', e.message);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user