// i18n const I18N = { en: { title: "Docker Stack Stats", autoRefresh: "Auto-refresh", refresh: "Refresh", stackProject: "Stack / Project", containers: "Containers", cpuSum: "CPU Sum", memSum: "Mem Sum", memPct: "Mem %", container: "Container", cpu: "CPU %", memMiB: "Mem (MiB)", memPctSys: "Mem %", loading: "Loading…", system: "System", serverVersion: "Docker", systemRam: "System RAM", runningContainers: "Running", cpuCores: "CPU Cores", totalCpu: "Total CPU", totalMem: "Total Mem (containers)", totalMemPct: "Total Mem %", groupByStack: "Group by stack", healthError: "Error loading data: " }, de: { title: "Docker-Stack-Statistiken", autoRefresh: "Auto-Aktualisierung", refresh: "Aktualisieren", stackProject: "Stack / Projekt", containers: "Container", cpuSum: "CPU Summe", memSum: "RAM Summe", memPct: "RAM %", container: "Container", cpu: "CPU %", memMiB: "RAM (MiB)", memPctSys: "RAM %", loading: "Lade…", system: "System", serverVersion: "Docker", systemRam: "System‑RAM", runningContainers: "Laufend", cpuCores: "CPU‑Kerne", totalCpu: "Gesamt‑CPU", totalMem: "Container‑RAM gesamt", totalMemPct: "Container‑RAM %", groupByStack: "Nach Stack gruppieren", healthError: "Fehler beim Laden der Daten: " } }; let lang = localStorage.getItem('lang') || (navigator.language || 'en').slice(0,2); if (!I18N[lang]) lang = 'en'; function t(key) { return (I18N[lang] && I18N[lang][key]) || I18N['en'][key] || key; } function updateSortIndicators() { // Remove old indicators document.querySelectorAll('.header-row .sort .sort-ind').forEach(s => s.remove()); // Add new indicator to active key in BOTH headers (only one visible, but safe) const addInd = (rootSel) => { document.querySelectorAll(rootSel + ' .sort').forEach(btn => { if (btn.dataset.sort === sort.key) { const ind = document.createElement('span'); ind.className = 'sort-ind'; ind.textContent = sort.dir === 'asc' ? ' ▲' : ' ▼'; btn.appendChild(ind); } }); }; addInd('#header-grouped'); addInd('#header-flat'); } function applyI18N() { document.getElementById('title').textContent = t('title'); document.querySelectorAll('[data-i18n]').forEach(el => { el.textContent = t(el.getAttribute('data-i18n')); }); const placeholders = { 'search': lang === 'de' ? 'Suche (alle Spalten)' : 'Search (any column)', 'f-stack': lang === 'de' ? 'filtern' : 'filter', 'f-count': lang === 'de' ? '≥, ≤, = oder Text' : '≥, ≤, = or text', 'f-cpu': lang === 'de' ? '≥, ≤, =' : '≥, ≤, =', 'f-mem': lang === 'de' ? '≥, ≤, = (MiB)' : '≥, ≤, = (MiB)', 'f-memPct': lang === 'de' ? '≥, ≤, =' : '≥, ≤, =', 'f-container': lang === 'de' ? 'filtern' : 'filter', 'f-stack-flat': lang === 'de' ? 'filtern' : 'filter', 'f-cpu-flat': lang === 'de' ? '≥, ≤, =' : '≥, ≤, =', 'f-mem-flat': lang === 'de' ? '≥, ≤, = (MiB)' : '≥, ≤, = (MiB)', 'f-memPct-flat': lang === 'de' ? '≥, ≤, =' : '≥, ≤, =' }; for (const [id, ph] of Object.entries(placeholders)) { const el = document.getElementById(id); if (el) el.setAttribute('placeholder', ph); } const tg = document.querySelector('#toggleGroup .label'); if (tg) tg.textContent = t('groupByStack'); // Re-apply indicator after texts may have changed updateSortIndicators(); } // number formatting function nf(opts={}) { return new Intl.NumberFormat(lang, opts); } function fmtPct(v) { return nf({minimumFractionDigits: 2, maximumFractionDigits: 2}).format(v) + ' %'; } function fmtMiB(v) { return nf({minimumFractionDigits: 2, maximumFractionDigits: 2}).format(v) + ' MiB'; } async function fetchJSON(url) { const r = await fetch(url); if (!r.ok) throw new Error(await r.text()); return r.json(); } function el(tag, attrs = {}, ...children) { const e = document.createElement(tag); for (const [k, v] of Object.entries(attrs)) { if (k === 'class') e.className = v; else if (k.startsWith('on') && typeof v === 'function') e.addEventListener(k.slice(2), v); else e.setAttribute(k, v); } for (const c of children) if (c != null) e.appendChild(typeof c === 'string' ? document.createTextNode(c) : c); return e; } function parseNumericFilter(input) { const s = input.trim(); if (!s) return null; const m = /^(>=|<=|=|>|<)?\s*([0-9]+(?:\.[0-9]+)?)$/.exec(s); if (!m) return (x) => String(x).toLowerCase().includes(s.toLowerCase()); const op = m[1] || '='; const val = Number(m[2]); return (x) => { const n = Number(x); if (op === '>=') return n >= val; if (op === '<=') return n <= val; if (op === '>') return n > val; if (op === '<') return n < val; return n === val; }; } let timer = null; let current = { groups: [], total: { cpuSum: 0, memSumMiB: 0, memPctSys: 0 }, sys: {}, containers: [] }; let sort = { key: 'group', dir: 'asc' }; const openGroups = new Set(JSON.parse(localStorage.getItem('openGroups') || '[]')); const rowMap = new Map(); // grouped rows const contRowMap = new Map(); // flat rows let groupByStack = (localStorage.getItem('groupByStack') ?? '1') === '1'; function applyGroupFilters(groups) { const q = document.getElementById('search').value.trim().toLowerCase(); const fStack = document.getElementById('f-stack').value.trim().toLowerCase(); const countF = parseNumericFilter(document.getElementById('f-count').value); const cpuF = parseNumericFilter(document.getElementById('f-cpu').value); const memF = parseNumericFilter(document.getElementById('f-mem').value); const memPctF= parseNumericFilter(document.getElementById('f-memPct').value); return groups.filter(g => { const blob = `${g.group} ${g.count} ${g.cpuSum} ${g.memSumMiB} ${g.memPctSys}`.toLowerCase(); if (q && !blob.includes(q)) return false; if (fStack && !g.group.toLowerCase().includes(fStack)) return false; if (countF && !countF(g.count)) return false; if (cpuF && !cpuF(g.cpuSum)) return false; if (memF && !memF(g.memSumMiB)) return false; if (memPctF && !memPctF(g.memPctSys)) return false; return true; }); } function applyContainerFilters(conts) { const q = document.getElementById('search').value.trim().toLowerCase(); const fName = document.getElementById('f-container').value.trim().toLowerCase(); const fStack = document.getElementById('f-stack-flat').value.trim().toLowerCase(); const cpuF = parseNumericFilter(document.getElementById('f-cpu-flat').value); const memF = parseNumericFilter(document.getElementById('f-mem-flat').value); const memPctF= parseNumericFilter(document.getElementById('f-memPct-flat').value); return conts.filter(c => { const blob = `${c.name} ${c.group} ${c.cpu} ${c.memMiB} ${c.memPctSys}`.toLowerCase(); if (q && !blob.includes(q)) return false; if (fName && !c.name.toLowerCase().includes(fName)) return false; if (fStack && !c.group.toLowerCase().includes(fStack)) return false; if (cpuF && !cpuF(c.cpu)) return false; if (memF && !memF(c.memMiB)) return false; if (memPctF && !memPctF(c.memPctSys)) return false; return true; }); } function applySort(list) { const arr = list.slice(); const k = sort.key, dir = sort.dir; arr.sort((a, b) => { let av = a[k], bv = b[k]; if (typeof av === 'string') { av = av.toLowerCase(); bv = bv.toLowerCase(); } if (av < bv) return dir === 'asc' ? -1 : 1; if (av > bv) return dir === 'asc' ? 1 : -1; return 0; }); return arr; } function setSort(key) { if (sort.key === key) sort.dir = (sort.dir === 'asc' ? 'desc' : 'asc'); else { sort.key = key; sort.dir = 'asc'; } updateSortIndicators(); if (groupByStack) renderGrouped(); else renderFlat(); } function renderHeaderClicks() { document.querySelectorAll('.header-row .sort').forEach(btn => { btn.addEventListener('click', () => setSort(btn.dataset.sort)); }); updateSortIndicators(); } async function refreshData() { const err = document.getElementById('error'); err.classList.add('hidden'); try { const summary = await fetchJSON('/api/summary'); current.total = summary.total; current.groups = summary.groups; current.sys = summary.sys || {}; const cont = await fetchJSON('/api/containers'); current.containers = cont.containers || []; renderSystemAndTotals(); if (groupByStack) renderGrouped(); else renderFlat(); if (groupByStack) { for (const g of openGroups) await refreshGroupDetail(g, true); } } catch (e) { err.textContent = t('healthError') + e.message; err.classList.remove('hidden'); } } function renderSystemAndTotals() { const s = current.sys || {}; const total = current.total || {}; const box = document.getElementById('sys'); box.classList.remove('hidden'); box.innerHTML = ''; const totalMemPct = (s.memTotalMiB > 0) ? (100 * (total.memSumMiB || 0) / s.memTotalMiB) : 0; box.appendChild(el('div', { class: 'row sys-row' }, el('div', {}, el('strong', {}, t('system'))), el('div', { class: 'col-num' }, `${t('serverVersion')}: ${s.serverVersion || '-'}`), el('div', { class: 'col-num' }, `${t('cpuCores')}: ${s.ncpu || '-'}`), el('div', { class: 'col-num' }, `${t('systemRam')}: ${fmtMiB(s.memTotalMiB || 0)}`) )); box.appendChild(el('div', { class: 'row sys-row' }, el('div', {}, el('strong', {}, t('totalCpu'))), el('div', { class: 'col-num' }, fmtPct(total.cpuSum || 0)), el('div', {}, el('strong', {}, t('totalMem'))), el('div', { class: 'col-num' }, `${fmtMiB(total.memSumMiB || 0)} · ${t('totalMemPct')}: ${fmtPct(totalMemPct)}`) )); } async function refreshGroupDetail(groupName, preserve=false) { try { const data = await fetchJSON(`/api/group/${encodeURIComponent(groupName)}`); let details = rowMap.get(groupName)?.details; if (!details) return; const frag = document.createDocumentFragment(); for (const c of data.containers) { frag.appendChild(el('div', { class: 'row container-row grid-containers' }, el('div', { class: 'container-name col-name' }, c.name), el('div', { class: 'col-num spacer' }, ''), el('div', { class: 'col-num' }, fmtPct(c.cpu)), el('div', { class: 'col-num' }, fmtMiB(c.memMiB)), el('div', { class: 'col-num' }, fmtPct(c.memPctSys)) )); } details.innerHTML = ''; details.appendChild(frag); details.dataset.loaded = '1'; } catch (e) { let details = rowMap.get(groupName)?.details; if (details) details.textContent = (t('healthError') + e.message); } } function ensureGroupRow(g) { const root = document.getElementById('stacks'); let entry = rowMap.get(g.group); if (entry) return entry; const row = el('div', { class: 'card stack' }); const header = el('div', { class: 'row grid-stacks clickable' }); const nameEl = el('div', { class: 'stack-name col-name', 'data-field': 'name' }, g.group); const countEl= el('div', { class: 'col-num', 'data-field': 'count' }, String(g.count)); const cpuEl = el('div', { class: 'col-num', 'data-field': 'cpu' }, fmtPct(g.cpuSum)); const memEl = el('div', { class: 'col-num', 'data-field': 'mem' }, fmtMiB(g.memSumMiB)); const pctEl = el('div', { class: 'col-num', 'data-field': 'pct' }, fmtPct(g.memPctSys)); header.appendChild(nameEl); header.appendChild(countEl); header.appendChild(cpuEl); header.appendChild(memEl); header.appendChild(pctEl); const details = el('div', { class: 'details hidden', 'data-group': g.group }, el('div', { class: 'hint' }, t('loading'))); header.addEventListener('click', async () => { const isOpen = !details.classList.contains('hidden'); if (isOpen) { details.classList.add('hidden'); openGroups.delete(g.group); } else { details.classList.remove('hidden'); openGroups.add(g.group); await refreshGroupDetail(g.group, true); } localStorage.setItem('openGroups', JSON.stringify([...openGroups])); }); row.appendChild(header); row.appendChild(details); root.appendChild(row); entry = { row, header, nameEl, countEl, cpuEl, memEl, pctEl, details }; rowMap.set(g.group, entry); return entry; } function renderGrouped() { document.getElementById('header-grouped').classList.remove('hidden'); document.getElementById('stacks').classList.remove('hidden'); document.getElementById('header-flat').classList.add('hidden'); document.getElementById('containers').classList.add('hidden'); const filtered = applyGroupFilters(current.groups); const sorted = applySort(filtered); const root = document.getElementById('stacks'); const existing = new Set(rowMap.keys()); for (const g of sorted) { const entry = ensureGroupRow(g); entry.nameEl.textContent = g.group; entry.countEl.textContent = String(g.count); entry.cpuEl.textContent = fmtPct(g.cpuSum); entry.memEl.textContent = fmtMiB(g.memSumMiB); entry.pctEl.textContent = fmtPct(g.memPctSys); existing.delete(g.group); root.appendChild(entry.row); } for (const gone of existing) { const entry = rowMap.get(gone); if (entry) entry.row.remove(); rowMap.delete(gone); openGroups.delete(gone); } localStorage.setItem('openGroups', JSON.stringify([...openGroups])); updateSortIndicators(); } function ensureContainerRow(key) { const root = document.getElementById('containers'); let entry = contRowMap.get(key); if (entry) return entry; const row = el('div', { class: 'card cont' }); const header = el('div', { class: 'row grid-flat' }); const nameEl = el('div', { class: 'col-name', 'data-field': 'name' }); const groupEl= el('div', { class: 'col-name', 'data-field': 'group' }); const cpuEl = el('div', { class: 'col-num', 'data-field': 'cpu' }); const memEl = el('div', { class: 'col-num', 'data-field': 'mem' }); const pctEl = el('div', { class: 'col-num', 'data-field': 'pct' }); header.appendChild(nameEl); header.appendChild(groupEl); header.appendChild(cpuEl); header.appendChild(memEl); header.appendChild(pctEl); row.appendChild(header); root.appendChild(row); entry = { row, nameEl, groupEl, cpuEl, memEl, pctEl }; contRowMap.set(key, entry); return entry; } function renderFlat() { document.getElementById('header-grouped').classList.add('hidden'); document.getElementById('stacks').classList.add('hidden'); document.getElementById('header-flat').classList.remove('hidden'); document.getElementById('containers').classList.remove('hidden'); const filtered = applyContainerFilters(current.containers); const sorted = applySort(filtered); const root = document.getElementById('containers'); const existing = new Set(contRowMap.keys()); for (const c of sorted) { const key = c.id; const entry = ensureContainerRow(key); entry.nameEl.textContent = c.name; entry.groupEl.textContent = c.group; entry.cpuEl.textContent = fmtPct(c.cpu); entry.memEl.textContent = fmtMiB(c.memMiB); entry.pctEl.textContent = fmtPct(c.memPctSys); existing.delete(key); root.appendChild(entry.row); } for (const gone of existing) { const entry = contRowMap.get(gone); if (entry) entry.row.remove(); contRowMap.delete(gone); } updateSortIndicators(); } function setupAutoRefresh() { const chk = document.getElementById('autorefresh'); const intervalInput = document.getElementById('interval'); function schedule() { if (timer) { clearInterval(timer); timer = null; } if (chk.checked) { const sec = Math.max(2, Number(intervalInput.value) || 5); timer = setInterval(refreshData, sec * 1000); } } chk.addEventListener('change', () => { localStorage.setItem('autorefresh', chk.checked ? '1' : '0'); schedule(); }); intervalInput.addEventListener('change', () => { localStorage.setItem('interval', intervalInput.value); schedule(); }); const ar = localStorage.getItem('autorefresh'); const iv = localStorage.getItem('interval'); if (ar !== null) chk.checked = ar === '1'; if (iv !== null) intervalInput.value = iv; schedule(); } function setupTheme() { const btn = document.getElementById('theme'); const root = document.documentElement; const saved = localStorage.getItem('theme') || 'dark'; root.setAttribute('data-theme', saved); btn.addEventListener('click', () => { const cur = root.getAttribute('data-theme') || 'dark'; const next = cur === 'dark' ? 'light' : 'dark'; root.setAttribute('data-theme', next); localStorage.setItem('theme', next); }); } function setupFilters() { const ids = ['search','f-stack','f-count','f-cpu','f-mem','f-memPct','f-container','f-stack-flat','f-cpu-flat','f-mem-flat','f-memPct-flat','lang']; for (const id of ids) { const elmt = document.getElementById(id); if (!elmt) continue; if (id === 'lang') { elmt.value = lang; elmt.addEventListener('change', () => { lang = elmt.value; localStorage.setItem('lang', lang); applyI18N(); renderSystemAndTotals(); if (groupByStack) renderGrouped(); else renderFlat(); }); } else { elmt.addEventListener('input', () => { if (groupByStack) renderGrouped(); else renderFlat(); }); } } renderHeaderClicks(); } function setupGroupingToggle() { const tgl = document.getElementById('toggleGroup'); tgl.checked = groupByStack; tgl.addEventListener('change', () => { groupByStack = tgl.checked; localStorage.setItem('groupByStack', groupByStack ? '1' : '0'); if (groupByStack) { renderGrouped(); for (const g of openGroups) refreshGroupDetail(g, true); } else { renderFlat(); } updateSortIndicators(); }); } window.addEventListener('DOMContentLoaded', () => { document.getElementById('refresh').addEventListener('click', refreshData); setupGroupingToggle(); setupAutoRefresh(); setupTheme(); setupFilters(); applyI18N(); updateSortIndicators(); refreshData(); });