Files
UmbrelApps/whatsnext-evolu-relay/index.html.template
T
HarmenandClaude Opus 5 b3b1881af2 De pagina van Evolu Relay op de lijst van de gebruiker
Negen punten uit echt gebruik met 0.4.0, alle negen gedaan. Twee ervan waren
onderzoeksvragen en die staan onderaan.

Evolu Relay 0.5.0
- Een tijdvenster van twee minuten voor nieuwe eigenaars, met een stopknop. De
  teller zit in het relay-proces en niet in de pagina: een teller in een tabblad
  dat je sluit, sluit de deur niet. policy.js kreeg learningUntil, isLearningOpen
  en expireLearning.
- decideOwner kijkt naar isLearningOpen en niet naar het veld learning. De lus die
  een verlopen venster opruimt loopt elke twee seconden, en in dat gat zou een
  onbekende alsnog binnenkomen.
- Zonder STATE_VERSION te verhogen, met een toets die dat verdedigt: een verhoging
  zou de allowlist van de draaiende installatie laten afwijzen en de deur sluiten
  voor eigenaars die er al in stonden.
- Labels op een eigenaar-id, in een eigen labels.json met de agent als enige
  schrijver. Een label zegt niets over toegang, dus de relay hoeft het niet te
  weten; het is daardoor meteen opgeslagen en werkt ook als de relay omligt.
- Geblokkeerde en geweigerde eigenaars in een kader, met een badge die zegt welke
  van de twee het is. De badge staat buiten het hover-blok, anders is dat
  onderscheid onzichtbaar tenzij je over de regel gaat.
- Maatvoering gelijk aan Electrum Gate: 1760px, hetzelfde raster, icoon van 64
  pixels, dezelfde kop, versienummer erachter. Uitleg uit de kaders, knoppen pas
  bij hover, geen voetregel.

Electrum Gate 0.0.24
- Menu-item "About this app", in beide apps.
- De statuswidget zei "Answering" met "answered in 7 ms, from inside the app" en
  zegt nu "Running" met de meting eronder. De nuance dat de controle van container
  naar container loopt is verplaatst naar een eigen kopje in die dialoog, waar er
  ruimte voor is; vier woorden waren te weinig.

Toetsen en gereedschap
- tests/test_relay_agent.py (nieuw, 65 toetsen) en tests/test_paginas_parsen.mjs
  (nieuw). Muteertests gedraaid op de beslissende regels.
- Een dollarteken-toets in test_appstore_vorm.py. Het commentaar in drie bestanden
  beweerde al dat die test bestond; nu is dat waar.
- Een toets dat er geen werkbestanden in een app-map staan. umbreld kopieert de
  hele map naar het apparaat en in de back-up.
- tools/voorbeeldpagina.mjs maakt van een *.template een pagina die je in een
  browser kunt openen. Dat vond meteen twee echte opmaakfouten.

De twee onderzoeksvragen
- Een geweigerde eigenaar komt niet in de database: isOwnerAllowed zit in de
  WebSocket-upgrade, dus het is een 401 en een gesloten socket. Het gewenste gevolg
  treedt wel op, via de client: die is local-first en levert bij toelating de hele
  geschiedenis. Blokkeren werkt daarentegen pas bij de volgende verbinding, en dat
  staat als open punt.
- De blobs zijn niet met een xpub te ontcijferen; een OwnerId komt daar niet uit.
  Met de SLIP-21-node van het apparaat kan het wel, maar die geeft volledige
  zeggenschap, dus dat hoort niet in een relay. Als plan-punt opgenomen bij de tool
  in HomeGit/Trezor.

Nog niet uitgerold: de image 0.5.0 moet gebouwd en geduwd worden. De digest staat
daarom niet in de compose, want een oude digest onder een nieuwe tag levert stil de
oude relay.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 12:26:56 +02:00

1192 lines
51 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<!--
═══════════════════════════════════════════════════════════════════════════════
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.
LET OP, en dit is de valstrik van dit bestand: umbreld haalt elke *.template
bij het starten door envsubst. Er mag dus GEEN dollarteken in staan behalve de
umbrel-variabelen die hier werkelijk ingevuld moeten worden. Dat sluit ook
JavaScript-template-literals uit, want die gebruiken accolades achter een
dollarteken en zouden stilzwijgend leeg worden. Alles hieronder plakt strings
dus met een plus aan elkaar. tests/test_appstore_vorm.py houdt dit dicht.
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.
═══════════════════════════════════════════════════════════════════════════════
-->
<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>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;
--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-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;
--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-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;
}
/* Dezelfde maat als Electrum Gate. Breed, maar niet oneindig: op een ultrawide
monitor zou een kader van 2500px regels opleveren die niemand leest. */
.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: 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 {
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); }
/* 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);
box-shadow: 0 1px 2px rgba(0,0,0,0.06), 0 4px 16px rgba(0,0,0,0.05);
}
.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); }
.stat-val.ok { color: var(--pos); }
/* 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-xl) - 1.8rem));
padding: 0.9rem 1.1rem;
overflow-wrap: anywhere;
color: var(--accent);
}
/* ── 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; overflow-wrap: anywhere; }
.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; overflow-wrap: anywhere; }
.list-item-sub.mono { font-family: var(--font-mono); }
.list-empty { font-size: 0.78rem; color: var(--text-ter); padding: 0.7rem 0; }
.owner-who { min-width: 0; }
/* 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; }
.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); }
.badge-neutral { background: var(--neutral-bg); color: var(--text-sec); }
/* ── 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">v${APP_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">Relay</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 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>
<!-- Het tijdvenster voor nieuwe eigenaars. De twee knoppen staan in de kop
en verschijnen bij hover over dit kader.
De teller loopt in het relay-proces en niet hier: een teller in de
browser verdwijnt als je het tabblad sluit, en dan blijft de deur open
staan zonder dat iemand dat ziet. Wat de pagina doet is de resterende
tijd van de agent aftellen tussen twee rondes. -->
<div class="card">
<div class="card-head">
<h2 class="t-h3">New owners</h2>
<!-- "Open" en niet "Open 2 min": de twee minuten staan op de onderregel,
waar er ruimte voor is. Met de lange tekst erin brak de kop van dit
kader over twee regels en stond de waarde lager dan in de drie
kaders ernaast. -->
<div class="hover-only">
<button class="btn btn-solid hidden" id="learn-open" type="button">Open</button>
<button class="btn btn-danger hidden" id="learn-stop" type="button">Close</button>
</div>
</div>
<div class="stat-body">
<div class="stat-val text unknown" id="learning-state">unknown</div>
<div class="stat-sub" id="learning-sub"></div>
</div>
</div>
</div>
<!-- ── De twee lijsten ───────────────────────────────────────────────── -->
<div class="card col-6">
<div class="card-head"><h2 class="t-h3">Accepted owners</h2></div>
<div id="allowed"></div>
</div>
<!-- Geweigerd 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. -->
<div class="card col-6">
<div class="card-head"><h2 class="t-h3">Refused owners</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>
<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>
<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. The address has to start with
<strong>http</strong> or <strong>https</strong>, not ws, even though the connection itself is
a websocket: the client works that out on its own.
</p>
<p>
Away from home you will need a way in, such as Tailscale or a reverse proxy with your own
domain.
</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. Opening the window for new owners accepts and remembers whoever connects
while it is open, so open it only while you pair a device of your own. One app can use more
than one owner id, so wait until nothing new appears before you close it again.
</p>
<p>
An owner that is refused 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.
</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';
// Hoelang de deur open gaat als je op de knop drukt. Twee minuten, op verzoek
// van de gebruiker (30-08-2026). Deze waarde gaat mee in de opdracht; de agent
// en het relay-proces begrenzen hem allebei nog een keer, want dit is de kant
// die van buiten komt.
var LEARNING_SECONDS = 120;
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;
// De resterende seconden van het tijdvenster, zoals de agent ze doorgaf, en het
// moment waarop dat was. Daartussen telt de pagina zelf af, zodat de teller
// loopt in plaats van elke vijf seconden te verspringen.
var learningLeft = null;
var learningLeftAt = 0;
// ── 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) {
var node = el(id);
var body = el(id + '-body');
if (body && text) { body.textContent = text; }
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 formatClock(seconds) {
var whole = Math.max(0, Math.round(seconds));
var minutes = Math.floor(whole / 60);
var rest = whole % 60;
return minutes + ':' + (rest < 10 ? '0' : '') + rest;
}
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) {
show('alert-notice', 'The app refused that: ' + (result.body.error || 'no reason given'));
} else if (whenDone) {
whenDone();
}
}, function () {
show('alert-notice', 'Could not reach the app.');
});
}
function send(command) {
if (busy) { return; }
busy = true;
hide('alert-notice');
post('api/command', command, function () {
show('alert-notice', 'Sent. The relay applies changes within a few seconds.');
}).then(function () {
busy = false;
window.setTimeout(refresh, 2500);
});
}
function sendLabel(ownerId, label) {
hide('alert-notice');
// 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);
}
el('learn-open').addEventListener('click', function () {
send({ action: 'set-learning', value: true, seconds: LEARNING_SECONDS });
});
el('learn-stop').addEventListener('click', function () {
send({ action: 'set-learning', value: false });
});
// ── 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.
if (entry.label) {
title.className = 'list-item-title';
title.textContent = entry.label;
sub.className = 'list-item-sub mono';
sub.textContent = shorten(entry.id);
sub.title = entry.id;
} 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);
// Met een label is er een derde regel nodig voor de tijden, want de tweede is
// dan het id.
if (entry.label) {
var extra = document.createElement('div');
extra.className = 'list-item-sub';
extra.textContent = describe(entry, kind);
extra.title = describeExact(entry);
who.appendChild(extra);
}
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.
var tags = document.createElement('div');
tags.className = 'owner-tags';
if (kind === 'blocked') { tags.appendChild(badge('Blocked', 'badge-neg')); }
if (kind === 'rejected') { tags.appendChild(badge('Turned away', 'badge-neutral')); }
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 ────────────────────────────────────────────────────────────
function fillList(id, groups, emptyText) {
var holder = el(id);
var total = 0;
groups.forEach(function (group) { total += group.entries.length; });
holder.textContent = '';
if (total === 0) {
var empty = document.createElement('p');
empty.className = 'list-empty';
empty.textContent = emptyText;
holder.appendChild(empty);
return;
}
groups.forEach(function (group) {
group.entries.forEach(function (entry) {
holder.appendChild(ownerRow(entry, group.kind));
});
});
}
// ── Het tijdvenster ───────────────────────────────────────────────────────
function drawLearning(owners) {
var open = el('learn-open');
var stop = el('learn-stop');
var node = el('learning-state');
if (owners.learning === true) {
// Met een tijdslot toont de widget de teller; zonder tijdslot alleen dat hij
// openstaat. Dat verschil is echt en hoort zichtbaar te zijn: "open" zonder
// teller sluit zichzelf niet.
if (typeof learningLeft === 'number') {
put('learning-state', formatClock(remainingSeconds()));
el('learning-sub').textContent = 'closes on its own';
} else {
put('learning-state', 'Open');
el('learning-sub').textContent = 'stays open until you close it';
}
node.classList.remove('bad');
node.classList.add('ok');
open.classList.remove('hidden');
open.textContent = 'Restart';
stop.classList.remove('hidden');
return;
}
node.classList.remove('ok', 'bad');
if (owners.learning === false) {
put('learning-state', 'Closed');
// Hier staat wat de knop niet zegt: hoe lang hij open gaat. Dat is de reden
// dat de knop "Open" mag heten en niet "Open 2 min" hoeft te heten.
el('learning-sub').textContent = 'opens for two minutes at a time';
open.classList.remove('hidden');
open.textContent = 'Open';
stop.classList.add('hidden');
return;
}
// Onbekend is geen synonym voor uit: uit is een keuze, onbekend is een reden
// om te kijken. Dat verschil hoort de pagina te tonen.
put('learning-state', null);
el('learning-sub').textContent = 'the relay has not reported this yet';
open.classList.add('hidden');
stop.classList.add('hidden');
}
function remainingSeconds() {
if (typeof learningLeft !== 'number') { return 0; }
return Math.max(0, learningLeft - (Date.now() - learningLeftAt) / 1000);
}
// Eén keer per seconde de teller bijwerken. Alleen de teller: de rest van de
// pagina komt uit de ronde hieronder, die elke vijf seconden loopt.
window.setInterval(function () {
if (typeof learningLeft !== 'number') { return; }
var node = el('learning-state');
if (node.classList.contains('ok')) {
node.textContent = formatClock(remainingSeconds());
}
}, 1000);
// ── De ronde ──────────────────────────────────────────────────────────────
function refresh() {
fetch('api/status', { cache: 'no-store' }).then(function (response) {
return response.json();
}).then(function (status) {
hide('alert-agent');
// 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';
el('relay-url').textContent =
'http://' + window.location.hostname + ':' + status.relay.publicPort;
el('relay-url').classList.remove('unknown');
// Het tijdvenster. De resterende tijd komt van de agent, want die rekent met
// de klok van de Umbrel en niet met die van de bezoeker.
if (typeof status.owners.learningSecondsLeft === 'number'
&& status.owners.learningSecondsLeft > 0) {
learningLeft = status.owners.learningSecondsLeft;
learningLeftAt = Date.now();
} else {
learningLeft = null;
}
drawLearning(status.owners);
// De lijsten, tenzij er iemand aan een label bezig is.
if (editing === null) {
fillList('allowed', [
{ kind: 'allowed', entries: status.owners.allowed }
], 'None yet. Open the window above and connect your app.');
fillList('refused', [
{ kind: 'blocked', entries: status.owners.blocked },
{ kind: 'rejected', entries: status.owners.rejected }
], 'None.');
}
if (status.owners.problem === 'onleesbaar') {
show('alert-owners', 'The list of owners could not be read and was moved aside. New owners '
+ 'are refused until you open the window above again.');
} else {
hide('alert-owners');
}
if (status.pendingCommand) {
show('alert-notice', 'A command is waiting for the relay to pick it up.');
}
}, function () {
show('alert-agent');
put('relay-state', null);
el('relay-state').classList.remove('ok', 'bad');
});
}
refresh();
window.setInterval(refresh, 5000);
})();
</script>
</body>
</html>