From 74cbcfe15f3a8625dbbfc88bc1abed829ec97933 Mon Sep 17 00:00:00 2001 From: Daniel Redetzke Date: Mon, 5 Oct 2026 09:09:44 +0300 Subject: [PATCH] Health card: public addresses up top, checks as tiles The public IPv4 and IPv6 addresses, with their uplink, lead the card. Every other check is a tile with a plain-word status, the raw setting and, when it fails, what is wrong. The header counts passing or failing checks. --- app.css | 26 ++++++++++++++++++---- app.js | 69 ++++++++++++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 86 insertions(+), 9 deletions(-) diff --git a/app.css b/app.css index 77b0018..b914951 100644 --- a/app.css +++ b/app.css @@ -173,10 +173,28 @@ fieldset { border: 0; margin: 0; padding: 0; min-width: 0; display: flex; flex-d .kv dt { color: var(--ink-2); } .kv dd { margin: 0; min-width: 0; overflow-wrap: anywhere; } -/* checks */ -.chk { display: flex; gap: 10px; align-items: center; padding: 10px 0; border-bottom: 1px solid var(--line-2); font-size: 13px; } -.chk:last-child { border-bottom: 0; } -.chk b { font-weight: 500; min-width: 160px; } +/* health: public addresses, then one tile per check */ +.hchead { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; } +.hchead > span { font-size: 13px; color: var(--ink-2); } +.hchead > span.bad { color: var(--bad-ink); font-weight: 500; } +.hcaddrs { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 12px; margin-top: 16px; } +.hcaddr { background: var(--ground); border-radius: 10px; padding: 14px 16px; min-width: 0; } +.hcaddr .l { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--ink-2); } +.hcaddr .v { margin-top: 4px; font-size: 15px; font-weight: 500; overflow-wrap: anywhere; } +.hcaddr .v.mono { font-size: 20px; } +.hcaddr .n { font-family: var(--sans); font-size: 12px; font-weight: 400; color: var(--ink-2); } +.hcaddr.bad { background: #fdf6f5; box-shadow: inset 0 0 0 1px #e6b3b0; } +.hcaddr.bad .v { color: var(--bad-ink); } +.hctiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 12px; margin-top: 12px; } +.hctile { border: 1px solid var(--line); border-radius: 10px; padding: 12px 14px; display: flex; flex-direction: column; gap: 4px; min-width: 0; } +.hctile .l { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--ink-2); } +.hctile .l > span:first-child { flex: 1; min-width: 0; } +.hctile .s { font-size: 15px; font-weight: 500; } +.hctile .r { font-size: 11.5px; color: var(--ink-3); overflow-wrap: anywhere; } +.hctile .p { font-size: 12.5px; color: var(--bad-ink); overflow-wrap: anywhere; } +.hctile.bad { border-color: #e6b3b0; background: #fdf6f5; } +.hctile.bad .s { color: var(--bad-ink); } +@media (max-width: 640px) { .hctiles { grid-template-columns: repeat(2, minmax(0, 1fr)); } } /* activity */ .ev { display: flex; gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--line-2); font-size: 13px; } diff --git a/app.js b/app.js index 8b79c25..ebfcde5 100644 --- a/app.js +++ b/app.js @@ -1510,6 +1510,69 @@ const DNS_PRESETS = [['Quad9', '9.9.9.9, 149.112.112.112']]; + // healthParts turns the server's checks into the Health card: the public + // address per IP family (from its uplink and public address checks), then + // one tile per other check with a plain-word status, the raw setting and, + // when it fails, what is wrong. + function healthParts(checks) { + const by = Object.fromEntries(checks.map((c) => [c.name, c])); + const addrs = []; + for (const fam of ['IPv4', 'IPv6']) { + const up = by[fam + ' uplink'], pub = by['Public ' + fam]; + if (!up) continue; + const m = pub && pub.ok ? /^(\S+) \((.+)\)$/.exec(pub.detail) : null; + addrs.push({ + label: 'Public ' + fam + (up.ok ? ' ยท ' + up.detail : ''), + ok: up.ok && (!pub || pub.ok), + value: m ? m[1] : (pub ? pub.detail : up.detail), + note: m ? m[2] : null, + mono: !!(pub && pub.ok), + }); + } + const sysctl = (d) => d.replace(/^net\.ipv[46]\.(conf\.)?/, ''); + const tiles = checks.filter((c) => !/^(IPv[46] uplink|Public IPv[46])$/.test(c.name)).map((c) => { + const t = { label: c.name, ok: c.ok, status: c.ok ? 'OK' : 'Problem', raw: null, problem: c.ok ? null : c.detail }; + switch (c.name) { + case 'WireGuard interface': t.status = c.detail; t.problem = null; break; + case 'IPv4 forwarding': case 'IPv6 forwarding': t.status = c.ok ? 'On' : 'Off'; t.raw = sysctl(c.detail); t.problem = null; break; + case 'IPv6 router announcements': { + const [setting, ...why] = c.detail.split(': '); + t.label = 'Router announcements'; + t.status = !c.ok ? 'Ignored' : setting.endsWith('=0') ? 'Not used' : 'Accepted'; + t.raw = sysctl(setting); + t.problem = c.ok || !why.length ? null : why.join(': '); + break; + } + case 'nftables rules': + t.status = c.ok ? 'Present' : 'Missing'; + if (/^table /.test(c.detail)) { t.raw = c.detail.replace(/ (present|missing)$/, ''); t.problem = null; } + break; + case 'Last apply': + if (c.ok) { const iso = c.detail.replace(/^applied /, ''); t.status = ago(iso); t.title = fmtStamp(iso); } else t.status = 'Failed'; + break; + case 'Latency check': t.status = c.ok ? 'Tunnel ping works' : 'Failing'; break; + } + return t; + }); + return { addrs, tiles, failing: checks.filter((c) => !c.ok).length, total: checks.length }; + } + + function healthCard(checks) { + const hp = healthParts(checks); + const dot = (ok) => [h('span', { class: ok ? 'dot ok' : 'dot bad' }), h('span', { class: 'sr' }, ok ? 'OK: ' : 'Problem: ')]; + return h('section', { class: 'card', 'aria-labelledby': 'hc' }, + h('div', { class: 'hchead' }, h('h2', { id: 'hc' }, 'Health'), + h('span', { class: hp.failing ? 'bad' : null }, hp.failing ? hp.failing + ' of ' + hp.total + ' checks failing' : 'All ' + hp.total + ' checks pass')), + hp.addrs.length ? h('div', { class: 'hcaddrs' }, hp.addrs.map((a) => h('div', { class: a.ok ? 'hcaddr' : 'hcaddr bad' }, + h('div', { class: 'l' }, dot(a.ok), a.label), + h('div', { class: a.mono ? 'v mono' : 'v' }, a.value, a.note ? h('span', { class: 'n' }, ' ' + a.note) : null)))) : null, + h('div', { class: 'hctiles' }, hp.tiles.map((t) => h('div', { class: t.ok ? 'hctile' : 'hctile bad', title: t.title || null }, + h('div', { class: 'l' }, h('span', null, t.label), dot(t.ok)), + h('div', { class: 's' }, t.status), + t.raw ? h('div', { class: 'r mono' }, t.raw) : null, + t.problem ? h('div', { class: 'p' }, t.problem) : null)))); + } + async function viewServer(wrap) { const [srv, st] = await Promise.all([api('GET', '/server'), api('GET', '/status')]); const orig = JSON.parse(JSON.stringify(srv)); @@ -1580,11 +1643,7 @@ fill(wrap, h('div', null, h('h1', null, 'Server'), h('p', { class: 'sub' }, 'WireGuard interface, address plan, client defaults and firewall')), result, - h('section', { class: 'card', 'aria-labelledby': 'hc' }, - h('h2', { id: 'hc' }, 'Health'), - h('div', { style: { marginTop: '8px' } }, st.checks.map((c) => h('div', { class: 'chk' }, - h('span', { class: c.ok ? 'dot ok' : 'dot bad' }), h('span', { class: 'sr' }, c.ok ? 'OK: ' : 'Problem: '), - h('b', null, c.name), h('span', { class: c.ok ? 'muted' : null }, c.detail))))), + healthCard(st.checks), h('section', { class: 'card', 'aria-labelledby': 'if' }, h('h2', { id: 'if' }, 'Interface'),