Files
UmbrelApps/whatsnext-evolu-relay/index.html.template
T

481 lines
16 KiB
Plaintext
Raw Normal View History

<!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; }
.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; }
.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">Your own sync server for Trezor Suite labels. Nothing leaves your machine.</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 Trezor Suite here</h2>
<div class="url-box" id="relay-url">-</div>
<p class="hint">
In Trezor Suite, open the developer settings and set the custom relay URL. Leave the quota manager
URL empty: Suite ignores it once a custom relay is set. The address must start with http or https;
Suite rejects ws.
</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, the next device that connects is accepted and remembered. Close it once your
own devices are paired.
</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 id = document.createElement('div');
id.className = 'id';
id.textContent = shorten(entry.id);
id.title = entry.id;
row.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(' · ');
row.appendChild(meta);
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');
if (status.owners.learning === true) {
setPill(learningPill, 'open', 'ok');
toggle.textContent = 'Close';
toggle.disabled = false;
} else if (status.owners.learning === false) {
setPill(learningPill, 'closed', 'unknown');
toggle.textContent = 'Open';
toggle.disabled = false;
} else {
setPill(learningPill, 'unknown', 'unknown');
toggle.textContent = 'Loading';
toggle.disabled = true;
}
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>