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) <noreply@anthropic.com>
This commit is contained in:
Eduard Wisch 2026-08-16 22:56:20 +02:00
parent bbf24c06b8
commit 460b85d708
12 changed files with 909 additions and 195 deletions

View file

@ -1923,6 +1923,16 @@ class NetDiagScannerPlugin : Plugin() {
.put("iface", iface) .put("iface", iface)
.put("type", type) .put("type", type)
.put("isDefault", iface == defaultIface) .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 (ipv4 != null) obj.put("ipv4", ipv4)
if (prefix > 0) obj.put("prefixLength", prefix) if (prefix > 0) obj.put("prefixLength", prefix)
if (gateway != null) obj.put("gateway", gateway) 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 */ /* WLAN-Empfangstracker — RSSI live beim Durchgehen durchs Gebäude */
/* --------------------------------------------------------------------- */ /* --------------------------------------------------------------------- */

View file

@ -54,6 +54,14 @@ const FELDER: Record<string, Feld> = {
geraete: { label: 'Je Gerät' }, geraete: { label: 'Je Gerät' },
ausfaelle: { label: 'Einzelne Ausfälle' }, ausfaelle: { label: 'Einzelne Ausfälle' },
anzahlGeraete: { label: 'Überwachte Geräte' }, 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 // Netz / DHCP / Dose
server: { label: 'DHCP-Server' }, server: { label: 'DHCP-Server' },
dhcpServer: { label: 'DHCP-Server' }, dhcpServer: { label: 'DHCP-Server' },

View file

@ -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<string, unknown>; 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<GeraetAuswertung | null>(
(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(' '),
},
};
}

View file

@ -24,196 +24,13 @@
*/ */
import { addMeasurement } from './protocols'; import { addMeasurement } from './protocols';
import type { import { buildMonitorMeasurement } from './monitor-auswertung';
DeviceMonitorSession, import type { DeviceMonitorSession, Measurement, Protocol } from './types';
MeasureStatus,
Measurement,
MonitorEvent,
Protocol,
} from './types';
function fmtTime(ts: number): string { // Die Rechnung selbst liegt in monitor-auswertung.ts, damit sie ohne
return new Date(ts).toLocaleTimeString('de-DE', { // Capacitor-Abhängigkeiten geladen und geprüft werden kann (tools/pruefe-monitor.mjs).
hour: '2-digit', export { buildMonitorMeasurement } from './monitor-auswertung';
minute: '2-digit', export type { GeraetAuswertung } from './monitor-auswertung';
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<string, unknown>; 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<GeraetAuswertung | null>(
(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(' '),
},
};
}
/** /**
* Sitzung abschließen und als Messung ins Protokoll legen. * Sitzung abschließen und als Messung ins Protokoll legen.

View file

@ -206,6 +206,25 @@ export interface ThroughputResult {
measured?: boolean; 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 */ /** Schnittstelle des nativen Plugins */
export interface NetDiagScannerPlugin { export interface NetDiagScannerPlugin {
/** Aktuelles Subnetz des Geräts ermitteln (z.B. "192.168.1.0/24") */ /** 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) */ /** Alle aktiven Netzwerk-Interfaces auflisten (WLAN, Ethernet incl. USB-RJ45, Mobilfunk) */
linkInfo(): Promise<{ links: LinkInfo[] }>; 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<DnsResult>;
/**
* 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<HttpsResult>;
/** Aktiven WLAN-Scan triggern (System-Cache nachladen); `triggered=false` bei Throttling */ /** Aktiven WLAN-Scan triggern (System-Cache nachladen); `triggered=false` bei Throttling */
startWifiScan(): Promise<{ triggered: boolean }>; startWifiScan(): Promise<{ triggered: boolean }>;
/** WLAN-Empfangstracker für ein BSSID starten — live RSSI per `wifiSignal`-Event */ /** WLAN-Empfangstracker für ein BSSID starten — live RSSI per `wifiSignal`-Event */
@ -835,6 +869,7 @@ const mock: NetDiagScannerPlugin = {
gateway: '192.168.1.1', gateway: '192.168.1.1',
dns: ['192.168.1.1', '1.1.1.1'], dns: ['192.168.1.1', '1.1.1.1'],
linkSpeedMbps: 1000, linkSpeedMbps: 1000,
validated: true,
}, },
{ {
iface: 'wlan0', iface: 'wlan0',
@ -851,10 +886,27 @@ const mock: NetDiagScannerPlugin = {
ssid: 'AllesWattLaeuft', ssid: 'AllesWattLaeuft',
bssid: 'AA:BB:CC:11:22:33', bssid: 'AA:BB:CC:11:22:33',
rssi: -52, 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() { async startWifiScan() {
return { triggered: true }; return { triggered: true };
}, },

View file

@ -17,6 +17,7 @@ import { snmpTool } from './netzwerk/snmp';
import { stressTestTool } from './netzwerk/stresstest'; import { stressTestTool } from './netzwerk/stresstest';
import { tracerouteTool } from './netzwerk/traceroute'; import { tracerouteTool } from './netzwerk/traceroute';
import { internetKetteTool } from './internet/internetkette';
import { iperfTool } from './internet/iperf'; import { iperfTool } from './internet/iperf';
/** /**
@ -36,7 +37,9 @@ export const TOOLS: Tool[] = [
snmpTool, snmpTool,
tracerouteTool, tracerouteTool,
stressTestTool, stressTestTool,
// Internet // Internet — die Kette steht bewusst vorn: sie ist die Erstdiagnose,
// wenn der Kunde „Internet geht nicht" sagt.
internetKetteTool,
iperfTool, iperfTool,
// Telefonie: folgt (SIP-Registrierung, FreePBX-Check, RTP-Qualität) // Telefonie: folgt (SIP-Registrierung, FreePBX-Check, RTP-Qualität)
]; ];

View file

@ -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';
}
}

View file

@ -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,
};
}

View file

@ -134,6 +134,15 @@ export interface LinkInfo {
bssid?: string; bssid?: string;
/** Empfangsstärke in dBm (nur wenn type='wifi') */ /** Empfangsstärke in dBm (nur wenn type='wifi') */
rssi?: number; 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 */ /** Einzelne RSSI-Probe des WLAN-Empfangstrackers */

View file

@ -1,6 +1,60 @@
<script lang="ts"> <script lang="ts">
import AppHeader from '$lib/components/AppHeader.svelte'; import AppHeader from '$lib/components/AppHeader.svelte';
import { debugLog } from '$lib/debuglog.svelte'; import { debugLog } from '$lib/debuglog.svelte';
import { internetKetteTool } from '$lib/tools/internet/internetkette';
import type { Protocol } from '$lib/types';
/*
* Selbsttest der Internet-Kette — misst, ohne irgendetwas zu speichern.
*
* Zwei Gründe, warum das hier sitzt und nicht nur im Werkzeug-Grid:
* - Vor Ort beantwortet er die erste aller Fragen („kommt das Messgerät
* selbst überhaupt ins Netz?"), bevor man ein Protokoll anlegt.
* - Er ist der einzige Weg, die nativen Methoden im Emulator zu prüfen,
* solange die Testinstanz nur HTTP spricht: der WebView blockiert aus
* der Origin `https://localhost` jede HTTP-Anfrage (Mixed Content),
* ohne Server gibt es keine Aufträge und damit kein Protokoll.
*/
let selbsttest = $state<{ laeuft: boolean; label: string; status: number; zeilen: string[] } | null>(
null,
);
async function ketteTesten() {
selbsttest = { laeuft: true, label: 'misst …', status: -1, zeilen: [] };
try {
const r = await internetKetteTool.run({
params: {},
// Das Werkzeug liest das Protokoll nicht — hier wird nichts gespeichert.
protocol: {} as unknown as Protocol,
});
const res = r.result as Record<string, unknown>;
selbsttest = {
laeuft: false,
label: r.label,
status: r.measureStatus,
zeilen: [
...((res.stufen as string[]) ?? []),
`Fehlerglied: ${res.fehlerglied}`,
...(res.systemUrteil ? [String(res.systemUrteil)] : []),
...(res.hinweis ? [String(res.hinweis)] : []),
],
};
} catch (e) {
selbsttest = {
laeuft: false,
label: 'Fehler beim Messen',
status: 2,
zeilen: [e instanceof Error ? e.message : String(e)],
};
}
}
const statusFarbe: Record<number, string> = {
0: 'text-emerald-400',
1: 'text-amber-400',
2: 'text-red-400',
3: 'text-zinc-400',
};
const levelColor: Record<string, string> = { const levelColor: Record<string, string> = {
info: 'text-zinc-400', info: 'text-zinc-400',
@ -48,6 +102,26 @@
</button> </button>
</div> </div>
<div class="border-b border-zinc-800 bg-zinc-900 px-3 py-2">
<button
class="w-full rounded bg-zinc-800 px-3 py-2 text-sm active:bg-zinc-700 disabled:opacity-50"
onclick={ketteTesten}
disabled={selbsttest?.laeuft}
>
Internet-Kette prüfen (misst nur, speichert nichts)
</button>
{#if selbsttest}
<p class="mt-2 text-sm {statusFarbe[selbsttest.status] ?? 'text-zinc-400'}">
{selbsttest.label}
</p>
<ul class="mt-1 space-y-0.5 font-mono text-xs text-zinc-400">
{#each selbsttest.zeilen as z, i (i)}
<li class="break-words">{z}</li>
{/each}
</ul>
{/if}
</div>
<div class="flex-1 overflow-y-auto p-2 font-mono text-xs"> <div class="flex-1 overflow-y-auto p-2 font-mono text-xs">
{#if debugLog.entries.length === 0} {#if debugLog.entries.length === 0}
<p class="p-4 text-center text-zinc-500">Keine Einträge.</p> <p class="p-4 text-center text-zinc-500">Keine Einträge.</p>

119
tools/pruefe-kette.mjs Normal file
View file

@ -0,0 +1,119 @@
/**
* Prueft die Bewertung der Internet-Kette gegen konstruierte Faelle mit von
* Hand ermitteltem Sollwert.
*
* Warum ueberhaupt: der Browser-Mock liefert fuer DNS und HTTPS immer den
* guten Fall, dort ist die Kette also IMMER gruen. Genau die Faelle, wegen
* derer das Werkzeug gebaut wurde (Gateway still, DNS tot, Anmeldeseite),
* kommen im Mock nicht vor.
*
* Start: node tools/pruefe-kette.mjs
* (laedt die TS-Datei direkt Node 22.6+ entfernt die Typannotationen selbst;
* deshalb darf kette-bewertung.ts keinen Laufzeit-Import enthalten)
*/
const { bewerteKette } = await import('../src/lib/tools/internet/kette-bewertung.ts');
const anschlussOk = { name: 'Anschluss', ok: true, detail: 'LAN (Kabel) eth0, IP 192.168.1.42/24' };
const gwOk = { name: 'Gateway', ok: true, detail: '192.168.1.1 antwortet in 2 ms', stillErlaubt: true };
const gwStill = { name: 'Gateway', ok: false, detail: 'antwortet auf keines von 4 Paketen', stillErlaubt: true };
const gwUnbekannt = { name: 'Gateway', ok: null, detail: 'kein Standard-Gateway gemeldet', stillErlaubt: true };
const dnsOk = { name: 'Namensauflösung', ok: true, detail: 'www.google.de → 142.250.185.67 (23 ms)' };
const dnsTot = { name: 'Namensauflösung', ok: false, detail: 'nicht auflösbar (UnknownHostException)' };
const httpsOk = { name: 'HTTPS-Abruf', ok: true, detail: 'HTTP 204 in 58 ms' };
const httpsTot = { name: 'HTTPS-Abruf', ok: false, detail: 'kein Verbindungsaufbau (timeout)' };
const faelle = [
{
name: 'Alles in Ordnung',
stufen: [anschlussOk, gwOk, dnsOk, httpsOk],
portal: false,
system: true,
soll: { status: 0, glied: '' },
},
{
name: 'Gateway antwortet nicht auf Ping, Internet geht -> GRUEN mit Hinweis',
stufen: [anschlussOk, gwStill, dnsOk, httpsOk],
portal: false,
system: true,
soll: { status: 0, glied: '', hinweisEnthaelt: 'Werkseinstellung' },
},
{
name: 'Gateway tot UND alles danach tot -> Fehlerglied Gateway',
stufen: [anschlussOk, gwStill, dnsTot, httpsTot],
portal: false,
system: false,
soll: { status: 2, glied: 'Gateway' },
},
{
name: 'Gateway ok, DNS tot -> Fehlerglied Namensaufloesung',
stufen: [anschlussOk, gwOk, dnsTot, httpsTot],
portal: false,
system: false,
soll: { status: 2, glied: 'Namensauflösung' },
},
{
name: 'DNS ok, HTTPS tot -> Fehlerglied HTTPS-Abruf',
stufen: [anschlussOk, gwOk, dnsOk, httpsTot],
portal: false,
system: false,
soll: { status: 2, glied: 'HTTPS-Abruf' },
},
{
name: 'Umleitung auf Anmeldeseite -> GELB "Anmeldeseite", NICHT rot "HTTPS tot"',
stufen: [anschlussOk, gwOk, dnsOk, { name: 'HTTPS-Abruf', ok: false, detail: 'HTTP 302 → Portal' }],
portal: true,
system: false,
soll: { status: 1, glied: 'Anmeldeseite', hinweisEnthaelt: 'Gäste-WLAN' },
},
{
name: 'Portal nur ueber das Systemflag erkannt -> GELB Anmeldeseite',
stufen: [anschlussOk, gwOk, dnsOk, httpsOk],
portal: true,
system: false,
soll: { status: 1, glied: 'Anmeldeseite' },
},
{
name: 'Alles ok, aber Android bestaetigt die Verbindung nicht -> GELB',
stufen: [anschlussOk, gwOk, dnsOk, httpsOk],
portal: false,
system: false,
soll: { status: 1, glied: '', hinweisEnthaelt: 'gefilterte' },
},
{
name: 'Kein Anschluss (keine IP) -> NICHT MESSBAR, keine Aussage ueber das Kundennetz',
stufen: [{ name: 'Anschluss', ok: null, detail: 'keine IP-Adresse' }],
portal: false,
system: undefined,
soll: { status: 3, glied: 'Anschluss' },
},
{
name: 'Kein Gateway gemeldet, Rest geht -> GRUEN mit Hinweis "nicht geprueft"',
stufen: [anschlussOk, gwUnbekannt, dnsOk, httpsOk],
portal: false,
system: true,
soll: { status: 0, glied: '', hinweisEnthaelt: 'nicht geprüft' },
},
{
name: 'Systemurteil unbekannt (aeltere Android-Version) -> GRUEN, kein Gelb aus Unwissen',
stufen: [anschlussOk, gwOk, dnsOk, httpsOk],
portal: false,
system: undefined,
soll: { status: 0, glied: '' },
},
];
let fehler = 0;
for (const f of faelle) {
const r = bewerteKette(f.stufen, f.portal, f.system);
let ok = r.status === f.soll.status && r.fehlerglied === f.soll.glied;
if (f.soll.hinweisEnthaelt && !(r.hinweis || '').includes(f.soll.hinweisEnthaelt)) ok = false;
if (!ok) fehler++;
console.log(`${ok ? 'OK ' : 'FEHL'} ${f.name}`);
console.log(` status=${r.status} (soll ${f.soll.status}) glied="${r.fehlerglied}" (soll "${f.soll.glied}")`);
console.log(` Label: ${r.label}`);
if (r.hinweis) console.log(` Hinweis: ${r.hinweis.slice(0, 120)}`);
console.log();
}
console.log(fehler === 0 ? 'ALLE FAELLE KORREKT' : `${fehler} FEHLER`);
process.exit(fehler === 0 ? 0 : 1);

View file

@ -3,11 +3,11 @@
* ausgerechnetem Sollwert. Die Rechnung landet als Verfuegbarkeitsaussage im * ausgerechnetem Sollwert. Die Rechnung landet als Verfuegbarkeitsaussage im
* Kundendokument sie muss stimmen, nicht plausibel aussehen. * Kundendokument sie muss stimmen, nicht plausibel aussehen.
*/ */
import { readFileSync } from 'node:fs'; // Laedt die TS-Datei direkt (Node 22.6+ entfernt die Typannotationen selbst).
// Vorher stand hier der Pfad einer von Hand erzeugten .mjs-Kopie in einem
// Sitzungsverzeichnis unter /tmp — nach dessen Wegfall lief dieser Pruefer
// nicht mehr, waehrend im Repo "alle Faelle korrekt" stand.
const { buildMonitorMeasurement } = await import('/tmp/claude-1000/-mnt-17---Entwicklungen-20---Projekte-NetzwerkDiagnose/c1dc785b-f713-4aae-a06d-1fe09a25cd1a/scratchpad/monitor.mjs'); const { buildMonitorMeasurement } = await import('../src/lib/monitor-auswertung.ts');
const T0 = new Date('2026-08-16T09:00:00').getTime(); const T0 = new Date('2026-08-16T09:00:00').getTime();
const min = (m) => T0 + m * 60000; const min = (m) => T0 + m * 60000;