diff --git a/.github/workflows/publish-image.yml b/.github/workflows/publish-image.yml index 3a8b5ff..b55f6ee 100644 --- a/.github/workflows/publish-image.yml +++ b/.github/workflows/publish-image.yml @@ -4,6 +4,7 @@ on: push: branches: - master + - "**" tags: - "**" @@ -34,3 +35,5 @@ jobs: context: . push: true tags: ghcr.io/${{ github.repository_owner }}/ffdd-node:${{ github.ref_name }} + cache-from: type=registry,ref=ghcr.io/${{ github.repository_owner }}/ffdd-node:buildcache + cache-to: type=registry,ref=ghcr.io/${{ github.repository_owner }}/ffdd-node:buildcache,mode=max diff --git a/Dockerfile b/Dockerfile index 65e8046..d921de7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -61,7 +61,79 @@ RUN npm install --no-audit --no-fund RUN npm run build -FROM alpine:3.23 AS runtime-base +FROM alpine:3.23 AS staging + +# Assemble the complete filesystem layout in /staging +RUN mkdir -p \ + /staging/data \ + /staging/etc/nginx \ + /staging/etc/service \ + /staging/run/freifunk/fastd/peers \ + /staging/run/freifunk/wireguard \ + /staging/run/freifunk/bmxd \ + /staging/run/freifunk/sysinfo \ + /staging/run/freifunk/www \ + /staging/usr/bin \ + /staging/usr/lib/bmxd \ + /staging/usr/lib/fastd \ + /staging/usr/local/bin \ + /staging/usr/local/lib \ + /staging/usr/local/share/freifunk/ui + +# Binaries from builder (only what we need) +COPY --from=builder /usr/local/bin/fastd /staging/usr/local/bin/ +COPY --from=builder /usr/local/lib/libuecc.so* /staging/usr/local/lib/ +COPY --from=builder /build/firmware/feeds/pool/bmxd/sources/bmxd /staging/usr/bin/ + +# UI build output +COPY --from=builder /build/ui/dist/ /staging/usr/local/share/freifunk/ui/ + +# License texts +COPY --from=builder /build/firmware/files/common/usr/lib/license/agreement-de.txt /staging/usr/local/share/freifunk/ +COPY --from=builder /build/firmware/files/common/usr/lib/license/pico-de.txt /staging/usr/local/share/freifunk/ +COPY --from=builder /build/firmware/license/gpl2-en.txt /staging/usr/local/share/freifunk/gpl2.txt +COPY --from=builder /build/firmware/license/gpl3-en.txt /staging/usr/local/share/freifunk/gpl3.txt + +# Config files +COPY config/defaults.yaml /staging/usr/local/share/freifunk/defaults.yaml +COPY config/nginx.conf /staging/etc/nginx/nginx.conf + +# Scripts +COPY scripts/backbone_runtime.py scripts/mesh-status.py scripts/node_config.py scripts/registrar.py scripts/sysinfo.py scripts/wireguard_status.py /staging/usr/local/bin/ +COPY scripts/fastd-backbone-cmd.sh /staging/usr/lib/fastd/backbone-cmd.sh +COPY scripts/bmxd-launcher.sh /staging/usr/local/bin/bmxd-launcher.sh +COPY scripts/bmxd-gateway.py /staging/usr/lib/bmxd/bmxd-gateway.py +COPY scripts/docker-entrypoint.sh /staging/usr/local/bin/docker-entrypoint.sh +COPY scripts/runit/ /staging/etc/service/ + +RUN chmod +x \ + /staging/usr/local/bin/docker-entrypoint.sh \ + /staging/usr/local/bin/mesh-status.py \ + /staging/usr/local/bin/registrar.py \ + /staging/usr/local/bin/sysinfo.py \ + /staging/usr/local/bin/wireguard_status.py \ + /staging/usr/lib/fastd/backbone-cmd.sh \ + /staging/usr/local/bin/bmxd-launcher.sh \ + /staging/usr/lib/bmxd/bmxd-gateway.py \ + /staging/etc/service/registrar/run \ + /staging/etc/service/sysinfo/run \ + /staging/etc/service/fastd/run \ + /staging/etc/service/wireguard/run \ + /staging/etc/service/bmxd/run \ + /staging/etc/service/mesh-status/run \ + /staging/etc/service/nginx/run + + +FROM staging AS tests + +RUN apk add --no-cache py3-yaml python3 +COPY scripts/backbone_runtime.py scripts/bmxd-gateway.py scripts/mesh-status.py scripts/node_config.py scripts/registrar.py scripts/run_gateway_script.py scripts/sysinfo.py scripts/wireguard_status.py /opt/freifunk-tests/scripts/ +COPY tests/ /opt/freifunk-tests/tests/ +RUN cd /opt/freifunk-tests \ + && python3 -m unittest discover -v -s tests -t . + + +FROM alpine:3.23 AS final ENV TZ=Europe/Berlin @@ -80,60 +152,14 @@ RUN apk add --no-cache \ runit \ tcpdump \ tzdata \ - wireguard-tools-wg - -RUN ln -snf "/usr/share/zoneinfo/$TZ" /etc/localtime \ + wireguard-tools-wg \ + && ln -snf "/usr/share/zoneinfo/$TZ" /etc/localtime \ && echo "$TZ" > /etc/timezone -COPY --from=builder /usr/local /usr/local -COPY --from=builder /build/firmware/feeds/pool/bmxd/sources/bmxd /usr/bin/bmxd - -RUN mkdir -p /data /run/freifunk/fastd/peers /run/freifunk/wireguard /run/freifunk/bmxd /run/freifunk/sysinfo /run/freifunk/www /usr/local/share/freifunk /usr/lib/bmxd /etc/service - -COPY config/defaults.yaml /usr/local/share/freifunk/defaults.yaml -COPY config/nginx.conf /etc/nginx/nginx.conf -COPY --from=builder /build/ui/dist/ /usr/local/share/freifunk/ui/ -COPY --from=builder /build/firmware/files/common/usr/lib/license/agreement-de.txt /usr/local/share/freifunk/agreement-de.txt -COPY --from=builder /build/firmware/files/common/usr/lib/license/pico-de.txt /usr/local/share/freifunk/pico-de.txt -COPY --from=builder /build/firmware/license/gpl2-en.txt /usr/local/share/freifunk/gpl2.txt -COPY --from=builder /build/firmware/license/gpl3-en.txt /usr/local/share/freifunk/gpl3.txt -COPY scripts/backbone_runtime.py scripts/node_config.py scripts/registrar.py scripts/sysinfo.py scripts/wireguard_status.py /usr/local/bin/ -COPY scripts/fastd-backbone-cmd.sh /usr/lib/fastd/backbone-cmd.sh -COPY scripts/bmxd-launcher.sh /usr/local/bin/bmxd-launcher.sh -COPY scripts/bmxd-gateway.py /usr/lib/bmxd/bmxd-gateway.py -COPY scripts/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh -COPY scripts/runit/ /etc/service/ - -RUN chmod +x \ - /usr/local/bin/docker-entrypoint.sh \ - /usr/local/bin/registrar.py \ - /usr/local/bin/sysinfo.py \ - /usr/local/bin/wireguard_status.py \ - /usr/lib/fastd/backbone-cmd.sh \ - /usr/local/bin/bmxd-launcher.sh \ - /usr/lib/bmxd/bmxd-gateway.py \ - /etc/service/registrar/run \ - /etc/service/sysinfo/run \ - /etc/service/fastd/run \ - /etc/service/wireguard/run \ - /etc/service/bmxd/run \ - /etc/service/nginx/run +# Single COPY from staging: all freifunk files in one layer +COPY --from=tests /staging/ / VOLUME ["/data"] ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"] CMD [] - - -FROM runtime-base AS tests - -COPY scripts/backbone_runtime.py scripts/node_config.py scripts/registrar.py scripts/sysinfo.py scripts/wireguard_status.py scripts/run_gateway_script.py scripts/bmxd-gateway.py /opt/freifunk-tests/scripts/ -COPY tests/ /opt/freifunk-tests/tests/ -RUN cd /opt/freifunk-tests \ - && python3 -m unittest discover -v -s tests -t . \ - && touch /tmp/tests-passed - - -FROM runtime-base AS final - -COPY --from=tests /tmp/tests-passed /tmp/tests-passed diff --git a/config/nginx.conf b/config/nginx.conf index 6e92861..ad3df3e 100644 --- a/config/nginx.conf +++ b/config/nginx.conf @@ -10,6 +10,7 @@ events { http { include /etc/nginx/mime.types; default_type application/octet-stream; + absolute_redirect off; access_log off; sendfile on; @@ -35,6 +36,11 @@ http { try_files /nodes.json =404; } + location = /mesh-status.json { + default_type application/json; + try_files /mesh-status.json =404; + } + location = / { return 302 /ui/; } diff --git a/docker-compose.yaml b/docker-compose.yaml index 91c615b..4e4baa5 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -12,7 +12,6 @@ services: - /dev/net/tun:/dev/net/tun ports: - "${HTTP_PORT:-80}:80" - - "${FASTD_PORT:-5002}:${FASTD_PORT:-5002}/udp" volumes: - ./data:/data mem_limit: 200m diff --git a/docs/erweiterung.md b/docs/erweiterung.md new file mode 100644 index 0000000..1a22731 --- /dev/null +++ b/docs/erweiterung.md @@ -0,0 +1,275 @@ +# Erweiterung eines ffdd-node Images + +Dieses Dokument beschreibt den Startpunkt für ein eigenes Erweiterungs-Image. + +## Basis + +## Projektstruktur für eine Erweiterung + +Lege ein separates Repo für deine Erweiterung an und übernimm als Basis: + +- `docker-compose.yaml` +- `.env.example` +- `.gitignore` +- `.dockerignore` + +Erstelle ein Basis `Dockerfile`: + +```Dockerfile +FROM ghcr.io/freifunkstuff/ffdd-node:master +``` + +Erstelle eine passende .env auf Basis von env.example. + +## Bauen und Testen + +Image bauen und Tests ausführen: + +```bash +# Image bauen +docker-compose build --pull + +# Starten und Logs anschauen +docker-compose up -d && docker-compose logs -f +``` + +## App integrieren + +### Contract + +Jede App folgt diesem minimalen Contract: + +**Konfiguration:** +- Alle Konfiguration erfolgt über app-spezifische Umgebungsvariablen, zum Beispiel `MYAPP_*` +- Typ-Konvertierung (str, int, float, bool) erfolgt in der App selbst +- Ein zentrales Defaults-YAML ist optional. + +**Verzeichnisse:** +- **Persistente Daten:** `/data/APP/` — wird beim Start erzeugt (0755), Daten bleiben über Neustarts erhalten +- **Image-lokale Daten:** `/var/lib/freifunk/APP/` — temporär, wird bei Image-Update gelöscht, optional + +**Beispiel ENV-Variablen in .env:** +```bash +MYAPP_ENABLED=true +MYAPP_INTERVAL=3600 +MYAPP_DEBUG=false +``` + +### Statusdateien im Webroot bereitstellen + +Wenn eine App einen JSON-Status nach außen bereitstellen soll, gilt dasselbe Muster wie bei `sysinfo` und `mesh-status`: + +- Die App schreibt ihre Laufzeitdatei atomar in ein flüchtiges Laufzeitverzeichnis, zum Beispiel nach `/run/freifunk/state/myapp-status.json` +- Für den stabilen HTTP-Pfad wird im Webroot `/run/freifunk/www/` ein Symlink angelegt, zum Beispiel `/run/freifunk/www/myapp-status.json` +- `nginx` liefert nur diesen stabilen Pfad aus und erzeugt den Inhalt nicht selbst + +Beispiel: + +```text +/run/freifunk/state/myapp-status.json +/run/freifunk/www/myapp-status.json -> /run/freifunk/state/myapp-status.json +GET /myapp-status.json +``` + +Praktisch bedeutet das: + +- Producer schreiben in ihr eigenes Runtime-Verzeichnis +- Das Webroot enthält nur veröffentlichte Dateinamen oder Symlinks +- Der HTTP-Pfad bleibt stabil, auch wenn sich das interne Runtime-Verzeichnis einer App ändert + +Wichtig für die Auslieferung: + +- Die Datei selbst sollte mit mindestens `0644` geschrieben werden +- Alle übergeordneten Verzeichnisse auf dem Pfad zum Symlink-Ziel müssen für den `nginx`-Prozess durchsuchbar sein, in der Praxis also typischerweise mindestens `0755` +- Fehlt dieses Execute-Bit auf einem Verzeichnis, endet der Request trotz vorhandenem Symlink mit `403 Forbidden` + +Minimal nötig sind also drei Dinge: Runtime-Datei schreiben, Symlink im Webroot anlegen und den Pfad für `nginx` lesbar bzw. durchsuchbar machen. + +### Wann reicht ein JSON-Endpoint? + +Nicht jede Erweiterung braucht einen eigenen Menüpunkt in der UI. + +- Ein zusätzlicher JSON-Endpoint reicht, wenn die bestehende UI nur weitere Daten anzeigen soll, zum Beispiel ein zusätzliches Panel oder weitere Kennzahlen auf einer vorhandenen Seite +- Ein eigener UI-View ist erst dann sinnvoll, wenn die Erweiterung einen eigenen Bedienkontext, eigene Interaktion oder eine eigene Seite innerhalb der Navigation braucht + +Faustregel: Neue Daten allein sind noch keine UI-Erweiterung. Ein neuer View ist erst dann sinnvoll, wenn die bestehende Seite dafür fachlich zu eng wird. + +### Optionaler Plattform-Dienst: Mesh-Status + +Apps können den aktuellen Mesh-Zustand über `/run/freifunk/state/mesh-status.json` lesen, müssen davon aber nicht abhängen. +Die Datei wird vom Dienst `mesh-status` zyklisch aktualisiert. + +Beispiel: + +```json +{ + "updated_at": "2026-03-29T00:34:12.000000+01:00", + "mesh": { + "connected": true, + "stable": true, + "checked_links": 3, + "reachable_links": 2, + "connected_duration": 31, + "stable_after": 30 + }, + "gateway": { + "selected": "10.200.200.200", + "connected": true + } +} +``` + +Bedeutung: + +- `mesh.connected`: mindestens eines der geprüften Ziele aus `bmxd -c --links` ist erreichbar +- `mesh.stable`: `mesh.connected` liegt seit mindestens `stable_after` Sekunden ohne Unterbrechung an +- `mesh.checked_links`: Anzahl der aktuell geprüften Link-Ziele +- `mesh.reachable_links`: Anzahl der aktuell erreichbaren Link-Ziele +- `mesh.connected_duration`: Sekunden seit Beginn des aktuellen zusammenhängenden `mesh.connected`-Zustands +- `gateway.selected`: aktuell von `bmxd -c --gateways` ausgewähltes Gateway, leer wenn keines selektiert ist +- `gateway.connected`: das aktuell selektierte Gateway ist erreichbar + +Für Apps ist `mesh.stable` das robustere Startsignal als `gateway.connected`, weil Mesh auch ohne selektiertes Gateway sinnvoll benutzbar sein kann. +Wenn eine App keinen Mesh-Bezug hat, kann dieser Plattform-Dienst ignoriert werden. + +### UI-Erweiterungen + +Wenn eine App einen eigenen Menüpunkt in der Standard-UI bekommen soll, klinkt sie sich als zusätzlicher View in die bestehende SPA ein. +Topbar, Sidebar, Routing und Grundlayout bleiben dabei in der Basis-UI. +Die Erweiterung liefert nur ihren eigenen Inhaltsbereich. + +Ziel ist ein einheitliches Erscheinungsbild ohne harte Runtime-Kopplung an das Basis-Frontend. +Für UI-Erweiterungen gilt folgender Minimalvertrag: + +- Die Erweiterung verwendet dieselbe visuelle Sprache: Farben, Abstände, Status-Pills, Tabellenstil und allgemeine Layout-Konventionen +- Die Erweiterung kann intern dasselbe Framework wie die Basis-UI verwenden, zum Beispiel Preact +- Die Erweiterung hängt aber nicht von der konkret im Basis-Image eingebauten Framework-Version ab +- Die Erweiterung liefert ein eigenes Bundle aus und erwartet nicht, dass Host-Komponenten oder die Host-Runtime direkt importierbar sind +- Geteilt wird ein kleiner UI-Vertrag, nicht die komplette interne Frontend-Struktur + +Minimaler technischer Vertrag: + +- Die Basis-UI besitzt Navigation, Hash-Routing, Kopfbereich und allgemeines Seitenlayout +- Eine Erweiterung wird nicht per Verzeichnis-Scan gefunden, sondern über eine Registry-Datei angemeldet +- Das Bundle der Erweiterung wird im abgeleiteten Image nach `/usr/local/share/freifunk/ui-extensions/APP/` kopiert +- Zur Laufzeit wird es nach `/run/freifunk/www/ui/extensions/APP/` veröffentlicht +- Die Registry-Datei `/ui/extensions/index.json` wird von der Plattform erzeugt; einzelne Erweiterungen schreiben diese Datei nicht direkt +- Wenn mehrere Erweiterungen vorhanden sind, führt die Plattform deren Einträge zu einer gemeinsamen Registry zusammen +- Die Basis-UI lädt `/ui/extensions/index.json`, baut daraus die Menüeinträge und lädt das aktive Bundle dynamisch +- Erweiterungen deklarieren die von ihnen benötigten JSON-Endpunkte im jeweiligen Registry-Eintrag, damit die Basis-UI diese in ihren gemeinsamen Refresh-Zyklus aufnehmen kann +- Eine Erweiterung liefert mindestens Menü-Key, Label, Reihenfolge und einen Renderer für den Content-Bereich + +Beispiel für die Registry-Datei: + +```json +{ + "extensions": [ + { + "id": "metadata", + "label": "Metadaten", + "order": 100, + "hash": "metadata", + "entry": "/ui/extensions/metadata/index.js", + "endpoints": [ + "/metadata.json" + ] + } + ] +} +``` + +Minimaler Renderer-Vertrag: + +```javascript +export function render(container, context) { + container.textContent = 'Metadata view'; +} + +export function dispose(container) { + container.textContent = ''; +} +``` + +Dabei gilt: + +- `hash` bestimmt den View-Key in der SPA, zum Beispiel `#metadata` +- `entry` verweist auf das gebaute Bundle der Erweiterung +- `render()` rendert nur den Inhaltsbereich, nicht die gesamte Seite +- Die Basis-UI lädt Core- und Extension-Daten in einem gemeinsamen Refresh-Zyklus, typischerweise alle 30 Sekunden +- Erweiterungen starten dafür standardmäßig keine eigenen Polling-Timer +- `context` enthält nur kleine stabile Host-Helfer und den aktuellen Datenstand der Erweiterung, zum Beispiel `data`, `error`, `refreshNow()`, `fetchJson(url)`, `fetchText(url)`, `safe(value)` und einfache Formatierungsfunktionen +- Alles außerhalb dieses kleinen `context`-Vertrags gilt als intern und wird von Erweiterungen nicht direkt verwendet + +Bewusst nicht Teil des Vertrags sind: + +- direkte Imports interner Komponenten aus der Basis-SPA +- Abhängigkeit von einer exakt gleichen Host-Framework-Version +- eigene Topbar oder Sidebar innerhalb der Erweiterung +- eine separate, vollständig unabhängige Web-App für kleine Zusatzfunktionen + +Damit bleibt die UI konsistent, und Updates des Basis-Images können erfolgen, ohne dass jede Erweiterung an interne Frontend-Details gekoppelt ist. + +### Installation + +Die Anwendung wird im Dockerfile in das Image installiert. +Dabei werden auch alle benötigten Laufzeitabhängigkeiten eingebracht. +Das Ergebnis ist ein fester Einstiegspunkt im Image, auf den das runit-Service zeigt. + +Zusätzlich werden im Dockerfile die Service-Dateien nach `/etc/service/APP/` kopiert. +Falls die App einen eigenen Failfast-Check benötigt, wird dafür ein ausführbarer Hook unter `/etc/docker-entrypoint.d/` installiert. + +### Runit Service + +Services werden als runit-Skripte unter `runit/APP/run` integriert: + +```bash +#!/bin/sh +printf '%s [myapp] gestartet\n' "$(date '+%Y-%m-%d %H:%M:%S %z')" +exec sleep infinity +``` + +Der Dienst wird automatisch von runit gestartet und bei Fehler neu gestartet. +Das runit-Service zeigt auf den installierten Einstiegspunkt im Image, nicht auf einen beliebigen Quellpfad aus dem Build-Kontext. +Für erste Integrationsschritte muss der Prozess im Vordergrund dauerhaft weiterlaufen; ein einmaliges Loggen und direktes Beenden führt nur zu Neustart-Schleifen durch runit. + +### Validierung (Optional) + +Wenn deine App Validierung benötigt, registriere einen Hook unter `/etc/docker-entrypoint.d/NN-APP` (Exit-Code != 0 = Fehler): + +```bash +#!/bin/sh +/usr/local/bin/myapp --checkconfig +``` + +Hooks werden beim Container-Start (nach festen Basis-Checks) nacheinander ausgeführt. Nur Apps mit Validierungsbedarf brauchen einen Hook. + +### Logging + +Verwende das Standard-Log-Prefix Format: + +```python +from datetime import datetime, timezone + +def log_info(message: str) -> None: + tz = datetime.now(timezone.utc).strftime("%z") + tz_formatted = tz[:-2] + ":" + tz[-2:] # +0100 → +01:00 + timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") + " " + tz_formatted + print(f"{timestamp} [myapp] {message}", flush=True) +``` + +**Format:** `YYYY-MM-DD HH:MM:SS ±HH:MM [APP]` + +Beispiel: +``` +2026-03-28 23:18:06 +01:00 [myapp] App gestartet +2026-03-28 23:19:12 +01:00 [myapp] ERROR: Config ungültig +``` + +**Wichtig:** Nutze `-u` Flag bei python3 für unbuffertes Output: + +```bash +#!/bin/sh +exec python3 -u /usr/local/bin/myapp/main.py --loop --interval 60 +``` + +Alle Logs gehen auf stdout/stderr und werden von Docker/runit/svlogd protokolliert. diff --git a/docs/server-node.md b/docs/server-node.md index f437557..47019a4 100644 --- a/docs/server-node.md +++ b/docs/server-node.md @@ -30,6 +30,8 @@ Der Server-Node ist als einzelner Container mit klar getrennten Laufzeitrollen a - [dockernode/scripts/wireguard_status.py](dockernode/scripts/wireguard_status.py) liest `wireguard.env`, pollt `wg show dump` und loggt Zustände für konfigurierte Peers. - [dockernode/scripts/runit/wireguard/run](dockernode/scripts/runit/wireguard/run) startet den WireGuard-Statusdienst zyklisch mit Polling-Intervall und Stale-Schwelle. - [dockernode/scripts/runit/fastd/run](dockernode/scripts/runit/fastd/run) startet `fastd`, sobald die vom Registrator erzeugte `fastd.conf` vorhanden ist. +- [dockernode/scripts/mesh-status.py](dockernode/scripts/mesh-status.py) bewertet zyklisch den aktuellen Mesh-Zustand anhand von `bmxd --links` und `bmxd --gateways` und schreibt das Ergebnis nach `/run/freifunk/state/mesh-status.json`. +- [dockernode/scripts/runit/mesh-status/run](dockernode/scripts/runit/mesh-status/run) startet den Mesh-Status-Dienst zyklisch im Vordergrund. - [dockernode/scripts/runit/nginx/run](dockernode/scripts/runit/nginx/run) startet `nginx` mit `daemon off` und liefert `/run/freifunk/www` auf Port 80 aus. - [dockernode/config/nginx.conf](dockernode/config/nginx.conf) definiert die nginx-Auslieferung für JSON-Endpunkte (`/sysinfo.json`, `/sysinfo-json.cgi`, `/nodes.json`), die UI unter `/ui/` sowie statische Rechtstexte unter `/licenses/*`. - [dockernode/scripts/bmxd-launcher.sh](dockernode/scripts/bmxd-launcher.sh) wartet auf die vom Registrator erzeugte `bmxd.env`, bereitet Interfaces und Policy Rule vor und startet anschließend `bmxd`. @@ -50,14 +52,22 @@ Wichtig dabei: Der Registrator startet `fastd` und `bmxd` nicht direkt per `exec ### Laufzeit- und Änderungsmodell - Persistente Knotendaten liegen in `/data/node.yaml`. -- Flüchtige Laufzeitdateien liegen unter `/run/freifunk/fastd`, `/run/freifunk/wireguard`, `/run/freifunk/bmxd`, `/run/freifunk/sysinfo` und `/run/freifunk/www`. +- Flüchtige Laufzeitdateien liegen unter `/run/freifunk/fastd`, `/run/freifunk/wireguard`, `/run/freifunk/bmxd`, `/run/freifunk/sysinfo`, `/run/freifunk/state` und `/run/freifunk/www`. - Der WireGuard-Dienst liest seine Konfiguration aus `/run/freifunk/wireguard/wireguard.env` und loggt Änderungen ausschließlich anhand der vom Registrator erzeugten Peer-Liste und `wg`-Live-Daten. - Der Registrator läuft zyklisch und prüft in jedem Durchlauf, ob sich registrierungsrelevante oder gerenderte Inhalte geändert haben. - Der Sysinfo-Dienst läuft ebenfalls zyklisch und schreibt immer den aktuellen JSON-Stand in das volatile Runtime-Verzeichnis. +- Der Mesh-Status-Dienst schreibt zyklisch nach `/run/freifunk/state/mesh-status.json` und fasst dort den beobachteten Zustand von Mesh und selektiertem Gateway zusammen. - Nur bei inhaltlichen Änderungen werden Runtime-Dateien neu geschrieben. - Im Loop-Modus löst der Registrator danach gezielt `sv restart` für `fastd` und/oder `bmxd` aus. - Backbone-Routing zwischen mehreren Fastd-/WireGuard-Links erfolgt nicht per Bridge, sondern über `bmxd`-gesteuerte Routen in der Policy-Routing-Tabelle. +Semantik von `/run/freifunk/state/mesh-status.json`: + +- `mesh.connected`: mindestens ein geprüftes Ziel aus `bmxd -c --links` ist erreichbar +- `mesh.stable`: `mesh.connected` liegt seit mindestens 30 Sekunden ohne Unterbrechung an +- `gateway.selected`: aktuell selektiertes Gateway aus `bmxd -c --gateways`, leer wenn keines selektiert ist +- `gateway.connected`: das selektierte Gateway ist erreichbar + Damit ergibt sich folgende Semantik: - Erststart: indirekt über die vom Registrator erzeugten Dateien diff --git a/scripts/dev-sync-container.sh b/scripts/dev-sync-container.sh index 89ab7c1..c3063e6 100755 --- a/scripts/dev-sync-container.sh +++ b/scripts/dev-sync-container.sh @@ -41,6 +41,7 @@ if [ "$mode" = "full" ]; then sync_file config/defaults.yaml /usr/local/share/freifunk/defaults.yaml sync_file scripts/node_config.py /usr/local/bin/node_config.py 755 sync_file scripts/backbone_runtime.py /usr/local/bin/backbone_runtime.py 755 + sync_file scripts/mesh-status.py /usr/local/bin/mesh-status.py 755 sync_file scripts/registrar.py /usr/local/bin/registrar.py 755 sync_file scripts/sysinfo.py /usr/local/bin/sysinfo.py 755 sync_file scripts/wireguard_status.py /usr/local/bin/wireguard_status.py 755 @@ -52,6 +53,7 @@ if [ "$mode" = "full" ]; then sync_file scripts/runit/wireguard/run /etc/service/wireguard/run 755 sync_file scripts/runit/fastd/run /etc/service/fastd/run 755 sync_file scripts/runit/bmxd/run /etc/service/bmxd/run 755 + sync_file scripts/runit/mesh-status/run /etc/service/mesh-status/run 755 sync_file scripts/runit/nginx/run /etc/service/nginx/run 755 fi @@ -64,9 +66,9 @@ fi docker-compose exec -T dockernode sh -lc 'mkdir -p /run/freifunk/www/licenses && rm -f /run/freifunk/www/licenses/license.txt && ln -snf /usr/local/share/freifunk/agreement-de.txt /run/freifunk/www/licenses/agreement-de.txt && ln -snf /usr/local/share/freifunk/pico-de.txt /run/freifunk/www/licenses/pico-de.txt && ln -snf /usr/local/share/freifunk/gpl2.txt /run/freifunk/www/licenses/gpl2.txt && ln -snf /usr/local/share/freifunk/gpl3.txt /run/freifunk/www/licenses/gpl3.txt' if [ "$mode" = "full" ]; then - docker-compose exec -T dockernode sh -lc 'nginx -t && sv restart registrar && sv restart sysinfo && sv restart wireguard && sv restart fastd && sv restart bmxd && sv restart nginx && sleep 1 && sv status registrar && sv status sysinfo && sv status wireguard && sv status fastd && sv status bmxd && sv status nginx' + docker-compose exec -T dockernode sh -lc 'nginx -t && sv restart registrar && sv restart sysinfo && sv restart wireguard && sv restart fastd && sv restart bmxd && sv restart mesh-status && sv restart nginx && sleep 1 && sv status registrar && sv status sysinfo && sv status wireguard && sv status fastd && sv status bmxd && sv status mesh-status && sv status nginx' else - docker-compose exec -T dockernode sh -lc 'nginx -t >/dev/null && sv status nginx' + docker-compose exec -T dockernode sh -lc 'nginx -t >/dev/null && sv restart nginx && sleep 1 && sv status nginx' fi echo "sync complete ($mode)" diff --git a/scripts/docker-entrypoint.sh b/scripts/docker-entrypoint.sh index c1e7667..618619b 100644 --- a/scripts/docker-entrypoint.sh +++ b/scripts/docker-entrypoint.sh @@ -30,6 +30,15 @@ if [ "${SKIP_FAILFAST:-0}" != "1" ]; then python3 /usr/local/bin/sysinfo.py --checkconfig fi +# Dynamische App-Hooks +if [ -d /etc/docker-entrypoint.d ]; then + for hook in /etc/docker-entrypoint.d/*; do + if [ -x "$hook" ]; then + "$hook" || exit 1 + fi + done +fi + if [ "${REGISTRAR_ONLY:-0}" = "1" ]; then exec python3 /usr/local/bin/registrar.py fi diff --git a/scripts/mesh-status.py b/scripts/mesh-status.py new file mode 100644 index 0000000..e9279b9 --- /dev/null +++ b/scripts/mesh-status.py @@ -0,0 +1,244 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import json +import os +import re +import subprocess +import tempfile +import time +from datetime import datetime +from pathlib import Path + + +STARTUP_INTERVAL = int(os.environ.get("MESH_STATUS_STARTUP_INTERVAL", "1")) +STEADY_INTERVAL = int(os.environ.get("MESH_STATUS_INTERVAL", "5")) +PING_TIMEOUT = int(os.environ.get("MESH_STATUS_PING_TIMEOUT", "1")) +MAX_LINK_TARGETS = int(os.environ.get("MESH_STATUS_MAX_LINK_TARGETS", "3")) +STABLE_AFTER = int(os.environ.get("MESH_STATUS_STABLE_AFTER", "30")) +OUTPUT_PATH = Path(os.environ.get("MESH_STATUS_OUTPUT", "/run/freifunk/state/mesh-status.json")) +WEBROOT = Path(os.environ.get("MESH_STATUS_WEBROOT", "/run/freifunk/www")) +WEB_LINK = os.environ.get("MESH_STATUS_WEB_LINK", "mesh-status.json") + +LINK_PATTERN = re.compile( + r"^(?P\d+\.\d+\.\d+\.\d+)\s+(?P\S+)\s+(?P\d+\.\d+\.\d+\.\d+)\s+(?P\d+)\s+(?P\d+)\s+(?P\d+)" +) +GATEWAY_PATTERN = re.compile( + r"^(?P=?>)?\s*(?P\d+\.\d+\.\d+\.\d+)\s+" + r"(?P\d+\.\d+\.\d+\.\d+)\s+" + r"(?P\d+),\s*(?P[01]),\s*(?P\S+)" +) + + +def log(message: str) -> None: + timestamp = datetime.now().astimezone().strftime("%Y-%m-%d %H:%M:%S %z") + print(f"{timestamp} [mesh-status] {message}", flush=True) + + +def read_links() -> list[dict[str, str]]: + result = subprocess.run(["bmxd", "-c", "--links"], capture_output=True, text=True, check=False) + if result.returncode != 0: + return [] + + links: list[dict[str, str]] = [] + seen: set[tuple[str, str]] = set() + for raw_line in result.stdout.splitlines(): + line = raw_line.strip() + if not line: + continue + match = LINK_PATTERN.match(line) + if not match: + continue + originator = match.group("originator") + interface = match.group("iface") + key = (originator, interface) + if key in seen: + continue + seen.add(key) + links.append( + { + "originator": originator, + "neighbor": match.group("neighbor"), + "interface": interface, + } + ) + return links + + +def select_link_targets(links: list[dict[str, str]], max_targets: int) -> list[dict[str, str]]: + if max_targets <= 0: + return [] + return links[:max_targets] + + +def read_selected_gateway() -> str: + result = subprocess.run(["bmxd", "-c", "--gateways"], capture_output=True, text=True, check=False) + if result.returncode != 0: + return "" + + for raw_line in result.stdout.splitlines(): + line = raw_line.strip() + if not line: + continue + match = GATEWAY_PATTERN.match(line) + if match and match.group("selected"): + return match.group("originator") + return "" + + +def ping(ip_address: str, timeout: int) -> bool: + result = subprocess.run( + ["ping", "-c", "1", "-W", str(timeout), ip_address], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + ) + return result.returncode == 0 + + +def build_status_payload( + *, + selected_gateway: str, + gateway_connected: bool, + link_targets: list[dict[str, str]], + reachable_targets: set[str], + connected_duration: int, +) -> dict[str, object]: + checked_links = len(link_targets) + reachable_count = len(reachable_targets) + mesh_connected = reachable_count > 0 + mesh_stable = mesh_connected and connected_duration >= STABLE_AFTER + + return { + "updated_at": datetime.now().astimezone().isoformat(), + "mesh": { + "connected": mesh_connected, + "stable": mesh_stable, + "checked_links": checked_links, + "reachable_links": reachable_count, + "connected_duration": connected_duration, + "stable_after": STABLE_AFTER, + }, + "gateway": { + "selected": selected_gateway, + "connected": gateway_connected, + }, + } + + +def write_json_atomic(path: Path, payload: dict[str, object]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with tempfile.NamedTemporaryFile("w", dir=path.parent, delete=False, encoding="utf-8") as handle: + json.dump(payload, handle, indent=2, sort_keys=True) + handle.write("\n") + temp_name = handle.name + os.chmod(temp_name, 0o644) + os.replace(temp_name, path) + + +def publish_web_link(output_path: Path, webroot: Path, link_name: str) -> None: + webroot.mkdir(parents=True, exist_ok=True) + link_path = webroot / link_name + if link_path.is_symlink() or link_path.exists(): + link_path.unlink() + link_path.symlink_to(output_path) + + +def state_signature(payload: dict[str, object]) -> tuple[object, ...]: + return mesh_state(payload), gateway_state(payload) + + +def mesh_state(payload: dict[str, object]) -> str: + mesh = payload["mesh"] + if mesh["stable"]: + return "stable" + if mesh["connected"]: + return "connected" + return "disconnected" + + +def gateway_state(payload: dict[str, object]) -> tuple[str, str]: + gateway = payload["gateway"] + selected = str(gateway["selected"] or "") + status = "connected" if gateway["connected"] else "disconnected" + return selected, status + + +def describe_state(payload: dict[str, object]) -> str: + gateway_ip, gateway_status = gateway_state(payload) + if gateway_ip: + gateway_text = f"Gateway {gateway_ip} {gateway_status}" + else: + gateway_text = f"Gateway {gateway_status}" + return f"Mesh {mesh_state(payload)}, {gateway_text}" + + +def run_loop( + startup_interval: int, + steady_interval: int, + timeout: int, + max_link_targets: int, + stable_after: int, +) -> None: + log( + f"starting mesh status loop with startup interval {startup_interval}s, steady interval {steady_interval}s, stable after {stable_after}s and {max_link_targets} link target(s)" + ) + previous_signature: tuple[object, ...] | None = None + startup_mode = True + mesh_connected_since: float | None = None + + while True: + loop_started = time.monotonic() + links = read_links() + link_targets = select_link_targets(links, max_link_targets) + selected_gateway = read_selected_gateway() + + reachable_targets: set[str] = set() + for entry in link_targets: + if ping(entry["originator"], timeout): + reachable_targets.add(entry["originator"]) + + gateway_connected = False + if selected_gateway: + if selected_gateway in reachable_targets: + gateway_connected = True + else: + gateway_connected = ping(selected_gateway, timeout) + + mesh_connected = bool(reachable_targets) + if mesh_connected: + if mesh_connected_since is None: + mesh_connected_since = loop_started + connected_duration = int(loop_started - mesh_connected_since) + else: + mesh_connected_since = None + connected_duration = 0 + + payload = build_status_payload( + selected_gateway=selected_gateway, + gateway_connected=gateway_connected, + link_targets=link_targets, + reachable_targets=reachable_targets, + connected_duration=connected_duration, + ) + write_json_atomic(OUTPUT_PATH, payload) + publish_web_link(OUTPUT_PATH, WEBROOT, WEB_LINK) + + signature = state_signature(payload) + if signature != previous_signature: + log(describe_state(payload)) + previous_signature = signature + + if startup_mode and (payload["mesh"]["connected"] or payload["gateway"]["connected"]): + startup_mode = False + + time.sleep(startup_interval if startup_mode else steady_interval) + + +def main() -> int: + run_loop(STARTUP_INTERVAL, STEADY_INTERVAL, PING_TIMEOUT, MAX_LINK_TARGETS, STABLE_AFTER) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) \ No newline at end of file diff --git a/scripts/runit/mesh-status/run b/scripts/runit/mesh-status/run new file mode 100644 index 0000000..14154ad --- /dev/null +++ b/scripts/runit/mesh-status/run @@ -0,0 +1,2 @@ +#!/bin/sh +exec python3 -u /usr/local/bin/mesh-status.py \ No newline at end of file diff --git a/tests/test_mesh_status.py b/tests/test_mesh_status.py new file mode 100644 index 0000000..96b921a --- /dev/null +++ b/tests/test_mesh_status.py @@ -0,0 +1,121 @@ +from __future__ import annotations + +import importlib.util +import unittest +from pathlib import Path +from unittest.mock import patch + + +SCRIPTS_DIR = Path(__file__).resolve().parents[1] / "scripts" +MODULE_PATH = SCRIPTS_DIR / "mesh-status.py" +SPEC = importlib.util.spec_from_file_location("mesh_status", MODULE_PATH) +assert SPEC is not None +assert SPEC.loader is not None +mesh_status = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(mesh_status) + + +class MeshStatusTests(unittest.TestCase): + def test_read_links_parses_unique_originators_per_interface(self) -> None: + raw_output = "\n".join( + [ + "10.1.1.2 tbb_fastd 10.200.200.16 255 255 255", + "10.1.1.2 tbb_fastd 10.200.200.16 255 255 255", + "10.1.1.3 vlan42 10.200.200.17 200 190 180", + ] + ) + + with patch.object( + mesh_status.subprocess, + "run", + return_value=type("Result", (), {"returncode": 0, "stdout": raw_output})(), + ): + links = mesh_status.read_links() + + self.assertEqual( + links, + [ + {"originator": "10.200.200.16", "neighbor": "10.1.1.2", "interface": "tbb_fastd"}, + {"originator": "10.200.200.17", "neighbor": "10.1.1.3", "interface": "vlan42"}, + ], + ) + + def test_read_selected_gateway_returns_selected_originator(self) -> None: + raw_output = "\n".join( + [ + " 10.200.200.11 10.201.200.11 128, 1, 96MBit", + "> 10.200.200.16 10.201.200.16 255, 1, 100MBit", + ] + ) + + with patch.object( + mesh_status.subprocess, + "run", + return_value=type("Result", (), {"returncode": 0, "stdout": raw_output})(), + ): + selected_gateway = mesh_status.read_selected_gateway() + + self.assertEqual(selected_gateway, "10.200.200.16") + + def test_build_status_payload_marks_mesh_stable_when_three_targets_are_reachable(self) -> None: + payload = mesh_status.build_status_payload( + selected_gateway="10.200.200.16", + gateway_connected=True, + link_targets=[ + {"originator": "10.200.200.11", "neighbor": "10.201.200.11", "interface": "tbb_fastd"}, + {"originator": "10.200.200.16", "neighbor": "10.201.200.16", "interface": "tbb_fastd"}, + {"originator": "10.200.200.21", "neighbor": "10.201.200.21", "interface": "tbb_fastd"}, + ], + reachable_targets={"10.200.200.11", "10.200.200.16", "10.200.200.21"}, + connected_duration=30, + ) + + self.assertTrue(payload["mesh"]["connected"]) + self.assertTrue(payload["mesh"]["stable"]) + self.assertTrue(payload["gateway"]["connected"]) + + def test_build_status_payload_keeps_mesh_unstable_before_stable_after(self) -> None: + payload = mesh_status.build_status_payload( + selected_gateway="", + gateway_connected=False, + link_targets=[ + {"originator": "10.200.200.11", "neighbor": "10.201.200.11", "interface": "tbb_fastd"}, + {"originator": "10.200.200.16", "neighbor": "10.201.200.16", "interface": "tbb_fastd"}, + {"originator": "10.200.200.21", "neighbor": "10.201.200.21", "interface": "tbb_fastd"}, + ], + reachable_targets={"10.200.200.11", "10.200.200.16", "10.200.200.21"}, + connected_duration=29, + ) + + self.assertTrue(payload["mesh"]["connected"]) + self.assertFalse(payload["mesh"]["stable"]) + self.assertFalse(payload["gateway"]["connected"]) + + def test_build_status_payload_allows_stable_without_gateway(self) -> None: + payload = mesh_status.build_status_payload( + selected_gateway="", + gateway_connected=False, + link_targets=[ + {"originator": "10.200.200.11", "neighbor": "10.201.200.11", "interface": "tbb_fastd"}, + ], + reachable_targets={"10.200.200.11"}, + connected_duration=30, + ) + + self.assertTrue(payload["mesh"]["connected"]) + self.assertTrue(payload["mesh"]["stable"]) + self.assertFalse(payload["gateway"]["connected"]) + + def test_select_link_targets_limits_result_count(self) -> None: + links = [ + {"originator": "10.200.200.11", "neighbor": "10.201.200.11", "interface": "a"}, + {"originator": "10.200.200.16", "neighbor": "10.201.200.16", "interface": "b"}, + {"originator": "10.200.200.21", "neighbor": "10.201.200.21", "interface": "c"}, + {"originator": "10.200.200.68", "neighbor": "10.201.200.68", "interface": "d"}, + ] + + self.assertEqual(mesh_status.select_link_targets(links, 3), links[:3]) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 963ee4e..cf79584 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -1,7 +1,36 @@ +import type { ComponentChild } from 'preact'; import { useEffect, useMemo, useRef, useState } from 'preact/hooks'; -import type { BackbonePayload, BackbonePeer, NodesPayload, NodeRow, SysinfoPayload } from './types'; - -type ViewKey = 'status' | 'nodes' | 'licenses'; +import type { + BackbonePayload, + BackbonePeer, + MeshStatusPayload, + NodesPayload, + NodeRow, + SysinfoPayload, + UiExtensionDefinition, + UiExtensionRuntimeData, + UiExtensionsIndexPayload, +} from './types'; + +type ViewKey = string; +type NavItem = { key: string; label: string; order: number }; +type LegalTextState = Record; +type ExtensionRuntimeState = Record; +type ExtensionModule = { + render: (container: HTMLElement, context: ExtensionRenderContext) => void | Promise; + dispose?: (container: HTMLElement) => void | Promise; +}; +type ExtensionRenderContext = { + data: Record; + error: string; + updatedAt: number | null; + nowSeconds: number; + refreshNow: () => void; + fetchJson: typeof loadJson; + fetchText: typeof loadText; + safe: typeof safe; + formatAge: (timestamp: string | undefined) => string; +}; const LICENSE_ITEMS = [ { label: 'Nutzungsbedingungen', href: '/licenses/agreement-de.txt' }, @@ -10,13 +39,13 @@ const LICENSE_ITEMS = [ { label: 'GPLv3', href: '/licenses/gpl3.txt' }, ]; -const NAV_ITEMS: Array<{ key: ViewKey; label: string }> = [ - { key: 'status', label: 'Status & Kontakt' }, - { key: 'nodes', label: 'Nodes' }, - { key: 'licenses', label: 'Rechtliches' }, +const CORE_NAV_ITEMS: NavItem[] = [ + { key: 'status', label: 'Status & Kontakt', order: 0 }, + { key: 'nodes', label: 'Nodes', order: 10 }, + { key: 'licenses', label: 'Rechtliches', order: 1000 }, ]; - -type LegalTextState = Record; +const EXTENSIONS_INDEX_URL = '/ui/extensions/index.json'; +const CORE_VIEW_KEYS = new Set(CORE_NAV_ITEMS.map((item) => item.key)); function safe(value: unknown, fallback = '—'): string { if (value === null || value === undefined || value === '') { @@ -27,10 +56,7 @@ function safe(value: unknown, fallback = '—'): string { function currentViewFromHash(): ViewKey { const hash = window.location.hash.replace('#', ''); - if (hash === 'nodes' || hash === 'licenses' || hash === 'status') { - return hash; - } - return 'status'; + return hash || 'status'; } function matchesFilter(row: NodeRow, query: string): boolean { @@ -81,6 +107,17 @@ async function loadJson(url: string): Promise { return response.json() as Promise; } +async function loadOptionalJson(url: string): Promise { + const response = await fetch(url, { cache: 'no-store' }); + if (response.status === 404) { + return null; + } + if (!response.ok) { + throw new Error(`HTTP ${response.status} für ${url}`); + } + return response.json() as Promise; +} + async function loadText(url: string): Promise { const response = await fetch(url, { cache: 'no-store' }); if (!response.ok) { @@ -95,6 +132,86 @@ function decodeHtmlEntities(text: string): string { return textarea.value; } +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function normalizeExtensions(payload: UiExtensionsIndexPayload | null): UiExtensionDefinition[] { + const seenIds = new Set(); + const seenHashes = new Set(); + const rawEntries = Array.isArray(payload?.extensions) ? payload?.extensions : []; + const extensions: UiExtensionDefinition[] = []; + + for (const rawEntry of rawEntries) { + if (!rawEntry || typeof rawEntry !== 'object') { + continue; + } + + const id = typeof rawEntry.id === 'string' ? rawEntry.id.trim() : ''; + const label = typeof rawEntry.label === 'string' ? rawEntry.label.trim() : ''; + const hash = typeof rawEntry.hash === 'string' ? rawEntry.hash.trim() : ''; + const entry = typeof rawEntry.entry === 'string' ? rawEntry.entry.trim() : ''; + const order = Number(rawEntry.order); + const style = typeof rawEntry.style === 'string' && rawEntry.style.trim() ? rawEntry.style.trim() : undefined; + const endpoints = Array.isArray(rawEntry.endpoints) + ? rawEntry.endpoints.filter((value): value is string => typeof value === 'string' && value.trim().length > 0) + : []; + + if (!id || !label || !hash || !entry || !Number.isFinite(order)) { + continue; + } + + if (CORE_VIEW_KEYS.has(hash) || seenIds.has(id) || seenHashes.has(hash)) { + continue; + } + + seenIds.add(id); + seenHashes.add(hash); + extensions.push({ id, label, hash, entry, order, style, endpoints }); + } + + return extensions.sort((left, right) => { + if (left.order !== right.order) { + return left.order - right.order; + } + return left.label.localeCompare(right.label, 'de'); + }); +} + +async function loadExtensionData(extensions: UiExtensionDefinition[]): Promise { + const result: ExtensionRuntimeState = {}; + + await Promise.all( + extensions.map(async (extension) => { + if (extension.endpoints.length === 0) { + result[extension.id] = { data: {}, error: '', updatedAt: null }; + return; + } + + const settled = await Promise.allSettled(extension.endpoints.map((url) => loadJson(url))); + const data: Record = {}; + const errors: string[] = []; + + extension.endpoints.forEach((url, index) => { + const entry = settled[index]; + if (entry.status === 'fulfilled') { + data[url] = entry.value; + return; + } + errors.push(`${url}: ${errorMessage(entry.reason)}`); + }); + + result[extension.id] = { + data, + error: errors.join(' | '), + updatedAt: Object.keys(data).length > 0 ? Math.floor(Date.now() / 1000) : null, + }; + }), + ); + + return result; +} + function qualityClass(value: unknown): string { const numeric = Number(String(value ?? '').trim()); if (!Number.isFinite(numeric)) { @@ -154,6 +271,40 @@ function KeyValueTable({ rows }: { rows: Array<[string, unknown]> }) { ); } +function boolLabel(value: boolean | undefined, whenTrue: string, whenFalse: string): string { + return value ? whenTrue : whenFalse; +} + +function StatusPanel({ meshStatus }: { meshStatus: MeshStatusPayload | null }) { + const mesh = meshStatus?.mesh || {}; + const gateway = meshStatus?.gateway || {}; + const meshText = mesh.stable ? 'Connected' : mesh.connected ? 'Stabilizing' : 'Disconnected'; + const meshClass = mesh.stable ? 'connected' : mesh.connected ? 'stale' : 'disconnected'; + const gatewayText = boolLabel(gateway.connected, 'Connected', 'Disconnected'); + const gatewayClass = gateway.connected ? 'connected' : 'disconnected'; + + return ( +
+

Status

+
+
+ Mesh + {meshText} +
+
+
+ Gateway + {gateway.selected ? {gateway.selected} : null} +
+
+ {gatewayText} +
+
+
+
+ ); +} + function BackboneTable({ peers }: { peers: BackbonePeer[] }) { return (
@@ -210,7 +361,7 @@ function NodeTable({ filter: string; emptyText: string; rowClassName?: (row: NodeRow) => string; - renderCell?: (row: NodeRow, column: keyof NodeRow) => string | JSX.Element; + renderCell?: (row: NodeRow, column: keyof NodeRow) => ComponentChild; }) { const filtered = useMemo( () => rows.filter((row) => matchesFilter(row, filter.trim().toLowerCase())), @@ -255,12 +406,144 @@ function NodeTable({ ); } +function ExtensionView({ + extension, + runtimeData, + nowSeconds, +}: { + extension: UiExtensionDefinition; + runtimeData: UiExtensionRuntimeData; + nowSeconds: number; +}) { + const hostRef = useRef(null); + const moduleRef = useRef(null); + const disposeRef = useRef<(() => void | Promise) | null>(null); + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(''); + + const context = useMemo(() => ({ + data: runtimeData.data, + error: runtimeData.error, + updatedAt: runtimeData.updatedAt, + nowSeconds, + refreshNow: () => undefined, + fetchJson: loadJson, + fetchText: loadText, + safe, + formatAge: (timestamp: string | undefined) => formatAge(timestamp, nowSeconds), + }), [nowSeconds, runtimeData.data, runtimeData.error, runtimeData.updatedAt]); + + useEffect(() => { + let canceled = false; + + async function loadModule() { + setLoading(true); + setLoadError(''); + moduleRef.current = null; + + if (disposeRef.current && hostRef.current) { + await Promise.resolve(disposeRef.current()); + disposeRef.current = null; + } + + if (hostRef.current) { + hostRef.current.textContent = ''; + } + + try { + if (extension.style && !document.querySelector(`link[data-extension-style="${extension.id}"]`)) { + const link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = extension.style; + link.dataset.extensionStyle = extension.id; + document.head.appendChild(link); + } + + const loaded = await import(/* @vite-ignore */ extension.entry) as ExtensionModule; + if (canceled) { + return; + } + if (typeof loaded.render !== 'function') { + throw new Error(`Extension ${extension.id} exportiert keine render()-Funktion`); + } + moduleRef.current = loaded; + disposeRef.current = loaded.dispose ? () => loaded.dispose!(hostRef.current!) : null; + if (hostRef.current) { + await Promise.resolve(loaded.render(hostRef.current, context)); + } + setLoading(false); + } catch (error) { + if (canceled) { + return; + } + setLoadError(errorMessage(error)); + setLoading(false); + } + } + + void loadModule(); + + return () => { + canceled = true; + const currentDispose = disposeRef.current; + const currentHost = hostRef.current; + disposeRef.current = null; + moduleRef.current = null; + if (currentDispose && currentHost) { + void Promise.resolve(currentDispose()); + } + if (currentHost) { + currentHost.textContent = ''; + } + }; + }, [extension.entry, extension.id, extension.style]); + + useEffect(() => { + let canceled = false; + + async function renderModule() { + if (!moduleRef.current || !hostRef.current) { + return; + } + try { + await Promise.resolve(moduleRef.current.render(hostRef.current, context)); + if (!canceled) { + setLoadError(''); + } + } catch (error) { + if (!canceled) { + setLoadError(errorMessage(error)); + } + } + } + + void renderModule(); + + return () => { + canceled = true; + }; + }, [context, extension.id]); + + return ( +
+

{extension.label}

+ {loading ?

Erweiterung wird geladen …

: null} + {loadError ?
Fehler beim Laden der Erweiterung: {loadError}
: null} + {!loadError ?
: null} +
+ ); +} + export function App() { const [view, setView] = useState(currentViewFromHash()); const [sysinfo, setSysinfo] = useState(null); const [nodes, setNodes] = useState(null); const [backbone, setBackbone] = useState(null); + const [meshStatus, setMeshStatus] = useState(null); const [error, setError] = useState(''); + const [extensions, setExtensions] = useState([]); + const [extensionsLoaded, setExtensionsLoaded] = useState(false); + const [extensionData, setExtensionData] = useState({}); const [nodeFilter, setNodeFilter] = useState(''); const [legalTexts, setLegalTexts] = useState({}); const [nowSeconds, setNowSeconds] = useState(Math.floor(Date.now() / 1000)); @@ -268,6 +551,51 @@ export function App() { const [retryAfter, setRetryAfter] = useState(0); const refreshInFlightRef = useRef(false); + const extensionSignature = useMemo( + () => JSON.stringify(extensions.map((extension) => [extension.id, extension.hash, extension.entry, extension.style || '', extension.endpoints.join('|')])), + [extensions], + ); + + async function refreshData(extensionsSnapshot: UiExtensionDefinition[], isCanceled?: () => boolean): Promise { + if (refreshInFlightRef.current) { + return; + } + + refreshInFlightRef.current = true; + const attemptAt = Math.floor(Date.now() / 1000); + setLastRefreshAttempt(attemptAt); + + try { + const [sysinfoData, nodesData, backboneData, meshStatusData, extensionRuntime] = await Promise.all([ + loadJson('/sysinfo.json'), + loadJson('/nodes.json'), + loadJson('/backbone.json'), + loadJson('/mesh-status.json'), + loadExtensionData(extensionsSnapshot), + ]); + + if (isCanceled?.()) { + return; + } + + setSysinfo(sysinfoData); + setNodes(nodesData); + setBackbone(backboneData); + setMeshStatus(meshStatusData); + setExtensionData(extensionRuntime); + setError(''); + setRetryAfter(0); + } catch (err) { + if (isCanceled?.()) { + return; + } + setError(errorMessage(err)); + setRetryAfter(attemptAt + 30); + } finally { + refreshInFlightRef.current = false; + } + } + useEffect(() => { const onHashChange = () => setView(currentViewFromHash()); window.addEventListener('hashchange', onHashChange); @@ -277,46 +605,40 @@ export function App() { useEffect(() => { let canceled = false; - async function refresh() { - if (refreshInFlightRef.current) { - return; - } - refreshInFlightRef.current = true; - - const attemptAt = Math.floor(Date.now() / 1000); - setLastRefreshAttempt(attemptAt); + async function loadRegistry() { try { - const [sysinfoData, nodesData, backboneData] = await Promise.all([ - loadJson('/sysinfo.json'), - loadJson('/nodes.json'), - loadJson('/backbone.json'), - ]); + const payload = await loadOptionalJson(EXTENSIONS_INDEX_URL); if (canceled) { return; } - setSysinfo(sysinfoData); - setNodes(nodesData); - setBackbone(backboneData); - setError(''); - setRetryAfter(0); + setExtensions(normalizeExtensions(payload)); } catch (err) { - if (canceled) { - return; + if (!canceled) { + console.error('UI extensions could not be loaded', err); + setExtensions([]); } - setError((err as Error).message); - setRetryAfter(attemptAt + 30); } finally { - refreshInFlightRef.current = false; + if (!canceled) { + setExtensionsLoaded(true); + } } } - refresh(); + void loadRegistry(); return () => { canceled = true; }; }, []); + useEffect(() => { + let canceled = false; + void refreshData(extensions, () => canceled); + return () => { + canceled = true; + }; + }, [extensionSignature]); + useEffect(() => { const intervalId = window.setInterval(() => { setNowSeconds(Math.floor(Date.now() / 1000)); @@ -332,8 +654,15 @@ export function App() { return; } - const timestampRaw = nodes?.timestamp || sysinfo?.timestamp || '0'; - const timestamp = Number(timestampRaw); + const runtimeTimestamps = [ + Number(nodes?.timestamp || 0), + Number(sysinfo?.timestamp || 0), + Number(backbone?.timestamp || 0), + meshStatus?.updated_at ? Math.floor(new Date(meshStatus.updated_at).getTime() / 1000) : 0, + ...Object.values(extensionData).map((entry) => entry.updatedAt || 0), + ].filter((value) => Number.isFinite(value) && value > 0); + + const timestamp = runtimeTimestamps.reduce((acc, value) => (value > acc ? value : acc), 0); const dataAge = timestamp > 0 ? nowSeconds - timestamp : Number.POSITIVE_INFINITY; const shouldRetryFailed = !!error && retryAfter > 0 && nowSeconds >= retryAfter; const shouldRefreshStale = !error && dataAge > 30; @@ -348,30 +677,27 @@ export function App() { const attemptAt = Math.floor(Date.now() / 1000); setLastRefreshAttempt(attemptAt); - refreshInFlightRef.current = true; + await refreshData(extensions); + } - try { - const [sysinfoData, nodesData, backboneData] = await Promise.all([ - loadJson('/sysinfo.json'), - loadJson('/nodes.json'), - loadJson('/backbone.json'), - ]); - setSysinfo(sysinfoData); - setNodes(nodesData); - setBackbone(backboneData); - setError(''); - setRetryAfter(0); - } catch (err) { - setError((err as Error).message); - setRetryAfter(attemptAt + 30); - } finally { - refreshInFlightRef.current = false; - } + void refreshIfNeeded(); + }, [backbone?.timestamp, error, extensionData, extensions, lastRefreshAttempt, meshStatus?.updated_at, nodes?.timestamp, nowSeconds, retryAfter, sysinfo?.timestamp]); + + useEffect(() => { + if (!extensionsLoaded) { + return; } - refreshIfNeeded(); - }, [error, lastRefreshAttempt, nowSeconds, nodes?.timestamp, retryAfter, sysinfo?.timestamp]); + const validViews = new Set([...CORE_NAV_ITEMS.map((item) => item.key), ...extensions.map((extension) => extension.hash)]); + if (validViews.has(view)) { + return; + } + setView('status'); + if (window.location.hash !== '#status') { + window.location.hash = '#status'; + } + }, [extensions, extensionsLoaded, view]); useEffect(() => { let canceled = false; @@ -409,16 +735,32 @@ export function App() { const bmxd = nodes?.bmxd || {}; const backbonePeers = backbone?.peers || []; const connectedBackbones = backbonePeers.filter((peer) => peer.status === 'connected').length; + const extensionNavItems = useMemo( + () => extensions.map((extension) => ({ key: extension.hash, label: extension.label, order: extension.order })), + [extensions], + ); + const navItems = useMemo( + () => [...CORE_NAV_ITEMS, ...extensionNavItems].sort((left, right) => left.order - right.order), + [extensionNavItems], + ); + const activeExtension = extensions.find((extension) => extension.hash === view) || null; const communityName = safe(common.community); const communityLink = common.domain ? `https://${common.domain}` : 'https://freifunk.net'; const titleName = safe(contact.name, 'Freifunk Knoten'); const titleNode = safe(common.node, '?'); const titleCommunity = safe(common.community, '?'); - const currentTimestamp = [sysinfo?.timestamp, nodes?.timestamp, backbone?.timestamp] - .map((value) => Number(value || 0)) - .reduce((latest, value) => (value > latest ? value : latest), 0) - .toString(); + const currentTimestamp = (() => { + const runtimeTimestamps = [sysinfo?.timestamp, nodes?.timestamp, backbone?.timestamp] + .map((value) => Number(value || 0)) + .filter((value) => Number.isFinite(value) && value > 0); + const meshTimestamp = meshStatus?.updated_at ? Math.floor(new Date(meshStatus.updated_at).getTime() / 1000) : 0; + const extensionTimestamps = Object.values(extensionData) + .map((entry) => entry.updatedAt || 0) + .filter((value) => value > 0); + const latest = [...runtimeTimestamps, meshTimestamp, ...extensionTimestamps].reduce((acc, value) => (value > acc ? value : acc), 0); + return String(latest); + })(); useEffect(() => { document.title = `${titleNode} ${titleName} - Freifunk ${titleCommunity}`; @@ -444,7 +786,7 @@ export function App() { diff --git a/ui/src/styles.css b/ui/src/styles.css index da5b793..0a80afb 100644 --- a/ui/src/styles.css +++ b/ui/src/styles.css @@ -174,13 +174,18 @@ a:hover { align-items: start; } +.panel-runtime-status { + grid-column: 2; + grid-row: 1; +} + .panel-status { grid-column: 1; - grid-row: 1 / span 2; + grid-row: 2; } .panel-contact { - grid-column: 2; + grid-column: 1; grid-row: 1; } @@ -189,6 +194,35 @@ a:hover { grid-row: 2; } +.status-summary { + display: grid; + gap: 0.55rem; + margin-bottom: 0.9rem; +} + +.status-summary-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.8rem; +} + +.status-summary-label { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.status-summary-value { + display: flex; + align-items: center; +} + +.status-summary-meta { + font-size: 0.92rem; + color: var(--muted); +} + .panel-backbones table td:last-child { width: 1%; } @@ -240,6 +274,10 @@ a:hover { margin-bottom: 0.5rem; } +.extension-host { + min-height: 12rem; +} + .table-wrap { overflow: auto; } diff --git a/ui/src/types.ts b/ui/src/types.ts index 26b25c7..3f8999f 100644 --- a/ui/src/types.ts +++ b/ui/src/types.ts @@ -71,3 +71,39 @@ export type BackbonePayload = { timestamp?: string; peers?: BackbonePeer[]; }; + +export type MeshStatusPayload = { + updated_at?: string; + mesh?: { + connected?: boolean; + stable?: boolean; + checked_links?: number; + reachable_links?: number; + connected_duration?: number; + stable_after?: number; + }; + gateway?: { + selected?: string; + connected?: boolean; + }; +}; + +export type UiExtensionDefinition = { + id: string; + label: string; + order: number; + hash: string; + entry: string; + endpoints: string[]; + style?: string; +}; + +export type UiExtensionsIndexPayload = { + extensions?: UiExtensionDefinition[]; +}; + +export type UiExtensionRuntimeData = { + data: Record; + error: string; + updatedAt: number | null; +};