Relay 0.7.0: een weg naar binnen, en de melding bij elke klik weg

Vier punten van de gebruiker, opgekomen tijdens het beproeven met de testclient.

De getimede leerstand is eruit; de wachtlijst is de enige weg naar binnen. Zijn
redenering: allebei de wegen vragen iemand die bij de app kan, dus het is
dubbelop, en het venster is de zwakste omdat het iedereen toelaat die er
toevallig in verbindt. Dat weegt zwaarder nu de relay op een publiek wss-adres
kan staan. De oorspronkelijke reden voor de leerstand, dat je je eigen OwnerId
nergens kon aflezen, verviel toen de weigerlijst dat id ging tonen. Daarmee
verdwijnt ook de bug die hij dezelfde dag meldde: een geleerde eigenaar bleef in
de weigerlijst staan terwijl hij al kon schrijven en lezen, want decideOwner
haalde hem niet van die lijst af en de knop allow wel.

"Refused owners" heet "Waiting list", met de badge Waiting en een teller waar de
widget van het tijdvenster stond. Het veld op schijf blijft rejected: hernoemen
zou een migratie zijn voor een woord dat niemand ziet.

Het adres onderaan zei http:// en dat kan nergens werken, want de relay spreekt
WebSocket en nooit HTTP. Nu ws://<host>:3852, met een regel over wss://<domein>
zonder poort achter een reverse proxy. Dat is precies de fout die diezelfde dag
een ronde kostte bij het koppelen van de testclient.

De melding bij elke klik is weg. Die stond in de gewone stroom van de pagina,
dus alles eronder schoof omlaag en weer omhoog. Nu gaan de knoppen in de lijsten
even op slot tot de ronde de nieuwe stand heeft; foutmeldingen blijven wel staan,
want die zeggen iets wat je nergens anders ziet.

STATE_VERSION blijft 1 en een owners.json van 0.6.0 leest door: learning en
learningUntil worden gelezen, genegeerd en niet teruggeschreven. Een verhoging
zou store.js de allowlist van een werkende installatie opzij laten schuiven.

Twee toetsen bewaken dat de leerstand niet terugsluipt: een onbekende eigenaar
wordt geweigerd ook met learning: true in het bestand, en set-learning is een
onbekende actie. Beide mutatie-getest.

De compose staat op 0.7.0 zonder digest, zodat het hard faalt tot de image
bestaat. Bouwen, duwen en pinnen ligt bij de gebruiker.

Suite: 424 goed, 0 fout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Harmen
2026-09-09 15:45:55 +02:00
co-authored by Claude Opus 5
parent 04c20a37f0
commit a092464e4d
13 changed files with 449 additions and 804 deletions
+104 -139
View File
@@ -431,6 +431,20 @@ body {
color: var(--accent);
}
/* De regel onder het adres: hoe het van buiten heet. Geen kader eromheen, want
het is uitleg bij de waarde erboven en geen tweede waarde. */
.note {
margin: 0.7rem 0 0;
font-size: 0.85rem;
line-height: 1.5;
color: var(--text-sec);
}
.note code {
font-family: var(--font-mono);
font-size: 0.95em;
color: var(--accent);
}
/* ── Lijstregels ────────────────────────────────────────────────────────── */
.list-item {
display: flex; justify-content: space-between; align-items: center;
@@ -462,6 +476,13 @@ body {
.btn:hover { background: var(--neutral-bg); }
.btn:active { transform: scale(0.97); }
.btn:disabled { opacity: 0.35; cursor: not-allowed; }
/* Bevroren terwijl er een opdracht onderweg is. Alleen de lijsten, want alleen
daar staan knoppen die iets veranderen, en de kaders erboven horen gewoon te
blijven bijwerken. Geen verschuiving en geen overlay: dit verandert niets aan
de afmetingen, en dat is precies waarom het de melding bovenaan vervangt. */
body.is-busy .list-item { opacity: 0.55; }
body.is-busy .list-item button { cursor: progress; }
.btn-solid { background: var(--accent); color: #fff; border-color: transparent; }
.btn-solid:hover { background: var(--accent); opacity: 0.88; }
.btn-danger { color: var(--neg); border-color: color-mix(in srgb, var(--neg) 45%, transparent); }
@@ -710,28 +731,17 @@ body {
</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. -->
<!-- Hoeveel eigenaars er op een beslissing wachten.
Tot 0.6.0 stond hier het tijdvenster voor nieuwe eigenaars, met een
knop om het twee minuten open te zetten. Dat is eruit in 0.7.0: er is
nog één weg naar binnen, en dat is de wachtlijst hieronder. Twee wegen
was dubbelop, en het venster was de zwakste van de twee omdat het
iederéén toeliet die er toevallig in verbond. -->
<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="card-head"><h2 class="t-h3">Waiting</h2></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 class="stat-val text unknown" id="waiting-state">unknown</div>
<div class="stat-sub" id="waiting-sub"></div>
</div>
</div>
<!-- De statuswidget staat RECHTS en niet links (keuze gebruiker,
@@ -759,14 +769,20 @@ body {
<div id="allowed"></div>
</div>
<!-- Geweigerd en geblokkeerd in één kader, op verzoek van de gebruiker
<!-- Wachtend en geblokkeerd in één kader, op verzoek van de gebruiker
(30-08-2026). Het waren twee kaders, en het onderscheid ertussen is voor
de gebruiker geen verschil in wat hij ermee doet: allebei zijn ze
buitengesloten en allebei kun je ze toelaten. Dat het bij de een een
eerdere keuze van jou was en bij de ander een poging van buiten, staat als
badge op de regel. -->
badge op de regel.
"Waiting list" en niet "Refused owners" sinds 0.7.0, op voorstel van de
gebruiker. Dat is wat het is: kandidaten die op een beslissing wachten, en
sinds het tijdvenster eruit is, is dit de enige weg naar binnen. Het veld
heet in owners.json nog `rejected`; hernoemen zou een migratie zijn voor
een woord dat niemand ziet. -->
<div class="card col-6">
<div class="card-head"><h2 class="t-h3">Refused owners</h2></div>
<div class="card-head"><h2 class="t-h3">Waiting list</h2></div>
<div id="refused"></div>
</div>
@@ -776,6 +792,15 @@ body {
<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>
<!-- De tweede regel is er sinds 0.7.0. Het adres hierboven werkt binnen je
eigen netwerk; van buiten loopt het via een reverse proxy, en dan is de
vorm anders én is dit de meest gemaakte fout: het schema wordt wss://
en de poort valt weg, want de proxy luistert op 443. -->
<p class="note">
That address works inside your own network. Through a reverse proxy with a certificate it
becomes <code>wss://your.domain</code>, <strong>without</strong> a port number: the proxy
listens on 443 and forwards to this one.
</p>
</div>
</div>
@@ -833,14 +858,15 @@ body {
<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.
over who may use it. Nobody gets in by default. An owner that connects for the first time is
turned away and lands on the <strong>waiting list</strong> with its owner id, and you decide
there whether it may stay. One app can use more than one owner id, so expect more than one
row per device.
</p>
<p>
An owner 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.
An owner on the waiting list syncs nothing at all, and nothing of it is stored: the relay
turns the connection away before it starts. Its device keeps everything locally and retries,
so allowing it later still brings the whole history across, and you do not have to be quick.
</p>
<p>
Owner ids are long and meaningless on purpose. Hover a row and use <strong>Label</strong> to
@@ -854,12 +880,6 @@ body {
(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
@@ -885,12 +905,6 @@ body {
// 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); }
@@ -951,12 +965,6 @@ body {
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'; }
@@ -1045,19 +1053,39 @@ body {
hide('alert-notice');
}
// Bevriezen in plaats van een melding bovenaan.
//
// Tot 0.6.0 verscheen bij élke klik "Sent. The relay applies changes within a
// few seconds." in de balk bovenaan. Die balk staat in de gewone stroom van de
// pagina, dus alles eronder schoof omlaag en bij het verdwijnen weer omhoog.
// Gemeld door de gebruiker op 09-09-2026, en terecht: dat is een melding over
// het verzendmechanisme en niet over wat jij deed. Wat je deed, zie je aan de
// lijsten zodra de relay de opdracht heeft opgepakt.
//
// In plaats daarvan gaan de knoppen even op slot. Dat verschuift niets, het
// toont dat er iets loopt, en het voorkomt een tweede klik terwijl de eerste
// nog onderweg is. De foutmeldingen blijven wél in de balk staan: die zeggen
// iets wat je nergens anders ziet, en die hóórt de pagina in de weg te leggen.
function setFrozen(frozen) {
document.body.classList.toggle('is-busy', frozen);
var knoppen = document.querySelectorAll('.list-item button');
for (var i = 0; i < knoppen.length; i += 1) { knoppen[i].disabled = frozen; }
}
function send(command) {
if (busy) { return; }
busy = true;
setFrozen(true);
clearNotice();
// 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);
post('api/command', command).then(function () {
// Verversen en pas daarná ontdooien: de ronde bouwt de lijsten opnieuw op,
// en tot dat gebeurd is staat de oude stand er nog. Ontdooien vóór de
// ronde zou een klikbare knop opleveren die nog bij de vorige stand hoort.
window.setTimeout(function () {
refresh();
busy = false;
setFrozen(false);
}, 2500);
});
}
@@ -1069,13 +1097,6 @@ body {
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.
@@ -1128,7 +1149,7 @@ body {
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')); }
if (kind === 'rejected') { tags.appendChild(badge('Waiting', 'badge-neutral')); }
row.appendChild(tags);
var actions = document.createElement('div');
@@ -1284,66 +1305,6 @@ body {
});
}
// ── 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) {
@@ -1380,26 +1341,19 @@ body {
put('db-modified', formatAgo(status.database.modified));
el('db-modified-sub').textContent = formatTime(status.database.modified) || 'never written to';
// ws:// en niet http://, en dat is geen schoonheidsfoutje: de relay is een
// WebSocket-server en spreekt nooit HTTP tegen een cliënt. Er stond http://
// tot 0.7.0, en dat kostte op 09-09-2026 een ronde zoeken bij het koppelen
// van de testcliënt.
el('relay-url').textContent =
'http://' + window.location.hostname + ':' + status.relay.publicPort;
'ws://' + 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.');
], 'None yet. Connect your app; it will show up in the waiting list.');
fillList('refused', [
{ kind: 'blocked', entries: status.owners.blocked },
@@ -1407,9 +1361,20 @@ body {
], 'None.');
}
// De teller met wachtenden. Dit is de plek waar tot 0.6.0 het tijdvenster
// stond; nu telt hij wie er op een beslissing wacht, want dat is sinds
// 0.7.0 de enige weg naar binnen.
var wachtend = (status.owners.rejected || []).length;
put('waiting-state', String(wachtend));
el('waiting-state').classList.remove('unknown');
el('waiting-sub').textContent = wachtend === 0
? 'nobody is asking to get in'
: (wachtend === 1 ? 'one owner wants in; allow it below if it is yours'
: 'owners want in; allow the ones you recognise below');
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.');
show('alert-owners', 'The list of owners could not be read and was moved aside. Everyone is '
+ 'on the waiting list again; allow the owners you recognise below.');
} else {
hide('alert-owners');
}