Beide pagina's volgen nu HomeGit/Docs/website-design-system.html, in dezelfde ronde als AssetApp. Drie dingen, en de volgorde is niet willekeurig: - een accentgloed achter de pagina, uit de linkerbovenhoek. Dit is de VOORWAARDE voor de rest en niet de finishing touch: een backdrop-filter op een egale ondergrond levert per definitie niets op, dus er moet eerst iets achter de vlakken staan; - kaarten doorschijnend, met een randlicht bovenlangs en een schaduw uit een token; - één hoek voor elk buitenste kader. De kaart van --radius-xl naar --radius-lg, gelijk aan het logkader, dat zijn geneste hoek daarvan afleidt. Het volle accentpalet van AssetApp (vijf kleuren, licht en donker) staat erin; beide apps blijven op oranje en krijgen bewust geen knop. Om dezelfde reden staat de diepte hier vast aan, terwijl het in AssetApp een instelling is: dit zijn statuspagina's met licht/donker en verder geen bediening. --pos is groen geworden in plaats van oranje. Hij volgde het accent, en dat las als merkkleur in plaats van als betekenis; zichtbaar gevolg is dat de badge "Running" nu groen is. Meegevonden en meteen gerepareerd: prefers-reduced-motion en prefers-reduced-transparency mogen niet in één media-query. Windows heeft een schakelaar "Animatie-effecten" die bij veel mensen uit staat en die zet reduced-motion op reduce; samen in één query bleef er van de hele opmaak niets over. Ze staan apart, en reduced-motion haalt alleen de overgang weg. Alleen de pagina's, dus geen nieuwe images: VERSION in tools/evolu-relay/build.sh blijft staan en alleen de manifesten gaan omhoog. Suite groen: 22 + 43 + 54 + 22 + 65 + 39 + 115. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2006 lines
96 KiB
Plaintext
2006 lines
96 KiB
Plaintext
<!DOCTYPE html>
|
||
<!--
|
||
Statuspagina van Electrum Gate.
|
||
|
||
Dit bestand is een template en dat is bewust. umbreld ververst bij een update
|
||
alleen een whitelist, en *.template staat daarin; een pagina onder web/ zou na
|
||
een update ongewijzigd blijven staan zonder dat er iets gemeld wordt.
|
||
|
||
Twee regels die daaruit volgen en die bij het wijzigen tellen:
|
||
|
||
1. gebruik geen accolade-variabelen behalve de umbrel-variabelen die hier
|
||
werkelijk ingevuld moeten worden. De invulling vervangt elke accolade-vorm
|
||
en zou de rest stilzwijgend leegmaken. Daarom staat er in het script
|
||
hieronder nergens een template-literal maar overal string-optelling;
|
||
2. wat de pagina niet uit een umbrel-variabele kan halen, komt uit status.json
|
||
dat de achtergrondlus in docker-compose.yml wegschrijft. 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.
|
||
-->
|
||
<html lang="en" data-theme="dark" data-accent="orange" data-depth="deep">
|
||
<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 staat hij vast aan, want dit is een
|
||
statuspagina met dark/light en verder geen knoppen, en een schuif erbij zou
|
||
meer bediening zijn dan de pagina zelf.
|
||
|
||
Het werkt omdat er iets ACHTER de vlakken komt te staan: een accentgloed uit
|
||
de linkerbovenhoek. Een backdrop-filter op een egale ondergrond levert per
|
||
definitie niets op, dus de gloed is het eerste deel en het glas het tweede. */
|
||
:root {
|
||
--surface: var(--bg-elevated);
|
||
--surface-inset: var(--bg-raised);
|
||
--surface-blur: 16px;
|
||
--surface-sat: 135%;
|
||
--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%);
|
||
}
|
||
[data-depth="deep"] {
|
||
--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) {
|
||
:root {
|
||
--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; }
|
||
}
|
||
|
||
/* 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 vijf, 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 ────────────────────────────────────────────────────────
|
||
Vijf gelijke widgets (27-08-2026). 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, want vijf
|
||
past daar niet in. Dat is nieuw op deze pagina: elke andere rij viel in tweeën.
|
||
|
||
Wat er bij het versmallen gebeurt, is daarom expliciet en niet aan auto-fit
|
||
overgelaten. Met vijf items levert elke stap een ongelijke laatste rij op, en
|
||
dan wil je zelf bepalen wélke widget alleen komt te staan. Dat is steeds de
|
||
laatste, de zelfcontrole, en die is als enige begrijpelijk zonder buur. */
|
||
.row1 {
|
||
grid-column: 1 / -1;
|
||
display: grid;
|
||
grid-template-columns: repeat(5, 1fr);
|
||
gap: 1.5rem;
|
||
}
|
||
/* Onder deze breedte wordt een widget van een vijfde te smal voor "Wrong
|
||
certificate" of een blokhoogte met scheidingstekens. Drie plus twee. */
|
||
@media (max-width: 1400px) { .row1 { grid-template-columns: repeat(3, 1fr); } }
|
||
@media (max-width: 900px) { .row1 { grid-template-columns: repeat(2, 1fr); } }
|
||
@media (max-width: 560px) { .row1 { grid-template-columns: 1fr; } }
|
||
|
||
/* De widgets zetten hun waarde onderaan, zodat de vijf 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 vijf de 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 allebei werkt. Groter en "Wrong certificate" loopt op
|
||
een vijfde van de breedte over drie regels; kleiner en een blokhoogte verliest
|
||
het gewicht dat hem 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. */
|
||
.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">v${APP_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">
|
||
|
||
<!-- Vijf widgets, alle vijf 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.
|
||
|
||
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. Vijf 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 al onder de blokhoogte als "as of ... ago". -->
|
||
<div class="stat-sub mono" id="backend-address">${APP_ELECTRS_NODE_IP}:${APP_ELECTRS_NODE_PORT}</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Reactietijd vóór blokhoogte, omgedraaid op verzoek van de gebruiker
|
||
(27-08-2026). Hij staat daarmee naast de Electrum-server waar hij over
|
||
gaat, en de blokhoogte schuift naar het midden van de rij. 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. -->
|
||
<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>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<div class="card-head">
|
||
<h2 class="t-h3">Block height</h2>
|
||
</div>
|
||
<div class="stat-body">
|
||
<div class="stat-val unknown" id="stat-height">unknown</div>
|
||
<div class="stat-sub" id="stat-height-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 in de badge bij de Electrum-server en onder de blokhoogte,
|
||
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); }
|
||
|
||
function num(n) {
|
||
return String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ' ');
|
||
}
|
||
|
||
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 blokhoogte 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);
|
||
|
||
// Blokhoogte
|
||
if (typeof backend.height === 'number') {
|
||
put('stat-height', num(backend.height));
|
||
el('stat-height-sub').textContent = age !== null ? 'as of ' + agoText(age) : '';
|
||
}
|
||
|
||
// Reactietijd
|
||
if (typeof backend.response_ms === 'number') {
|
||
put('stat-latency', backend.response_ms, ' ms');
|
||
}
|
||
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 blokhoogte 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); }
|
||
|
||
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');
|
||
// 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);
|
||
|
||
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>
|