umbrelOS leest per store één repo, dus twee apps in twee repo's kan niet. Deze repo is de store en bevat vanaf nu Electrum Gate en het werk aan Evolu Relay. Opgezet als verse repo op verzoek van de gebruiker: de historie van ElectrumTLS en van EvoluRelay komt niet mee. Dat heeft één gevolg dat verder gaat dan opruimen. In de historie van ElectrumTLS staat het domein van de gebruiker en het certificaatpad, van vóór de opschoning van 19-08. Die komt hier niet in. Zolang die repo op de Git-server blijft staan verandert dat niets, dus het weghalen ervan is het laatste stuk van open punt 3 van het plan Appstore, en geen bijzaak. De store zelf hoefde niet te veranderen: store-id whatsnext, en dus blijft het app-id whatsnext-electrum-gate. Dat hangt aan het store-id en niet aan de URL, dus voor umbrelOS is dit dezelfde app in een andere store. Dat de store op 19-08 naar de maker genoemd werd in plaats van naar deze ene app, betaalt zich hier uit. Wat de documentatie betreft is dit één wortel voor beide apps, en dat was de reden om samen te voegen en niet de prijs ervan: de appstore-spec, het pinnen van images en de werkwijze golden al voor allebei en stonden in twee repo's naast elkaar. De kruisverwijzing die daarvoor nodig was (Referenties/Umbrel-appstore.md in de oude EvoluRelay-repo) is verdwenen; wat daarin stond over de plekken waar de relay een ander geval is, staat nu als ontwerp in het masterplan Umbrelapp §4. Botsende namen kregen een achtervoegsel met de app, en alleen die: Publicatie werd Publicatie-Gate en Publicatie-Relay, CHANGELOG.md werd CHANGELOG-electrum-gate.md. Proefopstelling kreeg 007, tussen de twee bestaande nummers, zodat de bovenkant van de reeks op tier-orde blijft staan. CONTINUE_HERE.md heeft een kolom App, maar de tiers lopen over beide apps heen: er is één volgorde van werken. Electrum Gate gaat naar 0.0.15, want website, repo, support, submission en icon wijzen nu naar UmbrelApps en zonder versieverhoging rolt dat niet uit. De release notes leggen aan de gebruiker uit dat hij de store opnieuw moet toevoegen. Of een geïnstalleerde app een wisseling van store-URL overleeft is nog steeds niet uitgezocht; dat blijkt bij het omzetten. Twee dingen in de plannen van Electrum Gate waren door deze verhuizing niet meer waar en zijn bijgewerkt: de taak "de repo hernoemen" in fase 7 is afgevinkt, en de repo-vorm in PLAN.md §4a toonde nog de store-id electrumtls, die al sinds fase 7 achterhaald was. Tests: 39 goed 0 fout en 54 goed 0 fout, niets overgeslagen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
|