Gemeld door de gebruiker binnen een uur na 0.5.0, op de pagina op zijn eigen Umbrel. "Sent. The relay applies changes within a few seconds." bleef staan tot je de pagina herlaadde, dus ook lang nadat hij niet meer waar was. Een ontbrekende tak: de ronde die elke vijf seconden loopt zette de melding wel aan maar nooit uit. De regel die daaruit volgt en die in het commentaar staat: de melding hoort van de RONDE te zijn en niet van de klik, want alleen de ronde weet of hij nog waar is. Hij verdwijnt nu zodra het relay-proces de opdracht heeft opgepakt, en blijft staan zolang de opdracht in de postbus ligt. Dat is het geval waarin er iets te melden valt, want normaal duurt dat twee seconden. Een weigering is de uitzondering en blijft staan tot de volgende opdracht: het is de enige uitleg die je krijgt van wat er misging, en die mag niet binnen vijf seconden verdwijnen. Met een vlag en niet door naar de klasse van het element te kijken, zoals de vorige versie van deze pagina deed; dan hangt gedrag aan een naam die er ook om opmaakredenen kan staan. Bijvangst: die weigering stond in de neutrale stijl en is nu rood. Een geweigerde opdracht in de neutrale stijl leest als een mededeling. Alleen index.html.template geraakt, dus geen nieuwe image: build.sh blijft op 0.5.0 en het manifest gaat naar 0.5.1. De changelog beweerde sinds diezelfde ochtend dat die twee nummers gelijk gehouden worden; de regel is dat een hogere VERSION een hoger manifest vraagt en niet andersom. Rechtgezet. Beide gedragingen zijn in een browser nagegaan tegen de voorbeeldweergave: de bevestiging is na zeven seconden verborgen, de weigering staat er dan nog. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1237 lines
53 KiB
Plaintext
1237 lines
53 KiB
Plaintext
<!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;
|
||
|
||
// 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;
|
||
|
||
// 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, 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 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) {
|
||
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');
|
||
}
|
||
|
||
function send(command) {
|
||
if (busy) { return; }
|
||
busy = true;
|
||
clearNotice();
|
||
// De melding is met opzet kort van leven: de ronde hieronder haalt hem weg
|
||
// zodra het relay-proces de opdracht heeft opgepakt, en dan is de nieuwe stand
|
||
// in de kaders zelf te zien. Blijft hij staan, dan heeft de relay hem nog
|
||
// niet gezien, en dán is er iets te melden.
|
||
post('api/command', command, function () {
|
||
show('alert-notice', 'Sent. The relay applies changes within a few seconds.', 'alert-neutral');
|
||
}).then(function () {
|
||
busy = false;
|
||
window.setTimeout(refresh, 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);
|
||
}
|
||
|
||
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');
|
||
}
|
||
|
||
// 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>
|