1339 lines
59 KiB
Plaintext
1339 lines
59 KiB
Plaintext
<!DOCTYPE html>
|
|||
|
|
<!--
|
||
|
|
Statuspagina van Electrum Gate.
|
||
|
|
|
||
|
|
Dit bestand is een template en dat is bewust. umbreld ververst bij een update
|
||
|
|
alleen een whitelist, en *.template staat daarin; een pagina onder web/ zou na
|
||
|
|
een update ongewijzigd blijven staan zonder dat er iets gemeld wordt.
|
||
|
|
|
||
|
|
Twee regels die daaruit volgen en die bij het wijzigen tellen:
|
||
|
|
|
||
|
|
1. gebruik geen accolade-variabelen behalve de umbrel-variabelen die hier
|
||
|
|
werkelijk ingevuld moeten worden. De invulling vervangt elke accolade-vorm
|
||
|
|
en zou de rest stilzwijgend leegmaken. Daarom staat er in het script
|
||
|
|
hieronder nergens een template-literal maar overal string-optelling;
|
||
|
|
2. wat de pagina niet uit een umbrel-variabele kan halen, komt uit status.json
|
||
|
|
dat de achtergrondlus in docker-compose.yml wegschrijft. Weet de pagina iets
|
||
|
|
niet, dan zegt hij dat. Hij verzint niets, want hij wordt juist geraadpleegd
|
||
|
|
op het moment dat er iets mis lijkt te zijn.
|
||
|
|
-->
|
||
|
|
<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>Electrum Gate</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;
|
||
|
|
--bg-overlay: #303030;
|
||
|
|
--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-mid: rgba(255,107,0,0.26);
|
||
|
|
--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;
|
||
|
|
--bg-overlay: #e8e8e8;
|
||
|
|
--border: rgba(0,0,0,0.07);
|
||
|
|
--border-mid: rgba(0,0,0,0.11);
|
||
|
|
--text-primary: #0a0a0a;
|
||
|
|
--text-sec: #888888;
|
||
|
|
--text-ter: #bbbbbb;
|
||
|
|
--accent: #E05500;
|
||
|
|
--accent-soft: rgba(224,85,0,0.08);
|
||
|
|
--accent-mid: rgba(224,85,0,0.16);
|
||
|
|
--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;
|
||
|
|
}
|
||
|
|
|
||
|
|
/* Breed, maar niet oneindig: op een ultrawide monitor zou een kader van 2500px
|
||
|
|
regels opleveren die niemand leest. 1760 is ruim genoeg voor drie kolommen
|
||
|
|
statuskaders naast een lijstkader. */
|
||
|
|
.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: eerst waar je naar kijkt als
|
||
|
|
je iets nakomt, onderaan waar je naar kijkt als je iets instelt. Bij een smal
|
||
|
|
scherm wordt elk kader vol breed en blijft die volgorde dus staan; er is geen
|
||
|
|
enkele plek waar de opmaak de volgorde omgooit. */
|
||
|
|
.grid {
|
||
|
|
display: grid;
|
||
|
|
grid-template-columns: repeat(12, 1fr);
|
||
|
|
/* 1,5rem en niet 1rem, op verzoek van de gebruiker (20-08-2026). De kaders
|
||
|
|
hebben 1,8rem ruimte binnenin en een radius van 28 pixels; met 1rem ertussen
|
||
|
|
is de ruimte tússen twee kaders kleiner dan de ruimte erbinnen, en dan gaan ze
|
||
|
|
aan elkaar plakken. De maat hoort eerder groter dan de binnenruimte te zijn
|
||
|
|
dan kleiner. Zelfde waarde in .stat-grid hieronder, anders valt de rij tegels
|
||
|
|
uit de toon met de rij eronder. */
|
||
|
|
gap: 1.5rem;
|
||
|
|
/* stretch en niet start: twee kaders naast elkaar horen even hoog te zijn, en
|
||
|
|
het hoogste kader van de rij bepaalt die hoogte. De kaders die iets hebben dat
|
||
|
|
mag meegroeien, zeggen dat zelf met flex hieronder; de rest wordt gewoon even
|
||
|
|
hoog en houdt zijn inhoud bovenaan. */
|
||
|
|
align-items: stretch;
|
||
|
|
}
|
||
|
|
.grid > * { min-width: 0; }
|
||
|
|
.grid > .card { display: flex; flex-direction: column; }
|
||
|
|
.col-5 { grid-column: span 5; }
|
||
|
|
.col-7 { grid-column: span 7; }
|
||
|
|
.col-full { grid-column: 1 / -1; }
|
||
|
|
.grid .card { margin-bottom: 0; }
|
||
|
|
|
||
|
|
/* Onder deze breedte is naast elkaar geen winst meer maar gedrang. */
|
||
|
|
@media (max-width: 1100px) {
|
||
|
|
.col-5, .col-7 { 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-caption { font-size: 0.72rem; line-height: 1.5; color: var(--text-sec); }
|
||
|
|
.t-label { font-size: 0.68rem; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-sec); }
|
||
|
|
.t-mono { font-family: var(--font-mono); font-size: 0.82rem; color: var(--accent); }
|
||
|
|
.t-muted { color: var(--text-sec); }
|
||
|
|
|
||
|
|
/* ── 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 verzoek van de gebruiker (20-08-2026) en groter dan het
|
||
|
|
schildje dat hier stond. Het bestand komt uit de app-map en wordt door de
|
||
|
|
compose in de webroot gemount; het schildje blijft als terugval staan voor het
|
||
|
|
geval die mount er niet is, want een gebroken-afbeeldingicoon in de kop is
|
||
|
|
erger dan een generiek merkje. */
|
||
|
|
.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. Staat er weer sinds de voetregel weg
|
||
|
|
is, want dit project 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. */
|
||
|
|
.head-version {
|
||
|
|
margin-left: 0.35rem;
|
||
|
|
font-size: 0.72rem;
|
||
|
|
/* 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. Dit is dezelfde kleur als de tagline
|
||
|
|
ernaast, dus het valt niet uit de toon. */
|
||
|
|
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); }
|
||
|
|
[data-theme="light"] .card { box-shadow: 0 1px 3px rgba(0,0,0,0.05), 0 6px 24px rgba(0,0,0,0.04); }
|
||
|
|
.card-head { display: flex; align-items: center; justify-content: space-between; gap: 1rem; margin-bottom: 1.1rem; }
|
||
|
|
|
||
|
|
/* Drie kolommen, en de reactietijd neemt er twee. Er stonden drie tegels van
|
||
|
|
gelijke breedte; de derde was de resterende geldigheid van het certificaat, en
|
||
|
|
die is er op 20-08-2026 uit omdat de kaart eronder hetzelfde zegt met een datum
|
||
|
|
erbij. De ruimte gaat naar de sparkline, want dat is het enige hier dat
|
||
|
|
daadwerkelijk breedte nodig heeft. */
|
||
|
|
.stat-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1.5rem; }
|
||
|
|
.stat-wide { grid-column: span 2; }
|
||
|
|
|
||
|
|
/* De drie tegels staan naast een kader met drie lijstregels en zijn van zichzelf
|
||
|
|
lager, wat een gat rechtsboven oplevert. Ze zetten hun getal dus onderaan, zodat
|
||
|
|
de drie getallen onderling op één lijn staan én op de laatste regel van het kader
|
||
|
|
ernaast. In de gestapelde weergave doet dit niets, want daar is geen buur om je
|
||
|
|
aan te meten. */
|
||
|
|
@media (min-width: 1101px) {
|
||
|
|
.stat-grid .card { display: flex; flex-direction: column; }
|
||
|
|
.stat-grid .stat-body { margin-top: auto; }
|
||
|
|
}
|
||
|
|
|
||
|
|
/* De twee clientlijsten naast elkaar zodra er ruimte is; het zijn twee losse
|
||
|
|
lijsten en geen vervolg op elkaar, dus onder elkaar is verspilde breedte. */
|
||
|
|
/* min() eromheen, en dat is niet cosmetisch: een kale minmax(420px, 1fr) eist een
|
||
|
|
track van minstens 420 pixels, ook op een scherm dat smaller is. Dan loopt de
|
||
|
|
hele lijst met kopieerknoppen buiten beeld, en dat is precies wat de gebruiker
|
||
|
|
op 20-08-2026 op zijn telefoon zag. Met min(420px, 100%) mag de track krimpen
|
||
|
|
tot de schermbreedte. */
|
||
|
|
.clients { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(420px, 100%), 1fr)); gap: 1.2rem 2.4rem; }
|
||
|
|
.stat-val { font-size: 1.9rem; font-weight: 700; letter-spacing: -0.025em; font-variant-numeric: tabular-nums; line-height: 1.1; }
|
||
|
|
.stat-val.unknown { color: var(--text-ter); font-size: 1.5rem; }
|
||
|
|
/* Even hoog als de sparkline hieronder, en met dezelfde bovenmarge. Anders staat
|
||
|
|
het getal van "Backend response" hoger dan de andere twee, want daar hangt een
|
||
|
|
grafiekje onder in plaats van een regel tekst. */
|
||
|
|
.stat-sub { font-size: 0.75rem; color: var(--text-sec); margin-top: 0.5rem; min-height: 34px; }
|
||
|
|
|
||
|
|
/* ── Badges ─────────────────────────────────────────────────────────────── */
|
||
|
|
/* Zonder stip, op verzoek van de gebruiker (20-08-2026). De kleur van de badge
|
||
|
|
zegt hetzelfde als die stip deed, en de tekst zegt het al met woorden. */
|
||
|
|
.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); }
|
||
|
|
|
||
|
|
/* ── 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-title { font-weight: 600; margin-bottom: 0.15rem; }
|
||
|
|
.alert-body { color: var(--text-sec); font-size: 0.8rem; }
|
||
|
|
.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; }
|
||
|
|
|
||
|
|
/* ── 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; }
|
||
|
|
.list-item-sub { font-size: 0.72rem; color: var(--text-sec); margin-top: 0.1rem; }
|
||
|
|
.list-item-val { font-size: 0.88rem; font-weight: 600; font-variant-numeric: tabular-nums; text-align: right; }
|
||
|
|
.list-item-val.mono { font-family: var(--font-mono); font-weight: 500; font-size: 0.8rem; }
|
||
|
|
.list-item-val.unknown { color: var(--text-ter); font-weight: 400; }
|
||
|
|
|
||
|
|
/* ── Certificaatkeuze ─────────────────────────────────────────────────────
|
||
|
|
Eén dropdown, op verzoek van de gebruiker (20-08-2026). Hier stond een lijst met
|
||
|
|
keuzerondjes, en de reden daarvoor was dat je bron, naam en resterende dagen
|
||
|
|
naast elkaar wilt kunnen vergelijken. Op de Umbrel van de gebruiker bleken het
|
||
|
|
veertien certificaten te zijn, en dan is dat argument omgeslagen: veertien rijen
|
||
|
|
in een scrollend kader maken van de keuze het drukste onderdeel van de pagina,
|
||
|
|
terwijl je er precies één keer iets mee doet.
|
||
|
|
|
||
|
|
Wat een dropdown niet kan, is een verlopen certificaat rood zetten: opmaak van
|
||
|
|
een option verschilt per browser. Daarom staat alles in de tekst van de optie
|
||
|
|
zelf, inclusief "expired" en "in use". Dat leest ook voor wie de kleur niet
|
||
|
|
ziet. */
|
||
|
|
.cert-choices { flex-shrink: 0; }
|
||
|
|
.cert-select {
|
||
|
|
width: 100%;
|
||
|
|
font-family: var(--font-mono);
|
||
|
|
font-size: 0.8rem;
|
||
|
|
color: var(--text-primary);
|
||
|
|
background: var(--bg-raised);
|
||
|
|
border: 1px solid var(--border);
|
||
|
|
border-radius: 8px;
|
||
|
|
padding: 0.62rem 0.7rem;
|
||
|
|
cursor: pointer;
|
||
|
|
}
|
||
|
|
.cert-select:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||
|
|
/* De marges hier zijn krapper dan elders in de pagina, en dat is een keuze van
|
||
|
|
20-08-2026: dit kader bepaalt de hoogte van zijn rij, dus elke ruimte die hier
|
||
|
|
overblijft komt als leegte terug onder het activiteitenlog ernaast. */
|
||
|
|
.cert-choose-head { display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; margin: 1.1rem 0 0.2rem; }
|
||
|
|
.cert-actions { display: flex; align-items: center; gap: 0.9rem; flex-wrap: wrap; margin-top: 0.9rem; flex-shrink: 0; }
|
||
|
|
.btn-solid { background: var(--accent); color: #fff; border-color: transparent; }
|
||
|
|
.btn-solid:hover { background: var(--accent); opacity: 0.88; }
|
||
|
|
.cert-empty { font-size: 0.78rem; color: var(--text-ter); padding: 0.7rem 0; }
|
||
|
|
|
||
|
|
/* Het uploadblok. Dicht is het één regel; dat is de bedoeling, want het is voor
|
||
|
|
de uitzondering en niet voor de gewone gang van zaken.
|
||
|
|
Geen eigen border-top: het staat onder een lijstregel en die trekt zijn lijn
|
||
|
|
al. Dat was de winst van de verplaatsing op 20-08-2026, één streep minder in
|
||
|
|
een kader dat er genoeg had. */
|
||
|
|
.upload { padding: 0.7rem 0 0; }
|
||
|
|
.upload summary {
|
||
|
|
cursor: pointer; font-size: 0.78rem; font-weight: 600;
|
||
|
|
color: var(--text-sec); list-style-position: inside;
|
||
|
|
}
|
||
|
|
.upload summary:hover { color: var(--text-primary); }
|
||
|
|
.upload-row { display: block; margin-bottom: 0.7rem; }
|
||
|
|
.upload-row .t-label { display: block; margin-bottom: 0.25rem; }
|
||
|
|
.upload-row input[type="file"] {
|
||
|
|
width: 100%; font-size: 0.75rem; color: var(--text-sec);
|
||
|
|
background: var(--bg-raised); border: 1px solid var(--border);
|
||
|
|
border-radius: 8px; padding: 0.45rem 0.55rem; cursor: pointer;
|
||
|
|
}
|
||
|
|
|
||
|
|
/* ── Logregels ──────────────────────────────────────────────────────────── */
|
||
|
|
.log {
|
||
|
|
background: var(--bg-raised);
|
||
|
|
border-radius: max(4px, calc(var(--radius-xl) - 1.8rem));
|
||
|
|
padding: 0.9rem 1.1rem;
|
||
|
|
font-family: var(--font-mono);
|
||
|
|
font-size: 0.75rem;
|
||
|
|
line-height: 1.9;
|
||
|
|
/* flex-basis 0 en niet auto, en dát is de hele hoogtekwestie van 20-08-2026.
|
||
|
|
Met basis auto telt de inhoud van dit blok mee voor de natuurlijke hoogte van
|
||
|
|
zijn kader, en dan duwt een log met tien regels de rij hoger dan het
|
||
|
|
certificaatkader ernaast nodig heeft. Het gevolg zag je aan de andere kant:
|
||
|
|
leegte onder de knop "Use this certificate", die precies verdween als je het
|
||
|
|
uploadblok uitklapte.
|
||
|
|
|
||
|
|
Met basis 0 draagt dit blok niets bij aan die natuurlijke hoogte. Het
|
||
|
|
certificaatkader bepaalt de rij, dit blok groeit ernaartoe, en uitklappen laat
|
||
|
|
dus beide kaders meegroeien. Wat er niet in past, scrolt hier, en dat is waar
|
||
|
|
een log voor gemaakt is.
|
||
|
|
|
||
|
|
Geen max-height meer: de rij is nu begrensd door het kader ernaast, dus een
|
||
|
|
eigen plafond zou alleen weer leegte onderaan opleveren. */
|
||
|
|
flex: 1 1 0;
|
||
|
|
/* De bodem voor het geval het certificaatkader ooit korter is dan dit, zoals
|
||
|
|
wanneer er geen enkel certificaat gevonden is. Er passen vier regels in. */
|
||
|
|
min-height: 110px;
|
||
|
|
overflow: auto;
|
||
|
|
overscroll-behavior: contain;
|
||
|
|
scrollbar-width: thin;
|
||
|
|
scrollbar-color: var(--scrollbar) transparent;
|
||
|
|
}
|
||
|
|
.log-line { display: flex; gap: 0.9rem; white-space: nowrap; }
|
||
|
|
.log-time { color: var(--text-ter); flex-shrink: 0; font-variant-numeric: tabular-nums; }
|
||
|
|
/* Breed genoeg voor het langste label, "session ended". Met 7.5em liep dat over
|
||
|
|
de tijdkolom van de regel eronder heen. */
|
||
|
|
.log-event { flex-shrink: 0; width: 9em; }
|
||
|
|
/* Geen text-overflow hier, met opzet. Met puntjes zou een smal scherm de bytes
|
||
|
|
stil afkappen; nu houdt de regel zijn breedte en scrollt het logblok, dus is
|
||
|
|
alles nog te bereiken. */
|
||
|
|
.log-detail { color: var(--text-sec); }
|
||
|
|
.log-open { color: var(--accent-text); }
|
||
|
|
.log-close { color: var(--text-sec); }
|
||
|
|
.log-data { color: var(--text-primary); }
|
||
|
|
.log-fail { color: var(--neg); }
|
||
|
|
.log-empty { color: var(--text-ter); white-space: normal; line-height: 1.6; }
|
||
|
|
|
||
|
|
.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);
|
||
|
|
}
|
||
|
|
.btn:hover { background: var(--neutral-bg); }
|
||
|
|
.btn:active { transform: scale(0.97); }
|
||
|
|
.btn:disabled { opacity: 0.35; cursor: not-allowed; }
|
||
|
|
.btn svg { width: 13px; height: 13px; }
|
||
|
|
|
||
|
|
/* Kopieerknop in een lijstregel verschijnt pas bij hover. Opacity en geen
|
||
|
|
display:none, want dan blijft hij met de tab-toets bereikbaar; focus-within
|
||
|
|
maakt hem dan alsnog zichtbaar. Op aanraakschermen bestaat hover niet, dus
|
||
|
|
daar staat hij altijd aan. */
|
||
|
|
.list-item .btn {
|
||
|
|
opacity: 0;
|
||
|
|
transition: opacity 0.18s var(--ease-out), background 0.2s var(--ease-out);
|
||
|
|
}
|
||
|
|
.list-item:hover .btn,
|
||
|
|
.list-item:focus-within .btn { opacity: 1; }
|
||
|
|
@media (hover: none) {
|
||
|
|
.list-item .btn { opacity: 1; }
|
||
|
|
}
|
||
|
|
|
||
|
|
/* ── Grafieken ──────────────────────────────────────────────────────────── */
|
||
|
|
.spark { width: 100%; height: 34px; display: block; margin-top: 0.5rem; overflow: visible; }
|
||
|
|
|
||
|
|
.hidden { display: none !important; }
|
||
|
|
|
||
|
|
/* Onder deze breedte past een regel niet meer naast zijn label. Alles wat op een
|
||
|
|
brede kaart rechts staat, gaat hier onder de titel staan in plaats van eruit te
|
||
|
|
lopen. Gemeld door de gebruiker op 20-08-2026: op zijn telefoon liepen de
|
||
|
|
verbindingsregels met hun kopieerknoppen en de logregels buiten beeld. */
|
||
|
|
@media (max-width: 700px) {
|
||
|
|
/* De lijstregels: titel op de eerste regel, waarde plus kopieerknop op de
|
||
|
|
tweede. overflow-wrap is nodig omdat host:poort:protocol geen spatie heeft en
|
||
|
|
dus nergens kan afbreken. */
|
||
|
|
.list-item { flex-wrap: wrap; align-items: flex-start; gap: 0.5rem; }
|
||
|
|
.list-item > * { flex-basis: 100%; min-width: 0; }
|
||
|
|
.list-item-val { text-align: left; overflow-wrap: anywhere; }
|
||
|
|
/* De kopieerknop naar de rechterrand. Zonder dit volgt hij de tekst en staat
|
||
|
|
hij dus per regel ergens anders, want het ene adres is langer dan het andere;
|
||
|
|
zo gezien door de gebruiker op 20-08-2026. Op de knop en niet als
|
||
|
|
space-between op de rij: bij de gesplitste weergave staat er ook een notitie
|
||
|
|
naast de waarde, en die hoort daar tegenaan te blijven staan. */
|
||
|
|
.list-item .btn { margin-left: auto; }
|
||
|
|
|
||
|
|
/* De logregels wrappen in plaats van te scrollen. Op een breed scherm is
|
||
|
|
scrollen beter, want dan blijft de kolomvorm staan; op een telefoon betekent
|
||
|
|
het dat je de bytes nooit ziet. Tijd en gebeurtenis blijven naast elkaar, de
|
||
|
|
rest gaat naar de volgende regel. */
|
||
|
|
.log-line { flex-wrap: wrap; white-space: normal; gap: 0.6rem; }
|
||
|
|
.log-event { width: auto; }
|
||
|
|
.log-detail { flex-basis: 100%; }
|
||
|
|
|
||
|
|
/* Op één kolom staat er geen kader meer naast om de hoogte van te lenen, dus
|
||
|
|
hier neemt het log wél zijn eigen hoogte. Met een plafond, want anders wordt
|
||
|
|
een etmaal aan regels één lange pagina; daarboven scrolt het blok zelf. */
|
||
|
|
.log { flex: 0 0 auto; min-height: 0; max-height: 60vh; }
|
||
|
|
|
||
|
|
/* De tegels op elkaar, en dan neemt de reactietijd geen twee kolommen meer. */
|
||
|
|
.stat-grid { grid-template-columns: 1fr; }
|
||
|
|
.stat-wide { grid-column: 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="M12 2 4 6v6c0 5 3.4 8.6 8 10 4.6-1.4 8-5 8-10V6z"></path>
|
||
|
|
<path d="m13 8-3.5 5H12l-1 3.5L14.5 11H12z"></path>
|
||
|
|
</svg>
|
||
|
|
</div>
|
||
|
|
<div class="head-text">
|
||
|
|
<h1 class="t-h1">Electrum Gate</h1>
|
||
|
|
<!-- Woordelijk dezelfde regel als de tagline in umbrel-app.yml, en dat is
|
||
|
|
het punt: één app, één regel. Hier stond "TLS in front of your own
|
||
|
|
Electrum server", en dat was de achterblijver van de oude naam. Bij het
|
||
|
|
hernoemen naar Electrum Gate op 19-08-2026 was de reden juist dat de
|
||
|
|
tekst over de opbrengst moet gaan en niet over TLS als middel; die
|
||
|
|
maatstaf gold nog niet voor deze regel. Rechtgezet op verzoek van de
|
||
|
|
gebruiker (20-08-2026).
|
||
|
|
|
||
|
|
Wijzig je de een, wijzig dan de ander:
|
||
|
|
tests/test_server_start_zonder_certificaat.py houdt ze gelijk. -->
|
||
|
|
<p class="t-body-sm">Your own node from anywhere, without waiting for Tor <span class="head-version">v${APP_VERSION}</span></p>
|
||
|
|
</div>
|
||
|
|
<button class="btn" id="theme-toggle" type="button" title="Switch between light and dark">
|
||
|
|
<span id="theme-label">Light</span>
|
||
|
|
</button>
|
||
|
|
</header>
|
||
|
|
|
||
|
|
<!-- Meldingen. Alle drie standaard verborgen; het script bepaalt welke past. -->
|
||
|
|
<div id="alert-nodata" 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>
|
||
|
|
<span class="alert-title">No readings yet</span>
|
||
|
|
<span class="alert-body" id="alert-nodata-body">The proxy has not written a status file yet. This is normal in the first few minutes after installing or restarting the app.</span>
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div id="alert-backend" 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>
|
||
|
|
<span class="alert-title">The Electrum server did not answer</span>
|
||
|
|
<span class="alert-body" id="alert-backend-body"></span>
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div id="alert-cert" class="alert hidden">
|
||
|
|
<span class="alert-icon">
|
||
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 2 4 6v6c0 5 3.4 8.6 8 10 4.6-1.4 8-5 8-10V6z"></path><path d="M12 8v4"></path><path d="M12 15.5v.01"></path></svg>
|
||
|
|
</span>
|
||
|
|
<span>
|
||
|
|
<span class="alert-title" id="alert-cert-title"></span>
|
||
|
|
<span class="alert-body" id="alert-cert-body"></span>
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div id="alert-nocert" 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>
|
||
|
|
<span class="alert-title">No certificate in use, so TLS is not running</span>
|
||
|
|
<span class="alert-body" id="alert-nocert-body"></span>
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- Waar de wallet heen moet. Het eerste dat iemand hier komt halen. -->
|
||
|
|
<!-- De volgorde hieronder is een keuze van de gebruiker (19-08-2026): eerst
|
||
|
|
waar je naar kijkt als je iets nakomt, en pas onderaan waar je naar kijkt
|
||
|
|
als je iets instelt. Kaders staan naast elkaar waar de breedte dat toelaat,
|
||
|
|
maar de leesvolgorde in de HTML is de bedoelde volgorde; bij smalle
|
||
|
|
schermen stapelt hij precies zo. -->
|
||
|
|
<div class="grid">
|
||
|
|
|
||
|
|
<section class="card col-5">
|
||
|
|
<div class="card-head">
|
||
|
|
<h2 class="t-h3">Electrum server</h2>
|
||
|
|
</div>
|
||
|
|
<div class="list-item">
|
||
|
|
<div>
|
||
|
|
<div class="list-item-title">Address</div>
|
||
|
|
<div class="list-item-sub">Chosen in the umbrelOS settings, not in this app</div>
|
||
|
|
</div>
|
||
|
|
<div class="list-item-val mono" id="backend-address">${APP_ELECTRS_NODE_IP}:${APP_ELECTRS_NODE_PORT}</div>
|
||
|
|
</div>
|
||
|
|
<div class="list-item">
|
||
|
|
<div>
|
||
|
|
<div class="list-item-title">Implementation</div>
|
||
|
|
<div class="list-item-sub">Recognised by address; shown as unknown if it has moved</div>
|
||
|
|
</div>
|
||
|
|
<div class="list-item-val unknown" id="backend-name">unknown</div>
|
||
|
|
</div>
|
||
|
|
<div class="list-item">
|
||
|
|
<div>
|
||
|
|
<div class="list-item-title">Last checked</div>
|
||
|
|
<div class="list-item-sub">Written by the proxy itself, not measured by this page</div>
|
||
|
|
</div>
|
||
|
|
<div class="list-item-val unknown" id="backend-checked">unknown</div>
|
||
|
|
</div>
|
||
|
|
</section>
|
||
|
|
|
||
|
|
<!-- De drie getallen die zeggen of het goed gaat. Precies zo opgebouwd als elk
|
||
|
|
ander kader op deze pagina: een card-head met een titel erin, dan de inhoud.
|
||
|
|
Ze hadden eerst een eigen kleine grijze kop in kapitalen, en dat maakte ze
|
||
|
|
een apart soort ding zonder dat daar een reden voor was. -->
|
||
|
|
<div class="stat-grid col-7">
|
||
|
|
<div class="card">
|
||
|
|
<div class="card-head">
|
||
|
|
<h2 class="t-h3">Block height</h2>
|
||
|
|
</div>
|
||
|
|
<div class="stat-body">
|
||
|
|
<div class="stat-val unknown" id="stat-height">unknown</div>
|
||
|
|
<div class="stat-sub" id="stat-height-sub"></div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<!-- Twee kolommen breed. Hiernaast stond een derde tegel met het aantal dagen
|
||
|
|
dat het certificaat nog geldig is, en die is er op verzoek van de
|
||
|
|
gebruiker uit (20-08-2026): de kaart Certificate hieronder zegt hetzelfde
|
||
|
|
en noemt de datum erbij, en bij minder dan dertig dagen staat er een
|
||
|
|
melding bovenaan de pagina. De vrijgekomen ruimte gaat hierheen, want de
|
||
|
|
sparkline is het enige op deze rij dat er iets mee doet. -->
|
||
|
|
<div class="card stat-wide">
|
||
|
|
<div class="card-head">
|
||
|
|
<h2 class="t-h3">Backend response</h2>
|
||
|
|
</div>
|
||
|
|
<div class="stat-body">
|
||
|
|
<div class="stat-val unknown" id="stat-latency">unknown</div>
|
||
|
|
<svg class="spark" id="spark" viewBox="0 0 100 34" preserveAspectRatio="none" aria-hidden="true"></svg>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<section class="card col-5">
|
||
|
|
<div class="card-head">
|
||
|
|
<h2 class="t-h3">Certificate</h2>
|
||
|
|
</div>
|
||
|
|
<div class="list-item">
|
||
|
|
<div>
|
||
|
|
<div class="list-item-title">In use</div>
|
||
|
|
<div class="list-item-sub" id="cert-source">source unknown</div>
|
||
|
|
</div>
|
||
|
|
<div class="list-item-val mono unknown" id="cert-domain">unknown</div>
|
||
|
|
</div>
|
||
|
|
<div class="list-item">
|
||
|
|
<div><div class="list-item-title">Valid until</div></div>
|
||
|
|
<div class="list-item-val unknown" id="cert-until">unknown</div>
|
||
|
|
</div>
|
||
|
|
<div class="list-item">
|
||
|
|
<div>
|
||
|
|
<div class="list-item-title">Last reload</div>
|
||
|
|
<div class="list-item-sub">A renewal is reloaded, so open connections stay up</div>
|
||
|
|
</div>
|
||
|
|
<div class="list-item-val unknown" id="cert-reload">unknown</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- Ingeklapt, want dit is voor wie geen reverse proxy heeft en dat is de
|
||
|
|
uitzondering. Dichtgeklapt kost het één regel, en dat was de opdracht:
|
||
|
|
een simpele pagina. De naam van het bestand wordt niet gevraagd; die
|
||
|
|
komt uit de hostnaam in het certificaat zelf, en dat is ook precies
|
||
|
|
waarom er geen padtrucs mogelijk zijn.
|
||
|
|
|
||
|
|
Hier en niet onder de keuzeknop, op verzoek van de gebruiker
|
||
|
|
(20-08-2026). Twee redenen: het spaart een horizontale lijn uit, want de
|
||
|
|
rij hierboven trekt er al een, en het is de logische volgorde. Wat je
|
||
|
|
uploadt komt in de keuzelijst hieronder terecht, dus eerst toevoegen en
|
||
|
|
dan kiezen. -->
|
||
|
|
<details class="upload">
|
||
|
|
<summary>Upload your own certificate</summary>
|
||
|
|
<p class="t-caption" style="margin: 0.5rem 0 0.8rem;">
|
||
|
|
For a certificate you manage yourself, without a reverse proxy on this machine. Both files stay on
|
||
|
|
this Umbrel. The key must be unencrypted, and it is checked against the certificate before anything
|
||
|
|
is stored.
|
||
|
|
</p>
|
||
|
|
<label class="upload-row">
|
||
|
|
<span class="t-label">Certificate, full chain</span>
|
||
|
|
<input type="file" id="up-cert" accept=".pem,.crt,.cer,.txt">
|
||
|
|
</label>
|
||
|
|
<label class="upload-row">
|
||
|
|
<span class="t-label">Private key</span>
|
||
|
|
<input type="file" id="up-key" accept=".key,.pem,.txt">
|
||
|
|
</label>
|
||
|
|
<div class="cert-actions">
|
||
|
|
<button class="btn" id="up-send" type="button">Upload</button>
|
||
|
|
<span class="t-caption" id="up-note"></span>
|
||
|
|
</div>
|
||
|
|
</details>
|
||
|
|
|
||
|
|
<!-- De keuzelijst. Schrijft de keuze met een PUT naar een afgeschermd pad;
|
||
|
|
de achtergrondlus pikt het bestand op en herlaadt nginx. -->
|
||
|
|
<div class="cert-choose-head">
|
||
|
|
<span class="t-label">Choose a certificate</span>
|
||
|
|
<span class="t-caption" id="cert-count"></span>
|
||
|
|
</div>
|
||
|
|
<!-- Eén regel, om twee redenen. Het scheelt hoogte, en dit kader bepaalt de
|
||
|
|
hoogte van de rij. En de oude tekst noemde Nginx Proxy Manager als bron,
|
||
|
|
terwijl die mount nog uitgecommentarieerd staat in de compose omdat het
|
||
|
|
pad niet geverifieerd is; wat er stond kon dus niet waar zijn. Waar een
|
||
|
|
certificaat vandaan komt staat toch al in de optie zelf. -->
|
||
|
|
<p class="t-caption" style="margin-bottom: 0.6rem;">
|
||
|
|
Pick the one matching the name your wallet connects to.
|
||
|
|
</p>
|
||
|
|
<div id="cert-choices" class="cert-choices"></div>
|
||
|
|
<div class="cert-actions">
|
||
|
|
<button class="btn btn-solid" id="cert-apply" type="button" disabled>Use this certificate</button>
|
||
|
|
<span class="t-caption" id="cert-apply-note"></span>
|
||
|
|
</div>
|
||
|
|
</section>
|
||
|
|
|
||
|
|
<!-- Voorstel, nog niet besloten. De agent stelt de regels samen uit de
|
||
|
|
stream-access-log van nginx. Bewust zonder client-IP en zonder bronpoort:
|
||
|
|
dat is precies het soort gegeven dat deze app dichtzet, en het is niet
|
||
|
|
nodig om te zien of het werkt.
|
||
|
|
|
||
|
|
Staat rechts en is het brede kader van de twee: logregels zijn monospace en
|
||
|
|
hebben breedte nodig, en zo valt de kolomgrens samen met die van de rij
|
||
|
|
erboven in plaats van ertegenin. -->
|
||
|
|
<section class="card col-7">
|
||
|
|
<div class="card-head">
|
||
|
|
<h2 class="t-h3">Activity log</h2>
|
||
|
|
</div>
|
||
|
|
<div class="log" id="activity-log">
|
||
|
|
<div class="log-empty" id="activity-empty">Nothing recorded yet. Lines appear here as wallets connect through the gateway.</div>
|
||
|
|
</div>
|
||
|
|
</section>
|
||
|
|
|
||
|
|
<!-- Per client, want juist de kleine verschillen in notatie kosten mensen
|
||
|
|
tijd. De rijen komen uit Referenties/Clients.md.
|
||
|
|
Staat onderaan en niet bovenaan: dit is het kader dat je één keer nodig
|
||
|
|
hebt bij het instellen, en daarna nooit meer. Het verbindingsadres met de
|
||
|
|
kopieerknop zat eerst in een eigen kader erboven; dat was hetzelfde kader
|
||
|
|
twee keer, dus het is hierin opgegaan. -->
|
||
|
|
<section class="card col-full">
|
||
|
|
<div class="card-head">
|
||
|
|
<h2 class="t-h3">Setting up your wallet</h2>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- Hier stond het verbindingsadres nog een keer groot, met een kopieerknop.
|
||
|
|
Weg op verzoek van de gebruiker (19-08-2026), en zonder verlies: datzelfde
|
||
|
|
adres staat hieronder bij elke client, in de vorm die díe client wil, en
|
||
|
|
daar zit de kopieerknop ook. -->
|
||
|
|
<p class="t-caption" style="margin: 0 0 1.4rem;">
|
||
|
|
Use <strong>SSL</strong> as the connection type. If you connect from outside your own network, your
|
||
|
|
router may forward a different external port to this one.
|
||
|
|
</p>
|
||
|
|
|
||
|
|
<div class="clients">
|
||
|
|
<div>
|
||
|
|
<div class="t-label" style="margin-bottom: 0.4rem;">Paste as one line</div>
|
||
|
|
<div id="clients-oneline"></div>
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<div class="t-label" style="margin-bottom: 0.4rem;">Host and port separately, with SSL turned on</div>
|
||
|
|
<div id="clients-split"></div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<p class="t-caption" style="margin-top: 1.2rem;">
|
||
|
|
Wasabi and Samourai use their own backend and do not speak the Electrum protocol. Specter Desktop
|
||
|
|
talks to Bitcoin Core directly. Hardware wallets connect through one of the apps above rather than
|
||
|
|
on their own.
|
||
|
|
</p>
|
||
|
|
</section>
|
||
|
|
|
||
|
|
</div><!-- /grid -->
|
||
|
|
|
||
|
|
<!-- Hier stond een voetregel met "Last reading ... ago" en het versienummer. Die
|
||
|
|
is er op verzoek van de gebruiker uit (19-08-2026): de leeftijd van de
|
||
|
|
gegevens staat al in de badge bij de Electrum-server en onder de blokhoogte,
|
||
|
|
en het versienummer is ook in umbrelOS zelf te zien. -->
|
||
|
|
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<noscript>
|
||
|
|
<p style="max-width:1760px;margin:1rem auto;padding:1rem 1.2rem;border-radius:14px;background:rgba(255,255,255,0.08);font-size:0.84rem;">
|
||
|
|
This page needs JavaScript to read the status file. Without it, none of the values above are filled in.
|
||
|
|
</p>
|
||
|
|
</noscript>
|
||
|
|
|
||
|
|
<script>
|
||
|
|
// Geen template-literals in dit bestand: umbreld vult elke accolade-variabele in
|
||
|
|
// en zou ze leegmaken. Overal string-optelling dus.
|
||
|
|
(function () {
|
||
|
|
'use strict';
|
||
|
|
|
||
|
|
var STATUS_URL = 'status.json';
|
||
|
|
var REFRESH_MS = 60000;
|
||
|
|
|
||
|
|
// Waar elke client het veld verstopt. Uit Referenties/Clients.md; wie hier
|
||
|
|
// een regel bijzet, zet hem daar ook bij, anders lopen ze uit elkaar.
|
||
|
|
var CLIENTS_ONELINE = [
|
||
|
|
{ name: 'Trezor Suite', where: 'Settings, Networks, the gear next to Bitcoin. Also in the mobile app' },
|
||
|
|
{ name: 'Electrum', where: 'Tools, Network, Server. Desktop and Android' }
|
||
|
|
];
|
||
|
|
|
||
|
|
var CLIENTS_SPLIT = [
|
||
|
|
{ name: 'Sparrow Wallet', where: 'Preferences, Server, Private Electrum' },
|
||
|
|
{ name: 'BlueWallet', where: 'Settings, Network, Electrum server' },
|
||
|
|
{ name: 'Nunchuk', where: 'Settings, Network' },
|
||
|
|
{ name: 'Blockstream', where: 'Network settings, custom Electrum server. Leave the Tor switch off' },
|
||
|
|
{ name: 'BitBoxApp', where: 'Advanced settings, connect your own full node' }
|
||
|
|
];
|
||
|
|
|
||
|
|
// ── Thema ───────────────────────────────────────────────────────────────
|
||
|
|
var root = document.documentElement;
|
||
|
|
var saved = null;
|
||
|
|
try { saved = localStorage.getItem('eg-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);
|
||
|
|
document.getElementById('theme-label').textContent = theme === 'dark' ? 'Light' : 'Dark';
|
||
|
|
}
|
||
|
|
|
||
|
|
document.getElementById('theme-toggle').addEventListener('click', function () {
|
||
|
|
var next = root.getAttribute('data-theme') === 'dark' ? 'light' : 'dark';
|
||
|
|
applyTheme(next);
|
||
|
|
try { localStorage.setItem('eg-theme', next); } catch (e) { /* privémodus; niet erg */ }
|
||
|
|
});
|
||
|
|
|
||
|
|
// ── 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, suffix) {
|
||
|
|
var node = el(id);
|
||
|
|
if (!node) { return; }
|
||
|
|
if (value === null || value === undefined || value === '') {
|
||
|
|
node.textContent = 'unknown';
|
||
|
|
node.classList.add('unknown');
|
||
|
|
} else {
|
||
|
|
node.textContent = suffix ? String(value) + suffix : String(value);
|
||
|
|
node.classList.remove('unknown');
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function show(id, on) { el(id).classList.toggle('hidden', !on); }
|
||
|
|
|
||
|
|
function num(n) {
|
||
|
|
return String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ' ');
|
||
|
|
}
|
||
|
|
|
||
|
|
function whenLocal(unix) {
|
||
|
|
if (!unix) { return null; }
|
||
|
|
return new Date(unix * 1000).toLocaleString();
|
||
|
|
}
|
||
|
|
|
||
|
|
function agoText(seconds) {
|
||
|
|
if (seconds < 90) { return Math.max(0, Math.round(seconds)) + ' seconds ago'; }
|
||
|
|
if (seconds < 5400) { return Math.round(seconds / 60) + ' minutes ago'; }
|
||
|
|
if (seconds < 172800) { return Math.round(seconds / 3600) + ' hours ago'; }
|
||
|
|
return Math.round(seconds / 86400) + ' days ago';
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── De pagina vullen ────────────────────────────────────────────────────
|
||
|
|
function render(s, nowSec) {
|
||
|
|
var interval = (s.interval_seconds || 300);
|
||
|
|
var age = s.generated_at ? (nowSec - s.generated_at) : null;
|
||
|
|
|
||
|
|
// Hoe oud de gegevens zijn, hangt niet meer aan een melding of een badge. Dat
|
||
|
|
// is twee keer op verzoek van de gebruiker weggehaald: de melding bovenaan op
|
||
|
|
// 19-08-2026, de badge bij de Electrum-server op 20-08-2026. Wat het zegt staat
|
||
|
|
// nog op de plek waar het thuishoort, namelijk onder de blokhoogte als
|
||
|
|
// "as of ... ago" en in de rij "Last checked".
|
||
|
|
//
|
||
|
|
// De leeftijd zelf blijft dus in gebruik, alleen niet meer als aparte
|
||
|
|
// waarschuwing; interval blijft er voor de vergelijking in agoText.
|
||
|
|
show('alert-nodata', false);
|
||
|
|
|
||
|
|
var tls = s.tls || {};
|
||
|
|
var backend = s.backend || {};
|
||
|
|
var cert = s.certificate || {};
|
||
|
|
|
||
|
|
drawClients(tls.domain, tls.port);
|
||
|
|
|
||
|
|
// Blokhoogte
|
||
|
|
if (typeof backend.height === 'number') {
|
||
|
|
put('stat-height', num(backend.height));
|
||
|
|
el('stat-height-sub').textContent = age !== null ? 'as of ' + agoText(age) : '';
|
||
|
|
}
|
||
|
|
|
||
|
|
// Reactietijd
|
||
|
|
if (typeof backend.response_ms === 'number') {
|
||
|
|
put('stat-latency', backend.response_ms, ' ms');
|
||
|
|
}
|
||
|
|
drawSpark(s.history || []);
|
||
|
|
|
||
|
|
// Certificaat
|
||
|
|
var daysLeft = null;
|
||
|
|
// daysLeft stuurt alleen nog de melding bovenaan; de tegel die dit getal
|
||
|
|
// toonde is eruit. De kaart hieronder noemt de datum, en dat is wat je wilt
|
||
|
|
// weten als je hier komt kijken.
|
||
|
|
if (cert.not_after) {
|
||
|
|
daysLeft = Math.floor((cert.not_after - nowSec) / 86400);
|
||
|
|
put('cert-until', whenLocal(cert.not_after));
|
||
|
|
}
|
||
|
|
// Geen actief certificaat betekent geen TLS, en dat is het ergste wat deze
|
||
|
|
// app kan overkomen. Dus geen subtiele grijze tekst maar de foutmelding
|
||
|
|
// bovenaan, met de reden die de agent meestuurt.
|
||
|
|
var noCert = !cert.selected;
|
||
|
|
show('alert-nocert', noCert);
|
||
|
|
if (noCert) {
|
||
|
|
el('alert-nocert-body').textContent = (cert.reason || 'The agent did not say why.')
|
||
|
|
+ ' Wallets cannot connect until a certificate is in use.';
|
||
|
|
}
|
||
|
|
|
||
|
|
el('cert-source').textContent = cert.source
|
||
|
|
? 'from ' + cert.source + (cert.reason ? ' · ' + cert.reason : '')
|
||
|
|
: (cert.reason || 'source unknown');
|
||
|
|
put('cert-domain', tls.domain);
|
||
|
|
put('cert-reload', whenLocal(cert.last_reload));
|
||
|
|
renderCertAlert(daysLeft);
|
||
|
|
drawCertChoices(s.certificates, cert.selected, nowSec);
|
||
|
|
|
||
|
|
// Backend
|
||
|
|
put('backend-name', backend.name);
|
||
|
|
if (backend.address) { put('backend-address', backend.address); }
|
||
|
|
put('backend-checked', age !== null ? agoText(age) : null);
|
||
|
|
|
||
|
|
// Hier stond een badge met "answered 7 seconds ago". Die is eruit op verzoek
|
||
|
|
// van de gebruiker (20-08-2026), en met reden: de rij "Last checked" eronder
|
||
|
|
// zegt hetzelfde, en dan staat dezelfde meting twee keer in één kader.
|
||
|
|
//
|
||
|
|
// Wat er zonder die badge niet verloren gaat is het slechte geval: een
|
||
|
|
// backend die niet antwoordt geeft de rode melding bovenaan de pagina, en die
|
||
|
|
// is luider dan een badge in een hoek ooit was.
|
||
|
|
if (backend.reachable === false) {
|
||
|
|
el('alert-backend-body').textContent =
|
||
|
|
'At the last check, ' + (backend.address || 'the backend') + ' did not respond. '
|
||
|
|
+ 'TLS still terminates here, but your wallet has nothing to talk to. '
|
||
|
|
+ 'Check whether your Electrum server app is running in umbrelOS.';
|
||
|
|
show('alert-backend', true);
|
||
|
|
} else {
|
||
|
|
show('alert-backend', false);
|
||
|
|
}
|
||
|
|
|
||
|
|
drawActivity(s.log);
|
||
|
|
}
|
||
|
|
|
||
|
|
function renderCertAlert(daysLeft) {
|
||
|
|
if (daysLeft === null || daysLeft === undefined || daysLeft > 30) {
|
||
|
|
show('alert-cert', false);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
var node = el('alert-cert');
|
||
|
|
node.classList.remove('hidden', 'alert-warn', 'alert-error');
|
||
|
|
if (daysLeft < 0) {
|
||
|
|
node.classList.add('alert-error');
|
||
|
|
el('alert-cert-title').textContent = 'The certificate has expired';
|
||
|
|
el('alert-cert-body').textContent =
|
||
|
|
'Wallets will refuse this connection. Zoraxy should have renewed it; check the '
|
||
|
|
+ 'certificate there.';
|
||
|
|
} else {
|
||
|
|
node.classList.add('alert-warn');
|
||
|
|
el('alert-cert-title').textContent =
|
||
|
|
'The certificate expires in ' + daysLeft + (daysLeft === 1 ? ' day' : ' days');
|
||
|
|
el('alert-cert-body').textContent =
|
||
|
|
'Zoraxy normally renews well before this point. If the number keeps dropping, the '
|
||
|
|
+ 'renewal is not running.';
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Certificaatkeuze ────────────────────────────────────────────────────
|
||
|
|
// De lus schrijft in status.json wat hij in de drie gemounte mappen gevonden
|
||
|
|
// heeft. De keuze gaat er met een PUT naartoe; de lus leest dat bestand en
|
||
|
|
// herlaadt nginx. De pagina bevestigt dus niet zelf dat het gelukt is, hij
|
||
|
|
// wacht tot de volgende status.json het bevestigt.
|
||
|
|
var SELECT_URL = 'api/certificate';
|
||
|
|
var chosenId = null;
|
||
|
|
|
||
|
|
function drawCertChoices(certs, selected, nowSec) {
|
||
|
|
var box = el('cert-choices');
|
||
|
|
var apply = el('cert-apply');
|
||
|
|
box.innerHTML = '';
|
||
|
|
|
||
|
|
// Het aantal in de kop, want zodra de lijst scrollt is niet meer te zien
|
||
|
|
// hoeveel er onder de rand staan.
|
||
|
|
var count = certs ? certs.length : 0;
|
||
|
|
el('cert-count').textContent = count ? count + ' found' : '';
|
||
|
|
|
||
|
|
if (!certs || !certs.length) {
|
||
|
|
var empty = document.createElement('div');
|
||
|
|
empty.className = 'cert-empty';
|
||
|
|
empty.textContent = 'No certificates found yet. Once the app has looked through Zoraxy, '
|
||
|
|
+ 'Nginx Proxy Manager and its own folder, they are listed here.';
|
||
|
|
box.appendChild(empty);
|
||
|
|
apply.disabled = true;
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
// De keuze van de gebruiker overleeft een verversing. Het oude keuzerondje
|
||
|
|
// deed dat niet: elke ronde zette de lijst zichzelf terug op wat er in
|
||
|
|
// gebruik was, dus een keuze die je net gemaakt had verdween binnen tien
|
||
|
|
// seconden weer. Vandaar dat chosenId hier voorgaat op selected.
|
||
|
|
var show = chosenId || selected;
|
||
|
|
|
||
|
|
var select = document.createElement('select');
|
||
|
|
select.className = 'cert-select';
|
||
|
|
|
||
|
|
if (!show) {
|
||
|
|
var hint = document.createElement('option');
|
||
|
|
hint.value = '';
|
||
|
|
hint.textContent = 'Pick the one matching the name your wallet connects to...';
|
||
|
|
select.appendChild(hint);
|
||
|
|
}
|
||
|
|
|
||
|
|
for (var i = 0; i < certs.length; i++) {
|
||
|
|
var c = certs[i];
|
||
|
|
var option = document.createElement('option');
|
||
|
|
option.value = c.id;
|
||
|
|
option.textContent = choiceLabel(c, selected, nowSec);
|
||
|
|
// Een onleesbaar certificaat staat er wel in, want het verzwijgen maakt
|
||
|
|
// een verkeerde keuze onvindbaar, maar het is niet te kiezen: de agent
|
||
|
|
// weigert het even hard, en dan liever hier dan na een klik.
|
||
|
|
option.disabled = !!c.unreadable;
|
||
|
|
option.selected = c.id === show;
|
||
|
|
select.appendChild(option);
|
||
|
|
}
|
||
|
|
|
||
|
|
select.addEventListener('change', function () {
|
||
|
|
chosenId = select.value || null;
|
||
|
|
updateApply(selected);
|
||
|
|
});
|
||
|
|
|
||
|
|
box.appendChild(select);
|
||
|
|
updateApply(selected);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Alles in één regel tekst, want opmaak binnen een option is per browser
|
||
|
|
// anders. De volgorde is die van het kiezen: eerst de naam waarop je wallet
|
||
|
|
// verbindt, dan waar het vandaan komt, dan of het nog geldig is.
|
||
|
|
function choiceLabel(c, selected, nowSec) {
|
||
|
|
var parts = [c.domain || c.name || c.id];
|
||
|
|
if (c.name && c.name !== c.domain) { parts.push(c.name); }
|
||
|
|
parts.push(c.source || 'unknown source');
|
||
|
|
|
||
|
|
if (c.unreadable) {
|
||
|
|
parts.push('cannot be read');
|
||
|
|
} else if (!c.not_after) {
|
||
|
|
parts.push('date unknown');
|
||
|
|
} else if (c.not_after < nowSec) {
|
||
|
|
parts.push('expired');
|
||
|
|
} else {
|
||
|
|
var days = Math.floor((c.not_after - nowSec) / 86400);
|
||
|
|
parts.push(days + (days === 1 ? ' day left' : ' days left'));
|
||
|
|
}
|
||
|
|
|
||
|
|
if (c.id === selected) { parts.push('in use'); }
|
||
|
|
return parts.join(' · ');
|
||
|
|
}
|
||
|
|
|
||
|
|
function updateApply(selected) {
|
||
|
|
var apply = el('cert-apply');
|
||
|
|
apply.disabled = !chosenId || chosenId === selected;
|
||
|
|
if (!chosenId || chosenId === selected) {
|
||
|
|
el('cert-apply-note').textContent = '';
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
el('cert-apply').addEventListener('click', function () {
|
||
|
|
if (!chosenId) { return; }
|
||
|
|
var apply = el('cert-apply');
|
||
|
|
var note = el('cert-apply-note');
|
||
|
|
apply.disabled = true;
|
||
|
|
note.textContent = 'Saving the choice...';
|
||
|
|
|
||
|
|
fetch(SELECT_URL, {
|
||
|
|
method: 'PUT',
|
||
|
|
headers: { 'Content-Type': 'text/plain' },
|
||
|
|
body: chosenId
|
||
|
|
}).then(function (r) {
|
||
|
|
if (!r.ok) { throw new Error('status ' + r.status); }
|
||
|
|
// Bewust geen "done": nginx moet het certificaat nog herladen, en dat
|
||
|
|
// blijkt pas uit de volgende status.json.
|
||
|
|
note.textContent = 'Saved. The gateway reloads within a minute; this page will show it.';
|
||
|
|
setTimeout(load, 5000);
|
||
|
|
}).catch(function (e) {
|
||
|
|
note.textContent = 'Could not save the choice (' + e.message + '). '
|
||
|
|
+ 'The agent may not be running; check the app logs in umbrelOS.';
|
||
|
|
apply.disabled = false;
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
// ── Een certificaat uploaden ────────────────────────────────────────────
|
||
|
|
// Twee bestanden als tekst in een JSON-verzoek, en niet als multipart-formulier:
|
||
|
|
// de agent zou daar een parser voor nodig hebben die sinds Python 3.13 niet meer
|
||
|
|
// in de standaardbibliotheek zit, en inlezen kan de browser zelf.
|
||
|
|
//
|
||
|
|
// De pagina valideert bewust niets. De agent controleert of de sleutel bij het
|
||
|
|
// certificaat hoort, en dat is de enige controle die telt; hier iets voorzeggen
|
||
|
|
// zou twee waarheden opleveren die uit elkaar gaan lopen.
|
||
|
|
var UPLOAD_URL = 'api/certificate/upload';
|
||
|
|
|
||
|
|
function readFile(input) {
|
||
|
|
return new Promise(function (resolve, reject) {
|
||
|
|
var file = input.files && input.files[0];
|
||
|
|
if (!file) { resolve(null); return; }
|
||
|
|
var reader = new FileReader();
|
||
|
|
reader.onload = function () { resolve(String(reader.result)); };
|
||
|
|
reader.onerror = function () { reject(new Error('could not read ' + file.name)); };
|
||
|
|
reader.readAsText(file);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
el('up-send').addEventListener('click', function () {
|
||
|
|
var button = el('up-send');
|
||
|
|
var note = el('up-note');
|
||
|
|
|
||
|
|
Promise.all([readFile(el('up-cert')), readFile(el('up-key'))])
|
||
|
|
.then(function (files) {
|
||
|
|
if (!files[0] || !files[1]) {
|
||
|
|
throw new Error('Pick both files: the certificate and its key.');
|
||
|
|
}
|
||
|
|
button.disabled = true;
|
||
|
|
note.textContent = 'Checking the pair...';
|
||
|
|
return fetch(UPLOAD_URL, {
|
||
|
|
method: 'POST',
|
||
|
|
headers: { 'Content-Type': 'application/json' },
|
||
|
|
body: JSON.stringify({ cert: files[0], key: files[1] })
|
||
|
|
});
|
||
|
|
})
|
||
|
|
.then(function (r) {
|
||
|
|
return r.json().then(function (body) {
|
||
|
|
if (!r.ok) { throw new Error(body.error || ('status ' + r.status)); }
|
||
|
|
return body;
|
||
|
|
});
|
||
|
|
})
|
||
|
|
.then(function (body) {
|
||
|
|
// Wel voorselecteren, niet toepassen. Uploaden en kiezen blijven twee
|
||
|
|
// handelingen, zodat er precies één plek is waar TLS van certificaat
|
||
|
|
// wisselt; het kost de gebruiker één klik op de knop hierboven.
|
||
|
|
if (body.id) { chosenId = body.id; }
|
||
|
|
note.textContent = 'Added. It is selected above, now press '
|
||
|
|
+ '"Use this certificate".';
|
||
|
|
button.disabled = false;
|
||
|
|
load();
|
||
|
|
})
|
||
|
|
.catch(function (e) {
|
||
|
|
note.textContent = e.message;
|
||
|
|
button.disabled = false;
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
// ── Clientregels ────────────────────────────────────────────────────────
|
||
|
|
// Zonder domein en poort krijgt elke rij hetzelfde onbekend-veld als de rest
|
||
|
|
// van de pagina. Een half ingevulde regel die iemand toch kopieert, is erger
|
||
|
|
// dan een lege.
|
||
|
|
function drawClients(domain, port) {
|
||
|
|
fill('clients-oneline', CLIENTS_ONELINE, function () {
|
||
|
|
return (domain && port) ? domain + ':' + port + ':s' : null;
|
||
|
|
});
|
||
|
|
fill('clients-split', CLIENTS_SPLIT, function () {
|
||
|
|
return domain ? domain : null;
|
||
|
|
}, port ? 'port ' + port : null);
|
||
|
|
}
|
||
|
|
|
||
|
|
function fill(containerId, clients, valueFor, extra) {
|
||
|
|
var box = el(containerId);
|
||
|
|
box.innerHTML = '';
|
||
|
|
for (var i = 0; i < clients.length; i++) {
|
||
|
|
var value = valueFor();
|
||
|
|
var row = document.createElement('div');
|
||
|
|
row.className = 'list-item';
|
||
|
|
|
||
|
|
var left = document.createElement('div');
|
||
|
|
var title = document.createElement('div');
|
||
|
|
title.className = 'list-item-title';
|
||
|
|
title.textContent = clients[i].name;
|
||
|
|
var sub = document.createElement('div');
|
||
|
|
sub.className = 'list-item-sub';
|
||
|
|
sub.textContent = clients[i].where;
|
||
|
|
left.appendChild(title);
|
||
|
|
left.appendChild(sub);
|
||
|
|
|
||
|
|
var right = document.createElement('div');
|
||
|
|
right.style.display = 'flex';
|
||
|
|
right.style.alignItems = 'center';
|
||
|
|
right.style.gap = '0.6rem';
|
||
|
|
|
||
|
|
var val = document.createElement('span');
|
||
|
|
val.className = 'list-item-val mono' + (value ? '' : ' unknown');
|
||
|
|
val.textContent = value ? value : 'unknown';
|
||
|
|
right.appendChild(val);
|
||
|
|
|
||
|
|
if (extra) {
|
||
|
|
var note = document.createElement('span');
|
||
|
|
note.className = 'list-item-meta t-caption';
|
||
|
|
note.textContent = extra;
|
||
|
|
right.appendChild(note);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (value) {
|
||
|
|
right.appendChild(copyButton(value));
|
||
|
|
}
|
||
|
|
|
||
|
|
row.appendChild(left);
|
||
|
|
row.appendChild(right);
|
||
|
|
box.appendChild(row);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function copyButton(value) {
|
||
|
|
var b = document.createElement('button');
|
||
|
|
b.type = 'button';
|
||
|
|
b.className = 'btn';
|
||
|
|
b.textContent = 'Copy';
|
||
|
|
b.addEventListener('click', function () {
|
||
|
|
copy(value, b);
|
||
|
|
});
|
||
|
|
return b;
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Grafiekjes ──────────────────────────────────────────────────────────
|
||
|
|
function drawSpark(history) {
|
||
|
|
var svg = el('spark');
|
||
|
|
var points = [];
|
||
|
|
for (var i = 0; i < history.length; i++) {
|
||
|
|
if (typeof history[i].response_ms === 'number') { points.push(history[i].response_ms); }
|
||
|
|
}
|
||
|
|
if (points.length < 2) { svg.innerHTML = ''; return; }
|
||
|
|
|
||
|
|
var max = Math.max.apply(null, points);
|
||
|
|
var min = Math.min.apply(null, points);
|
||
|
|
var span = (max - min) || 1;
|
||
|
|
var d = '';
|
||
|
|
for (var j = 0; j < points.length; j++) {
|
||
|
|
var x = (j / (points.length - 1)) * 100;
|
||
|
|
var y = 32 - ((points[j] - min) / span) * 28;
|
||
|
|
d += (j === 0 ? 'M' : 'L') + x.toFixed(2) + ' ' + y.toFixed(2) + ' ';
|
||
|
|
}
|
||
|
|
svg.innerHTML = '<path d="' + d.trim() + '" fill="none" stroke="var(--accent)" '
|
||
|
|
+ 'stroke-width="1.6" stroke-linejoin="round" stroke-linecap="round" '
|
||
|
|
+ 'vector-effect="non-scaling-stroke"></path>';
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Activiteitenlog ─────────────────────────────────────────────────────
|
||
|
|
// Regels komen uit status.json. Nginx schrijft een stream-sessie pas weg bij
|
||
|
|
// het sluiten, dus de open-regel en de verkeersregels worden door de
|
||
|
|
// achtergrondlus uit de verbindingsteller afgeleid; zie PLAN.md paragraaf 4e.
|
||
|
|
//
|
||
|
|
// 'session ended' en niet 'disconnected'. De gebruiker vroeg op 20-08-2026 wat
|
||
|
|
// die regel betekende, en dat was terecht: er stond een tijd, een duur en twee
|
||
|
|
// aantallen bytes, en niets zei dat de tijd het éinde van een sessie is en de
|
||
|
|
// duur terugloopt. Zo'n regel leest als een storing, terwijl het de gewone
|
||
|
|
// afsluiting van een wallet-verbinding is.
|
||
|
|
//
|
||
|
|
// 'probe' staat er sinds 20-08-2026 naast 'refused', en niet in het rood: een
|
||
|
|
// doorgestuurde poort wordt gescand, en zo'n scan gaf een rode regel "refused"
|
||
|
|
// terwijl er niets geweigerd is en niets gebeurd is. Rood hoort voor het geval
|
||
|
|
// waarin er wél verkeer was en het daarna stukliep, want dat is het geval waar
|
||
|
|
// je iets aan moet doen.
|
||
|
|
var EVENTS = {
|
||
|
|
connect: { label: 'connected', cls: 'log-open' },
|
||
|
|
disconnect: { label: 'session ended', cls: 'log-close' },
|
||
|
|
traffic: { label: 'traffic', cls: 'log-data' },
|
||
|
|
probe: { label: 'probe', cls: 'log-close' },
|
||
|
|
refused: { label: 'refused', cls: 'log-fail' },
|
||
|
|
reload: { label: 'cert reload', cls: 'log-open' },
|
||
|
|
start: { label: 'gateway up', cls: 'log-open' }
|
||
|
|
};
|
||
|
|
|
||
|
|
// Hier stond kort een badge met "1 wallet connected". Die is er op verzoek van
|
||
|
|
// de gebruiker weer uit (20-08-2026), samen met de andere badges: het log zelf is
|
||
|
|
// genoeg. De verbindingsteller blijft wél bestaan, want die levert de
|
||
|
|
// connected-regel in dit log; hij staat alleen niet meer apart in een hoek.
|
||
|
|
function drawActivity(lines) {
|
||
|
|
var box = el('activity-log');
|
||
|
|
box.innerHTML = '';
|
||
|
|
|
||
|
|
if (!lines || !lines.length) {
|
||
|
|
var empty = document.createElement('div');
|
||
|
|
empty.className = 'log-empty';
|
||
|
|
empty.textContent = 'Nothing recorded yet. Lines appear here as wallets connect '
|
||
|
|
+ 'through the gateway.';
|
||
|
|
box.appendChild(empty);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
var newestFirst = lines.slice().sort(function (a, b) { return b.t - a.t; });
|
||
|
|
for (var i = 0; i < newestFirst.length; i++) {
|
||
|
|
box.appendChild(logRow(newestFirst[i]));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function logRow(entry) {
|
||
|
|
var kind = EVENTS[entry.event] || { label: entry.event || 'event', cls: 'log-close' };
|
||
|
|
|
||
|
|
var row = document.createElement('div');
|
||
|
|
row.className = 'log-line';
|
||
|
|
|
||
|
|
var time = document.createElement('span');
|
||
|
|
time.className = 'log-time';
|
||
|
|
time.textContent = clockLocal(entry.t);
|
||
|
|
|
||
|
|
var event = document.createElement('span');
|
||
|
|
event.className = 'log-event ' + kind.cls;
|
||
|
|
event.textContent = kind.label;
|
||
|
|
|
||
|
|
var detail = document.createElement('span');
|
||
|
|
detail.className = 'log-detail';
|
||
|
|
detail.textContent = logDetail(entry);
|
||
|
|
|
||
|
|
row.appendChild(time);
|
||
|
|
row.appendChild(event);
|
||
|
|
row.appendChild(detail);
|
||
|
|
return row;
|
||
|
|
}
|
||
|
|
|
||
|
|
// "up" en "down" waren hier niet te volgen: het is niet duidelijk wie van de
|
||
|
|
// twee omhoog stuurt. Nu staat de richting er met de wallet erbij, en bij de
|
||
|
|
// duur staat dat hij achterwaarts geldt.
|
||
|
|
function logDetail(entry) {
|
||
|
|
var parts = [];
|
||
|
|
if (typeof entry.seconds === 'number') { parts.push('lasted ' + duration(entry.seconds)); }
|
||
|
|
// Nul beide kanten op wordt weggelaten. Bij een scan zou er anders
|
||
|
|
// "0 B from wallet, 0 B to wallet" staan naast een notitie die al zegt dat er
|
||
|
|
// niets doorgegeven is, en "wallet" is dan ook het verkeerde woord: het was
|
||
|
|
// geen wallet.
|
||
|
|
if (entry.bytes_in || entry.bytes_out) {
|
||
|
|
parts.push(bytes(entry.bytes_in) + ' from wallet, ' + bytes(entry.bytes_out) + ' to wallet');
|
||
|
|
}
|
||
|
|
if (entry.note) { parts.push(entry.note); }
|
||
|
|
return parts.join(' · ');
|
||
|
|
}
|
||
|
|
|
||
|
|
function clockLocal(unix) {
|
||
|
|
if (!unix) { return '--:--:--'; }
|
||
|
|
var d = new Date(unix * 1000);
|
||
|
|
return pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds());
|
||
|
|
}
|
||
|
|
|
||
|
|
function pad(n) { return (n < 10 ? '0' : '') + n; }
|
||
|
|
|
||
|
|
function duration(sec) {
|
||
|
|
if (!sec && sec !== 0) { return 'length unknown'; }
|
||
|
|
if (sec < 60) { return sec + ' s'; }
|
||
|
|
if (sec < 3600) { return Math.round(sec / 60) + ' min'; }
|
||
|
|
return (sec / 3600).toFixed(1) + ' h';
|
||
|
|
}
|
||
|
|
|
||
|
|
function bytes(n) {
|
||
|
|
if (typeof n !== 'number') { return '?'; }
|
||
|
|
if (n < 1024) { return n + ' B'; }
|
||
|
|
if (n < 1048576) { return (n / 1024).toFixed(1) + ' kB'; }
|
||
|
|
return (n / 1048576).toFixed(1) + ' MB';
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Kopieerknop ─────────────────────────────────────────────────────────
|
||
|
|
// De knop is niet optioneel meer: sinds het losse verbindingsadres eruit is, is
|
||
|
|
// elke kopieerknop er een uit een clientregel. Een terugval op een vast element
|
||
|
|
// zou nu naar iets wijzen dat niet bestaat.
|
||
|
|
function copy(text, btn) {
|
||
|
|
function done(ok) {
|
||
|
|
btn.textContent = ok ? 'Copied' : 'Press Ctrl+C';
|
||
|
|
setTimeout(function () { btn.textContent = 'Copy'; }, 2000);
|
||
|
|
}
|
||
|
|
if (navigator.clipboard && window.isSecureContext) {
|
||
|
|
navigator.clipboard.writeText(text).then(function () { done(true); }, function () { done(false); });
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
// Zonder https is de clipboard-API er niet, en de app wordt lokaal over
|
||
|
|
// http bekeken. Vandaar deze terugval.
|
||
|
|
var ta = document.createElement('textarea');
|
||
|
|
ta.value = text;
|
||
|
|
ta.setAttribute('readonly', '');
|
||
|
|
ta.style.position = 'fixed';
|
||
|
|
ta.style.opacity = '0';
|
||
|
|
document.body.appendChild(ta);
|
||
|
|
ta.select();
|
||
|
|
var ok = false;
|
||
|
|
try { ok = document.execCommand('copy'); } catch (e) { ok = false; }
|
||
|
|
document.body.removeChild(ta);
|
||
|
|
done(ok);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Ophalen ─────────────────────────────────────────────────────────────
|
||
|
|
function load() {
|
||
|
|
fetch(STATUS_URL, { cache: 'no-store' })
|
||
|
|
.then(function (r) {
|
||
|
|
if (!r.ok) { throw new Error('status ' + r.status); }
|
||
|
|
return r.json();
|
||
|
|
})
|
||
|
|
.then(function (s) { render(s, Math.floor(Date.now() / 1000)); })
|
||
|
|
.catch(function () {
|
||
|
|
show('alert-nodata', true);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
drawClients(null, null);
|
||
|
|
drawActivity(null);
|
||
|
|
drawCertChoices(null, null, Math.floor(Date.now() / 1000));
|
||
|
|
load();
|
||
|
|
setInterval(load, REFRESH_MS);
|
||
|
|
})();
|
||
|
|
</script>
|
||
|
|
</body>
|
||
|
|
</html>
|