Files
UmbrelApps/whatsnext-evolu-relay/index.html.template
T
HarmenandClaude Opus 5 c57197637f 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>
2026-08-28 12:08:19 +02:00

498 lines
18 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. 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.
Het ontwerp volgt HomeGit/Docs/website-design-system.html, net als Electrum
Gate, zodat de twee apps naast elkaar op hetzelfde dashboard herkenbaar 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.
═══════════════════════════════════════════════════════════════════════════════
-->
<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-full: 999px;
--font-base: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
--font-mono: 'SF Mono', 'Fira Code', ui-monospace, Menlo, 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);
--text-primary: #f2f2f2;
--text-sec: #a3a3a3;
--text-ter: #6a6a6a;
--accent: #FF6B00;
--accent-soft: rgba(255,107,0,0.16);
--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);
}
[data-theme="light"][data-accent="orange"] {
--bg: #ffffff;
--bg-elevated: #f7f7f7;
--bg-raised: #f0f0f0;
--border: rgba(0,0,0,0.12);
--text-primary: #0a0a0a;
--text-sec: #888888;
--text-ter: #bbbbbb;
--accent: #E05500;
--accent-soft: rgba(224,85,0,0.08);
--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);
}
* { box-sizing: border-box; }
body {
margin: 0;
padding: 24px 16px 64px;
background: var(--bg);
color: var(--text-primary);
font-family: var(--font-base);
font-size: 15px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
.wrap { max-width: 780px; margin: 0 auto; }
header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
}
header img { width: 40px; height: 40px; border-radius: var(--radius-sm); }
header h1 { font-size: 1.35rem; margin: 0; font-weight: 700; letter-spacing: -0.01em; }
header .spacer { flex: 1; }
.sub { color: var(--text-sec); margin: 0 0 24px; font-size: 0.92rem; }
.theme-toggle {
background: var(--bg-raised);
border: 1px solid var(--border);
color: var(--text-sec);
border-radius: var(--radius-full);
padding: 6px 14px;
font: inherit;
font-size: 0.8rem;
cursor: pointer;
}
.card {
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 20px;
margin-bottom: 16px;
}
.card h2 {
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-ter);
margin: 0 0 14px;
font-weight: 600;
}
.stat-row { display: flex; flex-wrap: wrap; gap: 24px; }
.stat { min-width: 130px; }
.stat-label { font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-ter); }
.stat-val { font-size: 1.15rem; font-weight: 600; margin-top: 2px; }
.stat-val.mono { font-family: var(--font-mono); font-size: 0.95rem; }
.pill {
display: inline-block;
padding: 3px 10px;
border-radius: var(--radius-full);
font-size: 0.75rem;
font-weight: 600;
}
.pill.ok { background: var(--pos-bg); color: var(--pos); }
.pill.bad { background: var(--neg-bg); color: var(--neg); }
.pill.unknown { background: var(--neutral-bg); color: var(--text-sec); }
.url-box {
font-family: var(--font-mono);
font-size: 0.85rem;
background: var(--bg-raised);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 10px 12px;
overflow-wrap: anywhere;
color: var(--accent);
}
.hint { color: var(--text-sec); font-size: 0.85rem; margin-top: 10px; }
.switch-row { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; }
.switch-row .grow { flex: 1; min-width: 200px; }
button.action {
background: var(--accent-soft);
border: 1px solid var(--accent);
color: var(--accent);
border-radius: var(--radius-full);
padding: 8px 18px;
font: inherit;
font-size: 0.82rem;
font-weight: 600;
cursor: pointer;
transition: transform 0.15s var(--ease-out);
}
button.action:hover:not(:disabled) { transform: translateY(-1px); }
button.action:disabled { opacity: 0.45; cursor: default; }
button.action.quiet { background: transparent; border-color: var(--border); color: var(--text-sec); }
button.action.danger { background: var(--neg-bg); border-color: var(--neg); color: var(--neg); }
.owner {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 0;
border-top: 1px solid var(--border);
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;
}
.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; }
.notice {
border-radius: var(--radius-md);
padding: 12px 14px;
margin-bottom: 16px;
font-size: 0.88rem;
display: none;
}
.notice.show { display: block; }
.notice.warn { background: var(--neg-bg); color: var(--neg); }
.notice.info { background: var(--accent-soft); color: var(--accent); }
footer { color: var(--text-ter); font-size: 0.78rem; text-align: center; margin-top: 32px; }
</style>
</head>
<body>
<div class="wrap">
<header>
<img src="icon.png" alt="">
<h1>Evolu Relay</h1>
<span class="spacer"></span>
<button class="theme-toggle" id="theme">Light</button>
</header>
<p class="sub">Encrypted sync and backup for your local-first apps, on hardware you own.</p>
<div class="notice" id="notice"></div>
<div class="card">
<h2>Relay</h2>
<div class="stat-row">
<div class="stat">
<div class="stat-label">Status</div>
<div class="stat-val"><span class="pill unknown" id="relay-state">checking</span></div>
</div>
<div class="stat">
<div class="stat-label">Stored data</div>
<div class="stat-val mono" id="db-size">-</div>
</div>
<div class="stat">
<div class="stat-label">Last write</div>
<div class="stat-val mono" id="db-modified">-</div>
</div>
</div>
</div>
<div class="card">
<h2>Point your app here</h2>
<div class="url-box" id="relay-url">-</div>
<p class="hint">
Set this as the sync address in your Evolu app. Some apps only offer the field in their developer
or advanced settings. The address has to start with http or https, not ws, even though the
connection itself is a websocket: the client works that out on its own.
</p>
</div>
<div class="card">
<h2>New owners</h2>
<div class="switch-row">
<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, owners that connect are accepted and remembered.
</p>
</div>
<button class="action" id="learning-toggle" disabled>Loading</button>
</div>
</div>
<div class="card">
<h2>Accepted owners</h2>
<div id="allowed"><p class="empty">None yet.</p></div>
</div>
<div class="card">
<h2>Blocked owners</h2>
<div id="blocked"><p class="empty">None.</p></div>
</div>
<div class="card">
<h2>Refused attempts</h2>
<div id="rejected"><p class="empty">None.</p></div>
<p class="hint">
Devices that tried to connect while new owners were closed. Only the twenty most recent are kept.
</p>
</div>
<footer>Evolu Relay on umbrelOS &middot; WhatsNext?</footer>
</div>
<script>
(function () {
'use strict';
var root = document.documentElement;
var noticeBox = document.getElementById('notice');
var busy = false;
// ── Thema ──────────────────────────────────────────────────────────────────
var themeButton = document.getElementById('theme');
var applyTheme = function (theme) {
root.setAttribute('data-theme', theme);
themeButton.textContent = theme === 'dark' ? 'Light' : 'Dark';
try { localStorage.setItem('evolu-relay-theme', theme); } catch (error) { /* privémodus */ }
};
try {
var saved = localStorage.getItem('evolu-relay-theme');
if (saved === 'light' || saved === 'dark') applyTheme(saved);
} catch (error) { /* privémodus */ }
themeButton.addEventListener('click', function () {
applyTheme(root.getAttribute('data-theme') === 'dark' ? 'light' : 'dark');
});
// ── Hulpjes ────────────────────────────────────────────────────────────────
var notice = function (text, kind) {
noticeBox.textContent = text;
noticeBox.className = 'notice show ' + (kind || 'info');
};
var clearNotice = function () { noticeBox.className = 'notice'; };
var formatBytes = function (bytes) {
if (typeof bytes !== 'number') return '-';
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' kB';
return (bytes / (1024 * 1024)).toFixed(2) + ' MB';
};
var formatTime = function (value) {
if (typeof value !== 'string') return 'never';
var when = new Date(value);
if (isNaN(when.getTime())) return 'never';
return when.toLocaleString();
};
var setPill = function (element, text, kind) {
element.textContent = text;
element.className = 'pill ' + kind;
};
var shorten = function (ownerId) {
if (ownerId.length <= 24) return ownerId;
return ownerId.slice(0, 12) + '…' + ownerId.slice(-8);
};
// ── Opdrachten ─────────────────────────────────────────────────────────────
var send = function (command) {
if (busy) return;
busy = true;
clearNotice();
fetch('api/command', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(command)
}).then(function (response) {
return response.json().then(function (body) { return { ok: response.ok, body: body }; });
}).then(function (result) {
if (!result.ok) {
notice('The relay refused that: ' + (result.body.error || 'unknown reason'), 'warn');
return;
}
notice('Sent. The relay applies changes within a few seconds.', 'info');
}).catch(function () {
notice('Could not reach the agent.', 'warn');
}).then(function () {
busy = false;
window.setTimeout(refresh, 2500);
});
};
var ownerRow = function (entry, buttons) {
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;
who.appendChild(id);
var meta = document.createElement('div');
meta.className = 'meta';
var parts = [];
if (entry.firstSeen) parts.push('first seen ' + formatTime(entry.firstSeen));
if (entry.lastSeen) parts.push('last seen ' + formatTime(entry.lastSeen));
if (entry.attempts) parts.push(entry.attempts + ' attempts');
meta.textContent = parts.join(' · ');
who.appendChild(meta);
row.appendChild(who);
var holder = document.createElement('div');
holder.className = 'buttons';
buttons.forEach(function (spec) {
var button = document.createElement('button');
button.className = 'action ' + (spec.style || 'quiet');
button.textContent = spec.label;
button.addEventListener('click', function () { send(spec.command(entry.id)); });
holder.appendChild(button);
});
row.appendChild(holder);
return row;
};
var fillList = function (element, entries, emptyText, buttons) {
element.textContent = '';
if (!entries.length) {
var empty = document.createElement('p');
empty.className = 'empty';
empty.textContent = emptyText;
element.appendChild(empty);
return;
}
entries.forEach(function (entry) { element.appendChild(ownerRow(entry, buttons)); });
};
// ── De ronde ───────────────────────────────────────────────────────────────
var refresh = function () {
fetch('api/status', { cache: 'no-store' }).then(function (response) {
return response.json();
}).then(function (status) {
var relayPill = document.getElementById('relay-state');
if (status.relay.reachable === true) setPill(relayPill, 'running', 'ok');
else if (status.relay.reachable === false) setPill(relayPill, 'not responding', 'bad');
else setPill(relayPill, 'unknown', 'unknown');
document.getElementById('db-size').textContent = formatBytes(status.database.bytes);
document.getElementById('db-modified').textContent = formatTime(status.database.modified);
document.getElementById('relay-url').textContent =
'http://' + window.location.hostname + ':' + status.relay.publicPort;
var learningPill = document.getElementById('learning-state');
var toggle = document.getElementById('learning-toggle');
var learningHint = document.getElementById('learning-hint');
if (status.owners.learning === true) {
setPill(learningPill, 'open', 'ok');
toggle.textContent = 'Close';
toggle.disabled = false;
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';
toggle.disabled = false;
learningHint.textContent = 'Devices that are not on the list below are refused. Open this only '
+ 'while you pair another device of your own, then close it again.';
} else {
setPill(learningPill, 'unknown', 'unknown');
toggle.textContent = 'Loading';
toggle.disabled = true;
learningHint.textContent = 'The relay has not reported this yet.';
}
toggle.onclick = function () {
send({ action: 'set-learning', value: status.owners.learning !== true });
};
fillList(document.getElementById('allowed'), status.owners.allowed, 'None yet.', [
{ label: 'Block', style: 'danger', command: function (id) { return { action: 'block', ownerId: id }; } },
{ label: 'Forget', command: function (id) { return { action: 'forget', ownerId: id }; } }
]);
fillList(document.getElementById('blocked'), status.owners.blocked, 'None.', [
{ label: 'Allow', command: function (id) { return { action: 'allow', ownerId: id }; } },
{ label: 'Forget', command: function (id) { return { action: 'forget', ownerId: id }; } }
]);
fillList(document.getElementById('rejected'), status.owners.rejected, 'None.', [
{ label: 'Allow', command: function (id) { return { action: 'allow', ownerId: id }; } }
]);
if (status.owners.problem === 'onleesbaar') {
notice('The owner list could not be read and was moved aside. New owners are refused until you '
+ 'open them again above.', 'warn');
} else if (status.pendingCommand) {
notice('A command is waiting for the relay to pick it up.', 'info');
} else if (noticeBox.className.indexOf('warn') === -1) {
clearNotice();
}
}).catch(function () {
setPill(document.getElementById('relay-state'), 'agent unreachable', 'bad');
});
};
refresh();
window.setInterval(refresh, 5000);
})();
</script>
</body>
</html>