Wat de eerste echte gebruiksdag opleverde: logging, geschiedenis, uitlijning

Drie dingen die vandaag misgingen of ontbraken, en die alle drie uit gebruik
kwamen en niet uit nadenken.

De allowlist logde niets als een bekende eigenaar terugkwam en niets als er een
geweigerd werd. Daardoor stond er een tweede eigenaar drieentwintig keer aan de
deur zonder dat het log er iets over zei; het stond alleen op de pagina. Nu is er
een regel per eigenaar per keer dat het proces draait, dus ook bij terugkomst en
bij weigering, met een bovengrens omdat een weigering een id bevat dat de ander
zelf verzint. Een geweigerde schrijfactie logt voortaan hoeveel bytes er gevraagd
werden en welke grens gold, met de naam van de variabele erbij.

Allow gooide de geschiedenis weg. Een eigenaar die je alsnog toeliet kreeg "first
seen" op het moment van de klik, terwijl hij al twintig minuten stond te
kloppen. Juist daar wil je zien sinds wanneer. Met een toets erop.

En de knoppen stonden niet recht: id, gegevens en knoppen stonden naast elkaar,
dus de lengte van de gegevensregel bepaalde of de rij afbrak. Een eigenaar met
een last seen duwde zijn knoppen naar de volgende regel en dus naar links.
Nu staan naam en gegevens onder elkaar in een kolom en worden de knoppen altijd
naar rechts geduwd.

Inhoudelijk het belangrijkste: "de eerste eigenaar wint" is te smal. Een enkele
app kan meer dan een eigenaar gebruiken, en de pagina adviseerde de deur te
sluiten zodra de eerste binnen was. Daarmee sluit je je eigen tweede eigenaar
buiten. De tekst zegt nu te wachten tot er een minuut niets nieuws meer bij komt.

Image naar 0.3.0. De digest van 0.2.0 is uit de compose gehaald in plaats van
blijven staan: die zou naar de vorige image wijzen terwijl de tag iets anders
belooft.

Tests: alle vier groen (34, 54, 39 en 61 goed, 0 fout).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Harmen
2026-08-28 12:08:19 +02:00
co-authored by Claude Opus 5
parent f029144572
commit c57197637f
7 changed files with 88 additions and 19 deletions
+4
View File
@@ -141,6 +141,10 @@ const VREEMD = 'owner-van-een-ander';
const alsnog = applyCommand(geweigerd, { action: 'allow', ownerId: VREEMD }, LATER);
toets('een geweigerde poging kan alsnog toegelaten worden', alsnog.state.owners.length === 1);
toets('en verdwijnt dan uit de weigeringen', alsnog.state.rejected.length === 0);
toets(
'en houdt het moment waarop hij zich meldde, niet dat van de klik',
alsnog.state.owners[0].firstSeen === NU,
);
toets(
'en mag er daarna in, ook met de leerstand uit',
decideOwner(alsnog.state, VREEMD, LATER).allowed === true,
+1 -1
View File
@@ -36,7 +36,7 @@ set -eu
# verandert, want anders rolt umbrelOS hem niet uit. Dat heeft hier een keer een
# dag gekost. Ze zijn dus gelijk zolang alleen de image wijzigt, en lopen uiteen
# zodra er een reparatie in de app-map zit.
VERSION="0.2.0"
VERSION="0.3.0"
# Het register staat er expres in en dit is geen smaakkwestie: umbreld haalt élke
# image op via de Docker Engine API, dus een tag die alleen lokaal bestaat is voor
+36 -2
View File
@@ -115,6 +115,20 @@ process.on('SIGINT', stopCleanly);
// ── De relay ──────────────────────────────────────────────────────────────────
// Eén regel per eigenaar per keer dat dit proces draait, en niet per verbinding.
// Zonder deze rem staat het log vol zodra een cliënt opnieuw verbindt; zónder
// logging kostte het op 28-08-2026 juist een half uur om te zien dat een tweede
// eigenaar aan de deur stond te kloppen. De bovengrens erop is er omdat een
// weigering een id bevat dat de ander zelf verzint.
const MAX_ANNOUNCED = 100;
const announced = new Set();
const announceOnce = (key, message) => {
if (announced.has(key)) return;
if (announced.size < MAX_ANNOUNCED) announced.add(key);
console.log(message);
};
await runMain({ ...createRelayDeps(), console })(
createRelay({
port,
@@ -131,12 +145,32 @@ await runMain({ ...createRelayDeps(), console })(
state = decision.state;
if (decision.changed) markDirty();
const short = typeof ownerId === 'string' ? ownerId.slice(0, 12) : '(unusable)';
if (decision.reason === 'learned') {
console.log('[info] learned a new owner; turn learning off on the status page when done');
console.log(`[info] learned a new owner ${short}; one app can use more than one, so keep ` +
'learning open until nothing new shows up');
} else if (decision.allowed) {
announceOnce(`ok:${short}`, `[info] owner ${short} connected`);
} else {
announceOnce(
`no:${short}`,
`[info] refused owner ${short} (${decision.reason}); allow it on the status page if it is ` +
'yours',
);
}
return decision.allowed;
},
isOwnerWithinQuota: (_ownerId, requiredBytes) => requiredBytes <= maxWriteBytes,
isOwnerWithinQuota: (ownerId, requiredBytes) => {
if (requiredBytes <= maxWriteBytes) return true;
const short = typeof ownerId === 'string' ? ownerId.slice(0, 12) : '(unusable)';
announceOnce(
`quota:${short}`,
`[warn] refused a write of ${requiredBytes} bytes from ${short}; the limit is ` +
`${maxWriteBytes}. Raise RELAY_MAX_WRITE_BYTES if this is normal for your app.`,
);
return false;
},
}),
);
+16 -1
View File
@@ -169,11 +169,26 @@ export const applyCommand = (state, command, now) => {
const known = findOwner(state, command.ownerId);
// Ook een eigenaar die alleen als geweigerde poging bekend is, mag hiermee
// alsnog toegelaten worden. Dat is de knop naast zo'n regel op de pagina.
//
// De geschiedenis van die pogingen gaat mee. Dat leek een detail tot het
// op 28-08-2026 misging: de pagina meldde daarna "first seen" op het moment
// van de klik, terwijl die eigenaar al twintig minuten aan de deur stond.
// Juist bij een eigenaar die je alsnog toelaat wil je kunnen terugzien
// sinds wanneer hij er is.
const attempted = state.rejected.find((entry) => entry.id === command.ownerId);
const owners = known
? state.owners.map((owner) =>
owner.id === command.ownerId ? { ...owner, allowed: true } : owner,
)
: [...state.owners, { id: command.ownerId, allowed: true, firstSeen: now, lastSeen: null }];
: [
...state.owners,
{
id: command.ownerId,
allowed: true,
firstSeen: attempted && attempted.firstSeen ? attempted.firstSeen : now,
lastSeen: attempted ? attempted.lastSeen : null,
},
];
return {
state: {
...state,
+9 -4
View File
@@ -45,10 +45,15 @@ services:
# bepaalt de digest wat er gehaald wordt en is de tag alleen leesbaarheid.
# Houd de tag gelijk aan `version` in het manifest en aan VERSION in build.sh.
#
# Geduwd op 28-08-2026. Let op: dit is één architectuur (amd64), want er is
# alleen amd64 gebouwd. Voor de officiele store hoort er een multi-arch
# index-digest met arm64 in; zie het masterplan Publicatie-Relay.
image: sc.kamenier-hamer.nl/sysop/evolu-relay:0.2.0@sha256:5b2b4228c24a7e8a12a99869ca39729f47a0a01d6a346c623b22d6e62f2918c2
# Let op: dit is één architectuur (amd64), want er is alleen amd64 gebouwd.
# Voor de officiele store hoort er een multi-arch index-digest met arm64 in;
# zie het masterplan Publicatie-Relay.
#
# TODO de digest van 0.3.0 erachter zetten zodra hij geduwd is:
# sudo docker inspect --format '{{index .RepoDigests 0}}' <tag>
# De digest van 0.2.0 is er expres uit gehaald en niet blijven staan: die
# wijst naar de vorige image, en dan draait er iets anders dan de tag belooft.
image: sc.kamenier-hamer.nl/sysop/evolu-relay:0.3.0
restart: on-failure
ports:
# De relay zelf. Dit is de poort waar Zoraxy met TLS naartoe wijst, en de
+21 -10
View File
@@ -184,15 +184,21 @@ button.action.danger { background: var(--neg-bg); border-color: var(--neg); colo
flex-wrap: wrap;
}
.owner:first-of-type { border-top: none; }
/* De naam en de gegevens staan onder elkaar in één kolom, en de knoppen worden
met margin-left naar rechts geduwd. Zo staan ze bij elke regel op dezelfde
plek. Eerder stonden id, meta en knoppen náást elkaar, en dan bepaalde de
lengte van de meta-regel of de rij afbrak: een eigenaar met een 'last seen'
duwde zijn knoppen naar de volgende regel en dus naar links, een eigenaar
zonder hield ze rechts. */
.owner .who { flex: 1 1 240px; min-width: 0; }
.owner .id {
font-family: var(--font-mono);
font-size: 0.8rem;
overflow-wrap: anywhere;
flex: 1;
min-width: 220px;
}
.owner .meta { color: var(--text-ter); font-size: 0.75rem; }
.owner .buttons { display: flex; gap: 8px; }
.owner .meta { color: var(--text-ter); font-size: 0.75rem; margin-top: 2px; }
.owner .buttons { display: flex; gap: 8px; margin-left: auto; flex: 0 0 auto; }
.empty { color: var(--text-ter); font-size: 0.88rem; font-style: italic; }
@@ -257,8 +263,7 @@ footer { color: var(--text-ter); font-size: 0.78rem; text-align: center; margin-
<div class="grow">
<div class="stat-val"><span class="pill unknown" id="learning-state">unknown</span></div>
<p class="hint" id="learning-hint">
While this is open, the next device that connects is accepted and remembered. Close it once your
own devices are paired.
While this is open, owners that connect are accepted and remembered.
</p>
</div>
<button class="action" id="learning-toggle" disabled>Loading</button>
@@ -369,11 +374,14 @@ footer { color: var(--text-ter); font-size: 0.78rem; text-align: center; margin-
var row = document.createElement('div');
row.className = 'owner';
var who = document.createElement('div');
who.className = 'who';
var id = document.createElement('div');
id.className = 'id';
id.textContent = shorten(entry.id);
id.title = entry.id;
row.appendChild(id);
who.appendChild(id);
var meta = document.createElement('div');
meta.className = 'meta';
@@ -382,7 +390,9 @@ footer { color: var(--text-ter); font-size: 0.78rem; text-align: center; margin-
if (entry.lastSeen) parts.push('last seen ' + formatTime(entry.lastSeen));
if (entry.attempts) parts.push(entry.attempts + ' attempts');
meta.textContent = parts.join(' · ');
row.appendChild(meta);
who.appendChild(meta);
row.appendChild(who);
var holder = document.createElement('div');
holder.className = 'buttons';
@@ -433,8 +443,9 @@ footer { color: var(--text-ter); font-size: 0.78rem; text-align: center; margin-
setPill(learningPill, 'open', 'ok');
toggle.textContent = 'Close';
toggle.disabled = false;
learningHint.textContent = 'The next device that connects is accepted and remembered. Close '
+ 'this once your own devices are paired.';
learningHint.textContent = 'Any owner that connects is accepted and remembered. One app can '
+ 'use more than one owner, so wait until nothing new appears for a minute before you close '
+ 'this.';
} else if (status.owners.learning === false) {
setPill(learningPill, 'closed', 'unknown');
toggle.textContent = 'Open';
+1 -1
View File
@@ -11,7 +11,7 @@ manifestVersion: 1
id: whatsnext-evolu-relay
category: files
name: Evolu Relay
version: "0.2.2"
version: "0.3.0"
tagline: Encrypted sync and backup for your local-first apps
description: >-
Local-first apps keep your data on your own device and work whether or not you have a