first commit

This commit is contained in:
2025-11-11 10:36:31 +01:00
commit 80eb037b56
25 changed files with 4509 additions and 0 deletions

458
public/app.js Normal file
View File

@@ -0,0 +1,458 @@
// 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();
});

62
public/index.html Normal file
View File

@@ -0,0 +1,62 @@
<!doctype html>
<html data-theme="dark">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Docker Stack Stats</title>
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<div class="container">
<header>
<h1 id="title">Docker Stack Stats</h1>
<div class="controls">
<label class="toggle">
<input type="checkbox" id="toggleGroup" checked />
<span class="slider"></span>
<span class="label" data-i18n="groupByStack">Group by stack</span>
</label>
<input type="text" id="search" placeholder="Search (any column)">
<label><input type="checkbox" id="autorefresh" checked /> <span data-i18n="autoRefresh">Auto-refresh</span></label>
<input type="number" id="interval" min="2" value="5" />s
<button id="refresh" data-i18n="refresh">Refresh</button>
<button id="theme">Light/Dark</button>
<select id="lang">
<option value="en">English</option>
<option value="de">Deutsch</option>
</select>
</div>
</header>
<div id="error" class="error hidden"></div>
<section id="sys" class="sys card"></section>
<!-- GROUPED HEADER -->
<section id="header-grouped">
<div class="row header-row grid-stacks">
<div class="col-name"><button class="sort" data-sort="group"><span data-i18n="stackProject">Stack / Project</span></button><br><input id="f-stack" class="filter" placeholder="filter"></div>
<div class="col-num"><button class="sort" data-sort="count"><span data-i18n="containers">Containers</span></button><br><input id="f-count" class="filter" placeholder="≥, ≤, = or text"></div>
<div class="col-num"><button class="sort" data-sort="cpuSum"><span data-i18n="cpuSum">CPU Sum</span></button><br><input id="f-cpu" class="filter" placeholder="≥, ≤, ="></div>
<div class="col-num"><button class="sort" data-sort="memSumMiB"><span data-i18n="memSum">Mem Sum</span></button><br><input id="f-mem" class="filter" placeholder="≥, ≤, = (MiB)"></div>
<div class="col-num"><button class="sort" data-sort="memPctSys"><span data-i18n="memPct">Mem %</span></button><br><input id="f-memPct" class="filter" placeholder="≥, ≤, ="></div>
</div>
</section>
<!-- FLAT HEADER -->
<section id="header-flat" class="hidden">
<div class="row header-row grid-flat">
<div class="col-name"><button class="sort" data-sort="name"><span data-i18n="container">Container</span></button><br><input id="f-container" class="filter" placeholder="filter"></div>
<div class="col-name"><button class="sort" data-sort="group"><span data-i18n="stackProject">Stack / Project</span></button><br><input id="f-stack-flat" class="filter" placeholder="filter"></div>
<div class="col-num"><button class="sort" data-sort="cpu"><span data-i18n="cpu">CPU %</span></button><br><input id="f-cpu-flat" class="filter" placeholder="≥, ≤, ="></div>
<div class="col-num"><button class="sort" data-sort="memMiB"><span data-i18n="memMiB">Mem (MiB)</span></button><br><input id="f-mem-flat" class="filter" placeholder="≥, ≤, = (MiB)"></div>
<div class="col-num"><button class="sort" data-sort="memPctSys"><span data-i18n="memPctSys">Mem %</span></button><br><input id="f-memPct-flat" class="filter" placeholder="≥, ≤, ="></div>
</div>
</section>
<section id="stacks" class="stacks"></section>
<section id="containers" class="containers hidden"></section>
</div>
<script src="/app.js"></script>
</body>
</html>

66
public/styles.css Normal file
View File

@@ -0,0 +1,66 @@
:root {
--bg: #0b1020;
--card: #111833;
--text: #ebefff;
--muted: #a7b0d0;
--accent: #8cb0ff;
--border: #243056;
--grid-stacks: 1.4fr 90px 110px 120px 100px;
--grid-containers: 1.2fr 1fr 110px 120px 100px;
--pad-card: 6px;
--pad-row: 4px;
--gap-row: 8px;
--radius: 12px;
--fs: 14px;
}
[data-theme="light"] {
--bg: #f7f8ff;
--card: #ffffff;
--text: #07102a;
--muted: #5a6a8a;
--accent: #345dff;
--border: #dfe6fb;
}
* { box-sizing: border-box; }
body { margin: 0; font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Arial; background: var(--bg); color: var(--text); font-size: var(--fs); }
.container { max-width: 1100px; margin: 18px auto; padding: 0 12px; }
header { display: flex; flex-wrap: wrap; gap: 6px; justify-content: space-between; align-items: center; margin-bottom: 10px; }
h1 { font-size: 18px; margin: 0; }
.controls { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
input[type="number"], input[type="text"], select { padding: 4px 6px; border-radius: 8px; border: 1px solid var(--border); background: var(--card); color: var(--text); height: 28px; }
input[type="number"] { width: 70px; }
input[type="text"] { width: 200px; }
button { background: var(--accent); color: #fff; border: none; padding: 6px 10px; border-radius: 10px; cursor: pointer; font-weight: 600; height: 28px; }
button:hover { filter: brightness(1.05); }
.toggle { display: inline-flex; align-items: center; gap: 6px; }
.toggle input { display: none; }
.toggle .slider { position: relative; width: 42px; height: 22px; background: var(--border); border-radius: 999px; cursor: pointer; }
.toggle .slider::after { content: ''; position: absolute; top: 3px; left: 3px; width: 16px; height: 16px; background: #fff; border-radius: 50%; transition: transform .15s ease-in-out; }
.toggle input:checked + .slider::after { transform: translateX(20px); }
.toggle .label { user-select: none; }
.error { background: #c62828; color: #fff; padding: 6px 10px; border-radius: 10px; margin-bottom: 8px; }
.card { background: var(--card); border: 1px solid var(--border); border-radius: var(--radius); padding: var(--pad-card); margin-bottom: 8px; }
.row { display: grid; gap: var(--gap-row); align-items: center; padding: var(--pad-row) 4px; }
.grid-stacks { grid-template-columns: var(--grid-stacks); }
.grid-containers, .grid-flat { grid-template-columns: var(--grid-containers); }
.header-row { font-weight: 700; margin: 6px 0; padding: 6px; opacity: .95; background: color-mix(in oklab, var(--card) 88%, var(--text) 12%); border-radius: var(--radius); }
.header-row input { width: 100%; margin-top: 4px; height: 26px; }
.header-row .sort { background: transparent; color: var(--text); border: none; padding: 0; cursor: pointer; font-weight: 700; }
.stack-name, .container-name, .col-name { text-align: left; }
.col-num { text-align: right; font-variant-numeric: tabular-nums; }
.clickable { cursor: pointer; }
.details { margin-top: 4px; padding-top: 2px; }
.hidden { display: none; }
.hint { color: var(--muted); font-style: italic; }
.sys-row { grid-template-columns: 1fr auto auto auto; }