From 460b85d708d16c38d2df0a63ce86fdca9a25dafe Mon Sep 17 00:00:00 2001 From: Eduard Wisch Date: Sun, 16 Aug 2026 22:56:20 +0200 Subject: [PATCH] Internet-Kette: Anschluss, Gateway, Namensaufloesung und HTTPS in einem Lauf Die haeufigste Aussage beim Kunden ist "Internet geht nicht". Bisher musste der Techniker Ping, Traceroute und Browser einzeln bedienen und selbst schlussfolgern, welches Glied hakt. Das Werkzeug misst alle vier Stufen in einem Lauf und benennt das Fehlerglied. Neue native Methoden: dnsResolve (System-Resolver; die Grenze - Server nicht waehlbar, Cache dazwischen - steht im Code und im Ergebnis) und httpsCheck. Bei httpsCheck ist instanceFollowRedirects=false keine Feinheit, sondern der Kern: ein Gaeste-WLAN beantwortet jede Anfrage mit einer Umleitung auf sein Portal. Wer ihr folgt, bekommt ein sauberes 200 und meldet "Internet in Ordnung", obwohl ohne Anmeldung nichts geht. linkInfo liefert zusaetzlich NET_CAPABILITY_VALIDATED und _CAPTIVE_PORTAL - Androids eigenes Urteil, das uns keine einzige Messung kostet. Drei Entscheidungen fuer die Ehrlichkeit der Aussage: 1. Die Kette bricht nicht ab. Sehr viele Router beantworten Ping grundsaetzlich nicht - ein Abbruch dort meldete "Gateway tot" bei voellig gesundem Internet. 2. Eine stille Stufe ist kein Fehler, wenn eine spaetere antwortet. Wer eine Antwort aus dem Internet bekommt, hat sein Gateway benutzt. Ergebnis: gruen mit Hinweis statt rot. 3. Anmeldeseite schlaegt Fehlerglied. Sonst stuende im Kundendokument "Kein Internet - HTTPS antwortet nicht", obwohl der Techniker nur auf "Anmelden" tippen muss. Beim Pruefen aufgefallen und korrigiert. Die Bewertung liegt in kette-bewertung.ts ohne jeden Laufzeit-Import, damit tools/pruefe-kette.mjs sie laden kann: elf konstruierte Faelle gegen von Hand ermittelte Sollwerte, alle korrekt. Der Browser-Mock liefert fuer DNS und HTTPS bewusst nur den Gutfall und beweist nichts - das steht dort auch so. Dabei repariert: tools/pruefe-monitor.mjs importierte eine .mjs-Kopie aus einem /tmp-Sitzungsverzeichnis und lief nach dessen Wegfall nicht mehr, waehrend in der ROADMAP "alle Faelle korrekt" stand. Die Rechnung liegt jetzt in monitor-auswertung.ts, beide Pruefer laden die TS-Datei direkt. Neu in der Debug-Seite: "Internet-Kette pruefen (misst nur, speichert nichts)". Beantwortet vor Ort die erste aller Fragen - kommt das Messgeraet selbst ins Netz? - und ist der einzige Weg, die native Schicht im Emulator zu pruefen, solange die Testinstanz nur HTTP spricht (der WebView blockiert aus der Origin https://localhost jede HTTP-Anfrage, siehe ROADMAP). Im Emulator geprueft: Gutfall (alle vier Stufen mit Messwerten, validated=ja) und Flugmodus (Status "nicht messbar", Fehlerglied Anschluss, ausdruecklich KEINE Aussage ueber das Kundennetz). Co-Authored-By: Claude Opus 5 (1M context) --- .../netdiag/NetDiagScannerPlugin.kt | 115 ++++++++++ src/lib/messfelder.ts | 8 + src/lib/monitor-auswertung.ts | 199 ++++++++++++++++++ src/lib/monitor.ts | 195 +---------------- src/lib/scanner.ts | 52 +++++ src/lib/tools/index.ts | 5 +- src/lib/tools/internet/internetkette.ts | 156 ++++++++++++++ src/lib/tools/internet/kette-bewertung.ts | 162 ++++++++++++++ src/lib/types.ts | 9 + src/routes/debug/+page.svelte | 74 +++++++ tools/pruefe-kette.mjs | 119 +++++++++++ tools/pruefe-monitor.mjs | 10 +- 12 files changed, 909 insertions(+), 195 deletions(-) create mode 100644 src/lib/monitor-auswertung.ts create mode 100644 src/lib/tools/internet/internetkette.ts create mode 100644 src/lib/tools/internet/kette-bewertung.ts create mode 100644 tools/pruefe-kette.mjs diff --git a/android/app/src/main/java/de/data_it_solution/netdiag/NetDiagScannerPlugin.kt b/android/app/src/main/java/de/data_it_solution/netdiag/NetDiagScannerPlugin.kt index 11c5f6c..45eeb90 100644 --- a/android/app/src/main/java/de/data_it_solution/netdiag/NetDiagScannerPlugin.kt +++ b/android/app/src/main/java/de/data_it_solution/netdiag/NetDiagScannerPlugin.kt @@ -1923,6 +1923,16 @@ class NetDiagScannerPlugin : Plugin() { .put("iface", iface) .put("type", type) .put("isDefault", iface == defaultIface) + // Androids eigenes Urteil über die Verbindung. Das System + // prüft nach jedem Verbindungsaufbau selbst gegen einen + // Erreichbarkeitsdienst — dieses Ergebnis kostet uns hier + // keine einzige Messung und ist die verlässlichste Auskunft + // über ein Gäste-WLAN mit Anmeldeseite, die ohne Root zu + // bekommen ist. + obj.put("validated", caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)) + if (caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_CAPTIVE_PORTAL)) { + obj.put("captivePortal", true) + } if (ipv4 != null) obj.put("ipv4", ipv4) if (prefix > 0) obj.put("prefixLength", prefix) if (gateway != null) obj.put("gateway", gateway) @@ -1989,6 +1999,111 @@ class NetDiagScannerPlugin : Plugin() { } } + /* --------------------------------------------------------------------- */ + /* Internet-Kette — Namensauflösung und HTTPS-Abruf */ + /* --------------------------------------------------------------------- */ + + /** + * Namen auflösen über den System-Resolver. + * + * GRENZE, die im Ergebnis stehen muss: welcher DNS-Server befragt wird, ist + * hier NICHT wählbar, und zwischen Aufruf und Server liegt der Cache des + * Systems. Eine zweite Abfrage desselben Namens misst deshalb den Cache, + * nicht das Netz. Für „antwortet dieser bestimmte Server" braucht es einen + * eigenen UDP-Client auf Port 53 (eigener Roadmap-Punkt) — für die Frage + * „funktioniert Namensauflösung überhaupt" reicht dieser Weg. + */ + @PluginMethod + fun dnsResolve(call: PluginCall) { + val host = call.getString("host") ?: return call.reject("host fehlt") + val timeoutMs = (call.getInt("timeoutMs") ?: 5000).coerceIn(500, 30_000) + io.launch { + val t0 = System.nanoTime() + try { + val addrs = withTimeout(timeoutMs.toLong()) { + withContext(Dispatchers.IO) { InetAddress.getAllByName(host) } + } + val ms = (System.nanoTime() - t0) / 1e6 + val arr = JSArray() + for (a in addrs) a.hostAddress?.let { arr.put(it) } + resolve(call, JSObject() + .put("ok", arr.length() > 0) + .put("ips", arr) + .put("latenzMs", round1(ms))) + } catch (e: TimeoutCancellationException) { + resolve(call, JSObject() + .put("ok", false) + .put("ips", JSArray()) + .put("fehler", "Keine Antwort innerhalb von $timeoutMs ms")) + } catch (e: Exception) { + // UnknownHostException ist der Normalfall bei kaputtem DNS und + // keine Ausnahmesituation — deshalb aufgelöst statt reject(). + resolve(call, JSObject() + .put("ok", false) + .put("ips", JSArray()) + .put("fehler", e.message ?: e.javaClass.simpleName)) + } + } + } + + /** + * Eine URL abrufen und Statuszeile plus Laufzeit melden. + * + * `instanceFollowRedirects = false` ist hier keine Feinheit, sondern der + * Kern der Aussage: ein Gäste-WLAN mit Anmeldeseite beantwortet JEDE + * Anfrage mit einer Umleitung auf sein Portal. Folgt man ihr, kommt am Ende + * ein sauberes 200 zurück und die Messung meldet „Internet in Ordnung", + * obwohl ohne Anmeldung nichts geht. Ohne Weiterleitung steht die 302 mit + * ihrem Ziel im Protokoll und ist als Portal erkennbar. + */ + @PluginMethod + fun httpsCheck(call: PluginCall) { + val url = call.getString("url") ?: return call.reject("url fehlt") + val timeoutMs = (call.getInt("timeoutMs") ?: 8000).coerceIn(500, 60_000) + io.launch { + var conn: HttpURLConnection? = null + val t0 = System.nanoTime() + try { + val res = withTimeout(timeoutMs.toLong() + 2000L) { + withContext(Dispatchers.IO) { + val c = URL(url).openConnection() as HttpURLConnection + conn = c + c.instanceFollowRedirects = false + c.connectTimeout = timeoutMs + c.readTimeout = timeoutMs + c.requestMethod = "GET" + c.setRequestProperty("User-Agent", "NetDiag") + // Kein Zwischenspeicher: sonst misst der zweite Aufruf + // beim selben Kunden die eigene Festplatte. + c.useCaches = false + c.setRequestProperty("Cache-Control", "no-cache") + val code = c.responseCode + val ort = c.getHeaderField("Location") + Triple(code, ort, c.responseMessage ?: "") + } + } + val ms = (System.nanoTime() - t0) / 1e6 + val obj = JSObject() + .put("erreichbar", true) + .put("statusCode", res.first) + .put("statusText", res.third) + .put("latenzMs", round1(ms)) + if (!res.second.isNullOrEmpty()) obj.put("weiterleitungAuf", res.second) + resolve(call, obj) + } catch (e: TimeoutCancellationException) { + resolve(call, JSObject() + .put("erreichbar", false) + .put("fehler", "Keine Antwort innerhalb von $timeoutMs ms")) + } catch (e: Exception) { + resolve(call, JSObject() + .put("erreichbar", false) + .put("fehler", e.message ?: e.javaClass.simpleName)) + } finally { + try { conn?.disconnect() } catch (_: Exception) { } + } + } + } + /* --------------------------------------------------------------------- */ /* WLAN-Empfangstracker — RSSI live beim Durchgehen durchs Gebäude */ /* --------------------------------------------------------------------- */ diff --git a/src/lib/messfelder.ts b/src/lib/messfelder.ts index 0551fee..ebe11ca 100644 --- a/src/lib/messfelder.ts +++ b/src/lib/messfelder.ts @@ -54,6 +54,14 @@ const FELDER: Record = { geraete: { label: 'Je Gerät' }, ausfaelle: { label: 'Einzelne Ausfälle' }, anzahlGeraete: { label: 'Überwachte Geräte' }, + // Internet-Kette. `fehlerglied` ist die eigentliche Aussage des Werkzeugs — + // fehlt der Schlüssel hier oder in netdiagKundenfelder(), steht im + // Kundendokument nur noch die Ampel. + fehlerglied: { label: 'Fehlerglied' }, + stufen: { label: 'Geprüfte Stufen' }, + systemUrteil: { label: 'Urteil des Betriebssystems' }, + validated: { label: 'Internetverbindung bestätigt' }, + captivePortal: { label: 'Anmeldeseite erkannt' }, // Netz / DHCP / Dose server: { label: 'DHCP-Server' }, dhcpServer: { label: 'DHCP-Server' }, diff --git a/src/lib/monitor-auswertung.ts b/src/lib/monitor-auswertung.ts new file mode 100644 index 0000000..4370f0e --- /dev/null +++ b/src/lib/monitor-auswertung.ts @@ -0,0 +1,199 @@ +/** + * Rechnung des Geräte-Monitors — bewusst OHNE jeden Laufzeit-Import. + * + * Ausgelagert aus `monitor.ts`, damit `tools/pruefe-monitor.mjs` die Funktion + * direkt laden kann. Vorher importierte der Prüfer eine von Hand erzeugte + * .mjs-Kopie aus einem Sitzungsverzeichnis unter /tmp; nach dessen Wegfall + * lief er nicht mehr — im Repo stand trotzdem „alle Fälle korrekt". Eine + * Zusicherung, die man nicht nachrechnen kann, ist keine. + * + * Alles hier ist reine Rechnung ohne Netz- oder Datenbankzugriff. Was die + * Zahlen bedeuten und welche drei Fehler sie vorher systematisch zugunsten + * des Dokuments verfälscht haben, steht im Kopf von `monitor.ts`. + */ + +import type { DeviceMonitorSession, MeasureStatus, MonitorEvent } from './types'; + +function fmtTime(ts: number): string { + return new Date(ts).toLocaleTimeString('de-DE', { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); +} + +function fmtDuration(sec: number): string { + if (sec < 60) return Math.round(sec) + ' s'; + const m = Math.floor(sec / 60); + if (m < 60) { + const s = Math.round(sec % 60); + return s ? `${m} min ${s} s` : `${m} min`; + } + const h = Math.floor(m / 60); + const restM = m % 60; + return restM ? `${h} Std ${restM} min` : `${h} Std`; +} + +/** Prozent mit einer Nachkommastelle, deutsches Komma */ +function fmtProzent(p: number): string { + return p.toFixed(1).replace('.', ',') + ' %'; +} + +export interface GeraetAuswertung { + ip: string; + label: string; + ausfallzeitSek: number; + aussetzer: number; + laengsterAusfallSek: number; + verfuegbarkeitProzent: number; + /** einzelne Ausfälle als lesbare Zeilen */ + ausfaelle: string[]; +} + +/** + * Ausfallsegmente eines Geräts aus seinen Ereignissen bilden. + * + * Ein Ausfall beginnt mit 'down' und endet mit dem nächsten 'up'. Ist am Ende + * noch einer offen, wird er bis `bis` zugerechnet — er ist ja nicht dadurch + * verschwunden, dass die Messung aufhörte. + * + * `durationSec` aus dem up-Ereignis wird bewusst NICHT verwendet, sondern die + * Differenz der Zeitstempel: die native Dauer war in einem Fall nachweislich + * 0 (nämlich wenn der Ausfall schon vor dem Start bestand), und zwei Quellen + * für dieselbe Zahl gehen irgendwann auseinander. + */ +function segmenteFuer(events: MonitorEvent[], von: number, bis: number): number[][] { + const segmente: number[][] = []; + let offen: number | null = null; + for (const e of [...events].sort((a, b) => a.ts - b.ts)) { + if (e.type === 'down') { + if (offen === null) offen = e.ts; + } else if (offen !== null) { + segmente.push([offen, e.ts]); + offen = null; + } + } + if (offen !== null) segmente.push([offen, bis]); + // Auf den Messzeitraum begrenzen — ein Ereignis vor dem Start (Wiederauf- + // nahme einer älteren Sitzung) darf die Ausfallzeit nicht aufblähen. + return segmente + .map(([a, b]) => [Math.max(a, von), Math.min(b, bis)]) + .filter(([a, b]) => b > a); +} + +/** + * Aus einer Überwachungssitzung die Felder für die gespeicherte Messung bauen. + * + * @param sitzung die Sitzung mit ihren Ereignissen + * @param endeTs Ende des Messzeitraums (aus dem Plugin, sonst jetzt) + * @param abgerissen true = der Lauf wurde nicht sauber beendet (Prozess tot, + * nativer Lauf verschwunden) — dann steht das im Hinweis, + * statt eine Verfügbarkeit über eine Zeitspanne auszuweisen, + * in der gar nicht gemessen wurde. + */ +export function buildMonitorMeasurement( + sitzung: DeviceMonitorSession, + endeTs: number, + abgerissen = false, +): { label: string; result: Record; measureStatus: MeasureStatus } { + const von = sitzung.startedAt; + const bis = Math.max(von, endeTs); + const dauerSek = Math.max(1, Math.round((bis - von) / 1000)); + + const auswertung: GeraetAuswertung[] = sitzung.targets.map((t) => { + const eigene = sitzung.events.filter((e) => e.ip === t.ip); + const segmente = segmenteFuer(eigene, von, bis); + const ausfallzeitSek = Math.round( + segmente.reduce((s, [a, b]) => s + (b - a), 0) / 1000, + ); + const laengster = segmente.length + ? Math.round(Math.max(...segmente.map(([a, b]) => b - a)) / 1000) + : 0; + return { + ip: t.ip, + label: t.label || t.ip, + ausfallzeitSek, + aussetzer: segmente.length, + laengsterAusfallSek: laengster, + verfuegbarkeitProzent: Math.max( + 0, + Math.min(100, (1 - ausfallzeitSek / dauerSek) * 100), + ), + ausfaelle: segmente.map( + ([a, b]) => + `${t.label || t.ip} ${fmtTime(a)} – ${fmtTime(b)} (${fmtDuration((b - a) / 1000)})`, + ), + }; + }); + + // Schlechtestes Gerät bestimmt Label und Ampel (siehe Kopfkommentar) + const schlechtestes = auswertung.reduce( + (min, g) => (min === null || g.verfuegbarkeitProzent < min.verfuegbarkeitProzent ? g : min), + null, + ); + const gesamtAussetzer = auswertung.reduce((s, g) => s + g.aussetzer, 0); + + /* + * Bewertung. Die Schwellen sind bewusst streng: Für eine Kamera oder eine + * Wallbox ist ein Aussetzer pro Termin bereits das gemeldete Problem, nicht + * ein Schönheitsfehler. 100 % über den ganzen Zeitraum = grün, jeder + * Aussetzer = gelb, unter 95 % oder ein Gerät durchgehend tot = rot. + */ + let status: MeasureStatus = 0; + if (gesamtAussetzer > 0) status = 1; + if ((schlechtestes?.verfuegbarkeitProzent ?? 100) < 95) status = 2; + if (abgerissen) status = 3; // nicht messbar — die Grundlage stimmt nicht + + const hinweise: string[] = []; + if (abgerissen) { + hinweise.push( + 'Die Überwachung wurde nicht regulär beendet (App oder Gerät zwischendurch ' + + 'gestoppt). Der ausgewiesene Zeitraum endet beim letzten aufgezeichneten ' + + 'Ereignis; danach wurde nicht mehr gemessen.', + ); + } + // Messverfahren offenlegen: das gehört zu einer belastbaren Aussage dazu. + hinweise.push( + 'Gemessen durch regelmäßige Erreichbarkeitsprüfung alle ' + + `${sitzung.intervalSec} Sekunden. Ein Gerät, das Ping-Anfragen ` + + 'grundsätzlich nicht beantwortet, erscheint dabei als nicht erreichbar.', + ); + + const label = schlechtestes + ? gesamtAussetzer === 0 + ? `${auswertung.length} Geräte über ${fmtDuration(dauerSek)} durchgehend erreichbar` + : `${schlechtestes.label}: ${fmtProzent(schlechtestes.verfuegbarkeitProzent)} · ` + + `${gesamtAussetzer} ${gesamtAussetzer === 1 ? 'Aussetzer' : 'Aussetzer'} in ${fmtDuration(dauerSek)}` + : 'Überwachung ohne Geräte'; + + return { + label, + measureStatus: status, + result: { + dauerSekunden: dauerSek, + intervallSek: sitzung.intervalSec, + geraeteAnzahl: auswertung.length, + verfuegbarkeitProzent: Number((schlechtestes?.verfuegbarkeitProzent ?? 100).toFixed(1)), + ausfallzeitSek: schlechtestes?.ausfallzeitSek ?? 0, + aussetzer: gesamtAussetzer, + laengsterAusfallSek: Math.max(0, ...auswertung.map((g) => g.laengsterAusfallSek)), + // Vorformatierte Zeilen statt Objekte: MeasurementResult rendert Arrays + // zeilenweise, und dieselben Zeichenketten werden im PDF wiederverwendet. + geraete: auswertung.map( + (g) => + // Nur benannte Geräte bekommen die IP in Klammern dahinter — ohne + // eigenen Namen stand sonst „10.0.2.2 (10.0.2.2)" im Kundendokument. + `${g.label === g.ip ? g.ip : `${g.label} (${g.ip})`}: ` + + `${fmtProzent(g.verfuegbarkeitProzent)}` + + (g.aussetzer + ? ` · ${g.aussetzer} ${g.aussetzer === 1 ? 'Aussetzer' : 'Aussetzer'}` + + ` · ${fmtDuration(g.ausfallzeitSek)} Ausfall` + + ` · längster ${fmtDuration(g.laengsterAusfallSek)}` + : ' · durchgehend erreichbar'), + ), + ausfaelle: auswertung.flatMap((g) => g.ausfaelle), + hinweis: hinweise.join(' '), + }, + }; +} + diff --git a/src/lib/monitor.ts b/src/lib/monitor.ts index 85081c8..df69cd2 100644 --- a/src/lib/monitor.ts +++ b/src/lib/monitor.ts @@ -24,196 +24,13 @@ */ import { addMeasurement } from './protocols'; -import type { - DeviceMonitorSession, - MeasureStatus, - Measurement, - MonitorEvent, - Protocol, -} from './types'; +import { buildMonitorMeasurement } from './monitor-auswertung'; +import type { DeviceMonitorSession, Measurement, Protocol } from './types'; -function fmtTime(ts: number): string { - return new Date(ts).toLocaleTimeString('de-DE', { - hour: '2-digit', - minute: '2-digit', - second: '2-digit', - }); -} - -function fmtDuration(sec: number): string { - if (sec < 60) return Math.round(sec) + ' s'; - const m = Math.floor(sec / 60); - if (m < 60) { - const s = Math.round(sec % 60); - return s ? `${m} min ${s} s` : `${m} min`; - } - const h = Math.floor(m / 60); - const restM = m % 60; - return restM ? `${h} Std ${restM} min` : `${h} Std`; -} - -/** Prozent mit einer Nachkommastelle, deutsches Komma */ -function fmtProzent(p: number): string { - return p.toFixed(1).replace('.', ',') + ' %'; -} - -interface GeraetAuswertung { - ip: string; - label: string; - ausfallzeitSek: number; - aussetzer: number; - laengsterAusfallSek: number; - verfuegbarkeitProzent: number; - /** einzelne Ausfälle als lesbare Zeilen */ - ausfaelle: string[]; -} - -/** - * Ausfallsegmente eines Geräts aus seinen Ereignissen bilden. - * - * Ein Ausfall beginnt mit 'down' und endet mit dem nächsten 'up'. Ist am Ende - * noch einer offen, wird er bis `bis` zugerechnet — er ist ja nicht dadurch - * verschwunden, dass die Messung aufhörte. - * - * `durationSec` aus dem up-Ereignis wird bewusst NICHT verwendet, sondern die - * Differenz der Zeitstempel: die native Dauer war in einem Fall nachweislich - * 0 (nämlich wenn der Ausfall schon vor dem Start bestand), und zwei Quellen - * für dieselbe Zahl gehen irgendwann auseinander. - */ -function segmenteFuer(events: MonitorEvent[], von: number, bis: number): number[][] { - const segmente: number[][] = []; - let offen: number | null = null; - for (const e of [...events].sort((a, b) => a.ts - b.ts)) { - if (e.type === 'down') { - if (offen === null) offen = e.ts; - } else if (offen !== null) { - segmente.push([offen, e.ts]); - offen = null; - } - } - if (offen !== null) segmente.push([offen, bis]); - // Auf den Messzeitraum begrenzen — ein Ereignis vor dem Start (Wiederauf- - // nahme einer älteren Sitzung) darf die Ausfallzeit nicht aufblähen. - return segmente - .map(([a, b]) => [Math.max(a, von), Math.min(b, bis)]) - .filter(([a, b]) => b > a); -} - -/** - * Aus einer Überwachungssitzung die Felder für die gespeicherte Messung bauen. - * - * @param sitzung die Sitzung mit ihren Ereignissen - * @param endeTs Ende des Messzeitraums (aus dem Plugin, sonst jetzt) - * @param abgerissen true = der Lauf wurde nicht sauber beendet (Prozess tot, - * nativer Lauf verschwunden) — dann steht das im Hinweis, - * statt eine Verfügbarkeit über eine Zeitspanne auszuweisen, - * in der gar nicht gemessen wurde. - */ -export function buildMonitorMeasurement( - sitzung: DeviceMonitorSession, - endeTs: number, - abgerissen = false, -): { label: string; result: Record; measureStatus: MeasureStatus } { - const von = sitzung.startedAt; - const bis = Math.max(von, endeTs); - const dauerSek = Math.max(1, Math.round((bis - von) / 1000)); - - const auswertung: GeraetAuswertung[] = sitzung.targets.map((t) => { - const eigene = sitzung.events.filter((e) => e.ip === t.ip); - const segmente = segmenteFuer(eigene, von, bis); - const ausfallzeitSek = Math.round( - segmente.reduce((s, [a, b]) => s + (b - a), 0) / 1000, - ); - const laengster = segmente.length - ? Math.round(Math.max(...segmente.map(([a, b]) => b - a)) / 1000) - : 0; - return { - ip: t.ip, - label: t.label || t.ip, - ausfallzeitSek, - aussetzer: segmente.length, - laengsterAusfallSek: laengster, - verfuegbarkeitProzent: Math.max( - 0, - Math.min(100, (1 - ausfallzeitSek / dauerSek) * 100), - ), - ausfaelle: segmente.map( - ([a, b]) => - `${t.label || t.ip} ${fmtTime(a)} – ${fmtTime(b)} (${fmtDuration((b - a) / 1000)})`, - ), - }; - }); - - // Schlechtestes Gerät bestimmt Label und Ampel (siehe Kopfkommentar) - const schlechtestes = auswertung.reduce( - (min, g) => (min === null || g.verfuegbarkeitProzent < min.verfuegbarkeitProzent ? g : min), - null, - ); - const gesamtAussetzer = auswertung.reduce((s, g) => s + g.aussetzer, 0); - - /* - * Bewertung. Die Schwellen sind bewusst streng: Für eine Kamera oder eine - * Wallbox ist ein Aussetzer pro Termin bereits das gemeldete Problem, nicht - * ein Schönheitsfehler. 100 % über den ganzen Zeitraum = grün, jeder - * Aussetzer = gelb, unter 95 % oder ein Gerät durchgehend tot = rot. - */ - let status: MeasureStatus = 0; - if (gesamtAussetzer > 0) status = 1; - if ((schlechtestes?.verfuegbarkeitProzent ?? 100) < 95) status = 2; - if (abgerissen) status = 3; // nicht messbar — die Grundlage stimmt nicht - - const hinweise: string[] = []; - if (abgerissen) { - hinweise.push( - 'Die Überwachung wurde nicht regulär beendet (App oder Gerät zwischendurch ' + - 'gestoppt). Der ausgewiesene Zeitraum endet beim letzten aufgezeichneten ' + - 'Ereignis; danach wurde nicht mehr gemessen.', - ); - } - // Messverfahren offenlegen: das gehört zu einer belastbaren Aussage dazu. - hinweise.push( - 'Gemessen durch regelmäßige Erreichbarkeitsprüfung alle ' + - `${sitzung.intervalSec} Sekunden. Ein Gerät, das Ping-Anfragen ` + - 'grundsätzlich nicht beantwortet, erscheint dabei als nicht erreichbar.', - ); - - const label = schlechtestes - ? gesamtAussetzer === 0 - ? `${auswertung.length} Geräte über ${fmtDuration(dauerSek)} durchgehend erreichbar` - : `${schlechtestes.label}: ${fmtProzent(schlechtestes.verfuegbarkeitProzent)} · ` + - `${gesamtAussetzer} ${gesamtAussetzer === 1 ? 'Aussetzer' : 'Aussetzer'} in ${fmtDuration(dauerSek)}` - : 'Überwachung ohne Geräte'; - - return { - label, - measureStatus: status, - result: { - dauerSekunden: dauerSek, - intervallSek: sitzung.intervalSec, - geraeteAnzahl: auswertung.length, - verfuegbarkeitProzent: Number((schlechtestes?.verfuegbarkeitProzent ?? 100).toFixed(1)), - ausfallzeitSek: schlechtestes?.ausfallzeitSek ?? 0, - aussetzer: gesamtAussetzer, - laengsterAusfallSek: Math.max(0, ...auswertung.map((g) => g.laengsterAusfallSek)), - // Vorformatierte Zeilen statt Objekte: MeasurementResult rendert Arrays - // zeilenweise, und dieselben Zeichenketten werden im PDF wiederverwendet. - geraete: auswertung.map( - (g) => - // Nur benannte Geräte bekommen die IP in Klammern dahinter — ohne - // eigenen Namen stand sonst „10.0.2.2 (10.0.2.2)" im Kundendokument. - `${g.label === g.ip ? g.ip : `${g.label} (${g.ip})`}: ` + - `${fmtProzent(g.verfuegbarkeitProzent)}` + - (g.aussetzer - ? ` · ${g.aussetzer} ${g.aussetzer === 1 ? 'Aussetzer' : 'Aussetzer'}` + - ` · ${fmtDuration(g.ausfallzeitSek)} Ausfall` + - ` · längster ${fmtDuration(g.laengsterAusfallSek)}` - : ' · durchgehend erreichbar'), - ), - ausfaelle: auswertung.flatMap((g) => g.ausfaelle), - hinweis: hinweise.join(' '), - }, - }; -} +// Die Rechnung selbst liegt in monitor-auswertung.ts, damit sie ohne +// Capacitor-Abhängigkeiten geladen und geprüft werden kann (tools/pruefe-monitor.mjs). +export { buildMonitorMeasurement } from './monitor-auswertung'; +export type { GeraetAuswertung } from './monitor-auswertung'; /** * Sitzung abschließen und als Messung ins Protokoll legen. diff --git a/src/lib/scanner.ts b/src/lib/scanner.ts index 5732dd6..f599419 100644 --- a/src/lib/scanner.ts +++ b/src/lib/scanner.ts @@ -206,6 +206,25 @@ export interface ThroughputResult { measured?: boolean; } +/** Ergebnis einer Namensauflösung */ +export interface DnsResult { + ok: boolean; + ips: string[]; + latenzMs?: number; + fehler?: string; +} + +/** Ergebnis eines HTTP(S)-Abrufs ohne Weiterleitungsverfolgung */ +export interface HttpsResult { + erreichbar: boolean; + statusCode?: number; + statusText?: string; + latenzMs?: number; + /** Ziel einer 30x-Antwort — bei einem Gäste-WLAN die Adresse der Anmeldeseite */ + weiterleitungAuf?: string; + fehler?: string; +} + /** Schnittstelle des nativen Plugins */ export interface NetDiagScannerPlugin { /** Aktuelles Subnetz des Geräts ermitteln (z.B. "192.168.1.0/24") */ @@ -332,6 +351,21 @@ export interface NetDiagScannerPlugin { }>; /** Alle aktiven Netzwerk-Interfaces auflisten (WLAN, Ethernet incl. USB-RJ45, Mobilfunk) */ linkInfo(): Promise<{ links: LinkInfo[] }>; + /** + * Namen über den System-Resolver auflösen. + * + * GRENZE: welcher DNS-Server antwortet, ist nicht wählbar, und der + * System-Cache liegt dazwischen — eine zweite Abfrage desselben Namens misst + * den Cache. Für „antwortet dieser bestimmte Server" braucht es einen eigenen + * UDP-Client (eigener Roadmap-Punkt). + */ + dnsResolve(opts: { host: string; timeoutMs?: number }): Promise; + /** + * Eine URL abrufen, ohne Weiterleitungen zu folgen. Das Nichtfolgen ist der + * Kern: ein Gäste-WLAN mit Anmeldeseite beantwortet jede Anfrage mit einer + * Umleitung, und wer ihr folgt, bekommt ein sauberes 200 zurück. + */ + httpsCheck(opts: { url: string; timeoutMs?: number }): Promise; /** Aktiven WLAN-Scan triggern (System-Cache nachladen); `triggered=false` bei Throttling */ startWifiScan(): Promise<{ triggered: boolean }>; /** WLAN-Empfangstracker für ein BSSID starten — live RSSI per `wifiSignal`-Event */ @@ -835,6 +869,7 @@ const mock: NetDiagScannerPlugin = { gateway: '192.168.1.1', dns: ['192.168.1.1', '1.1.1.1'], linkSpeedMbps: 1000, + validated: true, }, { iface: 'wlan0', @@ -851,10 +886,27 @@ const mock: NetDiagScannerPlugin = { ssid: 'AllesWattLaeuft', bssid: 'AA:BB:CC:11:22:33', rssi: -52, + validated: true, }, ], }; }, + /* + * Der Mock liefert für DNS und HTTPS BEWUSST nur den guten Fall — im Browser + * gibt es kein Kundennetz, gegen das sich ein Ausfall ehrlich nachstellen + * ließe. Wer die Internet-Kette hier durchklickt, sieht deshalb immer Grün. + * Die Fehlerpfade (kein Gateway, DNS tot, Anmeldeseite, HTTPS blockiert) + * werden nicht über den Mock geprüft, sondern über `tools/pruefe-kette.mjs` + * gegen konstruierte Stufen mit von Hand ermitteltem Sollwert. Dieselbe + * Falle wie beim geplanten Geräte-Diff: der Mock sieht perfekt aus und + * beweist nichts. + */ + async dnsResolve(opts) { + return { ok: true, ips: ['142.250.185.67'], latenzMs: rnd(8, 40) }; + }, + async httpsCheck() { + return { erreichbar: true, statusCode: 204, statusText: 'No Content', latenzMs: rnd(30, 120) }; + }, async startWifiScan() { return { triggered: true }; }, diff --git a/src/lib/tools/index.ts b/src/lib/tools/index.ts index 591298d..b11df71 100644 --- a/src/lib/tools/index.ts +++ b/src/lib/tools/index.ts @@ -17,6 +17,7 @@ import { snmpTool } from './netzwerk/snmp'; import { stressTestTool } from './netzwerk/stresstest'; import { tracerouteTool } from './netzwerk/traceroute'; +import { internetKetteTool } from './internet/internetkette'; import { iperfTool } from './internet/iperf'; /** @@ -36,7 +37,9 @@ export const TOOLS: Tool[] = [ snmpTool, tracerouteTool, stressTestTool, - // Internet + // Internet — die Kette steht bewusst vorn: sie ist die Erstdiagnose, + // wenn der Kunde „Internet geht nicht" sagt. + internetKetteTool, iperfTool, // Telefonie: folgt (SIP-Registrierung, FreePBX-Check, RTP-Qualität) ]; diff --git a/src/lib/tools/internet/internetkette.ts b/src/lib/tools/internet/internetkette.ts new file mode 100644 index 0000000..5fc6e52 --- /dev/null +++ b/src/lib/tools/internet/internetkette.ts @@ -0,0 +1,156 @@ +/** + * Tool: Internet-Kette — Anschluss → Gateway → Namensauflösung → HTTPS. + * + * Die Standard-Erstdiagnose vor Ort. Die häufigste Aussage beim Kunden ist + * „Internet geht nicht"; bisher musste der Techniker Ping, Traceroute und + * Browser einzeln bedienen und selbst schlussfolgern, welches Glied hakt. + * Dieses Werkzeug misst alle Stufen in einem Lauf und benennt das Fehlerglied. + * + * Zwei Entwurfsentscheidungen, die für die Ehrlichkeit der Aussage wesentlich + * sind und deshalb hier stehen: + * + * 1. **Die Kette bricht nicht ab.** Auch nach einer fehlgeschlagenen Stufe + * laufen die restlichen weiter. Sonst wäre der häufigste Fall überhaupt + * nicht auswertbar: sehr viele Router beantworten Ping grundsätzlich nicht. + * Ein Abbruch dort meldete „Gateway tot" bei völlig gesundem Internet. + * 2. **Eine stille Stufe ist kein Fehler, wenn eine spätere Stufe antwortet.** + * Wer eine Antwort aus dem Internet bekommt, hat sein Gateway benutzt — + * dann ist dessen Schweigen eine Eigenheit, keine Störung, und wird als + * solche formuliert. + */ + +import { scanner } from '../../scanner'; +import type { Tool } from '../types'; +import { bewerteKette, type KettenStufe } from './kette-bewertung'; + +export { bewerteKette } from './kette-bewertung'; +export type { KettenStufe, KettenUrteil } from './kette-bewertung'; + +export const internetKetteTool: Tool = { + id: 'internetkette', + category: 'internet', + name: 'Internet-Kette', + icon: 'link', + description: 'Anschluss → Gateway → Namensauflösung → HTTPS in einem Lauf, mit benanntem Fehlerglied.', + scope: 'protocol', + quickRun: true, + params: [ + { key: 'dnsName', label: 'Name zum Auflösen', type: 'text', default: 'www.google.de' }, + { + key: 'url', + label: 'Adresse für den HTTPS-Abruf', + type: 'text', + default: 'https://connectivitycheck.gstatic.com/generate_204', + }, + ], + async run(ctx) { + const dnsName = String(ctx.params.dnsName || 'www.google.de').trim(); + const url = String(ctx.params.url || 'https://connectivitycheck.gstatic.com/generate_204').trim(); + + /* Stufe 1 — eigener Anschluss */ + const { links } = await scanner.linkInfo(); + const link = + links.find((l) => l.isDefault && l.ipv4) ?? + links.find((l) => l.ipv4 && l.type !== 'cellular') ?? + links.find((l) => l.ipv4); + + const anschlussText = link + ? `${anschlussName(link.type)} ${link.iface}, IP ${link.ipv4}/${link.prefixLength ?? '?'}` + : 'keine IP-Adresse auf keinem Anschluss'; + + const stufen: KettenStufe[] = [ + { name: 'Anschluss', ok: link ? true : null, detail: anschlussText }, + ]; + + /* Stufe 2 — Gateway */ + const gateway = link?.gateway; + if (!gateway) { + stufen.push({ + name: 'Gateway', + ok: null, + detail: 'kein Standard-Gateway gemeldet', + stillErlaubt: true, + }); + } else { + const q = await scanner.pingQuality({ host: gateway, count: 4 }); + stufen.push({ + name: 'Gateway', + ok: q.received > 0, + detail: + q.received > 0 + ? `${gateway} antwortet in ${q.medianMs ?? q.avgMs} ms (${q.received}/${q.sent} Pakete)` + : `${gateway} antwortet auf keines von ${q.sent} Paketen`, + stillErlaubt: true, + }); + } + + /* Stufe 3 — Namensauflösung */ + const dns = await scanner.dnsResolve({ host: dnsName, timeoutMs: 5000 }); + stufen.push({ + name: 'Namensauflösung', + ok: dns.ok, + detail: dns.ok + ? `${dnsName} → ${dns.ips.slice(0, 2).join(', ')}${dns.latenzMs !== undefined ? ` (${dns.latenzMs} ms)` : ''}` + : `${dnsName} nicht auflösbar${dns.fehler ? ` (${dns.fehler})` : ''}`, + }); + + /* Stufe 4 — HTTPS */ + const https = await scanner.httpsCheck({ url, timeoutMs: 8000 }); + // 2xx zählt als Erfolg, 204 ist die erwartete Antwort des Prüfdienstes. + // Eine Weiterleitung ist KEIN Erfolg — genau dort sitzt die Anmeldeseite. + const code = https.statusCode ?? 0; + const httpsOk = https.erreichbar && code >= 200 && code < 300; + const weiterleitung = https.erreichbar && code >= 300 && code < 400; + stufen.push({ + name: 'HTTPS-Abruf', + ok: https.erreichbar ? httpsOk : false, + detail: https.erreichbar + ? `HTTP ${code}${https.statusText ? ' ' + https.statusText : ''}` + + (https.latenzMs !== undefined ? ` in ${https.latenzMs} ms` : '') + + (https.weiterleitungAuf ? ` → Umleitung auf ${https.weiterleitungAuf}` : '') + : `kein Verbindungsaufbau${https.fehler ? ` (${https.fehler})` : ''}`, + }); + + const portalErkannt = weiterleitung || link?.captivePortal === true; + const urteil = bewerteKette(stufen, portalErkannt, link?.validated); + + // Systemurteil als eigene, klar als solche gekennzeichnete Zeile: es ist + // eine Fremdaussage, keine eigene Messung. + const systemText = + link?.validated === undefined + ? undefined + : link.validated + ? 'Android stuft diese Verbindung als bestätigt ein.' + : 'Android stuft diese Verbindung NICHT als bestätigt ein.'; + + return { + label: urteil.label, + measureStatus: urteil.status, + result: { + fehlerglied: urteil.fehlerglied || 'keines', + stufen: stufen.map( + (s, i) => + `${i + 1}. ${s.name}: ${s.detail}` + + (s.ok === true ? '' : s.ok === false ? ' — keine Antwort' : ' — nicht messbar'), + ), + gateway: gateway ?? undefined, + systemUrteil: systemText, + hinweis: urteil.hinweis, + }, + }; + }, +}; + +/** Anschlussart auf Deutsch — dieselbe Sprache wie netdiagKundentext() im Modul */ +function anschlussName(type: string): string { + switch (type) { + case 'ethernet': + return 'LAN (Kabel)'; + case 'wifi': + return 'WLAN'; + case 'cellular': + return 'Mobilfunk'; + default: + return 'Anschluss'; + } +} diff --git a/src/lib/tools/internet/kette-bewertung.ts b/src/lib/tools/internet/kette-bewertung.ts new file mode 100644 index 0000000..95d4a6f --- /dev/null +++ b/src/lib/tools/internet/kette-bewertung.ts @@ -0,0 +1,162 @@ +/** + * Bewertung der Internet-Kette — bewusst OHNE jeden Laufzeit-Import. + * + * Warum eine eigene Datei: die Fehlerpfade dieses Werkzeugs (kein Gateway, + * DNS tot, Anmeldeseite, HTTPS blockiert) lassen sich im Browser-Mock nicht + * ehrlich nachstellen — dort ist immer alles grün. Geprüft wird deshalb mit + * `tools/pruefe-kette.mjs` gegen konstruierte Stufen mit von Hand ermitteltem + * Sollwert. Damit dieser Prüfer die Funktion laden kann, darf hier nichts + * stehen, was `scanner.ts` (und damit Capacitor) nachzieht: nur `import type`, + * das beim Ausführen mit Node restlos verschwindet. + * + * Der Ausweg „Datei von Hand nach .mjs kopieren" wurde beim Monitor gewählt + * und hat sich als Sackgasse erwiesen: die Kopie lag in einem + * Sitzungsverzeichnis, das Prüfwerkzeug lief nach dessen Wegfall nicht mehr, + * und im Repo stand trotzdem „alle Fälle korrekt". + */ + +import type { MeasureStatus } from '../types'; + +/** Eine Stufe der Kette, so wie sie gemessen wurde */ +export interface KettenStufe { + /** Kurzname für das Kundendokument („Gateway", „Namensauflösung" …) */ + name: string; + /** + * true = Stufe hat geantwortet + * false = Stufe hat nicht geantwortet + * null = nicht messbar (die Voraussetzung fehlte, z.B. kein Gateway bekannt) + */ + ok: boolean | null; + /** Messwert/Antwort als Klartext für das Protokoll */ + detail: string; + /** + * true = diese Stufe darf allein kein Rot auslösen, solange eine spätere + * Stufe geantwortet hat. Sehr viele Router beantworten Ping grundsätzlich + * nicht; wer eine Antwort aus dem Internet bekommt, hat sein Gateway aber + * nachweislich benutzt. + */ + stillErlaubt?: boolean; +} + +/** Ergebnis der Kettenbewertung */ +export interface KettenUrteil { + status: MeasureStatus; + label: string; + /** benanntes Fehlerglied, leer wenn die Kette durchgeht */ + fehlerglied: string; + hinweis?: string; +} + +/** + * Aus den gemessenen Stufen das Urteil bilden. + * + * @param stufen die Stufen in Reihenfolge (Anschluss zuerst, HTTPS zuletzt) + * @param portalErkannt Anmeldeseite erkannt (Weiterleitung oder Systemurteil) + * @param systemUrteil Androids NET_CAPABILITY_VALIDATED, falls bekannt + */ +export function bewerteKette( + stufen: KettenStufe[], + portalErkannt: boolean, + systemUrteil?: boolean, +): KettenUrteil { + // Gar nichts messbar (kein Anschluss) — Status 3 statt einer Falschaussage + // über das Kundennetz. Ohne eigene IP haben wir schlicht nicht gemessen. + const anschluss = stufen[0]; + if (!anschluss || anschluss.ok === null) { + return { + status: 3, + label: 'Nicht messbar — kein aktiver Netzwerkanschluss', + fehlerglied: 'Anschluss', + hinweis: + 'Das Messgerät hatte keine IP-Adresse. Es wurde nichts über das Kundennetz gemessen.', + }; + } + + /* + * Die Anmeldeseite wird VOR der Fehlerglied-Suche entschieden. Sie erklärt + * sämtliche anderen Symptome: ein Gäste-WLAN-Portal beantwortet den + * HTTPS-Abruf mit einer Umleitung (die Stufe gilt damit als nicht bestanden) + * und biegt oft auch die Namensauflösung um. Ohne diesen Vorrang stand im + * Kundendokument „Kein Internet — HTTPS-Abruf antwortet nicht", obwohl der + * Server sauber geantwortet hat und der Techniker nur auf „Anmelden" tippen + * muss. Das ist der Unterschied zwischen einem Anruf beim Provider und + * einem erledigten Termin. + */ + if (portalErkannt) { + return { + status: 1, + label: 'Anmeldeseite im Weg — Internet erst nach Anmeldung', + fehlerglied: 'Anmeldeseite', + hinweis: + 'Die Anfrage wurde auf eine Anmeldeseite umgeleitet (typisch für ein ' + + 'Gäste-WLAN). Ohne Anmeldung kommt kein Gerät ins Internet — Kameras, ' + + 'Wechselrichter und Wallboxen können sich dort grundsätzlich nicht melden.', + }; + } + + // Index der letzten Stufe, die geantwortet hat — entscheidet darüber, ob das + // Schweigen einer früheren Stufe entschuldigt ist. + const letzteAntwortIdx = stufen.reduce((idx, s, i) => (s.ok === true ? i : idx), -1); + + // Fehlerglied: erste Stufe ohne Antwort, deren Schweigen nicht durch eine + // spätere Antwort erklärt ist. + let fehlerglied = ''; + for (let i = 0; i < stufen.length; i++) { + const s = stufen[i]; + if (s.ok !== false) continue; + if (s.stillErlaubt && letzteAntwortIdx > i) continue; + fehlerglied = s.name; + break; + } + + if (fehlerglied !== '') { + return { + status: 2, + label: `Kein Internet — ${fehlerglied} antwortet nicht`, + fehlerglied, + }; + } + + const httpsStufe = stufen[stufen.length - 1]; + const internetGeht = httpsStufe?.ok === true; + + // Alles geantwortet, aber Android hält die Verbindung nicht für bestätigt: + // „HTTPS geht, irgendetwas anderes klemmt trotzdem" — gelb, nicht grün. + if (internetGeht && systemUrteil === false) { + return { + status: 1, + label: 'Internet erreichbar, vom System aber nicht bestätigt', + fehlerglied: '', + hinweis: + 'Alle Stufen haben geantwortet, Android stuft die Verbindung dennoch ' + + 'nicht als bestätigt ein. Das deutet auf eine teilweise gefilterte ' + + 'Verbindung hin (Jugendschutz, Firewall, Portsperre).', + }; + } + + // Eine Stufe, die nicht messbar war (z.B. kein Gateway gemeldet), ist kein + // Erfolg — sie darf aber auch nicht als Störung ausgewiesen werden. + const stille = stufen.filter((s) => s.ok === false && s.stillErlaubt); + const unbekannt = stufen.filter((s) => s.ok === null && s.stillErlaubt); + const hinweise: string[] = []; + if (stille.length) { + hinweise.push( + `${stille.map((s) => s.name).join(' und ')} antwortet nicht auf Ping, ` + + 'leitet den Verkehr aber weiter — das ist bei vielen Routern die ' + + 'Werkseinstellung und keine Störung.', + ); + } + if (unbekannt.length) { + hinweise.push( + `${unbekannt.map((s) => s.name).join(' und ')} konnte nicht geprüft werden ` + + '(vom System nicht gemeldet).', + ); + } + + return { + status: 0, + label: 'Internet erreichbar — Gateway, Namensauflösung und HTTPS in Ordnung', + fehlerglied: '', + hinweis: hinweise.length ? hinweise.join(' ') : undefined, + }; +} diff --git a/src/lib/types.ts b/src/lib/types.ts index 75de64f..a3e5a2c 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -134,6 +134,15 @@ export interface LinkInfo { bssid?: string; /** Empfangsstärke in dBm (nur wenn type='wifi') */ rssi?: number; + /** + * Androids eigenes Urteil: das System hat über diese Verbindung erfolgreich + * einen Erreichbarkeitsdienst abgerufen (NET_CAPABILITY_VALIDATED). Kostet + * uns keine Messung und ist die verlässlichste Auskunft über ein Gäste-WLAN + * mit Anmeldeseite, die ohne Root zu bekommen ist. + */ + validated?: boolean; + /** System hat eine Anmeldeseite erkannt (NET_CAPABILITY_CAPTIVE_PORTAL) */ + captivePortal?: boolean; } /** Einzelne RSSI-Probe des WLAN-Empfangstrackers */ diff --git a/src/routes/debug/+page.svelte b/src/routes/debug/+page.svelte index 5cdde98..5e7409e 100644 --- a/src/routes/debug/+page.svelte +++ b/src/routes/debug/+page.svelte @@ -1,6 +1,60 @@