459 lines
18 KiB
JavaScript
459 lines
18 KiB
JavaScript
|
||
// 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();
|
||
});
|