Files

1430 lines
68 KiB
HTML
Raw Permalink Normal View History

<!DOCTYPE html>
<!--
═══════════════════════════════════════════════════════════════════════════════
De statuspagina van Evolu Relay.
Zichtbare tekst is Engels: dit is een publieke app store en dit is wat een
bezoeker ziet. Commentaar is Nederlands.
Dit bestand zit in de image (tools/evolu-relay/Dockerfile) als
/usr/share/nginx/html/index.html. Tot 0.5.6 was het een *.template in de
app-map die umbreld bij het starten door envsubst haalde, en daarom plakt
alles hieronder strings met een plus aan elkaar in plaats van met een
template-literal. Die beperking is weg, maar de stijl is gebleven: één pagina
in twee stijlen is erger dan één stijl die niet meer nodig is. De versie in de
kop komt uit api/status, waar umbreld hem vroeger rechtstreeks invulde.
De maatvoering komt uit Electrum Gate en niet uit een eigen ontwerp: dezelfde
breedte, hetzelfde raster, dezelfde kop met een icoon van 64 pixels, hetzelfde
menu met drie punten, en het versienummer achter de tagline. Dat is op verzoek
van de gebruiker (30-08-2026) en het is de reden dat er hier meer CSS staat dan
de pagina op het eerste gezicht nodig heeft: twee apps naast elkaar op hetzelfde
dashboard horen niet twee ontwerpen te zijn.
Wat er bewust NIET uit overgenomen is: de verwijzing naar Google Fonts. Een app
op een Umbrel hoort niet te wachten op een lettertype van buiten.
Drie keuzes die uit diezelfde ronde komen en die je bij het wijzigen moet
kennen, want ze zijn makkelijk terug te draaien met de beste bedoelingen:
1. er staan geen uitleggende regels meer in de kaders. De koppen zeggen wat er
staat; wat er verder te weten is, staat in de dialoog "About this app";
2. knoppen zijn onzichtbaar tot je over de regel of het kader gaat. Met opacity
en niet met display:none, zodat ze met de tab-toets bereikbaar blijven;
3. er is geen voetregel. Het versienummer stond daar en staat nu achter de
tagline, waar je het zoekt als je je afvraagt of een wijziging wel uitgerold
is.
═══════════════════════════════════════════════════════════════════════════════
-->
<!-- data-glow schakelt de accentgloed achter de pagina. "off" is de stand sinds
0.5.5 (verzoek van de gebruiker, 04-09-2026); "on" zet hem terug, en dat is
het enige wat er voor nodig is: alles staat in de tokenlaag onder "Diepte" en
geen enkele regel hieronder kent de stand.
De gloed hangt aan de combinatie met data-depth="deep" en niet aan zichzelf.
Dat is een eis van de gebruiker en het is ook de enige zinnige lezing: de
gloed is de onderste van de vier dieptelagen, dus hij aanzetten terwijl de
pagina vlak staat zou een verloop achter dekkende vlakken leggen. -->
<html lang="en" data-theme="dark" data-accent="orange" data-depth="deep" data-glow="off">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Evolu Relay</title>
<link rel="icon" href="icon.png">
<style>
:root {
--radius-sm: 8px;
--radius-md: 14px;
--radius-lg: 20px;
--radius-xl: 28px;
--radius-full: 999px;
--font-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--font-mono: 'SF Mono', 'Fira Code', 'Cascadia Mono', Consolas, monospace;
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
}
[data-theme="dark"][data-accent="orange"] {
--bg: #080808; --bg-elevated: #181818; --bg-raised: #242424; --bg-overlay: #303030;
--border: rgba(255,255,255,0.18); --border-mid: rgba(255,255,255,0.28);
--text-primary: #f2f2f2; --text-sec: #a3a3a3; --text-ter: #6a6a6a;
--accent: #FF6B00; --accent-soft: rgba(255,107,0,0.16); --accent-mid: rgba(255,107,0,0.26);
--accent-glow: rgba(255,107,0,0.30); --accent-text: #FF8124;
--pos: #39FF8A; --pos-bg: rgba(57,255,138,0.16); --neg: #ff5a5a; --neg-bg: rgba(255,69,69,0.18);
--neutral-bg: rgba(255,255,255,0.08); --input-bg: #1c1c1c; --scrollbar: #2c2c2c;
}
[data-theme="light"][data-accent="orange"] {
--bg: #ffffff; --bg-elevated: #f7f7f7; --bg-raised: #f0f0f0; --bg-overlay: #e8e8e8;
--border: rgba(0,0,0,0.12); --border-mid: rgba(0,0,0,0.18);
--text-primary: #0a0a0a; --text-sec: #888888; --text-ter: #bbbbbb;
--accent: #E05500; --accent-soft: rgba(224,85,0,0.08); --accent-mid: rgba(224,85,0,0.16);
--accent-glow: rgba(224,85,0,0.22); --accent-text: #E05500;
--pos: #007a48; --pos-bg: rgba(0,149,90,0.08); --neg: #cc2222; --neg-bg: rgba(204,34,34,0.08);
--neutral-bg: rgba(0,0,0,0.04); --input-bg: #f5f5f5; --scrollbar: #ddd;
}
[data-theme="dark"][data-accent="blue"] {
--bg: #080808; --bg-elevated: #181818; --bg-raised: #242424; --bg-overlay: #303030;
--border: rgba(255,255,255,0.18); --border-mid: rgba(255,255,255,0.28);
--text-primary: #f2f2f2; --text-sec: #a3a3a3; --text-ter: #6a6a6a;
--accent: #3B82F6; --accent-soft: rgba(59,130,246,0.16); --accent-mid: rgba(59,130,246,0.26);
--accent-glow: rgba(59,130,246,0.30); --accent-text: #6AA1FF;
--pos: #39FF8A; --pos-bg: rgba(57,255,138,0.16); --neg: #ff5a5a; --neg-bg: rgba(255,69,69,0.18);
--neutral-bg: rgba(255,255,255,0.08); --input-bg: #1c1c1c; --scrollbar: #2c2c2c;
}
[data-theme="light"][data-accent="blue"] {
--bg: #ffffff; --bg-elevated: #f7f7f7; --bg-raised: #f0f0f0; --bg-overlay: #e8e8e8;
--border: rgba(0,0,0,0.12); --border-mid: rgba(0,0,0,0.18);
--text-primary: #0a0a0a; --text-sec: #888888; --text-ter: #bbbbbb;
--accent: #2563EB; --accent-soft: rgba(37,99,235,0.08); --accent-mid: rgba(37,99,235,0.16);
--accent-glow: rgba(37,99,235,0.22); --accent-text: #2563EB;
--pos: #007a48; --pos-bg: rgba(0,149,90,0.08); --neg: #cc2222; --neg-bg: rgba(204,34,34,0.08);
--neutral-bg: rgba(0,0,0,0.04); --input-bg: #f5f5f5; --scrollbar: #ddd;
}
[data-theme="dark"][data-accent="lila"] {
--bg: #080808; --bg-elevated: #181818; --bg-raised: #242424; --bg-overlay: #303030;
--border: rgba(255,255,255,0.18); --border-mid: rgba(255,255,255,0.28);
--text-primary: #f2f2f2; --text-sec: #a3a3a3; --text-ter: #6a6a6a;
--accent: #B388FF; --accent-soft: rgba(179,136,255,0.16); --accent-mid: rgba(179,136,255,0.26);
--accent-glow: rgba(179,136,255,0.30); --accent-text: #C7A6FF;
--pos: #39FF8A; --pos-bg: rgba(57,255,138,0.16); --neg: #ff5a5a; --neg-bg: rgba(255,69,69,0.18);
--neutral-bg: rgba(255,255,255,0.08); --input-bg: #1c1c1c; --scrollbar: #2c2c2c;
}
[data-theme="light"][data-accent="lila"] {
--bg: #ffffff; --bg-elevated: #f7f7f7; --bg-raised: #f0f0f0; --bg-overlay: #e8e8e8;
--border: rgba(0,0,0,0.12); --border-mid: rgba(0,0,0,0.18);
--text-primary: #0a0a0a; --text-sec: #888888; --text-ter: #bbbbbb;
--accent: #7C3AED; --accent-soft: rgba(124,58,237,0.08); --accent-mid: rgba(124,58,237,0.16);
--accent-glow: rgba(124,58,237,0.22); --accent-text: #7C3AED;
--pos: #007a48; --pos-bg: rgba(0,149,90,0.08); --neg: #cc2222; --neg-bg: rgba(204,34,34,0.08);
--neutral-bg: rgba(0,0,0,0.04); --input-bg: #f5f5f5; --scrollbar: #ddd;
}
[data-theme="dark"][data-accent="teal"] {
--bg: #080808; --bg-elevated: #181818; --bg-raised: #242424; --bg-overlay: #303030;
--border: rgba(255,255,255,0.18); --border-mid: rgba(255,255,255,0.28);
--text-primary: #f2f2f2; --text-sec: #a3a3a3; --text-ter: #6a6a6a;
--accent: #14B8A6; --accent-soft: rgba(20,184,166,0.16); --accent-mid: rgba(20,184,166,0.26);
--accent-glow: rgba(20,184,166,0.30); --accent-text: #2DD4BF;
--pos: #39FF8A; --pos-bg: rgba(57,255,138,0.16); --neg: #ff5a5a; --neg-bg: rgba(255,69,69,0.18);
--neutral-bg: rgba(255,255,255,0.08); --input-bg: #1c1c1c; --scrollbar: #2c2c2c;
}
[data-theme="light"][data-accent="teal"] {
--bg: #ffffff; --bg-elevated: #f7f7f7; --bg-raised: #f0f0f0; --bg-overlay: #e8e8e8;
--border: rgba(0,0,0,0.12); --border-mid: rgba(0,0,0,0.18);
--text-primary: #0a0a0a; --text-sec: #888888; --text-ter: #bbbbbb;
--accent: #0D9488; --accent-soft: rgba(13,148,136,0.08); --accent-mid: rgba(13,148,136,0.16);
--accent-glow: rgba(13,148,136,0.22); --accent-text: #0D9488;
--pos: #007a48; --pos-bg: rgba(0,149,90,0.08); --neg: #cc2222; --neg-bg: rgba(204,34,34,0.08);
--neutral-bg: rgba(0,0,0,0.04); --input-bg: #f5f5f5; --scrollbar: #ddd;
}
[data-theme="dark"][data-accent="magenta"] {
--bg: #080808; --bg-elevated: #181818; --bg-raised: #242424; --bg-overlay: #303030;
--border: rgba(255,255,255,0.18); --border-mid: rgba(255,255,255,0.28);
--text-primary: #f2f2f2; --text-sec: #a3a3a3; --text-ter: #6a6a6a;
--accent: #E24DAE; --accent-soft: rgba(226,77,174,0.16); --accent-mid: rgba(226,77,174,0.26);
--accent-glow: rgba(226,77,174,0.30); --accent-text: #F072C4;
--pos: #39FF8A; --pos-bg: rgba(57,255,138,0.16); --neg: #ff5a5a; --neg-bg: rgba(255,69,69,0.18);
--neutral-bg: rgba(255,255,255,0.08); --input-bg: #1c1c1c; --scrollbar: #2c2c2c;
}
[data-theme="light"][data-accent="magenta"] {
--bg: #ffffff; --bg-elevated: #f7f7f7; --bg-raised: #f0f0f0; --bg-overlay: #e8e8e8;
--border: rgba(0,0,0,0.12); --border-mid: rgba(0,0,0,0.18);
--text-primary: #0a0a0a; --text-sec: #888888; --text-ter: #bbbbbb;
--accent: #BE2A86; --accent-soft: rgba(190,42,134,0.08); --accent-mid: rgba(190,42,134,0.16);
--accent-glow: rgba(190,42,134,0.22); --accent-text: #BE2A86;
--pos: #007a48; --pos-bg: rgba(0,149,90,0.08); --neg: #cc2222; --neg-bg: rgba(204,34,34,0.08);
--neutral-bg: rgba(0,0,0,0.04); --input-bg: #f5f5f5; --scrollbar: #ddd;
}
/* -- Diepte ------------------------------------------------------------------
Overgenomen uit HomeGit/Docs/website-design-system.html (31-08-2026). In
AssetApp is dit een instelling; hier is er geen schuif, want dit is een
statuspagina met dark/light en verder geen knoppen. De stand staat dus in het
html-element en niet in een menu.
**De gloed staat sinds 0.5.5 uit** (verzoek van de gebruiker, 04-09-2026).
Aanzetten is `data-glow="on"` op het html-element en verder niets: het blok
verderop zet
dan de twee verlopen én de blur. Dat blok eist ook `data-depth="deep"`, want
een gloed achter dekkende vlakken is geen stand die iemand bedoelt.
Dat de blur aan de gloed hangt is geen gemak maar de kern van hoe dit werkt.
Een backdrop-filter op een egale ondergrond levert per definitie niets op, dus
zonder gloed is de blur rekenwerk zonder resultaat: de gloed is het eerste
deel en het glas het tweede, in die volgorde.
Wat in deze app níet aan de diepte hangt: de doorschijnende kleur van de
vlakken en het randlicht staan onder [data-theme] en dus altijd aan. Dat is zo
sinds 0.5.2 en het blijft zo; in het designsysteem hangen die wél aan de
diepte, omdat daar een schuif zit die ze allemaal tegelijk omzet. */
:root {
--surface: var(--bg-elevated);
--surface-inset: var(--bg-raised);
--surface-blur: 0px;
--surface-sat: 100%;
--edge: none;
--ambient: none;
--shadow-1: 0 1px 2px rgba(0,0,0,0.06), 0 4px 16px rgba(0,0,0,0.05);
}
[data-theme="dark"] {
--surface: color-mix(in srgb, #1c1c1c 78%, transparent);
--surface-inset: color-mix(in srgb, #262626 76%, transparent);
--edge: linear-gradient(180deg, rgba(255,255,255,0.10), rgba(255,255,255,0) 42%);
--shadow-1: 0 1px 2px rgba(0,0,0,0.35), 0 10px 30px -18px rgba(0,0,0,0.9);
}
[data-theme="light"] {
--surface: color-mix(in srgb, #ffffff 72%, transparent);
--surface-inset: color-mix(in srgb, #f2f2f2 74%, transparent);
--edge: linear-gradient(180deg, rgba(255,255,255,0.85), rgba(255,255,255,0) 46%);
}
/* De gloed: twee verlopen, één uit de linkerbovenhoek en een tegenlichtje
rechtsboven, zodat het geheel niet als één vlek leest. De blur hoort erbij en
staat daarom in ditzelfde blok; zie de uitleg bovenaan.
De selector heeft twee attributen en dus een specificiteit die `:root` niet
haalt. Dat is de reden dat de media-query hieronder deze selector letterlijk
herhaalt: met alleen `:root` erin zou de wens "minder doorzichtigheid" de blur
niet meer uit krijgen, en dat is een stille regressie in precies de stand waar
iemand om vroeg. */
[data-depth="deep"][data-glow="on"] {
--surface-blur: 16px;
--surface-sat: 135%;
--ambient: radial-gradient(58rem 34rem at 8% -8%, var(--accent-glow), transparent 62%),
radial-gradient(46rem 30rem at 102% 4%, color-mix(in srgb, var(--accent) 14%, transparent), transparent 66%);
}
/* De twee toegankelijkheidswensen horen NIET in een gedeelde media-query, en dat
is een les die een ronde kostte: Windows heeft een schakelaar "Animatie-
effecten" die bij veel mensen uit staat, en die zet prefers-reduced-motion op
reduce. Samen in een query deed de hele stand dan niets. Ze zeggen ook echt
iets anders: de ene gaat over doorschijnende vlakken, de andere over
beweging. */
@media (prefers-reduced-transparency: reduce) {
/* Twee selectors, want de gloedstand hierboven heeft twee attributen en wint
anders van `:root`. Gelijke specificiteit plus later in het bestand is wat
hem hier laat winnen. */
:root, [data-depth="deep"][data-glow="on"] {
--surface: var(--bg-elevated); --surface-inset: var(--bg-raised);
--surface-blur: 0px; --surface-sat: 100%;
}
}
@media (prefers-reduced-motion: reduce) {
body::before, .card { transition: none; }
}
/* Hier stond een media-query die op een scherm smaller dan 900 pixels terugviel
op de vlakke stand (keuze gebruiker, 31-08-2026): de gloed is 58rem breed en
op een telefoon dus breder dan het scherm, waardoor het geen verloop meer was
maar een egale waas.
Weggehaald op 04-09-2026, toen de gloed er in het geheel uit ging: hij zette
de gloed uit (die nu al uit staat), de blur uit (die met de gloed meegaat) en
de vlakken op dekkend (het enige dat overbleef, en dat is zonder blur alleen
nog een iets andere grijstoon). Een breekpunt dat in beide standen hetzelfde
oplevert, houdt alleen de suggestie over dat er iets gebeurt. Zet je de gloed
met `data-glow="on"` terug, dan hoort dit blok er ook weer bij. */
/* De gloed ligt achter alles en vangt geen klikken. z-index 0 hier en 1 op .wrap:
zonder stapelcontext kan een fixed pseudo-element van body over de inhoud heen
komen te liggen. */
body::before {
content: ''; position: fixed; inset: 0; z-index: 0; pointer-events: none;
background: var(--ambient); transition: background 0.3s;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: var(--font-base);
background: var(--bg);
color: var(--text-primary);
-webkit-font-smoothing: antialiased;
padding: 2rem 1.75rem 3rem;
}
/* Dezelfde maat als Electrum Gate. Breed, maar niet oneindig: op een ultrawide
monitor zou een kader van 2500px regels opleveren die niemand leest. */
.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: bovenaan wat je nakomt,
onderaan wat je één keer instelt. Bij een smal scherm wordt elk kader vol breed
en blijft die volgorde staan. */
.grid {
display: grid;
grid-template-columns: repeat(12, 1fr);
gap: 1.5rem;
align-items: stretch;
}
.grid > * { min-width: 0; }
.grid > .card { display: flex; flex-direction: column; }
.col-6 { grid-column: span 6; }
.col-full { grid-column: 1 / -1; }
.grid .card { margin-bottom: 0; }
@media (max-width: 1000px) { .col-6 { 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-mono { font-family: var(--font-mono); font-size: 0.82rem; color: var(--accent); }
/* ── 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 de maat van Electrum Gate. Het bestand komt uit de app-map en
wordt door de compose in de webroot gemount; het merkje blijft als terugval
staan voor het geval die mount er niet is, want een gebroken-afbeeldingicoon in
de kop is erger dan een generiek teken. */
.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. Deze store 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.
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. */
.head-version {
margin-left: 0.35rem;
font-size: 0.72rem;
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: een zachte schaduw op wit geeft wel diepte
maar geen begrenzing. Dezelfde waarden als Electrum Gate. */
[data-theme="light"] .card {
border: 1px solid var(--border);
}
.card-head {
display: flex; align-items: center; justify-content: space-between;
gap: 1rem; margin-bottom: 1.1rem;
}
/* ── De bovenste rij ────────────────────────────────────────────────────────
Vier gelijke widgets. Een eigen raster en geen deling van de twaalf kolommen,
want dat is wat Electrum Gate met zijn vijf ook doet.
Wat er bij het versmallen gebeurt is expliciet en niet aan auto-fit gelaten: bij
vier wil je twee plus twee en geen drie plus één, want dan staat er één widget
alleen op een regel zonder dat daar een reden voor is. */
.row1 {
grid-column: 1 / -1;
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1.5rem;
}
/* 1400 en niet 1200, en dat is gemeten en geen smaak: onder deze breedte is een
vierde van de rij te smal voor de kop "New owners" naast de twee knoppen die er
in beeld komen. Die knoppen zijn onzichtbaar maar niet weg, want ze staan op
opacity en niet op display:none, en ze houden dus hun ruimte. Werd de kop dan
twee regels hoog, dan stond de waarde in dat ene kader lager dan in de drie
andere. Zelfde grens als de rij van vijf bij Electrum Gate. */
@media (max-width: 1400px) { .row1 { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 560px) { .row1 { grid-template-columns: 1fr; } }
/* De widgets zetten hun waarde onderaan, zodat de vier waarden op één lijn staan
ook als de ene kop over twee regels valt en de andere niet. */
.row1 .card { display: flex; flex-direction: column; min-height: 12rem; }
.row1 .stat-body { margin-top: auto; }
/* Eén maat voor alle widgets: een getal en een woord zijn hier even belangrijk en
horen dus niet van formaat te verschillen. */
.stat-val {
font-size: 1.55rem; font-weight: 700; letter-spacing: -0.025em;
font-variant-numeric: tabular-nums; line-height: 1.2;
}
.stat-val.unknown { color: var(--text-ter); }
.stat-val.text { overflow-wrap: anywhere; }
.stat-val.bad { color: var(--neg); }
/* De accentkleur en niet --pos (keuze gebruiker, 31-08-2026). "Running" is de
eigen toestand van de app en geen positief cijfer, dus het accent hoort erbij;
--pos betekent sinds het gedeelde palet "het staat in de plus" en is groen. Zo
blijven de twee betekenissen uit elkaar en volgt deze tekst het accent mee als
de app ooit een andere kleur krijgt. */
.stat-val.ok { color: var(--accent-text); }
/* Even hoog als bij Electrum Gate, zodat de waarden van beide apps op dezelfde
lijn liggen als je ze naast elkaar zet. De tekst staat onderin dat vak. */
.stat-sub {
font-size: 0.75rem; color: var(--text-sec);
margin-top: 0.5rem; min-height: 34px;
display: grid; align-items: end;
}
.stat-sub.mono { font-family: var(--font-mono); overflow-wrap: anywhere; }
/* ── 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-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; }
/* ── Het adres van de relay ─────────────────────────────────────────────── */
.url-box {
font-family: var(--font-mono);
font-size: 0.9rem;
background: var(--bg-raised);
border: 1px solid var(--border);
border-radius: max(4px, calc(var(--radius-lg) - 1.8rem));
padding: 0.9rem 1.1rem;
overflow-wrap: anywhere;
color: var(--accent);
}
/* Het aantal wachtenden, in de kop van dat kader. Kleiner en grijzer dan de kop
zelf: het is een telling en geen tweede kop. Bij nul staat er niets, want dan
zegt de lijst eronder al "None." */
.head-count { margin-left: 0.5rem; font-size: 0.85rem; font-weight: 500; color: var(--text-sec); }
/* ── 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; }
/* Elke rij is twee regels hoog, in alle lijsten, en dat wordt hier dichtgehouden
op de drie manieren waarop het alsnog kon verschuiven: een lange waarde die
afbreekt naar een derde regel (afkappen met een puntje, de volledige waarde
blijft als tooltip), een onderregel zonder inhoud (min-height reserveert de
regel), en het invoerveld tijdens labelen, dat lager is dan twee regels tekst
(min-height op owner-who). Waarom dit een eis is: de hoogte veranderde zodra je
een rij labelde. Verzoek van de gebruiker, 10-09-2026. */
.list-item-title {
font-size: 0.88rem; font-weight: 500; line-height: 1.5;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.list-item-title.mono { font-family: var(--font-mono); font-size: 0.8rem; font-weight: 400; }
.list-item-sub {
font-size: 0.72rem; color: var(--text-sec); margin-top: 0.1rem; line-height: 1.5;
min-height: 1.5em;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.list-item-sub.mono { font-family: var(--font-mono); }
.list-empty { font-size: 0.78rem; color: var(--text-ter); padding: 0.7rem 0; }
/* 0.88 × 1.5 voor de titel, 0.72 × 1.5 plus 0.1 marge voor de onderregel. */
.owner-who { min-width: 0; min-height: 2.5rem; }
/* De badge duwt zichzelf naar rechts en de knoppen komen erachter. Zo staat de
badge op elke regel op dezelfde plek, ook op een regel zonder knoppen in beeld. */
.owner-tags { display: flex; gap: 0.5rem; flex-shrink: 0; margin-left: auto; }
.owner-tags:empty { display: none; }
.owner-actions { display: flex; gap: 0.5rem; flex-shrink: 0; }
/* ── Knoppen ────────────────────────────────────────────────────────────── */
.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);
white-space: nowrap;
}
.btn:hover { background: var(--neutral-bg); }
.btn:active { transform: scale(0.97); }
.btn:disabled { opacity: 0.35; cursor: not-allowed; }
/* Bevroren terwijl er een opdracht onderweg is. Alleen de lijsten, want alleen
daar staan knoppen die iets veranderen, en de kaders erboven horen gewoon te
blijven bijwerken. Geen verschuiving en geen overlay: dit verandert niets aan
de afmetingen, en dat is precies waarom het de melding bovenaan vervangt. */
body.is-busy .list-item { opacity: 0.55; }
body.is-busy .list-item button { cursor: progress; }
.btn-solid { background: var(--accent); color: #fff; border-color: transparent; }
.btn-solid:hover { background: var(--accent); opacity: 0.88; }
.btn-danger { color: var(--neg); border-color: color-mix(in srgb, var(--neg) 45%, transparent); }
.btn-danger:hover { background: var(--neg-bg); }
/* Knoppen verschijnen pas bij hover, op verzoek van de gebruiker (30-08-2026).
Twee plekken met dezelfde regel: per lijstregel, en per kader voor de knoppen
die over het kader als geheel gaan.
Opacity en geen display:none, want dan blijven ze met de tab-toets bereikbaar;
focus-within maakt ze dan alsnog zichtbaar. Op aanraakschermen bestaat hover
niet, dus daar staan ze altijd aan. Zonder die laatste regel zijn de knoppen op
een telefoon onbereikbaar, en dat is niet "minder mooi" maar stuk. */
.hover-only {
opacity: 0;
transition: opacity 0.18s var(--ease-out);
}
.list-item:hover .hover-only,
.list-item:focus-within .hover-only,
.card:hover > .card-head > .hover-only,
.card:focus-within > .card-head > .hover-only { opacity: 1; }
@media (hover: none) {
.hover-only { opacity: 1; }
}
/* ── Het labelveld ────────────────────────────────────────────────────────
Een eigenaar-id is een reeks tekens zonder betekenis. Het label is wat de
gebruiker eraan hangt, en dit is het veld waarin dat gebeurt: hij komt in de
plaats van de titel van de regel zolang je bezig bent. */
.label-input {
width: min(22rem, 100%);
font-family: inherit; font-size: 0.85rem;
color: var(--text-primary);
background: var(--bg-raised);
border: 1px solid var(--accent);
border-radius: var(--radius-sm);
padding: 0.4rem 0.55rem;
}
.label-input:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
/* ── Badges ─────────────────────────────────────────────────────────────── */
.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); }
/* ── Het menu rechtsboven ───────────────────────────────────────────────── */
.menu { position: relative; flex-shrink: 0; }
.menu > summary { list-style: none; cursor: pointer; }
.menu > summary::-webkit-details-marker { display: none; }
.menu > summary::marker { content: ''; }
/* Geen knoplook: alleen de drie punten, met een accent bij hover. Flink van
formaat, want drie punten op de gewone knopgrootte zijn nauwelijks te zien en al
helemaal niet te raken. De letterafstand zet ze uit elkaar. */
.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); }
.menu[open] > .menu-button { color: var(--accent); }
.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 ─────────────────────────────────────────────────────────── */
.sheet {
/* Midden op het scherm, in beide richtingen. 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. */
position: fixed;
inset: 0;
margin: auto;
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. Niet rood: er gaat niets kapot van sluiten. */
.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. */
.sheet-body {
padding: 1.2rem 1.5rem 1.5rem;
overflow: auto;
max-height: calc(min(44rem, 100vh - 4rem) - 5rem);
scrollbar-width: thin;
scrollbar-color: var(--scrollbar) transparent;
}
.sheet-body h3 {
font-size: 0.78rem; font-weight: 600; letter-spacing: 0.08em;
text-transform: uppercase; color: var(--text-sec);
margin: 1.6rem 0 0.5rem;
}
.sheet-body h3:first-child { margin-top: 0; }
.sheet-body p { font-size: 0.85rem; line-height: 1.65; color: var(--text-sec); margin-bottom: 0.7rem; }
.sheet-body p strong { color: var(--text-primary); font-weight: 600; }
.sheet-body .t-mono { overflow-wrap: anywhere; }
.hidden { display: none !important; }
@media (max-width: 700px) {
.list-item { flex-wrap: wrap; align-items: flex-start; gap: 0.5rem; }
.list-item > * { flex-basis: 100%; min-width: 0; }
.owner-actions { margin-left: 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="M4 7h16"></path><path d="M4 12h16"></path><path d="M4 17h16"></path>
<circle cx="8" cy="7" r="1.6" fill="currentColor" stroke="none"></circle>
<circle cx="16" cy="12" r="1.6" fill="currentColor" stroke="none"></circle>
<circle cx="8" cy="17" r="1.6" fill="currentColor" stroke="none"></circle>
</svg>
</div>
<div class="head-text">
<h1 class="t-h1">Evolu Relay</h1>
<!-- Woordelijk dezelfde regel als de tagline in umbrel-app.yml, en dat is
het punt: één app, één regel. -->
<p class="t-body-sm">Encrypted sync and backup for your local-first apps <span class="head-version" id="head-version"></span></p>
</div>
<!-- Het menu rechtsboven, met details/summary en niet met eigen JavaScript:
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. -->
<details class="menu" id="menu">
<!-- Drie punten en geen hamburger: dat is wat umbrelOS bij zijn eigen apps
toont, en een pagina die in dat dashboard hangt hoort daar niet van af te
wijken. Middelste punten (U+00B7) en geen gewone punten: die staan op de
basislijn en zakken dus naar de onderrand van de knop. -->
<summary class="menu-button" title="Menu" aria-label="Menu">···</summary>
<div class="menu-panel">
<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: als menuregel
met een waarde erachter leest het als een bewering over hoe het nú
staat. -->
<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. Standaard verborgen; het script bepaalt welke past. -->
<div id="alert-agent" 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 id="alert-agent-body">The status page cannot reach its helper inside the app.</span>
</div>
<div id="alert-owners" 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 id="alert-owners-body"></span>
</div>
<div id="alert-notice" 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 id="alert-notice-body"></span>
</div>
<div class="grid">
<!-- ── De bovenste rij ────────────────────────────────────────────────── -->
<div class="row1">
<div class="card">
<div class="card-head"><h2 class="t-h3">Stored data</h2></div>
<div class="stat-body">
<div class="stat-val unknown" id="db-size">unknown</div>
<div class="stat-sub" id="db-sub"></div>
</div>
</div>
<div class="card">
<div class="card-head"><h2 class="t-h3">Last write</h2></div>
<div class="stat-body">
<div class="stat-val text unknown" id="db-modified">unknown</div>
<div class="stat-sub" id="db-modified-sub"></div>
</div>
</div>
<!-- Een lege plek, met opzet, op verzoek van de gebruiker (10-09-2026).
Hier stond tot 0.6.0 het tijdvenster voor nieuwe eigenaars en in 0.7.0
een teller met wachtenden. Die teller is eruit: het kader eronder heet
"Waiting" en heeft het aantal nu in zijn kop, dus de widget zei het
dubbel. De plek blijft leeg in plaats van dat de andere drie opschuiven,
want dan houdt "Status" de rechterkolom; zie de uitleg hieronder. -->
<div></div>
<!-- De statuswidget staat RECHTS en niet links (keuze gebruiker,
31-08-2026), gelijk aan Electrum Gate. Twee redenen die samenvallen:
de twee pagina's horen naast elkaar te lezen als één store, en dit is
de widget die als enige begrijpelijk is zonder buur, dus hij is de
juiste om alleen op een regel te belanden als het scherm versmalt.
De kop heet "Status" en niet "Relay": op een pagina die over de relay
gáát zegt "Relay" niets. De id's blijven relay-state en relay-sub, want
dat is nog steeds waar de waarde vandaan komt en hernoemen zou alleen
het JavaScript raken zonder dat iemand er iets aan heeft. -->
<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="relay-state">unknown</div>
<div class="stat-sub" id="relay-sub"></div>
</div>
</div>
</div>
<!-- ── De twee lijsten ───────────────────────────────────────────────── -->
<div class="card col-6">
<div class="card-head"><h2 class="t-h3">Accepted</h2></div>
<div id="allowed"></div>
</div>
<!-- Wachtend en geblokkeerd in één kader, op verzoek van de gebruiker
(30-08-2026). Het waren twee kaders, en het onderscheid ertussen is voor
de gebruiker geen verschil in wat hij ermee doet: allebei zijn ze
buitengesloten en allebei kun je ze toelaten. Dat het bij de een een
eerdere keuze van jou was en bij de ander een poging van buiten, staat als
badge op de regel.
"Waiting list" en niet "Refused owners" sinds 0.7.0, op voorstel van de
gebruiker. Dat is wat het is: kandidaten die op een beslissing wachten, en
sinds het tijdvenster eruit is, is dit de enige weg naar binnen. Het veld
heet in owners.json nog `rejected`; hernoemen zou een migratie zijn voor
een woord dat niemand ziet. -->
<div class="card col-6">
<!-- Het aantal staat in de kop en niet meer in een eigen widget bovenaan. -->
<div class="card-head">
<h2 class="t-h3">Waiting<span class="head-count hidden" id="waiting-count"></span></h2>
</div>
<div id="refused"></div>
</div>
<!-- ── Het adres ─────────────────────────────────────────────────────── -->
<!-- Onderaan en niet bovenaan: dit is wat je één keer overneemt in je app, en
de rij bovenin is wat je nakomt. -->
<div class="card col-full">
<div class="card-head"><h2 class="t-h3">Relay address</h2></div>
<!-- Hieronder stond in 0.7.0 een regel over de vorm achter een reverse
proxy (wss://, zonder poort). Eruit op verzoek van de gebruiker,
10-09-2026: dit kader is het adres en niets anders. Wat er over
bereikbaarheid van buiten te zeggen valt, staat in "About this app". -->
<div class="url-box" id="relay-url">unknown</div>
</div>
</div>
<!-- De dialoog "About this app". Hier staat wat eerder als uitleg in de kaders
stond, plus de tekst uit umbrel-app.yml. Met het dialog-element en niet met
een eigen overlay: focus, Escape en het afschermen van de pagina erachter
zitten erin. -->
<dialog id="about-dialog" class="sheet">
<form method="dialog" class="sheet-head">
<h2 class="t-h3">About this app</h2>
<!-- Een kruisje en niet het woord "Close". 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. -->
<button class="sheet-close" value="close" aria-label="Close" title="Close">×</button>
</form>
<div class="sheet-body">
<h3>What it is</h3>
<p>
Local-first apps keep your data on your own device and work whether or not you have a
connection. A relay is what lets a second device catch up: it holds the encrypted changes
until your other devices come online. This app runs that relay on your Umbrel, so the copy
in the middle sits on hardware you own.
</p>
<p>
It is the relay from the <strong>Evolu</strong> project, taken straight from the package they
publish and not reimplemented here. Any app built on Evolu can use it.
</p>
<h3>What the relay can see</h3>
<p>
Your data is encrypted on your device before it leaves. The relay holds encrypted blobs,
padded so their size says nothing, and never has the key. What it can see is an owner id,
which identifies data without identifying you, along with timestamps and the addresses that
connect.
</p>
<p>
Self-hosting does not make the data more secret than it already is. What it changes is who
keeps the encrypted copy, who can see that you are syncing at all, and whether anyone can
pull the service out from under you.
</p>
<h3>Pointing an app at it</h3>
<!-- Hier stond tot 0.7.1 dat het adres met http of https moet beginnen en
niet met ws. Dat sprak het kader onderaan tegen, dat sinds 0.7.0
`ws://` toont. Beide waren waar: de relay spreekt WebSocket, en het
veld van Trezor Suite neemt `http(s)://`, gemeten op 28-08-2026. Wat
eruit moest was de eis, niet de vorm. -->
<p>
Set the address at the bottom of this page as the sync address in your app. Some apps only
offer the field in their developer or advanced settings. Which scheme that field wants
depends on the app: <strong>ws://</strong> as shown below, or <strong>http://</strong>,
which some apps expect because the websocket starts as an HTTP request and they work the
rest out themselves. Same host and same port either way, so try the other one if the first
is refused.
</p>
<p>
Away from home you will need a way in, such as Tailscale or a reverse proxy with your own
domain. Behind a proxy with a certificate the address becomes <strong>wss://</strong> (or
https) on your own domain, without a port number: the proxy listens on 443 and forwards to
this one.
</p>
<h3>Who may use it</h3>
<p>
What this app adds is the part a self-hosted relay needs and a public one does not: control
over who may use it. Nobody gets in by default. An owner that connects for the first time is
turned away and lands on the <strong>waiting list</strong> with its owner id, and you decide
there whether it may stay. One app can use more than one owner id, so expect more than one
row per device.
</p>
<p>
An owner on the waiting list syncs nothing at all, and nothing of it is stored: the relay
turns the connection away before it starts. Its device keeps everything locally and retries,
so allowing it later still brings the whole history across, and you do not have to be quick.
</p>
<p>
Owner ids are long and meaningless on purpose. Hover a row and use <strong>Label</strong> to
give one a name of your own; the name is kept on the Umbrel and is only ever shown here.
</p>
</div>
</dialog>
</div>
<script>
(function () {
'use strict';
var root = document.documentElement;
// Eén opdracht tegelijk: de agent weigert een tweede zolang de eerste nog in de
// postbus ligt, en twee knoppen die tegelijk indrukbaar zijn zouden dat als een
// fout tonen terwijl er niets mis is.
var busy = false;
// Het id van de eigenaar die op dit moment een label krijgt, of null. Zolang dit
// gezet is, laat de ronde de lijsten met rust: opnieuw opbouwen zou het veld
// waarin je typt weghalen.
var editing = null;
// Staat er een weigering in de melding bovenaan?
//
// Waar dit voor is: de ronde ruimt die melding zelf op zodra hij niet meer waar
// is, en dat is precies goed voor "de opdracht is verstuurd". Maar een weigering
// mag niet binnen vijf seconden verdwijnen: dan is de enige uitleg van wat er
// misging weg voordat je hem gelezen hebt. Vandaar deze vlag; de ronde laat de
// melding met rust zolang hij aanstaat.
//
// Als vlag en niet door naar de klasse van het element te kijken. Dat laatste
// deed de vorige versie van deze pagina, en dan hangt het gedrag aan een naam in
// een class-attribuut die er ook om opmaakredenen kan staan.
var refusal = false;
// ── 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) {
var node = el(id);
if (!node) { return; }
if (value === null || value === undefined || value === '') {
node.textContent = 'unknown';
node.classList.add('unknown');
} else {
node.textContent = String(value);
node.classList.remove('unknown');
}
}
function show(id, text, kind) {
var node = el(id);
var body = el(id + '-body');
if (body && text) { body.textContent = text; }
if (kind) {
node.classList.remove('alert-neutral', 'alert-warn', 'alert-error');
node.classList.add(kind);
}
node.classList.remove('hidden');
}
function hide(id) { el(id).classList.add('hidden'); }
function formatBytes(bytes) {
if (typeof bytes !== 'number') { return null; }
if (bytes < 1024) { return bytes + ' B'; }
if (bytes < 1024 * 1024) { return (bytes / 1024).toFixed(1) + ' kB'; }
return (bytes / (1024 * 1024)).toFixed(2) + ' MB';
}
function formatTime(value) {
if (typeof value !== 'string') { return null; }
var when = new Date(value);
if (isNaN(when.getTime())) { return null; }
return when.toLocaleString();
}
// Hoe lang geleden, in woorden. Op de widget is "3 minutes ago" bruikbaarder dan
// een tijdstip; het tijdstip zelf staat op de onderregel.
function formatAgo(value) {
if (typeof value !== 'string') { return null; }
var when = new Date(value);
if (isNaN(when.getTime())) { return null; }
var seconds = Math.round((Date.now() - when.getTime()) / 1000);
if (seconds < 0) { seconds = 0; }
if (seconds < 60) { return 'just now'; }
var minutes = Math.round(seconds / 60);
if (minutes < 60) { return minutes + (minutes === 1 ? ' minute ago' : ' minutes ago'); }
var hours = Math.round(minutes / 60);
if (hours < 48) { return hours + (hours === 1 ? ' hour ago' : ' hours ago'); }
return Math.round(hours / 24) + ' days ago';
}
function shorten(ownerId) {
if (typeof ownerId !== 'string') { return 'unknown'; }
if (ownerId.length <= 26) { return ownerId; }
return ownerId.slice(0, 14) + '…' + ownerId.slice(-8);
}
// ── Thema ─────────────────────────────────────────────────────────────────
var saved = null;
try { saved = localStorage.getItem('er-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);
el('theme-label').textContent = theme === 'dark' ? 'Dark' : 'Light';
}
el('menu-theme').addEventListener('click', function () {
var next = root.getAttribute('data-theme') === 'dark' ? 'light' : 'dark';
applyTheme(next);
try { localStorage.setItem('er-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 dialoog ────────────────────────────────────────────────────────────
var sheet = el('about-dialog');
el('menu-about').addEventListener('click', function () {
closeMenu();
if (typeof sheet.showModal === 'function') {
sheet.showModal();
} else {
// Zonder showModal geen overlay en geen focusval, maar wel een zichtbare
// dialoog. Beter dan een menuregel die niets doet.
sheet.setAttribute('open', '');
}
});
// ── Opdrachten ────────────────────────────────────────────────────────────
function post(path, body, whenDone) {
return fetch(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
}).then(function (response) {
return response.json().then(function (parsed) {
return { ok: response.ok, body: parsed };
}, function () {
return { ok: response.ok, body: {} };
});
}).then(function (result) {
if (!result.ok) {
refusal = true;
show('alert-notice', 'The app refused that: ' + (result.body.error || 'no reason given'),
'alert-error');
} else if (whenDone) {
whenDone();
}
}, function () {
refusal = true;
show('alert-notice', 'Could not reach the app.', 'alert-error');
});
}
// Elke opdracht begint met een schone lei bovenaan: een weigering van de vorige
// poging hoort niet boven het resultaat van deze te blijven staan.
function clearNotice() {
refusal = false;
hide('alert-notice');
}
// Bevriezen in plaats van een melding bovenaan.
//
// Tot 0.6.0 verscheen bij élke klik "Sent. The relay applies changes within a
// few seconds." in de balk bovenaan. Die balk staat in de gewone stroom van de
// pagina, dus alles eronder schoof omlaag en bij het verdwijnen weer omhoog.
// Gemeld door de gebruiker op 09-09-2026, en terecht: dat is een melding over
// het verzendmechanisme en niet over wat jij deed. Wat je deed, zie je aan de
// lijsten zodra de relay de opdracht heeft opgepakt.
//
// In plaats daarvan gaan de knoppen even op slot. Dat verschuift niets, het
// toont dat er iets loopt, en het voorkomt een tweede klik terwijl de eerste
// nog onderweg is. De foutmeldingen blijven wél in de balk staan: die zeggen
// iets wat je nergens anders ziet, en die hóórt de pagina in de weg te leggen.
function setFrozen(frozen) {
document.body.classList.toggle('is-busy', frozen);
var knoppen = document.querySelectorAll('.list-item button');
for (var i = 0; i < knoppen.length; i += 1) { knoppen[i].disabled = frozen; }
}
function send(command) {
if (busy) { return; }
busy = true;
setFrozen(true);
clearNotice();
post('api/command', command).then(function () {
// Verversen en pas daarná ontdooien: de ronde bouwt de lijsten opnieuw op,
// en tot dat gebeurd is staat de oude stand er nog. Ontdooien vóór de
// ronde zou een klikbare knop opleveren die nog bij de vorige stand hoort.
window.setTimeout(function () {
refresh();
busy = false;
setFrozen(false);
}, 2500);
});
}
function sendLabel(ownerId, label) {
clearNotice();
// Geen busy-slot hieromheen, en dat is geen vergetelheid: een label gaat niet
// via de postbus van het relay-proces maar rechtstreeks naar een eigen bestand
// van de agent. Er is dus niets waarop het hoeft te wachten.
post('api/label', { ownerId: ownerId, label: label }).then(refresh);
}
// ── Een regel in een lijst ────────────────────────────────────────────────
// `kind` is 'allowed', 'blocked' of 'rejected'. Dat bepaalt de badge en welke
// knoppen er staan; de rest van de regel is voor alle drie hetzelfde.
function ownerRow(entry, kind) {
var row = document.createElement('div');
row.className = 'list-item';
var who = document.createElement('div');
who.className = 'owner-who';
var title = document.createElement('div');
var sub = document.createElement('div');
// Met een label staat het label bovenaan en het id eronder; zonder label is
// het id de titel. Zo staat het meest zeggende altijd op de eerste regel.
//
// ALTIJD TWEE REGELS, en dat is de kern van deze opzet (verzoek gebruiker,
// 10-09-2026). Tot 0.7.0 kwam er een derde regel bij zodra er een label
// stond, want de tweede was dan het id; het gevolg was dat een rij hoger
// werd op het moment dat je hem labelde en dat de rijen in de twee lijsten
// niet meer gelijk stonden. Nu deelt het id de onderregel met de tijden. Een
// vaste hoogte in CSS zou hetzelfde lijken te doen, maar dan staat er witruimte
// onder een rij zonder label en dat leest als een weergavefout.
if (entry.label) {
title.className = 'list-item-title';
title.textContent = entry.label;
title.title = entry.label;
sub.className = 'list-item-sub';
// Het id houdt zijn eigen letter, ook op een gedeelde regel: het is het
// enige hier dat je teken voor teken vergelijkt.
var idDeel = document.createElement('span');
idDeel.className = 'mono';
idDeel.textContent = shorten(entry.id);
idDeel.title = entry.id;
sub.appendChild(idDeel);
var rest = describe(entry, kind);
if (rest) {
var restDeel = document.createElement('span');
restDeel.textContent = ' · ' + rest;
restDeel.title = describeExact(entry);
sub.appendChild(restDeel);
}
} else {
title.className = 'list-item-title mono';
title.textContent = shorten(entry.id);
title.title = entry.id;
sub.className = 'list-item-sub';
sub.textContent = describe(entry, kind);
sub.title = describeExact(entry);
}
who.appendChild(title);
who.appendChild(sub);
row.appendChild(who);
// De badge staat BUITEN het hover-blok, en dat is geen slordigheid maar het
// verschil tussen een gegeven en een handeling. Hij stond er eerst in, en dan
// is niet te zien wélke van de twee soorten een regel is zolang je er niet
// overheen gaat: precies de informatie waarvoor de twee kaders zijn
// samengevoegd.
//
// Alleen "Blocked" nog, sinds 10-09-2026 op verzoek van de gebruiker. Een
// badge "Waiting" in een kader dat "Waiting" heet zegt niets extra; het
// onderscheid dat de badge moest maken blijft staan, want een geblokkeerde
// regel is nu precies de regel mét badge.
var tags = document.createElement('div');
tags.className = 'owner-tags';
if (kind === 'blocked') { tags.appendChild(badge('Blocked', 'badge-neg')); }
row.appendChild(tags);
var actions = document.createElement('div');
actions.className = 'owner-actions hover-only';
actions.appendChild(button(entry.label ? 'Rename' : 'Label', '', function () {
startEditing(row, who, entry);
}));
if (kind === 'allowed') {
actions.appendChild(button('Block', 'btn-danger', function () {
send({ action: 'block', ownerId: entry.id });
}));
} else {
actions.appendChild(button('Allow', 'btn-solid', function () {
send({ action: 'allow', ownerId: entry.id });
}));
}
actions.appendChild(button('Forget', 'btn-danger', function () {
send({ action: 'forget', ownerId: entry.id });
}));
row.appendChild(actions);
return row;
}
// Bij benadering en niet op de seconde. Hier stonden volledige tijdstempels, en
// twee daarvan naast elkaar liepen op een halve kaartbreedte over drie regels;
// dan is de onderregel drukker dan het id erboven. Wat je van een eigenaar wil
// weten is "is dit van vandaag of van vorige maand", en dat staat hier nu.
// Het exacte tijdstip blijft bereikbaar als tooltip; zie ownerRow.
function describe(entry, kind) {
var parts = [];
if (entry.firstSeen) { parts.push('first seen ' + formatAgo(entry.firstSeen)); }
if (entry.lastSeen) { parts.push('last seen ' + formatAgo(entry.lastSeen)); }
if (kind === 'rejected' && entry.attempts) {
parts.push(entry.attempts + (entry.attempts === 1 ? ' attempt' : ' attempts'));
}
return parts.join(' · ');
}
// Het exacte tijdstip, voor de tooltip op de onderregel.
function describeExact(entry) {
var parts = [];
if (entry.firstSeen) { parts.push('First seen ' + formatTime(entry.firstSeen)); }
if (entry.lastSeen) { parts.push('Last seen ' + formatTime(entry.lastSeen)); }
return parts.join('\n');
}
function badge(text, kind) {
var node = document.createElement('span');
node.className = 'badge ' + kind;
node.textContent = text;
return node;
}
function button(text, extra, onClick) {
var node = document.createElement('button');
node.type = 'button';
node.className = 'btn' + (extra ? ' ' + extra : '');
node.textContent = text;
node.addEventListener('click', onClick);
return node;
}
// ── Labelen ───────────────────────────────────────────────────────────────
// Het veld komt in de plaats van de eerste regel van de rij. Enter bewaart,
// Escape breekt af, en de focus verliezen bewaart ook: dat laatste is wat je
// verwacht als je ergens anders klikt nadat je iets ingetypt hebt.
function startEditing(row, who, entry) {
if (editing !== null) { return; }
editing = entry.id;
var vorm = who.innerHTML;
who.textContent = '';
var input = document.createElement('input');
input.className = 'label-input';
input.type = 'text';
input.maxLength = 48;
input.value = entry.label || '';
input.placeholder = 'A name for this owner';
who.appendChild(input);
var afgerond = false;
function stop() {
if (afgerond) { return; }
afgerond = true;
editing = null;
who.innerHTML = vorm;
}
function bewaar() {
if (afgerond) { return; }
var waarde = input.value.trim();
var ongewijzigd = waarde === (entry.label || '');
stop();
if (!ongewijzigd) { sendLabel(entry.id, waarde); }
}
input.addEventListener('keydown', function (e) {
if (e.key === 'Enter') { e.preventDefault(); bewaar(); }
// Escape zou anders ook de dialoog of het menu raken; hier hoort hij bij het
// veld en verder niets.
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); stop(); }
});
input.addEventListener('blur', bewaar);
input.focus();
input.select();
}
// ── De lijsten ────────────────────────────────────────────────────────────
// Meest recent gezien bovenaan, over de groepen heen: in het kader met
// geweigerden staat een blokkade van vanmorgen dus boven een afgewezen poging van
// vorige week, en niet andersom omdat "blocked" nu eenmaal eerst kwam. Tot 0.5.5
// stonden de rijen in de volgorde van het bestand, en dat is de volgorde waarin
// eigenaars ooit binnenkwamen; die zegt na een week niets meer. Op verzoek van
// de gebruiker, 07-09-2026.
//
// De volgorde verschuift vanzelf mee: de ronde van vijf seconden bouwt de lijst
// elke keer opnieuw op, dus hier hoeft niets voor herladen te worden. Wat
// "gezien" hier betekent staat bij het relay-proces: het tijdstip van de laatste
// verbinding, niet van de laatste sync erover.
function seenAt(entry) {
var when = new Date(entry.lastSeen || entry.firstSeen || 0).getTime();
return isNaN(when) ? 0 : when;
}
function fillList(id, groups, emptyText) {
var holder = el(id);
var rows = [];
groups.forEach(function (group) {
group.entries.forEach(function (entry) {
rows.push({ entry: entry, kind: group.kind });
});
});
holder.textContent = '';
if (rows.length === 0) {
var empty = document.createElement('p');
empty.className = 'list-empty';
empty.textContent = emptyText;
holder.appendChild(empty);
return;
}
rows.sort(function (a, b) { return seenAt(b.entry) - seenAt(a.entry); });
rows.forEach(function (row) {
holder.appendChild(ownerRow(row.entry, row.kind));
});
}
// ── De ronde ──────────────────────────────────────────────────────────────
function refresh() {
fetch('api/status', { cache: 'no-store' }).then(function (response) {
return response.json();
}).then(function (status) {
hide('alert-agent');
// De versie in de kop. Geen put(): die zet "unknown" neer als het ontbreekt,
// en een kop met "vunknown" is erger dan een kop zonder versie.
if (status.version) { el('head-version').textContent = 'v' + status.version; }
// De relay.
var relay = el('relay-state');
relay.classList.remove('ok', 'bad');
if (status.relay.reachable === true) {
put('relay-state', 'Running');
relay.classList.add('ok');
el('relay-sub').textContent = 'accepting connections on port ' + status.relay.publicPort;
} else if (status.relay.reachable === false) {
put('relay-state', 'Not running');
relay.classList.add('bad');
el('relay-sub').textContent = 'it did not accept a connection from inside the app';
} else {
put('relay-state', null);
el('relay-sub').textContent = 'no reading yet';
}
// De database.
put('db-size', formatBytes(status.database.bytes));
el('db-sub').textContent = status.database.bytes === null
? 'the relay has not created its database yet'
: 'encrypted, and padded so the size says nothing';
put('db-modified', formatAgo(status.database.modified));
el('db-modified-sub').textContent = formatTime(status.database.modified) || 'never written to';
// ws:// en niet http://, en dat is geen schoonheidsfoutje: de relay is een
// WebSocket-server en spreekt nooit HTTP tegen een cliënt. Er stond http://
// tot 0.7.0, en dat kostte op 09-09-2026 een ronde zoeken bij het koppelen
// van de testcliënt.
el('relay-url').textContent =
'ws://' + window.location.hostname + ':' + status.relay.publicPort;
el('relay-url').classList.remove('unknown');
// De lijsten, tenzij er iemand aan een label bezig is.
if (editing === null) {
fillList('allowed', [
{ kind: 'allowed', entries: status.owners.allowed }
], 'None yet. Connect your app; it will show up in the waiting list.');
fillList('refused', [
{ kind: 'blocked', entries: status.owners.blocked },
{ kind: 'rejected', entries: status.owners.rejected }
], 'None.');
}
// Het aantal wachtenden, in de kop van dat kader. Tot 0.7.0 was dit een
// eigen widget bovenaan met een regel eronder; die zei hetzelfde als het
// kader waar de lijst in staat.
var wachtend = (status.owners.rejected || []).length;
var teller = el('waiting-count');
teller.textContent = String(wachtend);
teller.classList.toggle('hidden', wachtend === 0);
if (status.owners.problem === 'onleesbaar') {
show('alert-owners', 'The list of owners could not be read and was moved aside. Everyone is '
+ 'on the waiting list again; allow the owners you recognise below.');
} else {
hide('alert-owners');
}
// De melding bovenaan is van deze ronde, niet van de klik die hem opriep.
// Hier stond alleen de tak die hem aanzet, en dus bleef "Sent." staan tot je
// de pagina herlaadde; gemeld door de gebruiker op 30-08-2026.
//
// Een weigering is de uitzondering en wordt overgeslagen: die mag niet binnen
// vijf seconden verdwijnen, want dan is de enige uitleg van wat er misging weg
// voordat je hem gelezen hebt. Hij gaat weg bij de volgende opdracht.
if (!refusal) {
if (status.pendingCommand) {
// Ligt de opdracht er nog, dan heeft het relay-proces hem niet opgepakt.
// Dat is het geval waarin een melding écht iets toevoegt: normaal is dat
// binnen twee seconden gebeurd.
show('alert-notice', 'A command is waiting for the relay to pick it up.', 'alert-neutral');
} else {
hide('alert-notice');
}
}
}, function () {
show('alert-agent');
put('relay-state', null);
el('relay-state').classList.remove('ok', 'bad');
});
}
refresh();
window.setInterval(refresh, 5000);
})();
</script>
</body>
</html>