Files
UmbrelApps/whatsnext-electrum-gate/index.html.template
T

1339 lines
59 KiB
Plaintext
Raw Normal View History

2026-08-25 16:27:57 +02:00
<!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">
<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-text: #FF8124;
--pos: #FF8124;
--pos-bg: rgba(255,107,0,0.18);
--neg: #ff5a5a;
--neg-bg: rgba(255,69,69,0.18);
--neutral-bg: rgba(255,255,255,0.08);
--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.07);
--border-mid: rgba(0,0,0,0.11);
--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-text: #E05500;
--pos: #D04800;
--pos-bg: rgba(224,85,0,0.08);
--neg: #cc2222;
--neg-bg: rgba(204,34,34,0.08);
--neutral-bg: rgba(0,0,0,0.04);
--scrollbar: #ddd;
}
* { 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 { 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 .stat-grid hieronder, anders valt de rij tegels
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; }
.col-5 { grid-column: span 5; }
.col-7 { grid-column: span 7; }
.col-full { grid-column: 1 / -1; }
.grid .card { margin-bottom: 0; }
/* Onder deze breedte is naast elkaar geen winst meer maar gedrang. */
@media (max-width: 1100px) {
.col-5, .col-7 { grid-column: 1 / -1; }
}
/* ── 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 {
background: var(--bg-elevated);
--radius: var(--radius-xl);
--padding: 1.8rem;
border-radius: var(--radius);
padding: var(--padding);
margin-bottom: 1rem;
}
[data-theme="dark"] .card { border: 1px solid var(--border); }
[data-theme="light"] .card { box-shadow: 0 1px 3px rgba(0,0,0,0.05), 0 6px 24px rgba(0,0,0,0.04); }
.card-head { display: flex; align-items: center; justify-content: space-between; gap: 1rem; margin-bottom: 1.1rem; }
/* Drie kolommen, en de reactietijd neemt er twee. Er stonden drie tegels van
gelijke breedte; de derde was de resterende geldigheid van het certificaat, en
die is er op 20-08-2026 uit omdat de kaart eronder hetzelfde zegt met een datum
erbij. De ruimte gaat naar de sparkline, want dat is het enige hier dat
daadwerkelijk breedte nodig heeft. */
.stat-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1.5rem; }
.stat-wide { grid-column: span 2; }
/* De drie tegels staan naast een kader met drie lijstregels en zijn van zichzelf
lager, wat een gat rechtsboven oplevert. Ze zetten hun getal dus onderaan, zodat
de drie getallen onderling op één lijn staan én op de laatste regel van het kader
ernaast. In de gestapelde weergave doet dit niets, want daar is geen buur om je
aan te meten. */
@media (min-width: 1101px) {
.stat-grid .card { display: flex; flex-direction: column; }
.stat-grid .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; }
.stat-val { font-size: 1.9rem; font-weight: 700; letter-spacing: -0.025em; font-variant-numeric: tabular-nums; line-height: 1.1; }
.stat-val.unknown { color: var(--text-ter); font-size: 1.5rem; }
/* 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. */
.stat-sub { font-size: 0.75rem; color: var(--text-sec); margin-top: 0.5rem; min-height: 34px; }
/* ── 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-xl) - 1.8rem));
padding: 0.9rem 1.1rem;
font-family: var(--font-mono);
font-size: 0.75rem;
line-height: 1.9;
/* flex-basis 0 en niet auto, en dát is de hele hoogtekwestie van 20-08-2026.
Met basis auto telt de inhoud van dit blok mee voor de natuurlijke hoogte van
zijn kader, en dan duwt een log met tien regels de rij hoger dan het
certificaatkader ernaast nodig heeft. Het gevolg zag je aan de andere kant:
leegte onder de knop "Use this certificate", die precies verdween als je het
uploadblok uitklapte.
Met basis 0 draagt dit blok niets bij aan die natuurlijke hoogte. Het
certificaatkader bepaalt de rij, dit blok groeit ernaartoe, en uitklappen laat
dus beide kaders meegroeien. Wat er niet in past, scrolt hier, en dat is waar
een log voor gemaakt is.
Geen max-height meer: de rij is nu begrensd door het kader ernaast, dus een
eigen plafond zou alleen weer leegte onderaan opleveren. */
flex: 1 1 0;
/* De bodem voor het geval het certificaatkader ooit korter is dan dit, zoals
wanneer er geen enkel certificaat gevonden is. Er passen vier regels in. */
min-height: 110px;
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; }
.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%; }
/* Op één kolom staat er geen kader meer naast om de hoogte van te lenen, dus
hier neemt het log wél zijn eigen hoogte. Met een plafond, want anders wordt
een etmaal aan regels één lange pagina; daarboven scrolt het blok zelf. */
.log { flex: 0 0 auto; min-height: 0; max-height: 60vh; }
/* De tegels op elkaar, en dan neemt de reactietijd geen twee kolommen meer. */
.stat-grid { grid-template-columns: 1fr; }
.stat-wide { grid-column: auto; }
}
@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>
<button class="btn" id="theme-toggle" type="button" title="Switch between light and dark">
<span id="theme-label">Light</span>
</button>
</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>
</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">
<section class="card col-5">
<div class="card-head">
<h2 class="t-h3">Electrum server</h2>
</div>
<div class="list-item">
<div>
<div class="list-item-title">Address</div>
<div class="list-item-sub">Chosen in the umbrelOS settings, not in this app</div>
</div>
<div class="list-item-val mono" id="backend-address">${APP_ELECTRS_NODE_IP}:${APP_ELECTRS_NODE_PORT}</div>
</div>
<div class="list-item">
<div>
<div class="list-item-title">Implementation</div>
<div class="list-item-sub">Recognised by address; shown as unknown if it has moved</div>
</div>
<div class="list-item-val unknown" id="backend-name">unknown</div>
</div>
<div class="list-item">
<div>
<div class="list-item-title">Last checked</div>
<div class="list-item-sub">Written by the proxy itself, not measured by this page</div>
</div>
<div class="list-item-val unknown" id="backend-checked">unknown</div>
</div>
</section>
<!-- De drie getallen die zeggen of het goed gaat. Precies zo opgebouwd als elk
ander kader op deze pagina: een card-head met een titel erin, dan de inhoud.
Ze hadden eerst een eigen kleine grijze kop in kapitalen, en dat maakte ze
een apart soort ding zonder dat daar een reden voor was. -->
<div class="stat-grid col-7">
<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>
<!-- Twee kolommen breed. Hiernaast stond een derde tegel met het aantal dagen
dat het certificaat nog geldig is, en die is er op verzoek van de
gebruiker uit (20-08-2026): de kaart Certificate hieronder zegt hetzelfde
en noemt de datum erbij, en bij minder dan dertig dagen staat er een
melding bovenaan de pagina. De vrijgekomen ruimte gaat hierheen, want de
sparkline is het enige op deze rij dat er iets mee doet. -->
<div class="card stat-wide">
<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>
<section class="card col-5">
<div class="card-head">
<h2 class="t-h3">Certificate</h2>
</div>
<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-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.
Hier en niet onder de keuzeknop, op verzoek van de gebruiker
(20-08-2026). Twee redenen: het spaart een horizontale lijn uit, want de
rij hierboven trekt er al een, en het is de logische volgorde. Wat je
uploadt komt in de keuzelijst hieronder 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, om twee redenen. Het scheelt hoogte, en dit kader bepaalt de
hoogte van de rij. En 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>
</section>
<!-- 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.
Staat rechts en is het brede kader van de twee: logregels zijn monospace en
hebben breedte nodig, en zo valt de kolomgrens samen met die van de rij
erboven in plaats van ertegenin. -->
<section class="card col-7">
<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">
<div class="card-head">
<h2 class="t-h3">Setting up your wallet</h2>
</div>
<!-- Hier stond het verbindingsadres nog een keer groot, met een kopieerknop.
Weg op verzoek van de gebruiker (19-08-2026), en zonder verlies: datzelfde
adres staat hieronder bij elke client, in de vorm die díe client wil, en
daar zit de kopieerknop ook. -->
<p class="t-caption" style="margin: 0 0 1.4rem;">
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>
<div class="t-label" style="margin-bottom: 0.4rem;">Paste as one line</div>
<div id="clients-oneline"></div>
</div>
<div>
<div class="t-label" style="margin-bottom: 0.4rem;">Host and port separately, with SSL turned on</div>
<div id="clients-split"></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>
</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);
document.getElementById('theme-label').textContent = theme === 'dark' ? 'Light' : 'Dark';
}
document.getElementById('theme-toggle').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 */ }
});
// ── 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 || {};
drawClients(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
var daysLeft = null;
// daysLeft stuurt alleen nog de melding bovenaan; de tegel die dit getal
// toonde is eruit. De kaart hieronder noemt de datum, en dat is wat je wilt
// weten als je hier komt kijken.
if (cert.not_after) {
daysLeft = Math.floor((cert.not_after - nowSec) / 86400);
put('cert-until', whenLocal(cert.not_after));
}
// 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');
put('cert-domain', tls.domain);
put('cert-reload', whenLocal(cert.last_reload));
renderCertAlert(daysLeft);
drawCertChoices(s.certificates, cert.selected, nowSec);
// Backend
put('backend-name', backend.name);
if (backend.address) { put('backend-address', backend.address); }
put('backend-checked', age !== null ? agoText(age) : null);
// 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.';
}
}
// ── 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;
});
});
// ── Clientregels ────────────────────────────────────────────────────────
// Zonder domein en poort krijgt elke rij hetzelfde onbekend-veld als de rest
// van de pagina. Een half ingevulde regel die iemand toch kopieert, is erger
// dan een lege.
function drawClients(domain, port) {
fill('clients-oneline', CLIENTS_ONELINE, function () {
return (domain && port) ? domain + ':' + port + ':s' : null;
});
fill('clients-split', CLIENTS_SPLIT, function () {
return domain ? domain : null;
}, port ? 'port ' + port : null);
}
function fill(containerId, clients, valueFor, extra) {
var box = el(containerId);
box.innerHTML = '';
for (var i = 0; i < clients.length; i++) {
var value = valueFor();
var row = document.createElement('div');
row.className = 'list-item';
var left = document.createElement('div');
var title = document.createElement('div');
title.className = 'list-item-title';
title.textContent = clients[i].name;
var sub = document.createElement('div');
sub.className = 'list-item-sub';
sub.textContent = clients[i].where;
left.appendChild(title);
left.appendChild(sub);
var right = document.createElement('div');
right.style.display = 'flex';
right.style.alignItems = 'center';
right.style.gap = '0.6rem';
var val = document.createElement('span');
val.className = 'list-item-val mono' + (value ? '' : ' unknown');
val.textContent = value ? value : 'unknown';
right.appendChild(val);
if (extra) {
var note = document.createElement('span');
note.className = 'list-item-meta t-caption';
note.textContent = extra;
right.appendChild(note);
}
if (value) {
right.appendChild(copyButton(value));
}
row.appendChild(left);
row.appendChild(right);
box.appendChild(row);
}
}
function copyButton(value) {
var b = document.createElement('button');
b.type = 'button';
b.className = 'btn';
b.textContent = 'Copy';
b.addEventListener('click', function () {
copy(value, b);
});
return b;
}
// ── 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);
});
}
drawClients(null, null);
drawActivity(null);
drawCertChoices(null, null, Math.floor(Date.now() / 1000));
load();
setInterval(load, REFRESH_MS);
})();
</script>
</body>
</html>