Files
2025-11-11 10:36:31 +01:00

459 lines
18 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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: "SystemRAM",
runningContainers: "Laufend",
cpuCores: "CPUKerne",
totalCpu: "GesamtCPU",
totalMem: "ContainerRAM gesamt",
totalMemPct: "ContainerRAM %",
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();
});