Overgenomen uit AssetApp op verzoek van de gebruiker: de kaders krijgen in het lichte thema een rand naast hun schaduw, en --border en --border-mid gaan van 0,07 en 0,11 naar 0,12 en 0,18 zwart. De schaduw wordt daarom strakker, 2 en 16 pixels in plaats van 3 en 24, zodat het samen niet zwaar wordt. Het donkere thema verandert niet. Daar staat een lichte rand op een donkere ondergrond en die zie je hoe dan ook; dit is een probleem dat alleen op wit bestaat. De opgehoogde randkleur raakt meer dan de kaders: ook de scheidingslijnen tussen lijstregels, het menupaneel, de dialoog en de invoervelden. Dat is de bedoeling. Dezelfde wijziging staat nu in het gedeelde ontwerpdocument onder HomeGit/Docs, want anders neemt de volgende app die dat document volgt de oude waarden weer over. Die map staat niet onder versiebeheer, dus die wijziging zit niet in deze commit. Verder twee verificaties van de gebruiker verwerkt: de pagina is op een telefoon in orde, en de uploadtest met een verkeerde sleutel is op zijn verzoek vervallen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1757 lines
82 KiB
Plaintext
1757 lines
82 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;
|
||
/* Steviger dan de 0,07 en 0,11 die hier stonden (27-08-2026). Overgenomen uit
|
||
AssetApp, waar het licht-thema deze stap al gemaakt had: op wit verdwijnt een
|
||
rand van 7 procent zwart vrijwel, en dan drijven de kaders zonder begrenzing
|
||
op de achtergrond. In het donkere thema speelt dat niet, want daar staat een
|
||
lichte rand op een donkere ondergrond en die zie je hoe dan ook. */
|
||
--border: rgba(0,0,0,0.12);
|
||
--border-mid: rgba(0,0,0,0.18);
|
||
--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 .row1 hieronder, anders valt de rij widgets
|
||
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; }
|
||
/* Hier stonden ook .col-5 en .col-7, met een mediaregel die ze onder 1100px vol
|
||
breed maakte. Sinds de tweede indeling (27-08-2026) is er geen rij meer die in
|
||
tweeën valt: de bovenste rij heeft zijn eigen raster van vijf, en het log en het
|
||
instelkader zijn allebei vol breed. Weggehaald in plaats van laten staan, want
|
||
ongebruikte klassen nodigen uit tot een indeling die er niet meer is. */
|
||
.col-full { grid-column: 1 / -1; }
|
||
.grid .card { margin-bottom: 0; }
|
||
|
||
/* ── 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); }
|
||
/* Rand én schaduw in het lichte thema (27-08-2026), overgenomen uit AssetApp.
|
||
Hier stond alleen een schaduw, en een zachte schaduw op wit geeft wel diepte
|
||
maar geen begrenzing: waar het kader ophoudt bleef een kwestie van kijken. De
|
||
rand doet dat werk, en de schaduw is daarom strakker geworden (2 en 16 pixels
|
||
in plaats van 3 en 24) zodat het samen niet zwaar wordt. */
|
||
[data-theme="light"] .card {
|
||
border: 1px solid var(--border);
|
||
box-shadow: 0 1px 2px rgba(0,0,0,0.06), 0 4px 16px rgba(0,0,0,0.05);
|
||
}
|
||
.card-head { display: flex; align-items: center; justify-content: space-between; gap: 1rem; margin-bottom: 1.1rem; }
|
||
|
||
/* ── De bovenste rij ────────────────────────────────────────────────────────
|
||
Vijf gelijke widgets (27-08-2026). Hier stond een raster van drie tegels waarvan
|
||
de reactietijd er twee nam, naast een kader met lijstregels; dat vroeg twee
|
||
kunstjes die nu weg zijn, namelijk meerekken met de hoogte van de buur en een
|
||
dubbele kolom voor de sparkline.
|
||
|
||
Een eigen raster en geen deling van de twaalf kolommen hierboven, want vijf
|
||
past daar niet in. Dat is nieuw op deze pagina: elke andere rij viel in tweeën.
|
||
|
||
Wat er bij het versmallen gebeurt, is daarom expliciet en niet aan auto-fit
|
||
overgelaten. Met vijf items levert elke stap een ongelijke laatste rij op, en
|
||
dan wil je zelf bepalen wélke widget alleen komt te staan. Dat is steeds de
|
||
laatste, de zelfcontrole, en die is als enige begrijpelijk zonder buur. */
|
||
.row1 {
|
||
grid-column: 1 / -1;
|
||
display: grid;
|
||
grid-template-columns: repeat(5, 1fr);
|
||
gap: 1.5rem;
|
||
}
|
||
/* Onder deze breedte wordt een widget van een vijfde te smal voor "Wrong
|
||
certificate" of een blokhoogte met scheidingstekens. Drie plus twee. */
|
||
@media (max-width: 1400px) { .row1 { grid-template-columns: repeat(3, 1fr); } }
|
||
@media (max-width: 900px) { .row1 { grid-template-columns: repeat(2, 1fr); } }
|
||
@media (max-width: 560px) { .row1 { grid-template-columns: 1fr; } }
|
||
|
||
/* De widgets zetten hun waarde onderaan, zodat de vijf getallen op één lijn
|
||
staan ook als de ene titel over twee regels valt en de andere niet.
|
||
|
||
De min-height is er op verzoek van de gebruiker (27-08-2026): zonder die maat
|
||
bepaalt de hoogste widget de rij, en dat leverde vijf krappe blokjes op waarin
|
||
de titel vrijwel tegen de waarde aan stond. Met wat meer hoogte krijgt de
|
||
waarde de ruimte om echt onderin te staan, en dát is wat margin-top: auto
|
||
hierboven doet zodra er hoogte over is.
|
||
|
||
Van 10,5rem naar 12rem diezelfde dag, na een misgelezen bericht van mijn kant:
|
||
er stond "mag niet iets groter" en dat was een tikfout voor het omgekeerde. */
|
||
.row1 .card { display: flex; flex-direction: column; min-height: 12rem; }
|
||
.row1 .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; }
|
||
/* Eén maat voor alle vijf de widgets (27-08-2026). Hier stonden er drie: 1,9rem
|
||
voor een getal, 1,5rem voor "unknown" en 1,3rem voor een woord. Op de rij naast
|
||
elkaar viel dat op, en terecht: "964 307" oogde groter dan "Fulcrum" ernaast,
|
||
terwijl het even belangrijk is.
|
||
|
||
1,55rem is de maat waarop allebei werkt. Groter en "Wrong certificate" loopt op
|
||
een vijfde van de breedte over drie regels; kleiner en een blokhoogte verliest
|
||
het gewicht dat hem het eerste maakt waar je naar kijkt. */
|
||
.stat-val { font-size: 1.55rem; font-weight: 700; letter-spacing: -0.025em; font-variant-numeric: tabular-nums; line-height: 1.2; }
|
||
/* Alleen de kleur, geen eigen maat meer: onbekend is een andere toestand van
|
||
dezelfde waarde en hoort niet ook nog van formaat te verschillen. */
|
||
.stat-val.unknown { color: var(--text-ter); }
|
||
/* Twee widgets tonen een woord in plaats van een getal. Die hoeven geen eigen
|
||
maat, wel een afbreekpunt: afkappen met puntjes zou hier de betekenis
|
||
wegnemen. */
|
||
.stat-val.text { overflow-wrap: anywhere; }
|
||
/* Rood is voor het geval waar je iets aan moet doen. Bewust niet voor "off": geen
|
||
certificaat gekozen is de normale begintoestand van een verse installatie, en
|
||
daar staat de melding bovenaan al voor. */
|
||
.stat-val.bad { color: var(--neg); }
|
||
.stat-sub.mono { font-family: var(--font-mono); overflow-wrap: anywhere; }
|
||
/* 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.
|
||
|
||
De tekst staat onderin dat vak en niet bovenin (27-08-2026). Dat was het
|
||
verschil dat de gebruiker zag: de sparkline is precies 34 pixels hoog en raakt
|
||
dus de onderrand, terwijl een regel tekst van 34 pixels ruimte bovenaan begon
|
||
en er leegte onder overliet. Nu liggen ze op dezelfde lijn.
|
||
|
||
Met grid en niet met flex: de tekst blijft dan een gewoon blok en breekt af
|
||
zoals hij hoort, ook de mono-variant met lange domeinnamen. */
|
||
.stat-sub {
|
||
font-size: 0.75rem; color: var(--text-sec);
|
||
margin-top: 0.5rem; min-height: 34px;
|
||
display: grid; align-items: end;
|
||
}
|
||
|
||
/* ── 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;
|
||
/* Sinds 27-08-2026 neemt dit blok gewoon zijn eigen hoogte. Hier stond
|
||
flex: 1 1 0, en dat was de oplossing voor een probleem dat niet meer bestaat:
|
||
het log stond naast het certificaatkader en die twee moesten samen groeien en
|
||
krimpen, anders viel er leegte onder de knop "Use this certificate" zodra je
|
||
het uploadblok uitklapte. Dat was de hele inhoud van 0.0.13.
|
||
|
||
De certificaatkeuze zit nu in een dialoog en dit kader is vol breed, dus er is
|
||
geen buur meer om je aan te meten. Wat overblijft is een plafond, want een
|
||
etmaal aan regels zou anders één lange pagina worden; daarboven scrolt het
|
||
blok zelf, en dat is waar een log voor gemaakt is. */
|
||
flex: 0 0 auto;
|
||
/* Er passen vier regels in; dat is de bodem voor een leeg of net gestart log. */
|
||
min-height: 110px;
|
||
/* De helft van wat het was, op verzoek van de gebruiker (27-08-2026). Met 60vh
|
||
vulde het log het scherm terwijl er zelden meer dan een handvol regels in
|
||
staat; wat er niet in past, scrolt hier toch al. */
|
||
max-height: 30vh;
|
||
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; }
|
||
|
||
/* ── Het menu rechtsboven ───────────────────────────────────────────────── */
|
||
.menu { position: relative; flex-shrink: 0; }
|
||
/* Het driehoekje van summary weg, in twee vormen: WebKit heeft zijn eigen
|
||
pseudo-element en de rest gaat via list-style. */
|
||
/* Alleen het driehoekje weg en de cursor; de maatvoering staat hieronder bij
|
||
.menu-button, want twee regels die allebei padding zetten laten de meest
|
||
specifieke winnen en dat is deze. */
|
||
.menu > summary { list-style: none; cursor: pointer; }
|
||
.menu > summary::-webkit-details-marker { display: none; }
|
||
.menu > summary::marker { content: ''; }
|
||
/* Geen knoplook, op verzoek van de gebruiker (27-08-2026): alleen de drie punten,
|
||
met een accent bij hover. Hier stond de klasse .btn, en die gaf er een rand en
|
||
een vlak omheen die niets toevoegden naast een kop met alleen het merk erin.
|
||
|
||
Flink van formaat, want drie punten op de gewone knopgrootte van 0,75rem zijn
|
||
nauwelijks te zien en al helemaal niet te raken. De letterafstand zet ze uit
|
||
elkaar, anders plakken ze in de meeste letterfamilies aan elkaar. De padding
|
||
blijft: dat is het raakvlak, ook al is er niets omheen te zien. */
|
||
.menu-button {
|
||
display: inline-flex; align-items: center; justify-content: center;
|
||
font-size: 1.4rem;
|
||
line-height: 1;
|
||
letter-spacing: 0.08em;
|
||
padding: 0.25rem 0.6rem 0.45rem;
|
||
min-height: 2rem;
|
||
color: var(--text-sec);
|
||
border-radius: var(--radius-sm);
|
||
transition: color 0.2s var(--ease-out);
|
||
}
|
||
.menu-button:hover { color: var(--accent); }
|
||
/* Ook accent zolang het menu openstaat, anders lijkt de knop terug te springen
|
||
naar rust terwijl het paneel eronder nog open is. */
|
||
.menu[open] > .menu-button { color: var(--accent); }
|
||
/* Zonder rand is er geen vorm die de standaard focusring volgt, dus die zetten
|
||
we zelf; met het toetsenbord moet zichtbaar zijn waar je staat. */
|
||
.menu-button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||
.menu-panel {
|
||
position: absolute; right: 0; top: calc(100% + 0.4rem);
|
||
z-index: 20; min-width: 15rem;
|
||
background: var(--bg-elevated);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius-md);
|
||
padding: 0.35rem;
|
||
box-shadow: 0 8px 30px rgba(0,0,0,0.18);
|
||
}
|
||
.menu-item {
|
||
display: flex; align-items: center; justify-content: space-between; gap: 1.2rem;
|
||
width: 100%; text-align: left;
|
||
font-family: inherit; font-size: 0.82rem; color: var(--text-primary);
|
||
background: transparent; border: 0; border-radius: var(--radius-sm);
|
||
padding: 0.55rem 0.65rem; cursor: pointer;
|
||
}
|
||
.menu-item:hover { background: var(--neutral-bg); }
|
||
.menu-meta { font-size: 0.75rem; color: var(--text-sec); }
|
||
|
||
/* ── De dialoog ─────────────────────────────────────────────────────────── */
|
||
/* Breder dan het kader waar deze inhoud uit komt, en dat mag: een dialoog leent
|
||
geen hoogte van een buur, dus de keuzelijst en het uploadblok kunnen hier
|
||
gewoon staan zonder dat er elders leegte ontstaat. */
|
||
.sheet {
|
||
/* Midden op het scherm, in beide richtingen (27-08-2026: hij kwam linksboven
|
||
terecht). Niet op de standaardopmaak van de browser vertrouwen: die zet een
|
||
dialog absoluut ten opzichte van zijn dichtstbijzijnde geplaatste voorouder,
|
||
en deze staat in het raster van de pagina. Vast plaatsen met inset 0 en
|
||
marge auto centreert hem tegen het scherm, ongeacht waar hij in de HTML
|
||
staat. */
|
||
position: fixed;
|
||
inset: 0;
|
||
margin: auto;
|
||
/* Alleen van belang in de terugvalroute zonder showModal; zie de HTML. */
|
||
grid-column: 1 / -1;
|
||
width: min(46rem, calc(100vw - 2rem));
|
||
max-height: min(44rem, calc(100vh - 4rem));
|
||
padding: 0;
|
||
color: var(--text-primary);
|
||
background: var(--bg-elevated);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius-lg);
|
||
overflow: hidden;
|
||
}
|
||
.sheet::backdrop { background: rgba(0,0,0,0.55); }
|
||
.sheet-head {
|
||
display: flex; align-items: center; justify-content: space-between; gap: 1rem;
|
||
padding: 1.3rem 1.5rem; border-bottom: 1px solid var(--border);
|
||
}
|
||
/* Kaal, met een accent bij hover, net als het menu en de chevron. Niet rood: er
|
||
gaat niets kapot van sluiten, en rood is op deze pagina voor het geval waar je
|
||
iets aan moet doen. */
|
||
.sheet-close {
|
||
display: inline-flex; align-items: center; justify-content: center;
|
||
flex-shrink: 0; width: 2rem; height: 2rem;
|
||
font-family: inherit; font-size: 1.5rem; line-height: 1;
|
||
color: var(--text-sec); background: transparent; border: 0;
|
||
border-radius: var(--radius-sm); cursor: pointer;
|
||
transition: color 0.2s var(--ease-out);
|
||
}
|
||
.sheet-close:hover { color: var(--accent); }
|
||
.sheet-close:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||
|
||
/* Alleen dit deel scrollt, zodat de sluitknop in beeld blijft bij twintig
|
||
certificaten. */
|
||
.sheet-body {
|
||
padding: 0.4rem 1.5rem 1.5rem;
|
||
overflow: auto;
|
||
max-height: calc(min(44rem, 100vh - 4rem) - 5rem);
|
||
scrollbar-width: thin;
|
||
scrollbar-color: var(--scrollbar) transparent;
|
||
}
|
||
|
||
/* De knop in de foutmelding "geen certificaat in gebruik". Op een eigen regel,
|
||
want in de melding staat een zin die je eerst leest. */
|
||
.alert-action { margin-top: 0.7rem; }
|
||
|
||
/* ── Het instelkader ────────────────────────────────────────────────────── */
|
||
.setup > summary { list-style: none; cursor: pointer; display: flex; align-items: baseline; gap: 0.8rem; flex-wrap: wrap; }
|
||
.setup > summary::-webkit-details-marker { display: none; }
|
||
.setup > summary::marker { content: ''; }
|
||
/* Een chevron die meedraait, want zonder het driehoekje van de browser is er
|
||
verder niets dat zegt dat dit open kan.
|
||
|
||
Links vóór de titel (27-08-2026, gebruiker). Hij stond rechts aan de rand, en
|
||
dat is ver weg van het woord waar hij iets over zegt; links staat hij waar het
|
||
driehoekje van de browser ook gestaan zou hebben.
|
||
|
||
Dicht wijst hij naar rechts, open naar beneden. align-self houdt hem op de
|
||
hoogte van de titel, want de regel eromheen lijnt op de basislijn uit en een
|
||
gedraaid vierkantje heeft er geen. */
|
||
.setup > summary::before {
|
||
content: ''; align-self: center; flex-shrink: 0;
|
||
width: 0.5rem; height: 0.5rem;
|
||
border-right: 2px solid var(--text-sec); border-bottom: 2px solid var(--text-sec);
|
||
transform: rotate(-45deg);
|
||
transition: transform 0.2s var(--ease-out), border-color 0.2s var(--ease-out);
|
||
}
|
||
.setup[open] > summary::before { transform: rotate(45deg); }
|
||
/* Accent bij hover, net als het menu rechtsboven: dit zijn de twee dingen op deze
|
||
pagina die opengaan als je erop klikt, en dan horen ze zich hetzelfde te
|
||
gedragen. */
|
||
.setup > summary:hover { color: var(--accent); }
|
||
.setup > summary:hover::before { border-color: var(--accent); }
|
||
.setup-form { min-width: 0; }
|
||
.setup-value {
|
||
display: flex; align-items: center; gap: 0.7rem; flex-wrap: wrap;
|
||
margin: 0.45rem 0 0.55rem;
|
||
}
|
||
/* De wallets onder de vorm die ze vragen. Naam en vindplaats op één regel, want
|
||
het gaat om het herkennen van je eigen wallet en niet om een handleiding. */
|
||
.setup-wallets { font-size: 0.75rem; line-height: 1.7; color: var(--text-sec); }
|
||
.setup-wallets strong { color: var(--text-primary); font-weight: 600; }
|
||
|
||
.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%; }
|
||
|
||
/* Het plafond blijft, de bodem niet: op een telefoon is 110 pixels aan lege
|
||
loghoogte zonde van het scherm. */
|
||
.log { min-height: 0; }
|
||
}
|
||
|
||
@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>
|
||
<!-- Het menu rechtsboven. Hier stond een losse knop voor licht en donker; die
|
||
is er op verzoek van de gebruiker (27-08-2026) in gegaan, samen met het
|
||
certificaatbeheer. Wat hier staat is wat je zelden aanraakt, en de kop
|
||
houdt daardoor niets meer dan het merk en de tagline.
|
||
|
||
Met details/summary en niet met eigen JavaScript, want dan werkt openen
|
||
en sluiten met het toetsenbord zonder dat wij daar iets voor doen. Wat er
|
||
wél bij moet is sluiten op Escape en bij een klik ernaast; dat staat in
|
||
het script, want dat kent details van zichzelf niet. -->
|
||
<details class="menu" id="menu">
|
||
<!-- Drie punten en geen hamburger, op aanwijzing van de gebruiker
|
||
(27-08-2026): dat is wat umbrelOS bij zijn eigen apps toont, en een
|
||
pagina die in dat dashboard hangt hoort daar niet van af te wijken.
|
||
|
||
Als tekst en niet als SVG, want dan schaalt hij mee met de lettergrootte
|
||
van de gebruiker. Middelste punten (U+00B7) en geen gewone punten: die
|
||
staan op de basislijn en zakken dus naar de onderrand van de knop,
|
||
terwijl deze vanzelf op halve hoogte staan. Hetzelfde teken dat elders
|
||
op deze pagina de velden in een keuzeregel scheidt. -->
|
||
<summary class="menu-button" title="Settings" aria-label="Settings">···</summary>
|
||
<div class="menu-panel">
|
||
<!-- "Settings…" en niet "Certificates", en zonder de naam van het actieve
|
||
certificaat erachter (27-08-2026, gebruiker). Die naam stond al als
|
||
onderregel van de certificaatwidget, dus hier was het een derde plek
|
||
voor dezelfde waarde. Het beletselteken is de gebruikelijke belofte
|
||
dat er een dialoog volgt en niet meteen een handeling. -->
|
||
<button class="menu-item" id="menu-certs" type="button">
|
||
<span>Settings…</span>
|
||
</button>
|
||
<!-- De regel toont de huidige stand en niet de handeling. De losse knop
|
||
hiervoor deed het andersom, met "Light" terwijl de pagina donker was.
|
||
Als knop klopte dat, want dat was waar je heen ging; als menuregel
|
||
leest het als een bewering over hoe het nú staat, en dan is het fout. -->
|
||
<button class="menu-item" id="menu-theme" type="button">
|
||
<span>Appearance</span>
|
||
<span class="menu-meta" id="theme-label">Dark</span>
|
||
</button>
|
||
</div>
|
||
</details>
|
||
</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>
|
||
<!-- Deze knop is geen extraatje maar de eis die de nieuwe indeling zelf
|
||
oproept. De certificaatkeuze zit sinds 27-08-2026 achter het menu, en
|
||
dit is precies de toestand waarin een gebruiker die keuze móet kunnen
|
||
vinden: een verse installatie heeft er nog geen. Dat is de klem van
|
||
0.0.3 in een andere vorm, en die komt niet terug. Het menu is de weg
|
||
terug voor later, deze knop is de weg erheen nu. Zie PLAN.md 4a2. -->
|
||
<button class="btn alert-action" id="alert-nocert-open" type="button">Choose a certificate</button>
|
||
</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">
|
||
|
||
<!-- Vijf widgets, alle vijf hetzelfde soort ding: een titel, één waarde, en
|
||
hooguit één onderregel. Opgegeven door de gebruiker op 27-08-2026 en
|
||
uitgeschreven in PLAN.md 4a2.
|
||
|
||
Hier stonden een kader met drie lijstregels voor de Electrum-server en
|
||
daarnaast twee tegels. Dat vroeg twee kunstjes die nu vervallen: de tegels
|
||
moesten meerekken met de hoogte van het kader ernaast, en de reactietijd
|
||
nam twee kolommen om de sparkline ruimte te geven. Vijf gelijke widgets
|
||
hebben geen buur om zich aan te meten.
|
||
|
||
De volgorde is die van het lezen: eerst waar je verkeer heen gaat, dan of
|
||
dat werkt, dan het certificaat, en als laatste of onze eigen voordeur
|
||
antwoordt. -->
|
||
<div class="row1">
|
||
<div class="card">
|
||
<div class="card-head">
|
||
<h2 class="t-h3">Electrum server</h2>
|
||
</div>
|
||
<div class="stat-body">
|
||
<div class="stat-val text unknown" id="backend-name">unknown</div>
|
||
<!-- Het adres als onderregel en niet als eigen lijstregel. De twee andere
|
||
regels die hier stonden zijn weg: "Chosen in the umbrelOS settings"
|
||
was uitleg bij een instelling die je hier toch niet wijzigt, en "Last
|
||
checked" staat al onder de blokhoogte als "as of ... ago". -->
|
||
<div class="stat-sub mono" id="backend-address">${APP_ELECTRS_NODE_IP}:${APP_ELECTRS_NODE_PORT}</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Reactietijd vóór blokhoogte, omgedraaid op verzoek van de gebruiker
|
||
(27-08-2026). Hij staat daarmee naast de Electrum-server waar hij over
|
||
gaat, en de blokhoogte schuift naar het midden van de rij. Let op: dit is
|
||
de leesvolgorde in de HTML en dus ook de stapelvolgorde op een smal
|
||
scherm; er staat nergens een order in de CSS die daarvan afwijkt. -->
|
||
<div class="card">
|
||
<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 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>
|
||
|
||
<!-- Het certificaat als getal en niet als kaart. Kiezen, uploaden en de
|
||
details zitten sinds 27-08-2026 in de dialoog achter het menu; wat hier
|
||
staat is wat je in één oogopslag wilt weten. Het aantal dagen tot
|
||
vernieuwing stond hier eerder als tegel en is er op 20-08-2026 uit
|
||
gegaan omdat de kaart eronder hetzelfde zei. Die kaart is er nu niet
|
||
meer, dus het getal komt terug. -->
|
||
<div class="card">
|
||
<div class="card-head">
|
||
<h2 class="t-h3">Certificate</h2>
|
||
</div>
|
||
<div class="stat-body">
|
||
<div class="stat-val unknown" id="cert-days">unknown</div>
|
||
<div class="stat-sub mono" id="cert-domain">unknown</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- De zelfcontrole, gebouwd in 0.0.16. Dit is de enige widget die over deze
|
||
app zelf gaat en niet over iets waar hij naar kijkt.
|
||
|
||
Let op het woord dat hier NIET staat: "reachable". De agent verbindt van
|
||
container naar container, dus een router die de poort niet meer
|
||
doorstuurt leest hier als in orde. Wat er gemeten is, is de voordeur zelf
|
||
en niet de weg ernaartoe; de onderregel zegt dat er ook bij. -->
|
||
<div class="card">
|
||
<div class="card-head">
|
||
<h2 class="t-h3">Status</h2>
|
||
</div>
|
||
<div class="stat-body">
|
||
<div class="stat-val text unknown" id="self-state">unknown</div>
|
||
<div class="stat-sub" id="self-sub"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- De dialoog met alles wat met certificaten te maken heeft: de huidige, de
|
||
keuze, en het uploaden. Te openen uit het menu rechtsboven en uit de
|
||
foutmelding bovenaan.
|
||
|
||
Met het dialog-element en niet met een eigen overlay: focus, Escape en het
|
||
afschermen van de pagina erachter zitten erin.
|
||
|
||
Hij staat binnen het raster en dat maakt in de praktijk niets uit: dicht is
|
||
een dialog display:none, en open via showModal() staat hij in de top layer,
|
||
dus in geen van beide gevallen is hij een rastercel. Alleen de terugvalroute
|
||
voor een browser zonder showModal zet hem gewoon in de stroom, en daarvoor
|
||
staat er in .sheet een grid-column op de volle breedte. -->
|
||
<dialog id="cert-dialog" class="sheet">
|
||
<form method="dialog" class="sheet-head">
|
||
<!-- Zelfde woord als de menuregel die hem opent. Stond hier "Certificates"
|
||
terwijl je op "Settings…" klikte, en dan lijkt het even of je ergens
|
||
anders bent uitgekomen dan je dacht. -->
|
||
<h2 class="t-h3">Settings</h2>
|
||
<!-- Een kruisje en niet het woord "Close" (27-08-2026, gebruiker). Het
|
||
maalteken U+00D7 en geen letter x: dat is het teken dat hiervoor bestaat
|
||
en het staat symmetrisch. Het aria-label houdt "Close" aan, want een
|
||
schermlezer die "maalteken" voorleest zegt niets over wat de knop doet. -->
|
||
<button class="sheet-close" value="close" aria-label="Close" title="Close">×</button>
|
||
</form>
|
||
|
||
<div class="sheet-body">
|
||
<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-dialog-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.
|
||
|
||
Boven de keuzelijst en niet eronder, op verzoek van de gebruiker
|
||
(20-08-2026): wat je uploadt komt in die lijst 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. 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>
|
||
</div><!-- /sheet-body -->
|
||
</dialog>
|
||
|
||
<!-- 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.
|
||
|
||
Over de volle breedte sinds 27-08-2026. Het stond naast het
|
||
certificaatkader, en die twee moesten daarvoor samen groeien en krimpen;
|
||
dat was de hele inhoud van 0.0.13. Nu de certificaatkeuze in een dialoog
|
||
zit, is er geen buur meer en vervalt die koppeling. Logregels zijn
|
||
monospace en hadden de breedte toch al het hardst nodig. -->
|
||
<section class="card col-full">
|
||
<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">
|
||
<!-- Dichtgeklapt, op verzoek van de gebruiker (27-08-2026). Dit kader heb je
|
||
één keer nodig en daarna nooit meer, en open nam het het onderste derde
|
||
deel van de pagina.
|
||
|
||
Er staan nu twee kopieerknoppen in plaats van één per wallet. Dat draait
|
||
een besluit van 19-08-2026 terug, en dat is opgeschreven in PLAN.md 4a2
|
||
zodat het niet per ongeluk hersteld wordt: er zijn feitelijk maar twee
|
||
vormen van hetzelfde adres, dus tien knoppen waren negen keer dezelfde
|
||
handeling.
|
||
|
||
Wat daarbij niet verloren mocht gaan is wélke wallet welke vorm wil. De
|
||
wallets staan daarom onder de vorm die ze vragen en niet als één lijst
|
||
ernaast; zonder die groepering staat er wel een adres, maar niet meer
|
||
waar het bij hoort. -->
|
||
<details class="setup" id="setup">
|
||
<summary class="setup-summary">
|
||
<span class="t-h3">Setting up your wallet</span>
|
||
<span class="t-caption">The address to paste, per wallet</span>
|
||
</summary>
|
||
|
||
<p class="t-caption" style="margin: 1rem 0 1.2rem;">
|
||
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 class="setup-form">
|
||
<div class="t-label">Paste as one line</div>
|
||
<div class="setup-value">
|
||
<span class="list-item-val mono unknown" id="oneline-value">unknown</span>
|
||
<button class="btn" id="oneline-copy" type="button">Copy</button>
|
||
</div>
|
||
<div class="setup-wallets" id="oneline-wallets"></div>
|
||
</div>
|
||
<div class="setup-form">
|
||
<div class="t-label">Host and port separately, with SSL turned on</div>
|
||
<div class="setup-value">
|
||
<span class="list-item-val mono unknown" id="split-value">unknown</span>
|
||
<span class="t-caption" id="split-port"></span>
|
||
<button class="btn" id="split-copy" type="button">Copy</button>
|
||
</div>
|
||
<div class="setup-wallets" id="split-wallets"></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>
|
||
</details>
|
||
</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);
|
||
// De huidige stand en niet waar je heen gaat. Als losse knop stond hier het
|
||
// omgekeerde, en dáár klopte dat: een knop belooft wat er gebeurt als je hem
|
||
// indrukt. Een menuregel met een waarde erachter leest als een bewering over
|
||
// hoe het nu staat, en dan is dezelfde tekst ineens fout.
|
||
document.getElementById('theme-label').textContent = theme === 'dark' ? 'Dark' : 'Light';
|
||
}
|
||
|
||
document.getElementById('menu-theme').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 */ }
|
||
});
|
||
|
||
// ── Het menu ────────────────────────────────────────────────────────────
|
||
// details opent en sluit zichzelf, ook met het toetsenbord. Wat het niet doet
|
||
// is sluiten bij een klik ernaast of op Escape, en dat is bij een menu wel de
|
||
// verwachting. Vandaar deze twee, en niet meer dan deze twee.
|
||
var menu = el('menu');
|
||
|
||
function closeMenu() { menu.removeAttribute('open'); }
|
||
|
||
document.addEventListener('click', function (e) {
|
||
if (menu.hasAttribute('open') && !menu.contains(e.target)) { closeMenu(); }
|
||
});
|
||
document.addEventListener('keydown', function (e) {
|
||
if (e.key === 'Escape' && menu.hasAttribute('open')) { closeMenu(); }
|
||
});
|
||
|
||
// ── De certificaatdialoog ───────────────────────────────────────────────
|
||
// Twee wegen erheen, en dat is met opzet. Het menu is de weg voor wie later
|
||
// iets wil wijzigen; de knop in de foutmelding is de weg voor wie nog nooit een
|
||
// certificaat gekozen heeft, en dat is de enige toestand waarin deze pagina
|
||
// écht iets van je vraagt. Zie PLAN.md 4a2.
|
||
var sheet = el('cert-dialog');
|
||
|
||
function openSheet() {
|
||
closeMenu();
|
||
if (typeof sheet.showModal === 'function') {
|
||
sheet.showModal();
|
||
} else {
|
||
// Zonder showModal geen overlay en geen focusval, maar wel een zichtbare
|
||
// dialoog. Beter dan een knop die niets doet.
|
||
sheet.setAttribute('open', '');
|
||
}
|
||
}
|
||
|
||
el('menu-certs').addEventListener('click', openSheet);
|
||
el('alert-nocert-open').addEventListener('click', openSheet);
|
||
|
||
// ── 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 || {};
|
||
|
||
drawSetup(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. Het aantal dagen tot vernieuwing staat sinds 27-08-2026 weer
|
||
// als widget op de pagina; het stond er tot 20-08-2026 als tegel en ging er
|
||
// toen uit omdat de kaart eronder hetzelfde zei. Die kaart is nu een dialoog,
|
||
// dus de dubbeling is weg en het getal hoort weer op de pagina.
|
||
var daysLeft = null;
|
||
var days = el('cert-days');
|
||
days.classList.remove('bad', 'text');
|
||
if (cert.not_after) {
|
||
daysLeft = Math.floor((cert.not_after - nowSec) / 86400);
|
||
put('cert-until', whenLocal(cert.not_after));
|
||
if (daysLeft < 0) {
|
||
put('cert-days', 'expired');
|
||
days.classList.add('bad', 'text');
|
||
} else {
|
||
put('cert-days', daysLeft + (daysLeft === 1 ? ' day' : ' days'));
|
||
}
|
||
} else if (!cert.selected) {
|
||
// Niet 'unknown': er is niets onbekends aan: er is er geen. De melding
|
||
// bovenaan zegt wat je eraan doet, dus hier volstaat het woord.
|
||
put('cert-days', 'none');
|
||
days.classList.add('text');
|
||
} else {
|
||
put('cert-days', null);
|
||
}
|
||
// 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');
|
||
// Twee keer dezelfde naam: als onderregel van de widget en in de dialoog. Dat
|
||
// is geen dubbeling maar twee plekken die elkaar niet zien; wie de dialoog
|
||
// opent, hoort er niet voor terug te hoeven bladeren.
|
||
put('cert-domain', tls.domain);
|
||
put('cert-dialog-domain', tls.domain);
|
||
put('cert-reload', whenLocal(cert.last_reload));
|
||
renderCertAlert(daysLeft);
|
||
drawCertChoices(s.certificates, cert.selected, nowSec);
|
||
|
||
// Backend. "Last checked" stond hier als derde lijstregel en is er op
|
||
// 27-08-2026 uit: hetzelfde staat onder de blokhoogte als "as of ... ago",
|
||
// en een widget met drie onderteksten is geen widget meer.
|
||
put('backend-name', backend.name);
|
||
if (backend.address) { put('backend-address', backend.address); }
|
||
|
||
drawSelfCheck(tls.self_check);
|
||
|
||
// 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.';
|
||
}
|
||
}
|
||
|
||
// ── De zelfcontrole ─────────────────────────────────────────────────────
|
||
// Gebouwd in de agent in 0.0.16, hier getoond sinds de tweede indeling.
|
||
//
|
||
// Let op het woord dat hier niet staat: "reachable". De agent verbindt van
|
||
// container naar container, dus dit zegt niets over de poortmapping naar de
|
||
// host of over de doorstuurregel in de router. "Answering" met daaronder "from
|
||
// inside the app" is wat er gemeten is; alles wat verder gaat, zou de tegel
|
||
// laten liegen op precies het punt waar iemand hem gelooft.
|
||
var SELF = {
|
||
ok: { label: 'Answering', bad: false },
|
||
failed: { label: 'Not answering', bad: true },
|
||
'wrong-certificate': { label: 'Wrong certificate', bad: true },
|
||
// Geen certificaat is de normale begintoestand van een verse installatie en
|
||
// dus niet rood; de melding bovenaan zegt al wat eraan te doen is.
|
||
off: { label: 'Off', bad: false },
|
||
pending: { label: 'Checking', bad: false }
|
||
};
|
||
|
||
function drawSelfCheck(self) {
|
||
var node = el('self-state');
|
||
var sub = el('self-sub');
|
||
var kind = self && self.state ? SELF[self.state] : null;
|
||
|
||
if (!kind) {
|
||
put('self-state', null);
|
||
node.classList.remove('bad');
|
||
// Dit is de toestand op een installatie met een oudere agent. Zwijgen zou
|
||
// hier als "in orde" lezen.
|
||
sub.textContent = self ? 'Reported as ' + self.state : 'The agent has not reported this yet.';
|
||
return;
|
||
}
|
||
|
||
put('self-state', kind.label);
|
||
node.classList.toggle('bad', kind.bad);
|
||
|
||
if (self.state === 'ok') {
|
||
sub.textContent = 'answered in ' + self.ms + ' ms, from inside the app';
|
||
} else if (self.state === 'off') {
|
||
sub.textContent = 'no certificate selected, so nothing is listening';
|
||
} else if (self.state === 'wrong-certificate') {
|
||
sub.textContent = 'not the certificate you selected; a reload may have failed';
|
||
} else if (self.state === 'pending') {
|
||
sub.textContent = 'measuring again after the certificate change';
|
||
} else {
|
||
sub.textContent = self.error ? String(self.error) : 'no reason given';
|
||
}
|
||
}
|
||
|
||
// ── 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;
|
||
});
|
||
});
|
||
|
||
// ── Het instelkader ─────────────────────────────────────────────────────
|
||
// Twee vormen van hetzelfde adres, dus twee kopieerknoppen (27-08-2026). Hier
|
||
// stond een kopieerknop per wallet, tien in totaal, en dat waren negen keer
|
||
// dezelfde handeling. Dat draait een besluit van 19-08-2026 terug; PLAN.md 4a2
|
||
// zegt waarom, zodat het niet per ongeluk hersteld wordt.
|
||
//
|
||
// De wallets blijven wél onder de vorm staan die ze vragen. Dat is de
|
||
// voorwaarde bij die vereenvoudiging: welke wallet welk formaat wil, is precies
|
||
// wat dit kader te vertellen heeft, en één lijst naast twee losse knoppen
|
||
// gooit dat weg.
|
||
//
|
||
// Zonder domein en poort blijft de waarde op 'unknown' staan, net als elders op
|
||
// de pagina. Een half ingevuld adres dat iemand toch kopieert is erger dan een
|
||
// leeg veld, dus de kopieerknop staat dan uit.
|
||
var oneLineValue = null;
|
||
var splitValue = null;
|
||
|
||
function drawSetup(domain, port) {
|
||
oneLineValue = (domain && port) ? domain + ':' + port + ':s' : null;
|
||
splitValue = domain || null;
|
||
put('oneline-value', oneLineValue);
|
||
put('split-value', splitValue);
|
||
el('split-port').textContent = port ? 'port ' + port : '';
|
||
el('oneline-copy').disabled = !oneLineValue;
|
||
el('split-copy').disabled = !splitValue;
|
||
}
|
||
|
||
// Eén keer getekend, want deze lijst verandert nooit; hij komt uit
|
||
// Referenties/Clients.md. De vindplaats staat erbij en dat is bewust: het gaat
|
||
// erom je eigen wallet te herkennen, en dan is "waar zit die instelling" de
|
||
// volgende vraag.
|
||
function walletList(id, clients) {
|
||
var box = el(id);
|
||
box.innerHTML = '';
|
||
for (var i = 0; i < clients.length; i++) {
|
||
var row = document.createElement('div');
|
||
var name = document.createElement('strong');
|
||
name.textContent = clients[i].name;
|
||
row.appendChild(name);
|
||
row.appendChild(document.createTextNode(' · ' + clients[i].where));
|
||
box.appendChild(row);
|
||
}
|
||
}
|
||
|
||
el('oneline-copy').addEventListener('click', function () {
|
||
if (oneLineValue) { copy(oneLineValue, el('oneline-copy')); }
|
||
});
|
||
el('split-copy').addEventListener('click', function () {
|
||
if (splitValue) { copy(splitValue, el('split-copy')); }
|
||
});
|
||
|
||
// ── 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);
|
||
});
|
||
}
|
||
|
||
walletList('oneline-wallets', CLIENTS_ONELINE);
|
||
walletList('split-wallets', CLIENTS_SPLIT);
|
||
drawSetup(null, null);
|
||
drawSelfCheck(null);
|
||
drawActivity(null);
|
||
drawCertChoices(null, null, Math.floor(Date.now() / 1000));
|
||
load();
|
||
setInterval(load, REFRESH_MS);
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|