Files
HarmenandClaude Fable 5.1 973b24a23f Gate 0.1.0: eigen image, het command-blok is een script, de app-map is leeg
Plan Eigenimage, fase 1 tot en met 3. De vier templates verhuizen naar
tools/electrum-gate/ zonder extensie; daarnaast Dockerfile (nginx:1.30-alpine
plus python3), entrypoint.sh (het command-blok van de compose, zonder $$) en
build.sh naar het voorbeeld van Evolu Relay. Een image voor beide containers,
gebouwd op de Umbrel; open punt 2 en 3 daarmee beslist.

Inhoudelijk anders dan alleen verplaatst: het log_format staat in stream.conf
zelf, het backend-adres komt via twee plaatshouders zonder dollarteken uit de
omgeving (ook in de server-service), en de pagina haalt versie en adres uit
status.json via GATE_APP_VERSION.

Tests mee verhuisd en uitgebreid: entrypoint.sh en Dockerfile in plaats van het
command-blok, en de tag in de compose gelijk aan VERSION in build.sh voor elke
eigen image. Mutatie-getest met drie ingrepen.

Nog niet gebouwd: er is hier geen Docker. De tag staat ongepind tot de eerste
push; dat is fase 4 en die is van de gebruiker.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 20:20:42 +02:00

2065 lines
101 KiB
HTML
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.
<!DOCTYPE html>
<!--
Statuspagina van Electrum Gate.
Dit bestand zit in de image (tools/electrum-gate/Dockerfile) als
/usr/share/nginx/html/index.html. Tot 0.0.29 was het een *.template in de
app-map die umbreld bij elke start door envsubst haalde, en daarom staat er in
het script hieronder nergens een template-literal maar overal string-optelling.
Die beperking is weg, maar de stijl is gebleven: één pagina in twee stijlen is
erger dan één stijl die niet meer nodig is.
Wat de pagina weet, komt uit status.json, dat de agent wegschrijft. Ook de
versie en het backend-adres, die umbreld vroeger rechtstreeks invulde. Weet de
pagina iets niet, dan zegt hij dat. Hij verzint niets, want hij wordt juist
geraadpleegd op het moment dat er iets mis lijkt te zijn.
-->
<!-- data-glow schakelt de accentgloed achter de pagina. "off" is de stand sinds
0.0.29 (verzoek van de gebruiker, 04-09-2026); "on" zet hem terug, en dat is
het enige wat er voor nodig is: alles staat in de tokenlaag onder "Diepte" en
geen enkele regel hieronder kent de stand.
De gloed hangt aan de combinatie met data-depth="deep" en niet aan zichzelf.
Dat is een eis van de gebruiker en het is ook de enige zinnige lezing: de
gloed is de onderste van de vier dieptelagen, dus hij aanzetten terwijl de
pagina vlak staat zou een verloop achter dekkende vlakken leggen. -->
<html lang="en" data-theme="dark" data-accent="orange" data-depth="deep" data-glow="off">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Electrum Gate</title>
<link rel="icon" href="icon.png">
<style>
:root {
--radius-sm: 8px;
--radius-md: 14px;
--radius-lg: 20px;
--radius-xl: 28px;
--radius-full: 999px;
--font-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--font-mono: 'SF Mono', 'Fira Code', 'Cascadia Mono', Consolas, monospace;
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
}
[data-theme="dark"][data-accent="orange"] {
--bg: #080808; --bg-elevated: #181818; --bg-raised: #242424; --bg-overlay: #303030;
--border: rgba(255,255,255,0.18); --border-mid: rgba(255,255,255,0.28);
--text-primary: #f2f2f2; --text-sec: #a3a3a3; --text-ter: #6a6a6a;
--accent: #FF6B00; --accent-soft: rgba(255,107,0,0.16); --accent-mid: rgba(255,107,0,0.26);
--accent-glow: rgba(255,107,0,0.30); --accent-text: #FF8124;
--pos: #39FF8A; --pos-bg: rgba(57,255,138,0.16); --neg: #ff5a5a; --neg-bg: rgba(255,69,69,0.18);
--neutral-bg: rgba(255,255,255,0.08); --input-bg: #1c1c1c; --scrollbar: #2c2c2c;
}
[data-theme="light"][data-accent="orange"] {
--bg: #ffffff; --bg-elevated: #f7f7f7; --bg-raised: #f0f0f0; --bg-overlay: #e8e8e8;
--border: rgba(0,0,0,0.12); --border-mid: rgba(0,0,0,0.18);
--text-primary: #0a0a0a; --text-sec: #888888; --text-ter: #bbbbbb;
--accent: #E05500; --accent-soft: rgba(224,85,0,0.08); --accent-mid: rgba(224,85,0,0.16);
--accent-glow: rgba(224,85,0,0.22); --accent-text: #E05500;
--pos: #007a48; --pos-bg: rgba(0,149,90,0.08); --neg: #cc2222; --neg-bg: rgba(204,34,34,0.08);
--neutral-bg: rgba(0,0,0,0.04); --input-bg: #f5f5f5; --scrollbar: #ddd;
}
[data-theme="dark"][data-accent="blue"] {
--bg: #080808; --bg-elevated: #181818; --bg-raised: #242424; --bg-overlay: #303030;
--border: rgba(255,255,255,0.18); --border-mid: rgba(255,255,255,0.28);
--text-primary: #f2f2f2; --text-sec: #a3a3a3; --text-ter: #6a6a6a;
--accent: #3B82F6; --accent-soft: rgba(59,130,246,0.16); --accent-mid: rgba(59,130,246,0.26);
--accent-glow: rgba(59,130,246,0.30); --accent-text: #6AA1FF;
--pos: #39FF8A; --pos-bg: rgba(57,255,138,0.16); --neg: #ff5a5a; --neg-bg: rgba(255,69,69,0.18);
--neutral-bg: rgba(255,255,255,0.08); --input-bg: #1c1c1c; --scrollbar: #2c2c2c;
}
[data-theme="light"][data-accent="blue"] {
--bg: #ffffff; --bg-elevated: #f7f7f7; --bg-raised: #f0f0f0; --bg-overlay: #e8e8e8;
--border: rgba(0,0,0,0.12); --border-mid: rgba(0,0,0,0.18);
--text-primary: #0a0a0a; --text-sec: #888888; --text-ter: #bbbbbb;
--accent: #2563EB; --accent-soft: rgba(37,99,235,0.08); --accent-mid: rgba(37,99,235,0.16);
--accent-glow: rgba(37,99,235,0.22); --accent-text: #2563EB;
--pos: #007a48; --pos-bg: rgba(0,149,90,0.08); --neg: #cc2222; --neg-bg: rgba(204,34,34,0.08);
--neutral-bg: rgba(0,0,0,0.04); --input-bg: #f5f5f5; --scrollbar: #ddd;
}
[data-theme="dark"][data-accent="lila"] {
--bg: #080808; --bg-elevated: #181818; --bg-raised: #242424; --bg-overlay: #303030;
--border: rgba(255,255,255,0.18); --border-mid: rgba(255,255,255,0.28);
--text-primary: #f2f2f2; --text-sec: #a3a3a3; --text-ter: #6a6a6a;
--accent: #B388FF; --accent-soft: rgba(179,136,255,0.16); --accent-mid: rgba(179,136,255,0.26);
--accent-glow: rgba(179,136,255,0.30); --accent-text: #C7A6FF;
--pos: #39FF8A; --pos-bg: rgba(57,255,138,0.16); --neg: #ff5a5a; --neg-bg: rgba(255,69,69,0.18);
--neutral-bg: rgba(255,255,255,0.08); --input-bg: #1c1c1c; --scrollbar: #2c2c2c;
}
[data-theme="light"][data-accent="lila"] {
--bg: #ffffff; --bg-elevated: #f7f7f7; --bg-raised: #f0f0f0; --bg-overlay: #e8e8e8;
--border: rgba(0,0,0,0.12); --border-mid: rgba(0,0,0,0.18);
--text-primary: #0a0a0a; --text-sec: #888888; --text-ter: #bbbbbb;
--accent: #7C3AED; --accent-soft: rgba(124,58,237,0.08); --accent-mid: rgba(124,58,237,0.16);
--accent-glow: rgba(124,58,237,0.22); --accent-text: #7C3AED;
--pos: #007a48; --pos-bg: rgba(0,149,90,0.08); --neg: #cc2222; --neg-bg: rgba(204,34,34,0.08);
--neutral-bg: rgba(0,0,0,0.04); --input-bg: #f5f5f5; --scrollbar: #ddd;
}
[data-theme="dark"][data-accent="teal"] {
--bg: #080808; --bg-elevated: #181818; --bg-raised: #242424; --bg-overlay: #303030;
--border: rgba(255,255,255,0.18); --border-mid: rgba(255,255,255,0.28);
--text-primary: #f2f2f2; --text-sec: #a3a3a3; --text-ter: #6a6a6a;
--accent: #14B8A6; --accent-soft: rgba(20,184,166,0.16); --accent-mid: rgba(20,184,166,0.26);
--accent-glow: rgba(20,184,166,0.30); --accent-text: #2DD4BF;
--pos: #39FF8A; --pos-bg: rgba(57,255,138,0.16); --neg: #ff5a5a; --neg-bg: rgba(255,69,69,0.18);
--neutral-bg: rgba(255,255,255,0.08); --input-bg: #1c1c1c; --scrollbar: #2c2c2c;
}
[data-theme="light"][data-accent="teal"] {
--bg: #ffffff; --bg-elevated: #f7f7f7; --bg-raised: #f0f0f0; --bg-overlay: #e8e8e8;
--border: rgba(0,0,0,0.12); --border-mid: rgba(0,0,0,0.18);
--text-primary: #0a0a0a; --text-sec: #888888; --text-ter: #bbbbbb;
--accent: #0D9488; --accent-soft: rgba(13,148,136,0.08); --accent-mid: rgba(13,148,136,0.16);
--accent-glow: rgba(13,148,136,0.22); --accent-text: #0D9488;
--pos: #007a48; --pos-bg: rgba(0,149,90,0.08); --neg: #cc2222; --neg-bg: rgba(204,34,34,0.08);
--neutral-bg: rgba(0,0,0,0.04); --input-bg: #f5f5f5; --scrollbar: #ddd;
}
[data-theme="dark"][data-accent="magenta"] {
--bg: #080808; --bg-elevated: #181818; --bg-raised: #242424; --bg-overlay: #303030;
--border: rgba(255,255,255,0.18); --border-mid: rgba(255,255,255,0.28);
--text-primary: #f2f2f2; --text-sec: #a3a3a3; --text-ter: #6a6a6a;
--accent: #E24DAE; --accent-soft: rgba(226,77,174,0.16); --accent-mid: rgba(226,77,174,0.26);
--accent-glow: rgba(226,77,174,0.30); --accent-text: #F072C4;
--pos: #39FF8A; --pos-bg: rgba(57,255,138,0.16); --neg: #ff5a5a; --neg-bg: rgba(255,69,69,0.18);
--neutral-bg: rgba(255,255,255,0.08); --input-bg: #1c1c1c; --scrollbar: #2c2c2c;
}
[data-theme="light"][data-accent="magenta"] {
--bg: #ffffff; --bg-elevated: #f7f7f7; --bg-raised: #f0f0f0; --bg-overlay: #e8e8e8;
--border: rgba(0,0,0,0.12); --border-mid: rgba(0,0,0,0.18);
--text-primary: #0a0a0a; --text-sec: #888888; --text-ter: #bbbbbb;
--accent: #BE2A86; --accent-soft: rgba(190,42,134,0.08); --accent-mid: rgba(190,42,134,0.16);
--accent-glow: rgba(190,42,134,0.22); --accent-text: #BE2A86;
--pos: #007a48; --pos-bg: rgba(0,149,90,0.08); --neg: #cc2222; --neg-bg: rgba(204,34,34,0.08);
--neutral-bg: rgba(0,0,0,0.04); --input-bg: #f5f5f5; --scrollbar: #ddd;
}
/* -- Diepte ------------------------------------------------------------------
Overgenomen uit HomeGit/Docs/website-design-system.html (31-08-2026). In
AssetApp is dit een instelling; hier is er geen schuif, want dit is een
statuspagina met dark/light en verder geen knoppen. De stand staat dus in het
html-element en niet in een menu.
**De gloed staat sinds 0.0.29 uit** (verzoek van de gebruiker, 04-09-2026).
Aanzetten is `data-glow="on"` op het html-element en verder niets: het blok
verderop zet
dan de twee verlopen én de blur. Dat blok eist ook `data-depth="deep"`, want
een gloed achter dekkende vlakken is geen stand die iemand bedoelt.
Let op wat in deze app níet aan de diepte hangt: de doorschijnende kleur van de
vlakken en het randlicht staan onder [data-theme] en dus altijd aan. Dat is zo
sinds 0.0.25 en het blijft zo; in het designsysteem hangen die wél aan de
diepte, omdat daar een schuif zit die ze allemaal tegelijk omzet.
Dat die twee aan hetzelfde attribuut hangen is geen gemak maar de kern van hoe
dit werkt. Een backdrop-filter op een egale ondergrond levert per definitie
niets op, dus zonder gloed is de blur rekenwerk zonder resultaat: de gloed is
het eerste deel en het glas het tweede, in die volgorde.
Wat de gloed NIET meeneemt, en dus ook zonder hem gewoon staat: de rand, de
schaduw, het randlicht en de kleur van de vlakken. Dat zijn geen glaseffecten;
ze werken op een egale ondergrond even goed. */
:root {
--surface: var(--bg-elevated);
--surface-inset: var(--bg-raised);
--surface-blur: 0px;
--surface-sat: 100%;
--edge: none;
--ambient: none;
--shadow-1: 0 1px 2px rgba(0,0,0,0.06), 0 4px 16px rgba(0,0,0,0.05);
}
[data-theme="dark"] {
--surface: color-mix(in srgb, #1c1c1c 78%, transparent);
--surface-inset: color-mix(in srgb, #262626 76%, transparent);
--edge: linear-gradient(180deg, rgba(255,255,255,0.10), rgba(255,255,255,0) 42%);
--shadow-1: 0 1px 2px rgba(0,0,0,0.35), 0 10px 30px -18px rgba(0,0,0,0.9);
}
[data-theme="light"] {
--surface: color-mix(in srgb, #ffffff 72%, transparent);
--surface-inset: color-mix(in srgb, #f2f2f2 74%, transparent);
--edge: linear-gradient(180deg, rgba(255,255,255,0.85), rgba(255,255,255,0) 46%);
}
/* De gloed: twee verlopen, één uit de linkerbovenhoek en een tegenlichtje
rechtsboven, zodat het geheel niet als één vlek leest. De blur hoort erbij en
staat daarom in ditzelfde blok; zie de uitleg bovenaan.
De selector heeft twee attributen en dus een specificiteit die `:root` niet
haalt. Dat is de reden dat de media-query hieronder deze selector letterlijk
herhaalt: met alleen `:root` erin zou de wens "minder doorzichtigheid" de blur
niet meer uit krijgen, en dat is een stille regressie in precies de stand waar
iemand om vroeg. */
[data-depth="deep"][data-glow="on"] {
--surface-blur: 16px;
--surface-sat: 135%;
--ambient: radial-gradient(58rem 34rem at 8% -8%, var(--accent-glow), transparent 62%),
radial-gradient(46rem 30rem at 102% 4%, color-mix(in srgb, var(--accent) 14%, transparent), transparent 66%);
}
/* De twee toegankelijkheidswensen horen NIET in een gedeelde media-query, en dat
is een les die een ronde kostte: Windows heeft een schakelaar "Animatie-
effecten" die bij veel mensen uit staat, en die zet prefers-reduced-motion op
reduce. Samen in een query deed de hele stand dan niets. Ze zeggen ook echt
iets anders: de ene gaat over doorschijnende vlakken, de andere over
beweging. */
@media (prefers-reduced-transparency: reduce) {
/* Twee selectors, want de gloedstand hierboven heeft twee attributen en wint
anders van `:root`. Gelijke specificiteit plus later in het bestand is wat
hem hier laat winnen. */
:root, [data-depth="deep"][data-glow="on"] {
--surface: var(--bg-elevated); --surface-inset: var(--bg-raised);
--surface-blur: 0px; --surface-sat: 100%;
}
}
@media (prefers-reduced-motion: reduce) {
body::before, .card { transition: none; }
}
/* Hier stond een media-query die op een scherm smaller dan 900 pixels terugviel
op de vlakke stand (keuze gebruiker, 31-08-2026): de gloed is 58rem breed en
op een telefoon dus breder dan het scherm, waardoor het geen verloop meer was
maar een egale waas.
Weggehaald op 04-09-2026, toen de gloed er in het geheel uit ging: hij zette
de gloed uit (die nu al uit staat), de blur uit (die met de gloed meegaat) en
de vlakken op dekkend (het enige dat overbleef, en dat is zonder blur alleen
nog een iets andere grijstoon). Een breekpunt dat in beide standen hetzelfde
oplevert, houdt alleen de suggestie over dat er iets gebeurt. Zet je de gloed
met `data-glow="on"` terug, dan hoort dit blok er ook weer bij. */
/* De gloed ligt achter alles en vangt geen klikken. z-index 0 hier en 1 op .wrap:
zonder stapelcontext kan een fixed pseudo-element van body over de inhoud heen
komen te liggen. */
body::before {
content: ''; position: fixed; inset: 0; z-index: 0; pointer-events: none;
background: var(--ambient); transition: background 0.3s;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: var(--font-base);
background: var(--bg);
color: var(--text-primary);
-webkit-font-smoothing: antialiased;
padding: 2rem 1.75rem 3rem;
}
/* Breed, maar niet oneindig: op een ultrawide monitor zou een kader van 2500px
regels opleveren die niemand leest. 1760 is ruim genoeg voor drie kolommen
statuskaders naast een lijstkader. */
.wrap { position: relative; z-index: 1; max-width: 1760px; margin: 0 auto; }
/* ── Het raster ─────────────────────────────────────────────────────────────
Twaalf kolommen, en de kaders vullen ze in de volgorde waarin ze in de HTML
staan. Die volgorde is de bedoelde leesvolgorde: eerst waar je naar kijkt als
je iets nakomt, onderaan waar je naar kijkt als je iets instelt. Bij een smal
scherm wordt elk kader vol breed en blijft die volgorde dus staan; er is geen
enkele plek waar de opmaak de volgorde omgooit. */
.grid {
display: grid;
grid-template-columns: repeat(12, 1fr);
/* 1,5rem en niet 1rem, op verzoek van de gebruiker (20-08-2026). De kaders
hebben 1,8rem ruimte binnenin en een radius van 28 pixels; met 1rem ertussen
is de ruimte tússen twee kaders kleiner dan de ruimte erbinnen, en dan gaan ze
aan elkaar plakken. De maat hoort eerder groter dan de binnenruimte te zijn
dan kleiner. Zelfde waarde in .row1 hieronder, anders valt de rij widgets
uit de toon met de rij eronder. */
gap: 1.5rem;
/* stretch en niet start: twee kaders naast elkaar horen even hoog te zijn, en
het hoogste kader van de rij bepaalt die hoogte. De kaders die iets hebben dat
mag meegroeien, zeggen dat zelf met flex hieronder; de rest wordt gewoon even
hoog en houdt zijn inhoud bovenaan. */
align-items: stretch;
}
.grid > * { min-width: 0; }
.grid > .card { display: flex; flex-direction: column; }
/* Hier stonden ook .col-5 en .col-7, met een mediaregel die ze onder 1100px vol
breed maakte. Sinds de tweede indeling (27-08-2026) is er geen rij meer die in
tweeën valt: de bovenste rij heeft zijn eigen raster van vier, en het log en het
instelkader zijn allebei vol breed. Weggehaald in plaats van laten staan, want
ongebruikte klassen nodigen uit tot een indeling die er niet meer is. */
.col-full { grid-column: 1 / -1; }
.grid .card { margin-bottom: 0; }
/* ── Typografie ─────────────────────────────────────────────────────────── */
.t-h1 { font-size: 2rem; font-weight: 700; letter-spacing: -0.025em; line-height: 1.15; }
.t-h3 { font-size: 1.1rem; font-weight: 600; letter-spacing: -0.01em; line-height: 1.3; }
.t-body-sm { font-size: 0.82rem; line-height: 1.6; color: var(--text-sec); }
.t-caption { font-size: 0.72rem; line-height: 1.5; color: var(--text-sec); }
.t-label { font-size: 0.68rem; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-sec); }
.t-mono { font-family: var(--font-mono); font-size: 0.82rem; color: var(--accent); }
.t-muted { color: var(--text-sec); }
/* ── Kop ────────────────────────────────────────────────────────────────── */
.head { display: flex; align-items: flex-start; gap: 1rem; margin-bottom: 1.75rem; }
.head-text { flex: 1; min-width: 0; }
.head-text p { margin-top: 0.35rem; }
/* Het app-icoon, op verzoek van de gebruiker (20-08-2026) en groter dan het
schildje dat hier stond. Het bestand komt uit de app-map en wordt door de
compose in de webroot gemount; het schildje blijft als terugval staan voor het
geval die mount er niet is, want een gebroken-afbeeldingicoon in de kop is
erger dan een generiek merkje. */
.mark {
width: 64px; height: 64px; border-radius: var(--radius-md);
background: var(--accent-soft); color: var(--accent);
display: grid; place-items: center; flex-shrink: 0;
overflow: hidden;
}
.mark img { width: 100%; height: 100%; object-fit: cover; display: block; }
.mark svg { width: 32px; height: 32px; }
/* Het versienummer, klein achter de tagline. Staat er weer sinds de voetregel weg
is, want dit project verloor een keer een dag aan een wijziging die niet uitrolde
door een niet-verhoogde version, en dan is "welke versie zie ik nu" precies de
vraag die je stelt. */
.head-version {
margin-left: 0.35rem;
font-size: 0.72rem;
/* text-sec en niet text-ter: die laatste is in de lichte variant #bbbbbb op wit
en dat is op deze grootte niet te lezen. Dit is dezelfde kleur als de tagline
ernaast, dus het valt niet uit de toon. */
color: var(--text-sec);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
/* ── Kaarten ────────────────────────────────────────────────────────────── */
.card {
position: relative;
background: var(--surface);
/* --radius-lg en niet --radius-xl (31-08-2026): elk buitenste kader heeft
dezelfde hoek, want ze staan in beeld naast en om elkaar heen en twee radii
lezen dan als een fout. Het logkader leidt zijn hoek hiervan af. */
--radius: var(--radius-lg);
--padding: 1.8rem;
border-radius: var(--radius);
padding: var(--padding);
margin-bottom: 1rem;
box-shadow: var(--shadow-1);
backdrop-filter: blur(var(--surface-blur)) saturate(var(--surface-sat));
-webkit-backdrop-filter: blur(var(--surface-blur)) saturate(var(--surface-sat));
transition: background 0.25s, box-shadow 0.25s;
}
/* Het randlicht: een verloop van licht naar niets over de bovenste 40 procent.
Dit is het verschil tussen doorzichtig en glas; zonder deze laag ziet een
halftransparante kaart er alleen bleek uit. De inhoud moet erbovenop blijven,
anders ligt er een waas over de tekst in plaats van erachter. */
.card::before {
content: ''; position: absolute; inset: 0; border-radius: inherit;
background: var(--edge); pointer-events: none;
}
.card > * { position: relative; }
[data-theme="dark"] .card { border: 1px solid var(--border); }
/* Rand én schaduw in het lichte thema (27-08-2026), overgenomen uit AssetApp.
Hier stond alleen een schaduw, en een zachte schaduw op wit geeft wel diepte
maar geen begrenzing: waar het kader ophoudt bleef een kwestie van kijken. De
rand doet dat werk, en de schaduw is daarom strakker geworden (2 en 16 pixels
in plaats van 3 en 24) zodat het samen niet zwaar wordt. */
[data-theme="light"] .card {
border: 1px solid var(--border);
}
.card-head { display: flex; align-items: center; justify-content: space-between; gap: 1rem; margin-bottom: 1.1rem; }
/* ── De bovenste rij ────────────────────────────────────────────────────────
Vier gelijke widgets (27-08-2026, sinds 0.0.28 vier in plaats van vijf). Hier
stond een raster van drie tegels waarvan de reactietijd er twee nam, naast een
kader met lijstregels; dat vroeg twee kunstjes die nu weg zijn, namelijk
meerekken met de hoogte van de buur en een dubbele kolom voor de sparkline.
Een eigen raster en geen deling van de twaalf kolommen hierboven. Met vier zou
dat kunnen (drie kolommen elk), maar de rij houdt zijn eigen breekpunten: de
twaalf kolommen breken bij andere breedtes dan waar een widget te smal wordt.
Wat er bij het versmallen gebeurt, is expliciet en niet aan auto-fit
overgelaten. Vier deelt door twee, dus elke stap levert nu volle rijen op; met
vijf stond hier een derde stap om te bepalen wélke widget alleen kwam te staan.
Die is vervallen. */
.row1 {
grid-column: 1 / -1;
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1.5rem;
}
/* Onder deze breedte wordt een widget van een kwart te smal voor "Wrong
certificate". Twee plus twee, en dus geen losse widget onderaan. */
@media (max-width: 1100px) { .row1 { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 560px) { .row1 { grid-template-columns: 1fr; } }
/* De widgets zetten hun waarde onderaan, zodat de getallen op één lijn staan ook
als de ene titel over twee regels valt en de andere niet.
De min-height is er op verzoek van de gebruiker (27-08-2026): zonder die maat
bepaalt de hoogste widget de rij, en dat leverde vijf krappe blokjes op waarin
de titel vrijwel tegen de waarde aan stond. Met wat meer hoogte krijgt de
waarde de ruimte om echt onderin te staan, en dát is wat margin-top: auto
hierboven doet zodra er hoogte over is.
Van 10,5rem naar 12rem diezelfde dag, na een misgelezen bericht van mijn kant:
er stond "mag niet iets groter" en dat was een tikfout voor het omgekeerde. */
.row1 .card { display: flex; flex-direction: column; min-height: 12rem; }
.row1 .stat-body { margin-top: auto; }
/* De twee clientlijsten naast elkaar zodra er ruimte is; het zijn twee losse
lijsten en geen vervolg op elkaar, dus onder elkaar is verspilde breedte. */
/* min() eromheen, en dat is niet cosmetisch: een kale minmax(420px, 1fr) eist een
track van minstens 420 pixels, ook op een scherm dat smaller is. Dan loopt de
hele lijst met kopieerknoppen buiten beeld, en dat is precies wat de gebruiker
op 20-08-2026 op zijn telefoon zag. Met min(420px, 100%) mag de track krimpen
tot de schermbreedte. */
.clients { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(420px, 100%), 1fr)); gap: 1.2rem 2.4rem; }
/* Eén maat voor alle widgets (27-08-2026). Hier stonden er drie: 1,9rem voor een
getal, 1,5rem voor "unknown" en 1,3rem voor een woord. Op de rij naast elkaar
viel dat op, en terecht: "964 307" oogde groter dan "Fulcrum" ernaast, terwijl
het even belangrijk is.
1,55rem is de maat waarop een getal en een woord allebei werken. Groter en
"Wrong certificate" loopt op een kwart van de breedte over drie regels; kleiner
en "7 ms" verliest het gewicht dat een waarde het eerste maakt waar je naar
kijkt. */
.stat-val { font-size: 1.55rem; font-weight: 700; letter-spacing: -0.025em; font-variant-numeric: tabular-nums; line-height: 1.2; }
/* Alleen de kleur, geen eigen maat meer: onbekend is een andere toestand van
dezelfde waarde en hoort niet ook nog van formaat te verschillen. */
.stat-val.unknown { color: var(--text-ter); }
/* Twee widgets tonen een woord in plaats van een getal. Die hoeven geen eigen
maat, wel een afbreekpunt: afkappen met puntjes zou hier de betekenis
wegnemen. */
.stat-val.text { overflow-wrap: anywhere; }
/* Rood is voor het geval waar je iets aan moet doen. Bewust niet voor "off": geen
certificaat gekozen is de normale begintoestand van een verse installatie, en
daar staat de melding bovenaan al voor. */
/* De accentkleur en niet --pos (keuze gebruiker, 31-08-2026, gelijk aan Evolu
Relay). "Running" is de eigen toestand van de app en geen positief cijfer, dus
het accent hoort erbij; --pos betekent sinds het gedeelde palet "het staat in de
plus" en is groen. Zo blijven die twee betekenissen uit elkaar en volgt deze
tekst het accent mee als de app ooit een andere kleur krijgt. */
.stat-val.ok { color: var(--accent-text); }
.stat-val.bad { color: var(--neg); }
.stat-sub.mono { font-family: var(--font-mono); overflow-wrap: anywhere; }
/* Even hoog als de sparkline hieronder, en met dezelfde bovenmarge. Anders staat
het getal van "Backend response" hoger dan de andere twee, want daar hangt een
grafiekje onder in plaats van een regel tekst.
De tekst staat onderin dat vak en niet bovenin (27-08-2026). Dat was het
verschil dat de gebruiker zag: de sparkline is precies 34 pixels hoog en raakt
dus de onderrand, terwijl een regel tekst van 34 pixels ruimte bovenaan begon
en er leegte onder overliet. Nu liggen ze op dezelfde lijn.
Met grid en niet met flex: de tekst blijft dan een gewoon blok en breekt af
zoals hij hoort, ook de mono-variant met lange domeinnamen. */
.stat-sub {
font-size: 0.75rem; color: var(--text-sec);
margin-top: 0.5rem; min-height: 34px;
display: grid; align-items: end;
}
/* ── Badges ─────────────────────────────────────────────────────────────── */
/* Zonder stip, op verzoek van de gebruiker (20-08-2026). De kleur van de badge
zegt hetzelfde als die stip deed, en de tekst zegt het al met woorden. */
.badge {
display: inline-flex; align-items: center;
font-size: 0.7rem; font-weight: 600;
padding: 0.25rem 0.65rem; border-radius: var(--radius-full);
letter-spacing: 0.02em; white-space: nowrap;
border: 1px solid color-mix(in srgb, currentColor 30%, transparent);
}
.badge-pos { background: var(--pos-bg); color: var(--pos); }
.badge-neg { background: var(--neg-bg); color: var(--neg); }
.badge-neutral { background: var(--neutral-bg); color: var(--text-sec); }
/* ── Meldingen ──────────────────────────────────────────────────────────── */
.alert {
display: flex; gap: 0.8rem; align-items: flex-start;
padding: 1rem 1.2rem; border-radius: var(--radius-md);
font-size: 0.84rem; margin-bottom: 0.75rem;
}
.alert-title { font-weight: 600; margin-bottom: 0.15rem; }
.alert-body { color: var(--text-sec); font-size: 0.8rem; }
.alert-error { background: var(--neg-bg); color: var(--neg); }
.alert-warn { background: var(--accent-soft); color: var(--accent-text); }
.alert-neutral { background: var(--neutral-bg); color: var(--text-sec); border: 1px solid var(--border); }
.alert-icon { flex-shrink: 0; margin-top: 0.1rem; }
.alert-icon svg { width: 16px; height: 16px; display: block; }
/* ── Lijstregels ────────────────────────────────────────────────────────── */
.list-item {
display: flex; justify-content: space-between; align-items: center;
gap: 1rem; padding: 0.85rem 0; border-bottom: 1px solid var(--border);
}
.list-item:last-child { border-bottom: none; }
.list-item-title { font-size: 0.88rem; font-weight: 500; }
.list-item-sub { font-size: 0.72rem; color: var(--text-sec); margin-top: 0.1rem; }
.list-item-val { font-size: 0.88rem; font-weight: 600; font-variant-numeric: tabular-nums; text-align: right; }
.list-item-val.mono { font-family: var(--font-mono); font-weight: 500; font-size: 0.8rem; }
.list-item-val.unknown { color: var(--text-ter); font-weight: 400; }
/* ── Certificaatkeuze ─────────────────────────────────────────────────────
Eén dropdown, op verzoek van de gebruiker (20-08-2026). Hier stond een lijst met
keuzerondjes, en de reden daarvoor was dat je bron, naam en resterende dagen
naast elkaar wilt kunnen vergelijken. Op de Umbrel van de gebruiker bleken het
veertien certificaten te zijn, en dan is dat argument omgeslagen: veertien rijen
in een scrollend kader maken van de keuze het drukste onderdeel van de pagina,
terwijl je er precies één keer iets mee doet.
Wat een dropdown niet kan, is een verlopen certificaat rood zetten: opmaak van
een option verschilt per browser. Daarom staat alles in de tekst van de optie
zelf, inclusief "expired" en "in use". Dat leest ook voor wie de kleur niet
ziet. */
.cert-choices { flex-shrink: 0; }
.cert-select {
width: 100%;
font-family: var(--font-mono);
font-size: 0.8rem;
color: var(--text-primary);
background: var(--bg-raised);
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.62rem 0.7rem;
cursor: pointer;
}
.cert-select:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
/* De marges hier zijn krapper dan elders in de pagina, en dat is een keuze van
20-08-2026: dit kader bepaalt de hoogte van zijn rij, dus elke ruimte die hier
overblijft komt als leegte terug onder het activiteitenlog ernaast. */
.cert-choose-head { display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; margin: 1.1rem 0 0.2rem; }
.cert-actions { display: flex; align-items: center; gap: 0.9rem; flex-wrap: wrap; margin-top: 0.9rem; flex-shrink: 0; }
.btn-solid { background: var(--accent); color: #fff; border-color: transparent; }
.btn-solid:hover { background: var(--accent); opacity: 0.88; }
.cert-empty { font-size: 0.78rem; color: var(--text-ter); padding: 0.7rem 0; }
/* Het uploadblok. Dicht is het één regel; dat is de bedoeling, want het is voor
de uitzondering en niet voor de gewone gang van zaken.
Geen eigen border-top: het staat onder een lijstregel en die trekt zijn lijn
al. Dat was de winst van de verplaatsing op 20-08-2026, één streep minder in
een kader dat er genoeg had. */
.upload { padding: 0.7rem 0 0; }
.upload summary {
cursor: pointer; font-size: 0.78rem; font-weight: 600;
color: var(--text-sec); list-style-position: inside;
}
.upload summary:hover { color: var(--text-primary); }
.upload-row { display: block; margin-bottom: 0.7rem; }
.upload-row .t-label { display: block; margin-bottom: 0.25rem; }
.upload-row input[type="file"] {
width: 100%; font-size: 0.75rem; color: var(--text-sec);
background: var(--bg-raised); border: 1px solid var(--border);
border-radius: 8px; padding: 0.45rem 0.55rem; cursor: pointer;
}
/* ── Logregels ──────────────────────────────────────────────────────────── */
.log {
background: var(--bg-raised);
border-radius: max(4px, calc(var(--radius-lg) - 1.8rem));
padding: 0.9rem 1.1rem;
font-family: var(--font-mono);
font-size: 0.75rem;
line-height: 1.9;
/* Sinds 27-08-2026 neemt dit blok gewoon zijn eigen hoogte. Hier stond
flex: 1 1 0, en dat was de oplossing voor een probleem dat niet meer bestaat:
het log stond naast het certificaatkader en die twee moesten samen groeien en
krimpen, anders viel er leegte onder de knop "Use this certificate" zodra je
het uploadblok uitklapte. Dat was de hele inhoud van 0.0.13.
De certificaatkeuze zit nu in een dialoog en dit kader is vol breed, dus er is
geen buur meer om je aan te meten. Wat overblijft is een plafond, want een
etmaal aan regels zou anders één lange pagina worden; daarboven scrolt het
blok zelf, en dat is waar een log voor gemaakt is. */
flex: 0 0 auto;
/* Er passen vier regels in; dat is de bodem voor een leeg of net gestart log. */
min-height: 110px;
/* De helft van wat het was, op verzoek van de gebruiker (27-08-2026). Met 60vh
vulde het log het scherm terwijl er zelden meer dan een handvol regels in
staat; wat er niet in past, scrolt hier toch al. */
max-height: 30vh;
overflow: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
scrollbar-color: var(--scrollbar) transparent;
}
.log-line { display: flex; gap: 0.9rem; white-space: nowrap; }
.log-time { color: var(--text-ter); flex-shrink: 0; font-variant-numeric: tabular-nums; }
/* Breed genoeg voor het langste label, "session ended". Met 7.5em liep dat over
de tijdkolom van de regel eronder heen. */
.log-event { flex-shrink: 0; width: 9em; }
/* Geen text-overflow hier, met opzet. Met puntjes zou een smal scherm de bytes
stil afkappen; nu houdt de regel zijn breedte en scrollt het logblok, dus is
alles nog te bereiken. */
.log-detail { color: var(--text-sec); }
.log-open { color: var(--accent-text); }
.log-close { color: var(--text-sec); }
.log-data { color: var(--text-primary); }
.log-fail { color: var(--neg); }
.log-empty { color: var(--text-ter); white-space: normal; line-height: 1.6; }
.btn {
display: inline-flex; align-items: center; gap: 0.4rem;
font-family: inherit; font-size: 0.75rem; font-weight: 600;
padding: 0.45rem 0.9rem; border-radius: var(--radius-sm);
border: 1px solid var(--border-mid); background: transparent;
color: var(--text-primary); cursor: pointer;
transition: background 0.2s var(--ease-out);
}
.btn:hover { background: var(--neutral-bg); }
.btn:active { transform: scale(0.97); }
.btn:disabled { opacity: 0.35; cursor: not-allowed; }
.btn svg { width: 13px; height: 13px; }
/* Kopieerknop in een lijstregel verschijnt pas bij hover. Opacity en geen
display:none, want dan blijft hij met de tab-toets bereikbaar; focus-within
maakt hem dan alsnog zichtbaar. Op aanraakschermen bestaat hover niet, dus
daar staat hij altijd aan. */
.list-item .btn {
opacity: 0;
transition: opacity 0.18s var(--ease-out), background 0.2s var(--ease-out);
}
.list-item:hover .btn,
.list-item:focus-within .btn { opacity: 1; }
@media (hover: none) {
.list-item .btn { opacity: 1; }
}
/* ── Grafieken ──────────────────────────────────────────────────────────── */
.spark { width: 100%; height: 34px; display: block; margin-top: 0.5rem; overflow: visible; }
/* ── Het menu rechtsboven ───────────────────────────────────────────────── */
.menu { position: relative; flex-shrink: 0; }
/* Het driehoekje van summary weg, in twee vormen: WebKit heeft zijn eigen
pseudo-element en de rest gaat via list-style. */
/* Alleen het driehoekje weg en de cursor; de maatvoering staat hieronder bij
.menu-button, want twee regels die allebei padding zetten laten de meest
specifieke winnen en dat is deze. */
.menu > summary { list-style: none; cursor: pointer; }
.menu > summary::-webkit-details-marker { display: none; }
.menu > summary::marker { content: ''; }
/* Geen knoplook, op verzoek van de gebruiker (27-08-2026): alleen de drie punten,
met een accent bij hover. Hier stond de klasse .btn, en die gaf er een rand en
een vlak omheen die niets toevoegden naast een kop met alleen het merk erin.
Flink van formaat, want drie punten op de gewone knopgrootte van 0,75rem zijn
nauwelijks te zien en al helemaal niet te raken. De letterafstand zet ze uit
elkaar, anders plakken ze in de meeste letterfamilies aan elkaar. De padding
blijft: dat is het raakvlak, ook al is er niets omheen te zien. */
.menu-button {
display: inline-flex; align-items: center; justify-content: center;
font-size: 1.4rem;
line-height: 1;
letter-spacing: 0.08em;
padding: 0.25rem 0.6rem 0.45rem;
min-height: 2rem;
color: var(--text-sec);
border-radius: var(--radius-sm);
transition: color 0.2s var(--ease-out);
}
.menu-button:hover { color: var(--accent); }
/* Ook accent zolang het menu openstaat, anders lijkt de knop terug te springen
naar rust terwijl het paneel eronder nog open is. */
.menu[open] > .menu-button { color: var(--accent); }
/* Zonder rand is er geen vorm die de standaard focusring volgt, dus die zetten
we zelf; met het toetsenbord moet zichtbaar zijn waar je staat. */
.menu-button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.menu-panel {
position: absolute; right: 0; top: calc(100% + 0.4rem);
z-index: 20; min-width: 15rem;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 0.35rem;
box-shadow: 0 8px 30px rgba(0,0,0,0.18);
}
.menu-item {
display: flex; align-items: center; justify-content: space-between; gap: 1.2rem;
width: 100%; text-align: left;
font-family: inherit; font-size: 0.82rem; color: var(--text-primary);
background: transparent; border: 0; border-radius: var(--radius-sm);
padding: 0.55rem 0.65rem; cursor: pointer;
}
.menu-item:hover { background: var(--neutral-bg); }
.menu-meta { font-size: 0.75rem; color: var(--text-sec); }
/* ── De dialoog ─────────────────────────────────────────────────────────── */
/* Breder dan het kader waar deze inhoud uit komt, en dat mag: een dialoog leent
geen hoogte van een buur, dus de keuzelijst en het uploadblok kunnen hier
gewoon staan zonder dat er elders leegte ontstaat. */
.sheet {
/* Midden op het scherm, in beide richtingen (27-08-2026: hij kwam linksboven
terecht). Niet op de standaardopmaak van de browser vertrouwen: die zet een
dialog absoluut ten opzichte van zijn dichtstbijzijnde geplaatste voorouder,
en deze staat in het raster van de pagina. Vast plaatsen met inset 0 en
marge auto centreert hem tegen het scherm, ongeacht waar hij in de HTML
staat. */
position: fixed;
inset: 0;
margin: auto;
/* Alleen van belang in de terugvalroute zonder showModal; zie de HTML. */
grid-column: 1 / -1;
width: min(46rem, calc(100vw - 2rem));
max-height: min(44rem, calc(100vh - 4rem));
padding: 0;
color: var(--text-primary);
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
overflow: hidden;
}
.sheet::backdrop { background: rgba(0,0,0,0.55); }
.sheet-head {
display: flex; align-items: center; justify-content: space-between; gap: 1rem;
padding: 1.3rem 1.5rem; border-bottom: 1px solid var(--border);
}
/* Kaal, met een accent bij hover, net als het menu en de chevron. Niet rood: er
gaat niets kapot van sluiten, en rood is op deze pagina voor het geval waar je
iets aan moet doen. */
.sheet-close {
display: inline-flex; align-items: center; justify-content: center;
flex-shrink: 0; width: 2rem; height: 2rem;
font-family: inherit; font-size: 1.5rem; line-height: 1;
color: var(--text-sec); background: transparent; border: 0;
border-radius: var(--radius-sm); cursor: pointer;
transition: color 0.2s var(--ease-out);
}
.sheet-close:hover { color: var(--accent); }
.sheet-close:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
/* Alleen dit deel scrollt, zodat de sluitknop in beeld blijft bij twintig
certificaten. */
.sheet-body {
padding: 0.4rem 1.5rem 1.5rem;
overflow: auto;
max-height: calc(min(44rem, 100vh - 4rem) - 5rem);
scrollbar-width: thin;
scrollbar-color: var(--scrollbar) transparent;
}
/* De knop in de foutmelding "geen certificaat in gebruik". Op een eigen regel,
want in de melding staat een zin die je eerst leest. */
.alert-action { margin-top: 0.7rem; }
/* De dialoog "About this app" is lopende tekst en geen lijst met waarden, dus hij
heeft opmaak nodig die de certificaatdialoog niet gebruikt. Kopjes in de maat
van een .t-label, want ze verdelen de tekst en zijn er niet om gelezen te
worden. */
.sheet-prose h3 {
font-size: 0.68rem; font-weight: 600; letter-spacing: 0.08em;
text-transform: uppercase; color: var(--text-sec);
margin: 1.6rem 0 0.5rem;
}
.sheet-prose h3:first-child { margin-top: 0; }
.sheet-prose p { font-size: 0.85rem; line-height: 1.65; color: var(--text-sec); margin-bottom: 0.7rem; }
.sheet-prose p strong { color: var(--text-primary); font-weight: 600; }
/* ── Het instelkader ────────────────────────────────────────────────────── */
.setup > summary { list-style: none; cursor: pointer; display: flex; align-items: baseline; gap: 0.8rem; flex-wrap: wrap; }
.setup > summary::-webkit-details-marker { display: none; }
.setup > summary::marker { content: ''; }
/* Een chevron die meedraait, want zonder het driehoekje van de browser is er
verder niets dat zegt dat dit open kan.
Links vóór de titel (27-08-2026, gebruiker). Hij stond rechts aan de rand, en
dat is ver weg van het woord waar hij iets over zegt; links staat hij waar het
driehoekje van de browser ook gestaan zou hebben.
Dicht wijst hij naar rechts, open naar beneden. align-self houdt hem op de
hoogte van de titel, want de regel eromheen lijnt op de basislijn uit en een
gedraaid vierkantje heeft er geen. */
.setup > summary::before {
content: ''; align-self: center; flex-shrink: 0;
width: 0.5rem; height: 0.5rem;
border-right: 2px solid var(--text-sec); border-bottom: 2px solid var(--text-sec);
transform: rotate(-45deg);
transition: transform 0.2s var(--ease-out), border-color 0.2s var(--ease-out);
}
.setup[open] > summary::before { transform: rotate(45deg); }
/* Accent bij hover, net als het menu rechtsboven: dit zijn de twee dingen op deze
pagina die opengaan als je erop klikt, en dan horen ze zich hetzelfde te
gedragen. */
.setup > summary:hover { color: var(--accent); }
.setup > summary:hover::before { border-color: var(--accent); }
.setup-form { min-width: 0; }
.setup-value {
display: flex; align-items: center; gap: 0.7rem; flex-wrap: wrap;
margin: 0.45rem 0 0.55rem;
}
/* De wallets onder de vorm die ze vragen. Naam en vindplaats op één regel, want
het gaat om het herkennen van je eigen wallet en niet om een handleiding. */
.setup-wallets { font-size: 0.75rem; line-height: 1.7; color: var(--text-sec); }
.setup-wallets strong { color: var(--text-primary); font-weight: 600; }
.hidden { display: none !important; }
/* Onder deze breedte past een regel niet meer naast zijn label. Alles wat op een
brede kaart rechts staat, gaat hier onder de titel staan in plaats van eruit te
lopen. Gemeld door de gebruiker op 20-08-2026: op zijn telefoon liepen de
verbindingsregels met hun kopieerknoppen en de logregels buiten beeld. */
@media (max-width: 700px) {
/* De lijstregels: titel op de eerste regel, waarde plus kopieerknop op de
tweede. overflow-wrap is nodig omdat host:poort:protocol geen spatie heeft en
dus nergens kan afbreken. */
.list-item { flex-wrap: wrap; align-items: flex-start; gap: 0.5rem; }
.list-item > * { flex-basis: 100%; min-width: 0; }
.list-item-val { text-align: left; overflow-wrap: anywhere; }
/* De kopieerknop naar de rechterrand. Zonder dit volgt hij de tekst en staat
hij dus per regel ergens anders, want het ene adres is langer dan het andere;
zo gezien door de gebruiker op 20-08-2026. Op de knop en niet als
space-between op de rij: bij de gesplitste weergave staat er ook een notitie
naast de waarde, en die hoort daar tegenaan te blijven staan. */
.list-item .btn { margin-left: auto; }
/* De logregels wrappen in plaats van te scrollen. Op een breed scherm is
scrollen beter, want dan blijft de kolomvorm staan; op een telefoon betekent
het dat je de bytes nooit ziet. Tijd en gebeurtenis blijven naast elkaar, de
rest gaat naar de volgende regel. */
.log-line { flex-wrap: wrap; white-space: normal; gap: 0.6rem; }
.log-event { width: auto; }
.log-detail { flex-basis: 100%; }
/* Het plafond blijft, de bodem niet: op een telefoon is 110 pixels aan lege
loghoogte zonde van het scherm. */
.log { min-height: 0; }
}
@media (max-width: 560px) {
body { padding: 1.25rem 0.9rem 2rem; }
.card { --padding: 1.3rem; padding: var(--padding); --radius: var(--radius-lg); border-radius: var(--radius); }
.t-h1 { font-size: 1.5rem; }
}
</style>
</head>
<body>
<div class="wrap">
<header class="head">
<div class="mark" aria-hidden="true">
<img src="icon.png" alt="" id="mark-icon"
onerror="this.remove(); document.getElementById('mark-fallback').classList.remove('hidden');">
<svg id="mark-fallback" class="hidden" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 2 4 6v6c0 5 3.4 8.6 8 10 4.6-1.4 8-5 8-10V6z"></path>
<path d="m13 8-3.5 5H12l-1 3.5L14.5 11H12z"></path>
</svg>
</div>
<div class="head-text">
<h1 class="t-h1">Electrum Gate</h1>
<!-- Woordelijk dezelfde regel als de tagline in umbrel-app.yml, en dat is
het punt: één app, één regel. Hier stond "TLS in front of your own
Electrum server", en dat was de achterblijver van de oude naam. Bij het
hernoemen naar Electrum Gate op 19-08-2026 was de reden juist dat de
tekst over de opbrengst moet gaan en niet over TLS als middel; die
maatstaf gold nog niet voor deze regel. Rechtgezet op verzoek van de
gebruiker (20-08-2026).
Wijzig je de een, wijzig dan de ander:
tests/test_server_start_zonder_certificaat.py houdt ze gelijk. -->
<p class="t-body-sm">Your own node from anywhere, without waiting for Tor <span class="head-version" id="head-version"></span></p>
</div>
<!-- Het menu rechtsboven. Hier stond een losse knop voor licht en donker; die
is er op verzoek van de gebruiker (27-08-2026) in gegaan, samen met het
certificaatbeheer. Wat hier staat is wat je zelden aanraakt, en de kop
houdt daardoor niets meer dan het merk en de tagline.
Met details/summary en niet met eigen JavaScript, want dan werkt openen
en sluiten met het toetsenbord zonder dat wij daar iets voor doen. Wat er
wél bij moet is sluiten op Escape en bij een klik ernaast; dat staat in
het script, want dat kent details van zichzelf niet. -->
<details class="menu" id="menu">
<!-- Drie punten en geen hamburger, op aanwijzing van de gebruiker
(27-08-2026): dat is wat umbrelOS bij zijn eigen apps toont, en een
pagina die in dat dashboard hangt hoort daar niet van af te wijken.
Als tekst en niet als SVG, want dan schaalt hij mee met de lettergrootte
van de gebruiker. Middelste punten (U+00B7) en geen gewone punten: die
staan op de basislijn en zakken dus naar de onderrand van de knop,
terwijl deze vanzelf op halve hoogte staan. Hetzelfde teken dat elders
op deze pagina de velden in een keuzeregel scheidt. -->
<summary class="menu-button" title="Settings" aria-label="Settings">···</summary>
<div class="menu-panel">
<!-- "Settings…" en niet "Certificates", en zonder de naam van het actieve
certificaat erachter (27-08-2026, gebruiker). Die naam stond al als
onderregel van de certificaatwidget, dus hier was het een derde plek
voor dezelfde waarde. Het beletselteken is de gebruikelijke belofte
dat er een dialoog volgt en niet meteen een handeling. -->
<button class="menu-item" id="menu-certs" type="button">
<span>Settings…</span>
</button>
<!-- "About this app" op verzoek van de gebruiker (30-08-2026), en aan
beide apps in deze store toegevoegd. Hier stond de marketingtekst
nergens op de pagina: die staat in umbrel-app.yml en dus alleen in de
winkel, terwijl je juist ná het installeren nog eens wil kunnen
nalezen wat dit ding voor je doet en wat het niet doet.
Boven "Appearance" en onder "Settings…": van wat je het meest doet
naar wat je het minst doet is hier de verkeerde ordening, want dan zou
het thema bovenaan staan. De ordening is wat over de app gaat eerst,
wat over deze pagina gaat daarna. -->
<button class="menu-item" id="menu-about" type="button">
<span>About this app…</span>
</button>
<!-- De regel toont de huidige stand en niet de handeling. De losse knop
hiervoor deed het andersom, met "Light" terwijl de pagina donker was.
Als knop klopte dat, want dat was waar je heen ging; als menuregel
leest het als een bewering over hoe het nú staat, en dan is het fout. -->
<button class="menu-item" id="menu-theme" type="button">
<span>Appearance</span>
<span class="menu-meta" id="theme-label">Dark</span>
</button>
</div>
</details>
</header>
<!-- Meldingen. Alle drie standaard verborgen; het script bepaalt welke past. -->
<div id="alert-nodata" class="alert alert-neutral hidden">
<span class="alert-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="9"></circle><path d="M12 8v5"></path><path d="M12 16.5v.01"></path></svg>
</span>
<span>
<span class="alert-title">No readings yet</span>
<span class="alert-body" id="alert-nodata-body">The proxy has not written a status file yet. This is normal in the first few minutes after installing or restarting the app.</span>
</span>
</div>
<div id="alert-backend" class="alert alert-error hidden">
<span class="alert-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 3 2 20h20z"></path><path d="M12 9v4"></path><path d="M12 16.5v.01"></path></svg>
</span>
<span>
<span class="alert-title">The Electrum server did not answer</span>
<span class="alert-body" id="alert-backend-body"></span>
</span>
</div>
<div id="alert-cert" class="alert hidden">
<span class="alert-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 2 4 6v6c0 5 3.4 8.6 8 10 4.6-1.4 8-5 8-10V6z"></path><path d="M12 8v4"></path><path d="M12 15.5v.01"></path></svg>
</span>
<span>
<span class="alert-title" id="alert-cert-title"></span>
<span class="alert-body" id="alert-cert-body"></span>
</span>
</div>
<div id="alert-nocert" class="alert alert-error hidden">
<span class="alert-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 3 2 20h20z"></path><path d="M12 9v4"></path><path d="M12 16.5v.01"></path></svg>
</span>
<span>
<span class="alert-title">No certificate in use, so TLS is not running</span>
<span class="alert-body" id="alert-nocert-body"></span>
<!-- Deze knop is geen extraatje maar de eis die de nieuwe indeling zelf
oproept. De certificaatkeuze zit sinds 27-08-2026 achter het menu, en
dit is precies de toestand waarin een gebruiker die keuze móet kunnen
vinden: een verse installatie heeft er nog geen. Dat is de klem van
0.0.3 in een andere vorm, en die komt niet terug. Het menu is de weg
terug voor later, deze knop is de weg erheen nu. Zie PLAN.md 4a2. -->
<button class="btn alert-action" id="alert-nocert-open" type="button">Choose a certificate</button>
</span>
</div>
<!-- Waar de wallet heen moet. Het eerste dat iemand hier komt halen. -->
<!-- De volgorde hieronder is een keuze van de gebruiker (19-08-2026): eerst
waar je naar kijkt als je iets nakomt, en pas onderaan waar je naar kijkt
als je iets instelt. Kaders staan naast elkaar waar de breedte dat toelaat,
maar de leesvolgorde in de HTML is de bedoelde volgorde; bij smalle
schermen stapelt hij precies zo. -->
<div class="grid">
<!-- Vier widgets, alle vier hetzelfde soort ding: een titel, één waarde, en
hooguit één onderregel. Opgegeven door de gebruiker op 27-08-2026 en
uitgeschreven in PLAN.md 4a2. Het waren er vijf tot 0.0.28; de blokhoogte
is eruit omdat het niet aan deze app is om die te tonen.
Hier stonden een kader met drie lijstregels voor de Electrum-server en
daarnaast twee tegels. Dat vroeg twee kunstjes die nu vervallen: de tegels
moesten meerekken met de hoogte van het kader ernaast, en de reactietijd
nam twee kolommen om de sparkline ruimte te geven. Gelijke widgets hebben
geen buur om zich aan te meten.
De volgorde is die van het lezen: eerst waar je verkeer heen gaat, dan of
dat werkt, dan het certificaat, en als laatste of onze eigen voordeur
antwoordt. -->
<div class="row1">
<div class="card">
<div class="card-head">
<h2 class="t-h3">Electrum server</h2>
</div>
<div class="stat-body">
<div class="stat-val text unknown" id="backend-name">unknown</div>
<!-- Het adres als onderregel en niet als eigen lijstregel. De twee andere
regels die hier stonden zijn weg: "Chosen in the umbrelOS settings"
was uitleg bij een instelling die je hier toch niet wijzigt, en "Last
checked" staat als "as of ... ago" onder de reactietijd. -->
<div class="stat-sub mono" id="backend-address"></div>
</div>
</div>
<!-- Reactietijd staat naast de Electrum-server waar hij over gaat. Let op:
dit is de leesvolgorde in de HTML en dus ook de stapelvolgorde op een
smal scherm; er staat nergens een order in de CSS die daarvan afwijkt.
De onderregel met de leeftijd van de meting hoorde tot 0.0.28 bij de
blokhoogte. Die widget is er op verzoek van de gebruiker uit (04-09-2026:
het is niet aan deze app om de blokhoogte te tonen), en de leeftijd
verhuisde mee naar hier. Het is dezelfde meting uit dezelfde ronde, en
zonder deze regel staat er nergens op de pagina meer hoe oud de
gegevens zijn.
Hij staat ná de sparkline en niet ervoor, want de onderregels van de vier
widgets horen op één lijn te staan. Ervoor zou de waarde van déze widget
nog eens de hoogte van de sparkline hoger komen dan de andere drie. -->
<div class="card">
<div class="card-head">
<h2 class="t-h3">Backend response</h2>
</div>
<div class="stat-body">
<div class="stat-val unknown" id="stat-latency">unknown</div>
<svg class="spark" id="spark" viewBox="0 0 100 34" preserveAspectRatio="none" aria-hidden="true"></svg>
<div class="stat-sub" id="stat-latency-sub"></div>
</div>
</div>
<!-- Het certificaat als getal en niet als kaart. Kiezen, uploaden en de
details zitten sinds 27-08-2026 in de dialoog achter het menu; wat hier
staat is wat je in één oogopslag wilt weten. Het aantal dagen tot
vernieuwing stond hier eerder als tegel en is er op 20-08-2026 uit
gegaan omdat de kaart eronder hetzelfde zei. Die kaart is er nu niet
meer, dus het getal komt terug. -->
<div class="card">
<div class="card-head">
<h2 class="t-h3">Certificate</h2>
</div>
<div class="stat-body">
<div class="stat-val unknown" id="cert-days">unknown</div>
<div class="stat-sub mono" id="cert-domain">unknown</div>
</div>
</div>
<!-- De zelfcontrole, gebouwd in 0.0.16. Dit is de enige widget die over deze
app zelf gaat en niet over iets waar hij naar kijkt.
Let op het woord dat hier NIET staat: "reachable". De agent verbindt van
container naar container, dus een router die de poort niet meer
doorstuurt leest hier als in orde. Wat er gemeten is, is de voordeur zelf
en niet de weg ernaartoe; de onderregel zegt dat er ook bij. -->
<div class="card">
<div class="card-head">
<h2 class="t-h3">Status</h2>
</div>
<div class="stat-body">
<div class="stat-val text unknown" id="self-state">unknown</div>
<div class="stat-sub" id="self-sub"></div>
</div>
</div>
</div>
<!-- De dialoog met alles wat met certificaten te maken heeft: de huidige, de
keuze, en het uploaden. Te openen uit het menu rechtsboven en uit de
foutmelding bovenaan.
Met het dialog-element en niet met een eigen overlay: focus, Escape en het
afschermen van de pagina erachter zitten erin.
Hij staat binnen het raster en dat maakt in de praktijk niets uit: dicht is
een dialog display:none, en open via showModal() staat hij in de top layer,
dus in geen van beide gevallen is hij een rastercel. Alleen de terugvalroute
voor een browser zonder showModal zet hem gewoon in de stroom, en daarvoor
staat er in .sheet een grid-column op de volle breedte. -->
<dialog id="cert-dialog" class="sheet">
<form method="dialog" class="sheet-head">
<!-- Zelfde woord als de menuregel die hem opent. Stond hier "Certificates"
terwijl je op "Settings…" klikte, en dan lijkt het even of je ergens
anders bent uitgekomen dan je dacht. -->
<h2 class="t-h3">Settings</h2>
<!-- Een kruisje en niet het woord "Close" (27-08-2026, gebruiker). Het
maalteken U+00D7 en geen letter x: dat is het teken dat hiervoor bestaat
en het staat symmetrisch. Het aria-label houdt "Close" aan, want een
schermlezer die "maalteken" voorleest zegt niets over wat de knop doet. -->
<button class="sheet-close" value="close" aria-label="Close" title="Close">×</button>
</form>
<div class="sheet-body">
<div class="list-item">
<div>
<div class="list-item-title">In use</div>
<div class="list-item-sub" id="cert-source">source unknown</div>
</div>
<div class="list-item-val mono unknown" id="cert-dialog-domain">unknown</div>
</div>
<div class="list-item">
<div><div class="list-item-title">Valid until</div></div>
<div class="list-item-val unknown" id="cert-until">unknown</div>
</div>
<div class="list-item">
<div>
<div class="list-item-title">Last reload</div>
<div class="list-item-sub">A renewal is reloaded, so open connections stay up</div>
</div>
<div class="list-item-val unknown" id="cert-reload">unknown</div>
</div>
<!-- Ingeklapt, want dit is voor wie geen reverse proxy heeft en dat is de
uitzondering. Dichtgeklapt kost het één regel, en dat was de opdracht:
een simpele pagina. De naam van het bestand wordt niet gevraagd; die
komt uit de hostnaam in het certificaat zelf, en dat is ook precies
waarom er geen padtrucs mogelijk zijn.
Boven de keuzelijst en niet eronder, op verzoek van de gebruiker
(20-08-2026): wat je uploadt komt in die lijst terecht, dus eerst
toevoegen en dan kiezen. -->
<details class="upload">
<summary>Upload your own certificate</summary>
<p class="t-caption" style="margin: 0.5rem 0 0.8rem;">
For a certificate you manage yourself, without a reverse proxy on this machine. Both files stay on
this Umbrel. The key must be unencrypted, and it is checked against the certificate before anything
is stored.
</p>
<label class="upload-row">
<span class="t-label">Certificate, full chain</span>
<input type="file" id="up-cert" accept=".pem,.crt,.cer,.txt">
</label>
<label class="upload-row">
<span class="t-label">Private key</span>
<input type="file" id="up-key" accept=".key,.pem,.txt">
</label>
<div class="cert-actions">
<button class="btn" id="up-send" type="button">Upload</button>
<span class="t-caption" id="up-note"></span>
</div>
</details>
<!-- De keuzelijst. Schrijft de keuze met een PUT naar een afgeschermd pad;
de achtergrondlus pikt het bestand op en herlaadt nginx. -->
<div class="cert-choose-head">
<span class="t-label">Choose a certificate</span>
<span class="t-caption" id="cert-count"></span>
</div>
<!-- Eén regel. De oude tekst noemde Nginx Proxy Manager als bron, terwijl die
mount nog uitgecommentarieerd staat in de compose omdat het pad niet
geverifieerd is; wat er stond kon dus niet waar zijn. Waar een certificaat
vandaan komt staat toch al in de optie zelf. -->
<p class="t-caption" style="margin-bottom: 0.6rem;">
Pick the one matching the name your wallet connects to.
</p>
<div id="cert-choices" class="cert-choices"></div>
<div class="cert-actions">
<button class="btn btn-solid" id="cert-apply" type="button" disabled>Use this certificate</button>
<span class="t-caption" id="cert-apply-note"></span>
</div>
</div><!-- /sheet-body -->
</dialog>
<!-- De dialoog "About this app", toegevoegd op verzoek van de gebruiker
(30-08-2026) en in dezelfde vorm aan Evolu Relay.
De tekst is die uit umbrel-app.yml, ingekort en met kopjes erin. Wijzig je
daar iets aan de strekking, dan hoort het hier ook te veranderen; ze staan
niet met een test aan elkaar vast omdat de vorm verschilt.
Wat hier bovendien staat en nergens anders: de grens van de statuswidget.
Die zei tot 0.0.24 "answered in 7 ms, from inside the app", en dat was
precies genoeg tekst om onduidelijk te zijn en te weinig om de nuance te
dragen. De nuance is nu hier, waar er ruimte voor is. -->
<dialog id="about-dialog" class="sheet">
<form method="dialog" class="sheet-head">
<h2 class="t-h3">About this app</h2>
<button class="sheet-close" value="close" aria-label="Close" title="Close">×</button>
</form>
<div class="sheet-body sheet-prose">
<h3>What it is for</h3>
<p>
The privacy win is already yours: you run the Electrum server. A public one gets asked for the
history of every address in your wallet, and that tells it which addresses and which balance
belong to one person. Your own server never reports back.
</p>
<p>
What is left is a trade. <strong>Tor</strong> is the more private way in, and every wallet
speaks it. But it adds hundreds of milliseconds to every request, it drops when a phone sleeps
or changes network, and plenty of networks block it outright. Electrum Gate is the other side
of that trade: a TLS front door on your node. Fast, no fingerprint to type over, and it works
on any network you happen to be on.
</p>
<h3>The certificate</h3>
<p>
It reuses the Let's Encrypt certificate your reverse proxy already manages, so there is
nothing to request and nothing to renew. A renewal is picked up on its own, without dropping
connections that are already open. You can also upload a certificate you manage yourself, if
you do not run a reverse proxy on this machine.
</p>
<p>
When more than one certificate could fit and you have not chosen, the app <strong>refuses</strong>
rather than picking one. A wrong certificate gives a connection that looks fine and only breaks
later, inside your wallet, on name verification.
</p>
<h3>Nothing in the middle</h3>
<p>
The connection runs from your wallet straight to your own node, encrypted with a certificate
you already own, for a domain you already control. There is no account to create, no tunnel
service that terminates your traffic along the way, and no client to install on every device
you use: the wallets already speak TLS, they only need an address. What it does ask of you is
one forwarded port on your router.
</p>
<h3>What the Status card does and does not prove</h3>
<p>
The app opens a TLS connection to its own front door every minute and checks that the
certificate it gets back is the one you selected. <strong>Running</strong> means that
succeeded.
</p>
<p>
It is measured from inside the app, so it says nothing about the way in from outside: a router
that no longer forwards the port, or a firewall in between, still reads as Running here. If a
wallet cannot connect while this card says Running, the fault is on the route and not in this
app.
</p>
<h3>Which wallets</h3>
<p>
Useful for wallets connecting from outside your home: Trezor Suite, Electrum, Sparrow,
BlueWallet, Nunchuk, Blockstream and BitBoxApp. Apps on the Umbrel itself do not need it, they
already reach the Electrum server directly.
</p>
</div>
</dialog>
<!-- Voorstel, nog niet besloten. De agent stelt de regels samen uit de
stream-access-log van nginx. Bewust zonder client-IP en zonder bronpoort:
dat is precies het soort gegeven dat deze app dichtzet, en het is niet
nodig om te zien of het werkt.
Over de volle breedte sinds 27-08-2026. Het stond naast het
certificaatkader, en die twee moesten daarvoor samen groeien en krimpen;
dat was de hele inhoud van 0.0.13. Nu de certificaatkeuze in een dialoog
zit, is er geen buur meer en vervalt die koppeling. Logregels zijn
monospace en hadden de breedte toch al het hardst nodig. -->
<section class="card col-full">
<div class="card-head">
<h2 class="t-h3">Activity log</h2>
</div>
<div class="log" id="activity-log">
<div class="log-empty" id="activity-empty">Nothing recorded yet. Lines appear here as wallets connect through the gateway.</div>
</div>
</section>
<!-- Per client, want juist de kleine verschillen in notatie kosten mensen
tijd. De rijen komen uit Referenties/Clients.md.
Staat onderaan en niet bovenaan: dit is het kader dat je één keer nodig
hebt bij het instellen, en daarna nooit meer. Het verbindingsadres met de
kopieerknop zat eerst in een eigen kader erboven; dat was hetzelfde kader
twee keer, dus het is hierin opgegaan. -->
<section class="card col-full">
<!-- Dichtgeklapt, op verzoek van de gebruiker (27-08-2026). Dit kader heb je
één keer nodig en daarna nooit meer, en open nam het het onderste derde
deel van de pagina.
Er staan nu twee kopieerknoppen in plaats van één per wallet. Dat draait
een besluit van 19-08-2026 terug, en dat is opgeschreven in PLAN.md 4a2
zodat het niet per ongeluk hersteld wordt: er zijn feitelijk maar twee
vormen van hetzelfde adres, dus tien knoppen waren negen keer dezelfde
handeling.
Wat daarbij niet verloren mocht gaan is wélke wallet welke vorm wil. De
wallets staan daarom onder de vorm die ze vragen en niet als één lijst
ernaast; zonder die groepering staat er wel een adres, maar niet meer
waar het bij hoort. -->
<details class="setup" id="setup">
<summary class="setup-summary">
<span class="t-h3">Setting up your wallet</span>
<span class="t-caption">The address to paste, per wallet</span>
</summary>
<p class="t-caption" style="margin: 1rem 0 1.2rem;">
Use <strong>SSL</strong> as the connection type. If you connect from outside your own network, your
router may forward a different external port to this one.
</p>
<div class="clients">
<div class="setup-form">
<div class="t-label">Paste as one line</div>
<div class="setup-value">
<span class="list-item-val mono unknown" id="oneline-value">unknown</span>
<button class="btn" id="oneline-copy" type="button">Copy</button>
</div>
<div class="setup-wallets" id="oneline-wallets"></div>
</div>
<div class="setup-form">
<div class="t-label">Host and port separately, with SSL turned on</div>
<div class="setup-value">
<span class="list-item-val mono unknown" id="split-value">unknown</span>
<span class="t-caption" id="split-port"></span>
<button class="btn" id="split-copy" type="button">Copy</button>
</div>
<div class="setup-wallets" id="split-wallets"></div>
</div>
</div>
<p class="t-caption" style="margin-top: 1.2rem;">
Wasabi and Samourai use their own backend and do not speak the Electrum protocol. Specter Desktop
talks to Bitcoin Core directly. Hardware wallets connect through one of the apps above rather than
on their own.
</p>
</details>
</section>
</div><!-- /grid -->
<!-- Hier stond een voetregel met "Last reading ... ago" en het versienummer. Die
is er op verzoek van de gebruiker uit (19-08-2026): de leeftijd van de
gegevens staat al onder de reactietijd, en het versienummer is ook in
umbrelOS zelf te zien. -->
</div>
<noscript>
<p style="max-width:1760px;margin:1rem auto;padding:1rem 1.2rem;border-radius:14px;background:rgba(255,255,255,0.08);font-size:0.84rem;">
This page needs JavaScript to read the status file. Without it, none of the values above are filled in.
</p>
</noscript>
<script>
// Geen template-literals in dit bestand: umbreld vult elke accolade-variabele in
// en zou ze leegmaken. Overal string-optelling dus.
(function () {
'use strict';
var STATUS_URL = 'status.json';
var REFRESH_MS = 60000;
// Waar elke client het veld verstopt. Uit Referenties/Clients.md; wie hier
// een regel bijzet, zet hem daar ook bij, anders lopen ze uit elkaar.
var CLIENTS_ONELINE = [
{ name: 'Trezor Suite', where: 'Settings, Networks, the gear next to Bitcoin. Also in the mobile app' },
{ name: 'Electrum', where: 'Tools, Network, Server. Desktop and Android' }
];
var CLIENTS_SPLIT = [
{ name: 'Sparrow Wallet', where: 'Preferences, Server, Private Electrum' },
{ name: 'BlueWallet', where: 'Settings, Network, Electrum server' },
{ name: 'Nunchuk', where: 'Settings, Network' },
{ name: 'Blockstream', where: 'Network settings, custom Electrum server. Leave the Tor switch off' },
{ name: 'BitBoxApp', where: 'Advanced settings, connect your own full node' }
];
// ── Thema ───────────────────────────────────────────────────────────────
var root = document.documentElement;
var saved = null;
try { saved = localStorage.getItem('eg-theme'); } catch (e) { saved = null; }
if (!saved && window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches) {
saved = 'light';
}
applyTheme(saved === 'light' ? 'light' : 'dark');
function applyTheme(theme) {
root.setAttribute('data-theme', theme);
// De huidige stand en niet waar je heen gaat. Als losse knop stond hier het
// omgekeerde, en dáár klopte dat: een knop belooft wat er gebeurt als je hem
// indrukt. Een menuregel met een waarde erachter leest als een bewering over
// hoe het nu staat, en dan is dezelfde tekst ineens fout.
document.getElementById('theme-label').textContent = theme === 'dark' ? 'Dark' : 'Light';
}
document.getElementById('menu-theme').addEventListener('click', function () {
var next = root.getAttribute('data-theme') === 'dark' ? 'light' : 'dark';
applyTheme(next);
try { localStorage.setItem('eg-theme', next); } catch (e) { /* privémodus; niet erg */ }
});
// ── Het menu ────────────────────────────────────────────────────────────
// details opent en sluit zichzelf, ook met het toetsenbord. Wat het niet doet
// is sluiten bij een klik ernaast of op Escape, en dat is bij een menu wel de
// verwachting. Vandaar deze twee, en niet meer dan deze twee.
var menu = el('menu');
function closeMenu() { menu.removeAttribute('open'); }
document.addEventListener('click', function (e) {
if (menu.hasAttribute('open') && !menu.contains(e.target)) { closeMenu(); }
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && menu.hasAttribute('open')) { closeMenu(); }
});
// ── De certificaatdialoog ───────────────────────────────────────────────
// Twee wegen erheen, en dat is met opzet. Het menu is de weg voor wie later
// iets wil wijzigen; de knop in de foutmelding is de weg voor wie nog nooit een
// certificaat gekozen heeft, en dat is de enige toestand waarin deze pagina
// écht iets van je vraagt. Zie PLAN.md 4a2.
var sheet = el('cert-dialog');
function openSheet() {
closeMenu();
if (typeof sheet.showModal === 'function') {
sheet.showModal();
} else {
// Zonder showModal geen overlay en geen focusval, maar wel een zichtbare
// dialoog. Beter dan een knop die niets doet.
sheet.setAttribute('open', '');
}
}
el('menu-certs').addEventListener('click', openSheet);
el('alert-nocert-open').addEventListener('click', openSheet);
// ── De dialoog "About this app" ─────────────────────────────────────────
// Dezelfde route als hierboven, met dezelfde terugval voor een browser zonder
// showModal.
var about = el('about-dialog');
el('menu-about').addEventListener('click', function () {
closeMenu();
if (typeof about.showModal === 'function') {
about.showModal();
} else {
about.setAttribute('open', '');
}
});
// ── Hulpjes ─────────────────────────────────────────────────────────────
function el(id) { return document.getElementById(id); }
// Zet een waarde, of laat het veld zichtbaar op onbekend staan. Nooit een
// lege string: een leeg veld leest als "in orde" en dat is het niet.
function put(id, value, suffix) {
var node = el(id);
if (!node) { return; }
if (value === null || value === undefined || value === '') {
node.textContent = 'unknown';
node.classList.add('unknown');
} else {
node.textContent = suffix ? String(value) + suffix : String(value);
node.classList.remove('unknown');
}
}
function show(id, on) { el(id).classList.toggle('hidden', !on); }
// Hier stond num(), dat een getal met spaties per duizend afbrak. De
// blokhoogte was de enige waarde die groot genoeg was om dat te vragen, en die
// widget is er sinds 0.0.28 uit.
function whenLocal(unix) {
if (!unix) { return null; }
return new Date(unix * 1000).toLocaleString();
}
function agoText(seconds) {
if (seconds < 90) { return Math.max(0, Math.round(seconds)) + ' seconds ago'; }
if (seconds < 5400) { return Math.round(seconds / 60) + ' minutes ago'; }
if (seconds < 172800) { return Math.round(seconds / 3600) + ' hours ago'; }
return Math.round(seconds / 86400) + ' days ago';
}
// ── De pagina vullen ────────────────────────────────────────────────────
function render(s, nowSec) {
var interval = (s.interval_seconds || 300);
var age = s.generated_at ? (nowSec - s.generated_at) : null;
// Hoe oud de gegevens zijn, hangt niet meer aan een melding of een badge. Dat
// is twee keer op verzoek van de gebruiker weggehaald: de melding bovenaan op
// 19-08-2026, de badge bij de Electrum-server op 20-08-2026. Wat het zegt staat
// nog op de plek waar het thuishoort, namelijk onder de reactietijd als
// "as of ... ago" en in de rij "Last checked".
//
// De leeftijd zelf blijft dus in gebruik, alleen niet meer als aparte
// waarschuwing; interval blijft er voor de vergelijking in agoText.
show('alert-nodata', false);
var tls = s.tls || {};
var backend = s.backend || {};
var cert = s.certificate || {};
drawSetup(tls.domain, tls.port);
// Reactietijd. De onderregel met de leeftijd van de meting stond tot 0.0.28
// onder de blokhoogte; die widget is eruit en de regel staat nu hier.
if (typeof backend.response_ms === 'number') {
put('stat-latency', backend.response_ms, ' ms');
el('stat-latency-sub').textContent = age !== null ? 'as of ' + agoText(age) : '';
}
drawSpark(s.history || []);
// Certificaat. Het aantal dagen tot vernieuwing staat sinds 27-08-2026 weer
// als widget op de pagina; het stond er tot 20-08-2026 als tegel en ging er
// toen uit omdat de kaart eronder hetzelfde zei. Die kaart is nu een dialoog,
// dus de dubbeling is weg en het getal hoort weer op de pagina.
var daysLeft = null;
var days = el('cert-days');
days.classList.remove('bad', 'text');
if (cert.not_after) {
daysLeft = Math.floor((cert.not_after - nowSec) / 86400);
put('cert-until', whenLocal(cert.not_after));
if (daysLeft < 0) {
put('cert-days', 'expired');
days.classList.add('bad', 'text');
} else {
put('cert-days', daysLeft + (daysLeft === 1 ? ' day' : ' days'));
}
} else if (!cert.selected) {
// Niet 'unknown': er is niets onbekends aan: er is er geen. De melding
// bovenaan zegt wat je eraan doet, dus hier volstaat het woord.
put('cert-days', 'none');
days.classList.add('text');
} else {
put('cert-days', null);
}
// Geen actief certificaat betekent geen TLS, en dat is het ergste wat deze
// app kan overkomen. Dus geen subtiele grijze tekst maar de foutmelding
// bovenaan, met de reden die de agent meestuurt.
var noCert = !cert.selected;
show('alert-nocert', noCert);
if (noCert) {
el('alert-nocert-body').textContent = (cert.reason || 'The agent did not say why.')
+ ' Wallets cannot connect until a certificate is in use.';
}
el('cert-source').textContent = cert.source
? 'from ' + cert.source + (cert.reason ? ' · ' + cert.reason : '')
: (cert.reason || 'source unknown');
// Twee keer dezelfde naam: als onderregel van de widget en in de dialoog. Dat
// is geen dubbeling maar twee plekken die elkaar niet zien; wie de dialoog
// opent, hoort er niet voor terug te hoeven bladeren.
put('cert-domain', tls.domain);
put('cert-dialog-domain', tls.domain);
put('cert-reload', whenLocal(cert.last_reload));
renderCertAlert(daysLeft);
drawCertChoices(s.certificates, cert.selected, nowSec);
// Backend. "Last checked" stond hier als derde lijstregel en is er op
// 27-08-2026 uit: hetzelfde staat onder de reactietijd als "as of ... ago",
// en een widget met drie onderteksten is geen widget meer.
put('backend-name', backend.name);
if (backend.address) { put('backend-address', backend.address); }
// De versie in de kop. Geen put(): die zet "unknown" neer als het ontbreekt,
// en een kop met "vunknown" is erger dan een kop zonder versie.
if (s.version) { el('head-version').textContent = 'v' + s.version; }
drawSelfCheck(tls.self_check);
// Hier stond een badge met "answered 7 seconds ago". Die is eruit op verzoek
// van de gebruiker (20-08-2026), en met reden: de rij "Last checked" eronder
// zegt hetzelfde, en dan staat dezelfde meting twee keer in één kader.
//
// Wat er zonder die badge niet verloren gaat is het slechte geval: een
// backend die niet antwoordt geeft de rode melding bovenaan de pagina, en die
// is luider dan een badge in een hoek ooit was.
if (backend.reachable === false) {
el('alert-backend-body').textContent =
'At the last check, ' + (backend.address || 'the backend') + ' did not respond. '
+ 'TLS still terminates here, but your wallet has nothing to talk to. '
+ 'Check whether your Electrum server app is running in umbrelOS.';
show('alert-backend', true);
} else {
show('alert-backend', false);
}
drawActivity(s.log);
}
function renderCertAlert(daysLeft) {
if (daysLeft === null || daysLeft === undefined || daysLeft > 30) {
show('alert-cert', false);
return;
}
var node = el('alert-cert');
node.classList.remove('hidden', 'alert-warn', 'alert-error');
if (daysLeft < 0) {
node.classList.add('alert-error');
el('alert-cert-title').textContent = 'The certificate has expired';
el('alert-cert-body').textContent =
'Wallets will refuse this connection. Zoraxy should have renewed it; check the '
+ 'certificate there.';
} else {
node.classList.add('alert-warn');
el('alert-cert-title').textContent =
'The certificate expires in ' + daysLeft + (daysLeft === 1 ? ' day' : ' days');
el('alert-cert-body').textContent =
'Zoraxy normally renews well before this point. If the number keeps dropping, the '
+ 'renewal is not running.';
}
}
// ── De zelfcontrole ─────────────────────────────────────────────────────
// Gebouwd in de agent in 0.0.16, hier getoond sinds de tweede indeling.
//
// Let op het woord dat hier niet staat: "reachable". De agent verbindt van
// container naar container, dus dit zegt niets over de poortmapping naar de host
// of over de doorstuurregel in de router. Alles wat verder gaat, zou de tegel
// laten liegen op precies het punt waar iemand hem gelooft.
//
// Hier stond "Answering", met daaronder "answered in 7 ms, from inside the app".
// De gebruiker meldde op 30-08-2026 dat die tegel onduidelijk is en dat die van
// Evolu Relay ("Running") meteen leest, en dat is terecht om twee redenen:
//
// - "Answering" is een tegenwoordig deelwoord en leest dus als een handeling die
// bezig is, niet als een toestand. "Checking" ernaast maakt dat erger, want
// dát is er wél een;
// - "from inside the app" is de nuance hierboven in vier woorden, en vier
// woorden zijn er te weinig voor. Wie het niet al weet, leest er niets uit.
//
// Wat er nu staat is "Running", met de meting eronder. De nuance is niet
// weggelaten maar verplaatst naar de dialoog "About this app", onder een eigen
// kopje: daar is ruimte om te zeggen wat het wél en niet bewijst. Haal dat kopje
// niet weg zonder hier iets terug te zetten, want dan staat de bewering nergens.
var SELF = {
ok: { label: 'Running', bad: false },
failed: { label: 'Not answering', bad: true },
'wrong-certificate': { label: 'Wrong certificate', bad: true },
// Geen certificaat is de normale begintoestand van een verse installatie en
// dus niet rood; de melding bovenaan zegt al wat eraan te doen is.
off: { label: 'Off', bad: false },
pending: { label: 'Checking', bad: false }
};
function drawSelfCheck(self) {
var node = el('self-state');
var sub = el('self-sub');
var kind = self && self.state ? SELF[self.state] : null;
if (!kind) {
put('self-state', null);
node.classList.remove('bad', 'ok');
// Dit is de toestand op een installatie met een oudere agent. Zwijgen zou
// hier als "in orde" lezen.
sub.textContent = self ? 'Reported as ' + self.state : 'The agent has not reported this yet.';
return;
}
put('self-state', kind.label);
node.classList.toggle('bad', kind.bad);
// Alleen 'ok' krijgt het accent. 'off' en 'pending' zijn niet fout maar ook
// niet in orde, en die horen dus de gewone tekstkleur te houden.
node.classList.toggle('ok', self.state === 'ok');
if (self.state === 'ok') {
// Wat er gemeten is, in gewone woorden. Het getal blijft, want dat is het
// enige harde gegeven op deze tegel; wat het niet bewijst staat in de
// dialoog "About this app".
sub.textContent = 'the TLS port answered in ' + self.ms + ' ms';
} else if (self.state === 'off') {
sub.textContent = 'no certificate selected, so nothing is listening';
} else if (self.state === 'wrong-certificate') {
sub.textContent = 'not the certificate you selected; a reload may have failed';
} else if (self.state === 'pending') {
sub.textContent = 'measuring again after the certificate change';
} else {
sub.textContent = self.error ? String(self.error) : 'no reason given';
}
}
// ── Certificaatkeuze ────────────────────────────────────────────────────
// De lus schrijft in status.json wat hij in de drie gemounte mappen gevonden
// heeft. De keuze gaat er met een PUT naartoe; de lus leest dat bestand en
// herlaadt nginx. De pagina bevestigt dus niet zelf dat het gelukt is, hij
// wacht tot de volgende status.json het bevestigt.
var SELECT_URL = 'api/certificate';
var chosenId = null;
function drawCertChoices(certs, selected, nowSec) {
var box = el('cert-choices');
var apply = el('cert-apply');
box.innerHTML = '';
// Het aantal in de kop, want zodra de lijst scrollt is niet meer te zien
// hoeveel er onder de rand staan.
var count = certs ? certs.length : 0;
el('cert-count').textContent = count ? count + ' found' : '';
if (!certs || !certs.length) {
var empty = document.createElement('div');
empty.className = 'cert-empty';
empty.textContent = 'No certificates found yet. Once the app has looked through Zoraxy, '
+ 'Nginx Proxy Manager and its own folder, they are listed here.';
box.appendChild(empty);
apply.disabled = true;
return;
}
// De keuze van de gebruiker overleeft een verversing. Het oude keuzerondje
// deed dat niet: elke ronde zette de lijst zichzelf terug op wat er in
// gebruik was, dus een keuze die je net gemaakt had verdween binnen tien
// seconden weer. Vandaar dat chosenId hier voorgaat op selected.
var show = chosenId || selected;
var select = document.createElement('select');
select.className = 'cert-select';
if (!show) {
var hint = document.createElement('option');
hint.value = '';
hint.textContent = 'Pick the one matching the name your wallet connects to...';
select.appendChild(hint);
}
for (var i = 0; i < certs.length; i++) {
var c = certs[i];
var option = document.createElement('option');
option.value = c.id;
option.textContent = choiceLabel(c, selected, nowSec);
// Een onleesbaar certificaat staat er wel in, want het verzwijgen maakt
// een verkeerde keuze onvindbaar, maar het is niet te kiezen: de agent
// weigert het even hard, en dan liever hier dan na een klik.
option.disabled = !!c.unreadable;
option.selected = c.id === show;
select.appendChild(option);
}
select.addEventListener('change', function () {
chosenId = select.value || null;
updateApply(selected);
});
box.appendChild(select);
updateApply(selected);
}
// Alles in één regel tekst, want opmaak binnen een option is per browser
// anders. De volgorde is die van het kiezen: eerst de naam waarop je wallet
// verbindt, dan waar het vandaan komt, dan of het nog geldig is.
function choiceLabel(c, selected, nowSec) {
var parts = [c.domain || c.name || c.id];
if (c.name && c.name !== c.domain) { parts.push(c.name); }
parts.push(c.source || 'unknown source');
if (c.unreadable) {
parts.push('cannot be read');
} else if (!c.not_after) {
parts.push('date unknown');
} else if (c.not_after < nowSec) {
parts.push('expired');
} else {
var days = Math.floor((c.not_after - nowSec) / 86400);
parts.push(days + (days === 1 ? ' day left' : ' days left'));
}
if (c.id === selected) { parts.push('in use'); }
return parts.join(' · ');
}
function updateApply(selected) {
var apply = el('cert-apply');
apply.disabled = !chosenId || chosenId === selected;
if (!chosenId || chosenId === selected) {
el('cert-apply-note').textContent = '';
}
}
el('cert-apply').addEventListener('click', function () {
if (!chosenId) { return; }
var apply = el('cert-apply');
var note = el('cert-apply-note');
apply.disabled = true;
note.textContent = 'Saving the choice...';
fetch(SELECT_URL, {
method: 'PUT',
headers: { 'Content-Type': 'text/plain' },
body: chosenId
}).then(function (r) {
if (!r.ok) { throw new Error('status ' + r.status); }
// Bewust geen "done": nginx moet het certificaat nog herladen, en dat
// blijkt pas uit de volgende status.json.
note.textContent = 'Saved. The gateway reloads within a minute; this page will show it.';
setTimeout(load, 5000);
}).catch(function (e) {
note.textContent = 'Could not save the choice (' + e.message + '). '
+ 'The agent may not be running; check the app logs in umbrelOS.';
apply.disabled = false;
});
});
// ── Een certificaat uploaden ────────────────────────────────────────────
// Twee bestanden als tekst in een JSON-verzoek, en niet als multipart-formulier:
// de agent zou daar een parser voor nodig hebben die sinds Python 3.13 niet meer
// in de standaardbibliotheek zit, en inlezen kan de browser zelf.
//
// De pagina valideert bewust niets. De agent controleert of de sleutel bij het
// certificaat hoort, en dat is de enige controle die telt; hier iets voorzeggen
// zou twee waarheden opleveren die uit elkaar gaan lopen.
var UPLOAD_URL = 'api/certificate/upload';
function readFile(input) {
return new Promise(function (resolve, reject) {
var file = input.files && input.files[0];
if (!file) { resolve(null); return; }
var reader = new FileReader();
reader.onload = function () { resolve(String(reader.result)); };
reader.onerror = function () { reject(new Error('could not read ' + file.name)); };
reader.readAsText(file);
});
}
el('up-send').addEventListener('click', function () {
var button = el('up-send');
var note = el('up-note');
Promise.all([readFile(el('up-cert')), readFile(el('up-key'))])
.then(function (files) {
if (!files[0] || !files[1]) {
throw new Error('Pick both files: the certificate and its key.');
}
button.disabled = true;
note.textContent = 'Checking the pair...';
return fetch(UPLOAD_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cert: files[0], key: files[1] })
});
})
.then(function (r) {
return r.json().then(function (body) {
if (!r.ok) { throw new Error(body.error || ('status ' + r.status)); }
return body;
});
})
.then(function (body) {
// Wel voorselecteren, niet toepassen. Uploaden en kiezen blijven twee
// handelingen, zodat er precies één plek is waar TLS van certificaat
// wisselt; het kost de gebruiker één klik op de knop hierboven.
if (body.id) { chosenId = body.id; }
note.textContent = 'Added. It is selected above, now press '
+ '"Use this certificate".';
button.disabled = false;
load();
})
.catch(function (e) {
note.textContent = e.message;
button.disabled = false;
});
});
// ── Het instelkader ─────────────────────────────────────────────────────
// Twee vormen van hetzelfde adres, dus twee kopieerknoppen (27-08-2026). Hier
// stond een kopieerknop per wallet, tien in totaal, en dat waren negen keer
// dezelfde handeling. Dat draait een besluit van 19-08-2026 terug; PLAN.md 4a2
// zegt waarom, zodat het niet per ongeluk hersteld wordt.
//
// De wallets blijven wél onder de vorm staan die ze vragen. Dat is de
// voorwaarde bij die vereenvoudiging: welke wallet welk formaat wil, is precies
// wat dit kader te vertellen heeft, en één lijst naast twee losse knoppen
// gooit dat weg.
//
// Zonder domein en poort blijft de waarde op 'unknown' staan, net als elders op
// de pagina. Een half ingevuld adres dat iemand toch kopieert is erger dan een
// leeg veld, dus de kopieerknop staat dan uit.
var oneLineValue = null;
var splitValue = null;
function drawSetup(domain, port) {
oneLineValue = (domain && port) ? domain + ':' + port + ':s' : null;
splitValue = domain || null;
put('oneline-value', oneLineValue);
put('split-value', splitValue);
el('split-port').textContent = port ? 'port ' + port : '';
el('oneline-copy').disabled = !oneLineValue;
el('split-copy').disabled = !splitValue;
}
// Eén keer getekend, want deze lijst verandert nooit; hij komt uit
// Referenties/Clients.md. De vindplaats staat erbij en dat is bewust: het gaat
// erom je eigen wallet te herkennen, en dan is "waar zit die instelling" de
// volgende vraag.
function walletList(id, clients) {
var box = el(id);
box.innerHTML = '';
for (var i = 0; i < clients.length; i++) {
var row = document.createElement('div');
var name = document.createElement('strong');
name.textContent = clients[i].name;
row.appendChild(name);
row.appendChild(document.createTextNode(' · ' + clients[i].where));
box.appendChild(row);
}
}
el('oneline-copy').addEventListener('click', function () {
if (oneLineValue) { copy(oneLineValue, el('oneline-copy')); }
});
el('split-copy').addEventListener('click', function () {
if (splitValue) { copy(splitValue, el('split-copy')); }
});
// ── Grafiekjes ──────────────────────────────────────────────────────────
function drawSpark(history) {
var svg = el('spark');
var points = [];
for (var i = 0; i < history.length; i++) {
if (typeof history[i].response_ms === 'number') { points.push(history[i].response_ms); }
}
if (points.length < 2) { svg.innerHTML = ''; return; }
var max = Math.max.apply(null, points);
var min = Math.min.apply(null, points);
var span = (max - min) || 1;
var d = '';
for (var j = 0; j < points.length; j++) {
var x = (j / (points.length - 1)) * 100;
var y = 32 - ((points[j] - min) / span) * 28;
d += (j === 0 ? 'M' : 'L') + x.toFixed(2) + ' ' + y.toFixed(2) + ' ';
}
svg.innerHTML = '<path d="' + d.trim() + '" fill="none" stroke="var(--accent)" '
+ 'stroke-width="1.6" stroke-linejoin="round" stroke-linecap="round" '
+ 'vector-effect="non-scaling-stroke"></path>';
}
// ── Activiteitenlog ─────────────────────────────────────────────────────
// Regels komen uit status.json. Nginx schrijft een stream-sessie pas weg bij
// het sluiten, dus de open-regel en de verkeersregels worden door de
// achtergrondlus uit de verbindingsteller afgeleid; zie PLAN.md paragraaf 4e.
//
// 'session ended' en niet 'disconnected'. De gebruiker vroeg op 20-08-2026 wat
// die regel betekende, en dat was terecht: er stond een tijd, een duur en twee
// aantallen bytes, en niets zei dat de tijd het éinde van een sessie is en de
// duur terugloopt. Zo'n regel leest als een storing, terwijl het de gewone
// afsluiting van een wallet-verbinding is.
//
// 'probe' staat er sinds 20-08-2026 naast 'refused', en niet in het rood: een
// doorgestuurde poort wordt gescand, en zo'n scan gaf een rode regel "refused"
// terwijl er niets geweigerd is en niets gebeurd is. Rood hoort voor het geval
// waarin er wél verkeer was en het daarna stukliep, want dat is het geval waar
// je iets aan moet doen.
var EVENTS = {
connect: { label: 'connected', cls: 'log-open' },
disconnect: { label: 'session ended', cls: 'log-close' },
traffic: { label: 'traffic', cls: 'log-data' },
probe: { label: 'probe', cls: 'log-close' },
refused: { label: 'refused', cls: 'log-fail' },
reload: { label: 'cert reload', cls: 'log-open' },
start: { label: 'gateway up', cls: 'log-open' }
};
// Hier stond kort een badge met "1 wallet connected". Die is er op verzoek van
// de gebruiker weer uit (20-08-2026), samen met de andere badges: het log zelf is
// genoeg. De verbindingsteller blijft wél bestaan, want die levert de
// connected-regel in dit log; hij staat alleen niet meer apart in een hoek.
function drawActivity(lines) {
var box = el('activity-log');
box.innerHTML = '';
if (!lines || !lines.length) {
var empty = document.createElement('div');
empty.className = 'log-empty';
empty.textContent = 'Nothing recorded yet. Lines appear here as wallets connect '
+ 'through the gateway.';
box.appendChild(empty);
return;
}
var newestFirst = lines.slice().sort(function (a, b) { return b.t - a.t; });
for (var i = 0; i < newestFirst.length; i++) {
box.appendChild(logRow(newestFirst[i]));
}
}
function logRow(entry) {
var kind = EVENTS[entry.event] || { label: entry.event || 'event', cls: 'log-close' };
var row = document.createElement('div');
row.className = 'log-line';
var time = document.createElement('span');
time.className = 'log-time';
time.textContent = clockLocal(entry.t);
var event = document.createElement('span');
event.className = 'log-event ' + kind.cls;
event.textContent = kind.label;
var detail = document.createElement('span');
detail.className = 'log-detail';
detail.textContent = logDetail(entry);
row.appendChild(time);
row.appendChild(event);
row.appendChild(detail);
return row;
}
// "up" en "down" waren hier niet te volgen: het is niet duidelijk wie van de
// twee omhoog stuurt. Nu staat de richting er met de wallet erbij, en bij de
// duur staat dat hij achterwaarts geldt.
function logDetail(entry) {
var parts = [];
if (typeof entry.seconds === 'number') { parts.push('lasted ' + duration(entry.seconds)); }
// Nul beide kanten op wordt weggelaten. Bij een scan zou er anders
// "0 B from wallet, 0 B to wallet" staan naast een notitie die al zegt dat er
// niets doorgegeven is, en "wallet" is dan ook het verkeerde woord: het was
// geen wallet.
if (entry.bytes_in || entry.bytes_out) {
parts.push(bytes(entry.bytes_in) + ' from wallet, ' + bytes(entry.bytes_out) + ' to wallet');
}
if (entry.note) { parts.push(entry.note); }
return parts.join(' · ');
}
function clockLocal(unix) {
if (!unix) { return '--:--:--'; }
var d = new Date(unix * 1000);
return pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds());
}
function pad(n) { return (n < 10 ? '0' : '') + n; }
function duration(sec) {
if (!sec && sec !== 0) { return 'length unknown'; }
if (sec < 60) { return sec + ' s'; }
if (sec < 3600) { return Math.round(sec / 60) + ' min'; }
return (sec / 3600).toFixed(1) + ' h';
}
function bytes(n) {
if (typeof n !== 'number') { return '?'; }
if (n < 1024) { return n + ' B'; }
if (n < 1048576) { return (n / 1024).toFixed(1) + ' kB'; }
return (n / 1048576).toFixed(1) + ' MB';
}
// ── Kopieerknop ─────────────────────────────────────────────────────────
// De knop is niet optioneel meer: sinds het losse verbindingsadres eruit is, is
// elke kopieerknop er een uit een clientregel. Een terugval op een vast element
// zou nu naar iets wijzen dat niet bestaat.
function copy(text, btn) {
function done(ok) {
btn.textContent = ok ? 'Copied' : 'Press Ctrl+C';
setTimeout(function () { btn.textContent = 'Copy'; }, 2000);
}
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(text).then(function () { done(true); }, function () { done(false); });
return;
}
// Zonder https is de clipboard-API er niet, en de app wordt lokaal over
// http bekeken. Vandaar deze terugval.
var ta = document.createElement('textarea');
ta.value = text;
ta.setAttribute('readonly', '');
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
var ok = false;
try { ok = document.execCommand('copy'); } catch (e) { ok = false; }
document.body.removeChild(ta);
done(ok);
}
// ── Ophalen ─────────────────────────────────────────────────────────────
function load() {
fetch(STATUS_URL, { cache: 'no-store' })
.then(function (r) {
if (!r.ok) { throw new Error('status ' + r.status); }
return r.json();
})
.then(function (s) { render(s, Math.floor(Date.now() / 1000)); })
.catch(function () {
show('alert-nodata', true);
});
}
walletList('oneline-wallets', CLIENTS_ONELINE);
walletList('split-wallets', CLIENTS_SPLIT);
drawSetup(null, null);
drawSelfCheck(null);
drawActivity(null);
drawCertChoices(null, null, Math.floor(Date.now() / 1000));
load();
setInterval(load, REFRESH_MS);
})();
</script>
</body>
</html>