Phase 1: Messverfahren liefern keine falschen Aussagen mehr

Vier Werkzeuge haben nachweislich Unsinn ins Kundenprotokoll geschrieben. Belege
sind echte Prod-Messungen; jeder Fix wurde im Emulator gegen den passenden
Fehlerfall nachgestellt.

SNMP (Snmp.kt, snmp.ts)
  Snmp.get lieferte bei JEDEM Problem null -> "-" -> parseInt("-")||0 -> 0.
  Ein Switch, der gar nicht antwortet, stand damit als "0 Fehler" in Gruen im
  Kunden-PDF (Prod #95/#96). Neu: Ergebnistyp Value/Timeout/Failure statt
  String?, Request-ID- und error-status-Pruefung, v2c-Ausnahmen (0x80/0x81/0x82)
  erkannt, ein Wiederholversuch bei Timeout. Das Werkzeug meldet jetzt
  "nicht messbar" statt Zahlen zu erfinden.
  Test: SNMP gegen einen Host ohne Agent -> status 3 "keine SNMP-Antwort".

Traceroute (pingWithTtl, traceroute.ts)
  Der Fallback-Regex \((\d+\.\d+\.\d+\.\d+)\) traf IMMER zuerst die ping-Kopfzeile
  "PING 8.8.8.8 (8.8.8.8) ...". Ein stummer Hop wurde dadurch als Ziel ausgegeben
  und der Trace beendet — daher "2 Hops bis 8.8.8.8, beide 0 ms" (Prod #13).
  Zwischen-Hops liefern zudem nie ein time=, die Zeit war strukturell 0.
  Neu: Kopfzeile verworfen, zeilenweise geparst, Reverse-DNS-Antworten erkannt,
  RTT selbst per nanoTime gestoppt, 3 Proben je TTL, waitFor(3s)+destroyForcibly,
  "keine Antwort" ist null und nicht 0, Bewertung haengt an "Ziel erreicht".
  Test: 8.8.8.8 -> 1 Hop mit echten 13,3 ms (statt 0 ms);
        192.0.2.1 -> status 3, alle Hops "* (keine Antwort)" statt gruen.

Ping (measurePing, ping.ts)
  Kein WifiLock, keine Aufwaermproben: zwischen den Proben schlief das Funkmodul
  ein, die Aufwachzeit landete in der Messung — 296 ms zum LAN-Gateway bei 0 %
  Verlust und rotem Status (Prod #4). WAKE_LOCK fehlte im Manifest, weshalb
  WifiLock.acquire() eine SecurityException warf, die ein leerer catch schluckte.
  Neu: WAKE_LOCK im Manifest, WifiLock (LOW_LATENCY) + PARTIAL_WAKE_LOCK um jede
  Messung, 2 verworfene Aufwaermproben, Median und Messaufschlag (avg-min)
  zusaetzlich ausgewiesen, Verlust als Kommazahl statt Ganzzahl-Division,
  Bewertung auf den Median statt den Mittelwert, "keine Antwort" != "0 ms".
  Test: 20 Proben -> Median 0,3 ms, Messaufschlag 0,1 ms, Verfahren dokumentiert.

Durchsatz (measureThroughput, iperf.ts)
  Kein soTimeout: nahm die Gegenstelle an, ohne zu senden, blockierte read()
  unbegrenzt und das Werkzeug-Fenster hing dauerhaft auf "Messung laeuft ...".
  Neu: soTimeout 4 s je Phase, harter withTimeout-Guard, Ist-Zeit statt
  angenommener halber Dauer, measured-Flag — ohne uebertragene Bytes gibt es
  kein rotes "0 Mbit/s" mehr, sondern "nicht messbar".
  Test: Gegenstelle nimmt an und schweigt -> Ergebnis nach 30 s statt Dauerhaenger.

Netzerkennung (firstLocalIpv4, getLocalSubnet, hostsInSubnet)
  firstLocalIpv4() gab bei fehlendem Netz hart "192.168.1.1" zurueck; zusammen mit
  dem Praefix-Fallback /24 entstand das Phantom-Netz 192.168.1.0/24, das auch noch
  ins Protokoll geschrieben wurde. Der Fehlerzweig in ipscan.ts konnte deshalb nie
  greifen. Neu: "" statt Phantom-IP, Link-Local uebersprungen, getLocalSubnet
  meldet einen Fehler, DHCP-Fallback nur bei WLAN-Transport (sonst wurde bei
  gestecktem USB-RJ45 die Maske aus dem WLAN genommen), unlesbares CIDR-Praefix
  wird abgelehnt statt still durch /24 ersetzt.
  Test: Flugmodus -> "Kein Netzbereich — WLAN/LAN nicht aktiv?" statt Phantom-Netz.

Stresstest-Ziel (stresstest.ts)
  Vorgabe 192.168.1.1 im Kundennetz 192.168.178.0/24 -> 5 Minuten ins Leere
  gemessen, Ergebnis "100 % Verlust" rot im Protokoll (Prod #126). Neu: kein
  fester Vorgabewert, leer = Gateway des aktiven Adapters, und eine Vorlaufprobe
  bricht ab, bevor minutenlang gegen ein totes Ziel gemessen wird.
  Test: Ziel 192.168.1.1 -> nach 7 s "antwortet nicht — Test nicht gestartet"
        (vorher: 60 s Messung mit rotem 100-%-Ergebnis).

Neue Bewertung "nicht messbar" (MeasureStatus 3)
  Bisher gab es nur ok/warn/fail. Ein Test, der gar nicht durchgefuehrt werden
  konnte, musste sich als eines davon ausgeben — meist als Gruen. Status 3 wird
  neutral grau dargestellt (Messkarte und Geraetekarte) und ist keine Aussage
  ueber das Kundennetz.

Geprueft: gradle compileDebugKotlin ok, svelte-check 0 Fehler (2 vorbestehende
Warnungen), alle sieben Faelle im Emulator (Pixel 6 / Android 14) durchgespielt
und die Ergebnisse in der Datenbank gegengelesen statt am Bildschirm geraten.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Eduard Wisch 2026-08-14 19:16:17 +02:00
parent 881ca7956f
commit 240d65d534
13 changed files with 906 additions and 162 deletions

View file

@ -51,6 +51,14 @@
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" /> <uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<!--
WAKE_LOCK ist Pflicht fuer WifiManager.WifiLock.acquire() und den
PARTIAL_WAKE_LOCK waehrend einer Messung. Ohne dieses Recht wirft
acquire() eine SecurityException — die Sperren waren damit wirkungslos,
das WLAN ging zwischen den Proben schlafen und die gemessenen Latenzen
enthielten die Aufwachzeit statt der Netzlaufzeit.
-->
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- Geräte-Monitor: Vordergrund-Dienst + Ausfall-Benachrichtigungen --> <!-- Geräte-Monitor: Vordergrund-Dienst + Ausfall-Benachrichtigungen -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

View file

@ -30,8 +30,10 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import java.io.BufferedReader import java.io.BufferedReader
import java.io.File import java.io.File
import java.io.FileOutputStream import java.io.FileOutputStream
@ -72,6 +74,86 @@ class NetDiagScannerPlugin : Plugin() {
private val io = CoroutineScope(Dispatchers.IO) private val io = CoroutineScope(Dispatchers.IO)
private val stressRuns = ConcurrentHashMap<String, StressRun>() private val stressRuns = ConcurrentHashMap<String, StressRun>()
/* --------------------------------------------------------------------- */
/* Funk/CPU wachhalten waehrend einer Messung */
/* --------------------------------------------------------------------- */
/**
* Haelt WLAN-Funkmodul und CPU waehrend einer Messung wach.
*
* Warum das eine Messgroesse ist und keine Nebensache: Zwischen zwei Proben
* liegen Pausen von 200 ms und mehr. Ohne Lock schaltet das WLAN in den
* Energiesparmodus und muss zum naechsten Beacon aufwachen die gemessene
* Zeit enthaelt dann die Aufwachzeit statt der Netzlaufzeit. Genau so kommt
* Prod-Messung #4 zustande: 296 ms Mittel zum LAN-Gateway bei 0 % Verlust,
* und selbst der Minimalwert lag bei 207 ms.
*
* Stolperfalle: `WifiLock.acquire()` braucht `android.permission.WAKE_LOCK`.
* Fehlt das Recht, fliegt eine SecurityException die frueher ein leerer
* catch-Block verschluckt hat, der Lock war also wirkungslos ohne jeden
* Hinweis. Deshalb wird hier protokolliert.
*/
private class RadioLocks(
val wifi: WifiManager.WifiLock?,
val wake: android.os.PowerManager.WakeLock?,
)
private fun acquireRadioLocks(tag: String, timeoutMs: Long): RadioLocks {
var wifi: WifiManager.WifiLock? = null
var wake: android.os.PowerManager.WakeLock? = null
try {
val wm = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager
val mode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
WifiManager.WIFI_MODE_FULL_LOW_LATENCY
} else {
@Suppress("DEPRECATION") WifiManager.WIFI_MODE_FULL_HIGH_PERF
}
wifi = wm.createWifiLock(mode, "netdiag-$tag")
wifi.acquire()
} catch (e: Exception) {
android.util.Log.w(TAG, "WifiLock ($tag) nicht moeglich: ${e.message}")
wifi = null
}
try {
val pm = context.applicationContext
.getSystemService(Context.POWER_SERVICE) as android.os.PowerManager
wake = pm.newWakeLock(android.os.PowerManager.PARTIAL_WAKE_LOCK, "netdiag:$tag")
// Immer mit Zeitgrenze anfordern — ein vergessener WakeLock wuerde
// sonst den Akku leersaugen.
wake.acquire(timeoutMs)
} catch (e: Exception) {
android.util.Log.w(TAG, "WakeLock ($tag) nicht moeglich: ${e.message}")
wake = null
}
return RadioLocks(wifi, wake)
}
/**
* Laeuft das aktive Netz ueber WLAN? Entscheidet, ob die WLAN-DHCP-Info als
* Fallback fuer Maske/Gateway ueberhaupt zum aktiven Interface passt.
*/
private fun isActiveTransportWifi(): Boolean {
return try {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return true
val cm = context.applicationContext
.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val caps = cm.activeNetwork?.let { cm.getNetworkCapabilities(it) } ?: return false
caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
} catch (e: Exception) {
android.util.Log.w(TAG, "isActiveTransportWifi: ${e.message}")
false
}
}
private fun releaseRadioLocks(locks: RadioLocks) {
try { if (locks.wifi?.isHeld == true) locks.wifi.release() } catch (e: Exception) {
android.util.Log.w(TAG, "WifiLock-Freigabe: ${e.message}")
}
try { if (locks.wake?.isHeld == true) locks.wake.release() } catch (e: Exception) {
android.util.Log.w(TAG, "WakeLock-Freigabe: ${e.message}")
}
}
/* --------------------------------------------------------------------- */ /* --------------------------------------------------------------------- */
/* Subnetz / lokale Netzwerkinfo */ /* Subnetz / lokale Netzwerkinfo */
/* --------------------------------------------------------------------- */ /* --------------------------------------------------------------------- */
@ -110,7 +192,12 @@ class NetDiagScannerPlugin : Plugin() {
} }
// 2. Fallback: WLAN-DHCP-Info (ältere Geräte / LinkProperties leer) // 2. Fallback: WLAN-DHCP-Info (ältere Geräte / LinkProperties leer)
if (ip.isEmpty() || prefix == 0) { // NUR wenn das aktive Netz auch wirklich WLAN ist. Sonst wuerde
// bei gestecktem USB-RJ45-Adapter (dessen LinkProperties oft
// prefixLength 0 melden) die Maske und das Gateway aus dem
// parallel verbundenen WLAN uebernommen — die App scannt dann
// ein ganz anderes Netz als das Kabel, in dem sie steckt.
if ((ip.isEmpty() || prefix == 0) && isActiveTransportWifi()) {
val wifi = context.applicationContext val wifi = context.applicationContext
.getSystemService(Context.WIFI_SERVICE) as WifiManager .getSystemService(Context.WIFI_SERVICE) as WifiManager
@Suppress("DEPRECATION") val dhcp = wifi.dhcpInfo @Suppress("DEPRECATION") val dhcp = wifi.dhcpInfo
@ -121,18 +208,31 @@ class NetDiagScannerPlugin : Plugin() {
} }
} }
// 3. Letzter Fallback // 3. Letzter Fallback: irgendein aktives Interface
var prefixQuelle = if (prefix in 1..32) "adapter" else ""
if (ip.isEmpty()) ip = firstLocalIpv4() if (ip.isEmpty()) ip = firstLocalIpv4()
if (prefix !in 1..32) prefix = 24
// Netzadresse aus IP + Präfix berechnen // Kein Netz = Fehler. Frueher wurde hier "192.168.1.1" erfunden und
val ipInt = ipv4ToInt(ip) ?: 0 // mit /24 zu einem Netz zusammengebaut, das es nirgends gab.
val ipInt = ipv4ToInt(ip)
if (ip.isEmpty() || ipInt == null) {
call.reject("Kein aktives IPv4-Netz — WLAN/LAN verbunden?")
return@launch
}
if (prefix !in 1..32) {
// Nur noch als benannte Annahme, nicht mehr stillschweigend
prefix = 24
prefixQuelle = "angenommen"
}
val mask = if (prefix == 0) 0 else (-1 shl (32 - prefix)) val mask = if (prefix == 0) 0 else (-1 shl (32 - prefix))
val network = intToIpv4(ipInt and mask) val network = intToIpv4(ipInt and mask)
resolve(call, JSObject() resolve(call, JSObject()
.put("subnet", "$network/$prefix") .put("subnet", "$network/$prefix")
.put("ip", ip) .put("ip", ip)
.put("prefix", prefix)
.put("prefixQuelle", prefixQuelle)
.put("gateway", gateway)) .put("gateway", gateway))
} catch (e: Exception) { } catch (e: Exception) {
call.reject("getLocalSubnet: ${e.message}") call.reject("getLocalSubnet: ${e.message}")
@ -152,6 +252,9 @@ class NetDiagScannerPlugin : Plugin() {
return call.reject("Subnetz ungültig oder zu groß (max /16): $subnet") return call.reject("Subnetz ungültig oder zu groß (max /16): $subnet")
} }
io.launch { io.launch {
// Ohne wachgehaltenes Funkmodul faellt bei einem laengeren Sweep die
// Trefferquote, weil das WLAN zwischendurch in den Sparmodus geht.
val locks = acquireRadioLocks("ipscan", (hosts.size * 60L).coerceIn(30_000L, 600_000L))
try { try {
// Parallel-Ping über ALLE Host-Adressen des Subnetzes — CIDR-genau, // Parallel-Ping über ALLE Host-Adressen des Subnetzes — CIDR-genau,
// also exakt der Bereich, den die Netzmaske aufspannt (/24, /23, /22 …). // also exakt der Bereich, den die Netzmaske aufspannt (/24, /23, /22 …).
@ -196,9 +299,17 @@ class NetDiagScannerPlugin : Plugin() {
if (type.isNotEmpty()) dev.put("deviceType", type) if (type.isNotEmpty()) dev.put("deviceType", type)
devices.put(dev) devices.put(dev)
} }
resolve(call, JSObject().put("devices", devices)) resolve(call, JSObject()
.put("devices", devices)
// Diagnosefelder: ein leeres Ergebnis ist etwas anderes als ein
// gescheiterter Scan — der Aufrufer kann das jetzt unterscheiden.
.put("probed", hosts.size)
.put("answered", alive.size)
.put("arpAvailable", arp.isNotEmpty()))
} catch (e: Exception) { } catch (e: Exception) {
call.reject("ipScan: ${e.message}") call.reject("ipScan: ${e.message}")
} finally {
releaseRadioLocks(locks)
} }
} }
} }
@ -418,10 +529,25 @@ class NetDiagScannerPlugin : Plugin() {
* Ohne Praefix wird /24 angenommen. Netz- und Broadcast-Adresse sind * Ohne Praefix wird /24 angenommen. Netz- und Broadcast-Adresse sind
* ausgenommen (ausser /31, /32). Leer bei ungueltig oder > /16. * ausgenommen (ausser /31, /32). Leer bei ungueltig oder > /16.
*/ */
/**
* Alle Host-Adressen eines CIDR-Bereichs.
*
* Zum Praefix: Frueher wurde ein fehlendes ODER unlesbares Praefix
* kommentarlos durch 24 ersetzt "192.168.144.0/zwanzig" ergab dann 254
* statt 4094 Adressen, und die Eingabe "192.168.153.0" ohne Praefix (siehe
* Prod-Messung #2) scannte still nur ein Viertelprozent des gemeinten Netzes.
* Ein fehlendes Praefix ist jetzt "nimm /24" (dokumentierte Annahme, der
* Aufrufer weist darauf hin), ein KAPUTTES Praefix dagegen ein Fehler.
*/
private fun hostsInSubnet(cidr: String): List<Int> { private fun hostsInSubnet(cidr: String): List<Int> {
val parts = cidr.trim().split('/') val parts = cidr.trim().split('/')
val ipInt = ipv4ToInt(parts[0].trim()) ?: return emptyList() val ipInt = ipv4ToInt(parts[0].trim()) ?: return emptyList()
val prefix = if (parts.size > 1) (parts[1].trim().toIntOrNull() ?: 24) else 24 val prefix = if (parts.size > 1) {
// Nicht interpretierbar -> ablehnen statt raten
parts[1].trim().toIntOrNull() ?: return emptyList()
} else {
24
}
if (prefix < 0 || prefix > 32) return emptyList() if (prefix < 0 || prefix > 32) return emptyList()
val mask = if (prefix == 0) 0 else (-1 shl (32 - prefix)) val mask = if (prefix == 0) 0 else (-1 shl (32 - prefix))
val network = ipInt and mask val network = ipInt and mask
@ -575,29 +701,74 @@ class NetDiagScannerPlugin : Plugin() {
} }
} }
/**
* Ping-Messung.
*
* Bekannte Grenze des Verfahrens: `InetAddress.isReachable()` schickt auf
* Android in aller Regel KEIN ICMP (dafuer fehlen die Rechte), sondern baut
* eine TCP-Verbindung auf. Die gemessene Zeit enthaelt daher immer etwas
* Verbindungsaufbau. Was sich sauber beheben laesst, ist hier gemacht:
* - Funk/CPU bleiben waehrend der Messung wach (sonst dominiert die
* WLAN-Aufwachzeit die Werte, siehe acquireRadioLocks)
* - die ersten Proben werden verworfen (Verbindungsaufbau, ARP, DNS-Cache)
* - zusaetzlich zu avg werden min und Median ausgewiesen; der Median ist
* gegen einzelne Ausreisser robust, min zeigt was das Netz wirklich kann
* - `messaufschlagMs` (avg - min) macht sichtbar, wie viel Messverfahren
* im Wert steckt
*/
private fun measurePing(host: String, count: Int): JSObject { private fun measurePing(host: String, count: Int): JSObject {
val times = ArrayList<Double>() val locks = acquireRadioLocks("ping", (count * 1500L) + 10_000L)
val addr = InetAddress.getByName(host) try {
repeat(count) { val times = ArrayList<Double>()
val t0 = System.nanoTime() val addr = InetAddress.getByName(host)
if (addr.isReachable(1000)) {
times.add((System.nanoTime() - t0) / 1_000_000.0) // Aufwaermproben: Ergebnis wird verworfen, sie wecken nur das Funkmodul
repeat(PING_WARMUP_PROBES) {
try { addr.isReachable(1000) } catch (_: Exception) { }
Thread.sleep(100)
} }
Thread.sleep(200)
repeat(count) {
val t0 = System.nanoTime()
val ok = try { addr.isReachable(1000) } catch (_: Exception) { false }
if (ok) times.add((System.nanoTime() - t0) / 1_000_000.0)
Thread.sleep(200)
}
val received = times.size
// Kommazahl statt Ganzzahl-Division: 1 von 20 verlorenen Paketen sind
// 5,0 % — vorher wurde alles unter 1 % auf 0 abgeschnitten.
val loss = if (count > 0) (count - received) * 100.0 / count else 0.0
val sorted = times.sorted()
val min = sorted.firstOrNull() ?: 0.0
val max = sorted.lastOrNull() ?: 0.0
val avg = if (times.isNotEmpty()) times.average() else 0.0
val median = when {
sorted.isEmpty() -> 0.0
sorted.size % 2 == 1 -> sorted[sorted.size / 2]
else -> (sorted[sorted.size / 2 - 1] + sorted[sorted.size / 2]) / 2.0
}
// Jitter: mittlere absolute Abweichung aufeinanderfolgender EMPFANGENER
// Proben. Verlorene Proben landen gar nicht erst in der Liste — das ist
// kein RFC-3550-Jitter, deshalb wird das Verfahren mit ausgegeben.
var jitter = 0.0
for (i in 1 until times.size) jitter += Math.abs(times[i] - times[i - 1])
if (times.size > 1) jitter /= (times.size - 1)
return JSObject()
.put("sent", count)
.put("received", received)
.put("lossPct", round1(loss))
.put("minMs", round1(min))
.put("avgMs", round1(avg))
.put("medianMs", round1(median))
.put("maxMs", round1(max))
.put("jitterMs", round1(jitter))
.put("messaufschlagMs", round1(if (times.isNotEmpty()) avg - min else 0.0))
.put("verfahren", "TCP-Erreichbarkeit (isReachable), kein ICMP")
} finally {
releaseRadioLocks(locks)
} }
val received = times.size
val loss = ((count - received) * 100) / count
val min = times.minOrNull() ?: 0.0
val max = times.maxOrNull() ?: 0.0
val avg = if (times.isNotEmpty()) times.average() else 0.0
// Jitter = mittlere absolute Abweichung aufeinanderfolgender Werte
var jitter = 0.0
for (i in 1 until times.size) jitter += Math.abs(times[i] - times[i - 1])
if (times.size > 1) jitter /= (times.size - 1)
return JSObject()
.put("sent", count).put("received", received).put("lossPct", loss)
.put("minMs", round1(min)).put("avgMs", round1(avg))
.put("maxMs", round1(max)).put("jitterMs", round1(jitter))
} }
/* --------------------------------------------------------------------- */ /* --------------------------------------------------------------------- */
@ -697,10 +868,36 @@ class NetDiagScannerPlugin : Plugin() {
io.launch { io.launch {
try { try {
val values = JSObject() val values = JSObject()
for (oid in oids) { var answered = false
values.put(oid, Snmp.get(host, community, oid) ?: "-") var firstProblem = ""
for ((idx, oid) in oids.withIndex()) {
when (val res = Snmp.get(host, community, oid)) {
is Snmp.Result.Value -> {
answered = true
values.put(oid, res.text)
}
is Snmp.Result.Failure -> {
// Der Agent hat geantwortet, nur diese OID gibt es nicht.
// Das Geraet ist also erreichbar.
answered = true
if (firstProblem.isEmpty()) firstProblem = res.reason
}
Snmp.Result.Timeout -> {
if (firstProblem.isEmpty()) {
firstProblem = "Keine Antwort auf SNMP (Port 161)"
}
// Antwortet schon die erste OID nicht, antwortet auch der
// Rest nicht — dann nicht noch 3x ins Timeout laufen.
if (idx == 0) break
}
}
} }
resolve(call, JSObject().put("values", values))
resolve(call, JSObject()
.put("values", values)
.put("answered", answered)
.put("error", firstProblem))
} catch (e: Exception) { } catch (e: Exception) {
call.reject("snmpGet: ${e.message}") call.reject("snmpGet: ${e.message}")
} }
@ -718,18 +915,41 @@ class NetDiagScannerPlugin : Plugin() {
try { try {
val hops = JSArray() val hops = JSArray()
var deadStreak = 0 var deadStreak = 0
var reachedTarget = false
for (ttl in 1..30) { for (ttl in 1..30) {
val hop = pingWithTtl(host, ttl) // Drei Proben je Hop wie bei traceroute/mtr: eine einzelne Probe
hops.put(JSObject() // ist bei ratenbegrenzten Routern reine Glückssache.
.put("ttl", ttl) var ip = "*"
.put("ip", hop.ip) val times = ArrayList<Double>()
.put("ms", hop.ms)) var reached = false
if (hop.ip == host || hop.reachedTarget) break repeat(3) {
// Nach 5 toten Hops in Folge abbrechen statt stur bis TTL 30 val h = pingWithTtl(host, ttl)
deadStreak = if (hop.ip == "*") deadStreak + 1 else 0 if (h.ip != "*" && ip == "*") ip = h.ip
if (h.ms != null) times.add(h.ms)
if (h.reachedTarget) reached = true
}
val hop = JSObject().put("ttl", ttl).put("ip", ip)
if (times.isNotEmpty()) {
hop.put("ms", round1(times.average()))
hop.put("minMs", round1(times.minOrNull() ?: 0.0))
} else {
// Kein Wert ist etwas anderes als 0 ms — sonst steht im
// Protokoll "0 ms" für einen Hop, der nie geantwortet hat.
hop.put("ms", null as Any?)
}
hop.put("antworten", times.size)
hops.put(hop)
if (reached) { reachedTarget = true; break }
deadStreak = if (ip == "*") deadStreak + 1 else 0
if (deadStreak >= 5) break if (deadStreak >= 5) break
} }
resolve(call, JSObject().put("hops", hops))
resolve(call, JSObject()
.put("hops", hops)
.put("reachedTarget", reachedTarget))
} catch (e: Exception) { } catch (e: Exception) {
call.reject("traceroute: ${e.message}") call.reject("traceroute: ${e.message}")
} }
@ -747,42 +967,87 @@ class NetDiagScannerPlugin : Plugin() {
val durationSec = call.getInt("durationSec") ?: 10 val durationSec = call.getInt("durationSec") ?: 10
io.launch { io.launch {
try { try {
// Einfacher TCP-Durchsatz gegen eine Sink/Source-Gegenstelle: // Harte Obergrenze: selbst wenn unten etwas klemmt, loest der Call auf.
// Download = empfangene Bytes, Upload = gesendete Bytes je Sekunde. // Vorher konnte read() unbegrenzt blockieren und das Werkzeug-Fenster
val res = measureThroughput(host, port, durationSec) // blieb dauerhaft auf "Messung laeuft ...".
val res = withTimeout((durationSec * 1000L) + 15_000L) {
measureThroughput(host, port, durationSec)
}
resolve(call, res) resolve(call, res)
} catch (e: TimeoutCancellationException) {
call.reject("Durchsatztest abgebrochen — Gegenstelle antwortet nicht")
} catch (e: Exception) { } catch (e: Exception) {
call.reject("throughput: ${e.message}") call.reject("throughput: ${e.message}")
} }
} }
} }
/**
* Roher TCP-Durchsatz gegen eine Sink/Source-Gegenstelle.
*
* ACHTUNG: Das ist KEIN iperf3. iperf3 erwartet zuerst ein 37-Byte-Cookie und
* einen laengenpraefigierten JSON-Parameterblock; hier werden schlicht Bytes
* geschrieben bzw. gelesen. Gegen einen echten iperf3-Server bricht die
* Verbindung deshalb ab. Umbau steht in ROADMAP_UMSETZUNG.md Phase 1.
*
* Jede Phase bekommt einen soTimeout ohne den blockiert read()/write()
* unbegrenzt, wenn die Gegenstelle zwar annimmt, aber nichts tut.
*/
private fun measureThroughput(host: String, port: Int, durationSec: Int): JSObject { private fun measureThroughput(host: String, port: Int, durationSec: Int): JSObject {
val buf = ByteArray(64 * 1024) val buf = ByteArray(64 * 1024)
var downBytes = 0L var downBytes = 0L
var upBytes = 0L var upBytes = 0L
var upSecs = 0.0
var downSecs = 0.0
val phaseMs = durationSec * 500L // je Haelfte der Gesamtdauer
// Upload-Phase // Upload-Phase
Socket().use { s -> Socket().use { s ->
s.soTimeout = SOCKET_IO_TIMEOUT_MS
s.connect(InetSocketAddress(host, port), 3000) s.connect(InetSocketAddress(host, port), 3000)
val end = System.currentTimeMillis() + durationSec * 500L val t0 = System.nanoTime()
val end = System.currentTimeMillis() + phaseMs
val out = s.getOutputStream() val out = s.getOutputStream()
while (System.currentTimeMillis() < end) { try {
out.write(buf); upBytes += buf.size while (System.currentTimeMillis() < end) {
out.write(buf); upBytes += buf.size
}
} catch (_: Exception) {
// Gegenstelle bricht ab: gemessen wird, was bis dahin durchging
} }
// Ist-Zeit statt angenommener Haelfte — ein vorzeitiger Abbruch wuerde
// sonst als niedriger Durchsatz erscheinen statt als kurze Messung.
upSecs = (System.nanoTime() - t0) / 1_000_000_000.0
} }
// Download-Phase // Download-Phase
Socket().use { s -> Socket().use { s ->
s.soTimeout = SOCKET_IO_TIMEOUT_MS
s.connect(InetSocketAddress(host, port), 3000) s.connect(InetSocketAddress(host, port), 3000)
val end = System.currentTimeMillis() + durationSec * 500L val t0 = System.nanoTime()
val end = System.currentTimeMillis() + phaseMs
val inp = s.getInputStream() val inp = s.getInputStream()
while (System.currentTimeMillis() < end) { try {
val n = inp.read(buf); if (n < 0) break; downBytes += n while (System.currentTimeMillis() < end) {
val n = inp.read(buf); if (n < 0) break; downBytes += n
}
} catch (_: Exception) {
// s.o.
} }
downSecs = (System.nanoTime() - t0) / 1_000_000_000.0
} }
val secs = durationSec / 2.0
val down = if (downSecs > 0.1 && downBytes > 0) downBytes * 8.0 / 1_000_000.0 / downSecs else 0.0
val up = if (upSecs > 0.1 && upBytes > 0) upBytes * 8.0 / 1_000_000.0 / upSecs else 0.0
return JSObject() return JSObject()
.put("downMbps", round1(downBytes * 8.0 / 1_000_000.0 / secs)) .put("downMbps", round1(down))
.put("upMbps", round1(upBytes * 8.0 / 1_000_000.0 / secs)) .put("upMbps", round1(up))
.put("downBytes", downBytes)
.put("upBytes", upBytes)
// Ohne uebertragene Bytes gab es keine Messung — der Aufrufer darf
// daraus keine "0 Mbit/s"-Bewertung machen.
.put("measured", downBytes > 0 || upBytes > 0)
} }
/* --------------------------------------------------------------------- */ /* --------------------------------------------------------------------- */
@ -799,13 +1064,23 @@ class NetDiagScannerPlugin : Plugin() {
// Hinweis: für Läufe > einige Minuten sollte ein Foreground-Service // Hinweis: für Läufe > einige Minuten sollte ein Foreground-Service
// gestartet werden, sonst kann Android den Prozess beenden. // gestartet werden, sonst kann Android den Prozess beenden.
io.launch { io.launch {
val end = System.currentTimeMillis() + durationSec * 1000L // Funk/CPU wachhalten. Ersetzt den Foreground-Service NICHT — bei
while (System.currentTimeMillis() < end && run.active) { // ausgeschaltetem Display kann Android den Lauf weiterhin einfrieren.
val q = measurePing(host, 5) // Der richtige Umbau steht in ROADMAP_UMSETZUNG.md Phase 2.
run.samples++ val locks = acquireRadioLocks("stress", (durationSec * 1000L) + 60_000L)
run.lossSum += q.getInteger("lossPct", 0) ?: 0 try {
run.avgSum += q.getDouble("avgMs") val end = System.currentTimeMillis() + durationSec * 1000L
run.maxMs = Math.max(run.maxMs, q.getDouble("maxMs")) while (System.currentTimeMillis() < end && run.active) {
val q = measurePing(host, 5)
run.samples++
// lossPct ist seit der Ping-Korrektur eine Kommazahl —
// getInteger() haette hier still 0 geliefert.
run.lossSum += q.getDouble("lossPct")
run.avgSum += q.getDouble("avgMs")
run.maxMs = Math.max(run.maxMs, q.getDouble("maxMs"))
}
} finally {
releaseRadioLocks(locks)
} }
} }
resolve(call, JSObject().put("runId", runId)) resolve(call, JSObject().put("runId", runId))
@ -825,9 +1100,9 @@ class NetDiagScannerPlugin : Plugin() {
} }
private class StressRun(val host: String, val durationSec: Int) { private class StressRun(val host: String, val durationSec: Int) {
var active = true @Volatile var active = true
var samples = 0 var samples = 0
var lossSum = 0 var lossSum = 0.0
var avgSum = 0.0 var avgSum = 0.0
var maxMs = 0.0 var maxMs = 0.0
} }
@ -1398,15 +1673,36 @@ class NetDiagScannerPlugin : Plugin() {
private fun intToIp(i: Int): String = private fun intToIp(i: Int): String =
"${i and 0xFF}.${i shr 8 and 0xFF}.${i shr 16 and 0xFF}.${i shr 24 and 0xFF}" "${i and 0xFF}.${i shr 8 and 0xFF}.${i shr 16 and 0xFF}.${i shr 24 and 0xFF}"
/**
* Erste brauchbare lokale IPv4-Adresse oder "" wenn es keine gibt.
*
* Frueher stand hier `return "192.168.1.1"` als Fallback. Zusammen mit dem
* Praefix-Fallback /24 entstand daraus das Phantom-Netz 192.168.1.0/24: die
* App hat munter ein Netz gescannt, das gar nicht existierte, und den Wert
* anschliessend ins Protokoll geschrieben. Der dafuer vorgesehene Fehlerzweig
* in ipscan.ts ("Kein Netzbereich — WLAN/LAN nicht aktiv?") konnte deshalb nie
* greifen. Kein Netz zu haben ist ein Fehler und muss auch so gemeldet werden.
*
* Link-Local (169.254.x.x) wird uebersprungen: das ist eine Selbstvergabe
* mangels DHCP und taugt nicht als Scan-Grundlage.
*/
private fun firstLocalIpv4(): String { private fun firstLocalIpv4(): String {
java.net.NetworkInterface.getNetworkInterfaces().toList().forEach { ni -> try {
ni.inetAddresses.toList().forEach { addr -> java.net.NetworkInterface.getNetworkInterfaces().toList().forEach { ni ->
if (!addr.isLoopbackAddress && addr is java.net.Inet4Address) { if (!ni.isUp || ni.isLoopback) return@forEach
return addr.hostAddress ?: "" ni.inetAddresses.toList().forEach { addr ->
if (!addr.isLoopbackAddress &&
!addr.isLinkLocalAddress &&
addr is java.net.Inet4Address
) {
return addr.hostAddress ?: ""
}
} }
} }
} catch (e: Exception) {
android.util.Log.w(TAG, "firstLocalIpv4: ${e.message}")
} }
return "192.168.1.1" return ""
} }
/** /proc/net/arp lesen -> Map IP -> MAC (kann auf neuen Android-Versionen leer sein) */ /** /proc/net/arp lesen -> Map IP -> MAC (kann auf neuen Android-Versionen leer sein) */
@ -1428,23 +1724,87 @@ class NetDiagScannerPlugin : Plugin() {
return map return map
} }
/** ping mit fester TTL -> (antwortende IP, Latenz in ms) */ /**
private data class Hop(val ip: String, val ms: Double, val reachedTarget: Boolean) * Ergebnis einer TTL-Sonde.
*
* @param ip antwortende IP oder "*" wenn niemand geantwortet hat
* @param ms Laufzeit in ms, oder null wenn nicht messbar
* @param reachedTarget true wenn das Ziel selbst geantwortet hat (Echo Reply)
*/
private data class Hop(val ip: String, val ms: Double?, val reachedTarget: Boolean)
/**
* ping mit fester TTL -> antwortender Hop.
*
* Frühere Fassung hatte zwei Fehler, die zusammen ganze Routen erfunden haben:
*
* 1. Fällt der Regex `From ([\d.]+)` durch (stummer Hop, oder ein Router, der
* mit Reverse-DNS antwortet: "From fritz.box (192.168.1.1)"), griff der
* Fallback `\((\d+\.\d+\.\d+\.\d+)\)`. Der trifft aber IMMER zuerst die
* Kopfzeile `PING 8.8.8.8 (8.8.8.8) 56 data bytes` ein Hop, der gar nicht
* geantwortet hat, wurde damit als Ziel ausgegeben und der Trace beendet.
* Das ist die Ursache für Prod-Messung #13 ("2 Hops bis 8.8.8.8").
* 2. Zwischen-Hops liefern grundsätzlich kein `time=` (das gibt es nur beim
* Echo Reply des Ziels) -> `ms` war strukturell 0.0.
*
* Jetzt: Kopfzeile wird verworfen, zeilenweise ausgewertet, die Laufzeit selbst
* gestoppt, und der Prozess bekommt eine harte Zeitgrenze (vorher konnte
* `waitFor()` unbegrenzt hängen).
*/
private fun pingWithTtl(host: String, ttl: Int): Hop { private fun pingWithTtl(host: String, ttl: Int): Hop {
var proc: Process? = null
return try { return try {
val proc = ProcessBuilder("/system/bin/ping", "-c", "1", "-W", "2", "-t", ttl.toString(), host) val t0 = System.nanoTime()
.redirectErrorStream(true).start() proc = ProcessBuilder(
"/system/bin/ping", "-n", "-c", "1", "-W", "2", "-t", ttl.toString(), host,
).redirectErrorStream(true).start()
val out = proc.inputStream.bufferedReader().readText() val out = proc.inputStream.bufferedReader().readText()
proc.waitFor() if (!proc.waitFor(3, TimeUnit.SECONDS)) {
val ip = Regex("""From ([\d.]+)""").find(out)?.groupValues?.get(1) proc.destroyForcibly()
?: Regex("""\((\d+\.\d+\.\d+\.\d+)\)""").find(out)?.groupValues?.get(1) return Hop("*", null, false)
?: "*" }
val ms = Regex("""time=([\d.]+)""").find(out)?.groupValues?.get(1)?.toDoubleOrNull() ?: 0.0 val elapsedMs = (System.nanoTime() - t0) / 1_000_000.0
val reached = out.contains("bytes from")
Hop(if (reached) host else ip, ms, reached) var ip = ""
var reached = false
var reportedMs: Double? = null
for (raw in out.lineSequence()) {
val line = raw.trim()
// Kopfzeile "PING 8.8.8.8 (8.8.8.8) 56(84) bytes of data." NIE auswerten
if (line.startsWith("PING ")) continue
// Ziel selbst: "64 bytes from 8.8.8.8: icmp_seq=1 ttl=115 time=12.3 ms"
if (line.contains("bytes from")) {
reached = true
if (ip.isEmpty()) {
ip = Regex("""bytes from ([\d.]+)""").find(line)?.groupValues?.get(1) ?: ""
}
reportedMs = Regex("""time[=<]([\d.]+)""").find(line)
?.groupValues?.get(1)?.toDoubleOrNull()
break
}
// Zwischen-Hop: "From 192.168.1.1 icmp_seq=1 Time to live exceeded"
// oder mit Namen: "From fritz.box (192.168.1.1) icmp_seq=1 ..."
if (line.startsWith("From ")) {
val m = Regex("""From (?:\S+ )?\(?(\d+\.\d+\.\d+\.\d+)\)?""").find(line)
if (m != null && ip.isEmpty()) ip = m.groupValues[1]
}
}
when {
// Echo Reply: bevorzugt die vom ping gemeldete Zeit, sonst die selbst gestoppte
reached -> Hop(if (ip.isNotEmpty()) ip else host, reportedMs ?: round1(elapsedMs), true)
// Antwortender Zwischen-Hop: ping meldet keine Zeit -> selbst gestoppte nutzen
ip.isNotEmpty() -> Hop(ip, round1(elapsedMs), false)
// Niemand hat geantwortet
else -> Hop("*", null, false)
}
} catch (e: Exception) { } catch (e: Exception) {
Hop("*", 0.0, false) try { proc?.destroyForcibly() } catch (_: Exception) { }
Hop("*", null, false)
} }
} }
@ -1471,6 +1831,14 @@ class NetDiagScannerPlugin : Plugin() {
private fun round1(v: Double): Double = Math.round(v * 10.0) / 10.0 private fun round1(v: Double): Double = Math.round(v * 10.0) / 10.0
companion object { companion object {
private const val TAG = "NetDiagScanner"
/** Lese-/Schreib-Zeitgrenze auf Mess-Sockets. Ohne sie blockiert read() unbegrenzt. */
private const val SOCKET_IO_TIMEOUT_MS = 4000
/** Proben, die vor einer Ping-Messung verworfen werden (Funkmodul aufwecken). */
private const val PING_WARMUP_PROBES = 2
/** /**
* Kuratierter OUI-Auszug der gängigsten Hersteller im Handwerksumfeld * Kuratierter OUI-Auszug der gängigsten Hersteller im Handwerksumfeld
* (Router, Switches, Kameras, Drucker, IoT, NAS). Kein Anspruch auf * (Router, Switches, Kameras, Drucker, IoT, NAS). Kein Anspruch auf

View file

@ -4,34 +4,77 @@ import java.io.ByteArrayOutputStream
import java.net.DatagramPacket import java.net.DatagramPacket
import java.net.DatagramSocket import java.net.DatagramSocket
import java.net.InetAddress import java.net.InetAddress
import java.net.SocketTimeoutException
/** /**
* Minimaler SNMP-v2c-GET-Client. * Minimaler SNMP-v2c-GET-Client.
* *
* Reicht für das Auslesen einzelner OIDs (Link-Speed, Fehlerzähler) von * Reicht für das Auslesen einzelner OIDs (Link-Speed, Fehlerzähler) von
* gemanagten Switches. Implementiert nur so viel BER-Kodierung wie nötig. * gemanagten Switches. Implementiert nur so viel BER-Kodierung wie nötig.
*
* WICHTIG warum das Ergebnis ein eigener Typ ist und kein `String?`:
* Früher lieferte diese Klasse bei JEDEM Problem `null`; der Aufrufer machte
* daraus `"-"`, `parseInt("-") || 0` ergab `0` und das Werkzeug meldete
* 0 Fehler" in Grün — obwohl der Switch überhaupt nicht geantwortet hatte.
* Genau so steht es in den Prod-Messungen #95/#96. Ein nicht erreichbarer Switch
* darf nie wie ein fehlerfreier Switch aussehen, deshalb wird der Grund jetzt
* mitgeliefert und der Aufrufer muss ihn behandeln.
*/ */
object Snmp { object Snmp {
/** Ergebnis einer OID-Abfrage — Wert, Zeitüberschreitung oder Fehler mit Grund. */
sealed class Result {
/** Der Agent hat geantwortet und einen Wert geliefert. */
data class Value(val text: String) : Result()
/** Keine Antwort in der vorgegebenen Zeit (kein Agent, Firewall, Gerät aus). */
object Timeout : Result()
/** Agent hat geantwortet, aber keinen brauchbaren Wert geliefert. */
data class Failure(val reason: String) : Result()
}
/** /**
* Eine OID per SNMP v2c GET abfragen. * Eine OID per SNMP v2c GET abfragen.
* *
* @return Wert als String oder null bei Fehler/Timeout * @param timeoutMs Wartezeit je Versuch
* @param attempts Anzahl Versuche (UDP darf ruhig einmal verloren gehen)
*/ */
fun get(host: String, community: String, oid: String): String? { fun get(
host: String,
community: String,
oid: String,
timeoutMs: Int = 1500,
attempts: Int = 2,
): Result {
var last: Result = Result.Timeout
repeat(attempts) {
last = getOnce(host, community, oid, timeoutMs)
// Nur bei Zeitüberschreitung erneut versuchen — eine inhaltliche
// Absage (falsche OID, error-status) wiederholt sich sowieso.
if (last !is Result.Timeout) return last
}
return last
}
private fun getOnce(host: String, community: String, oid: String, timeoutMs: Int): Result {
// Request-ID zufällig, damit eine verspätete Antwort auf eine frühere
// Abfrage nicht als Antwort auf diese hier durchgeht.
val requestId = (1..0x7FFFFFFE).random()
return try { return try {
val request = buildGetRequest(community, oid) val request = buildGetRequest(community, oid, requestId)
val socket = DatagramSocket() DatagramSocket().use { sock ->
socket.soTimeout = 2500 sock.soTimeout = timeoutMs
socket.use { sock.send(DatagramPacket(request, request.size, InetAddress.getByName(host), 161))
it.send(DatagramPacket(request, request.size, InetAddress.getByName(host), 161))
val buf = ByteArray(2048) val buf = ByteArray(2048)
val resp = DatagramPacket(buf, buf.size) val resp = DatagramPacket(buf, buf.size)
it.receive(resp) sock.receive(resp)
parseFirstValue(buf, resp.length) parseResponse(buf, resp.length, requestId)
} }
} catch (e: SocketTimeoutException) {
Result.Timeout
} catch (e: Exception) { } catch (e: Exception) {
null Result.Failure(e.message ?: "SNMP-Fehler")
} }
} }
@ -80,59 +123,143 @@ object Snmp {
return tlv(0x06, out.toByteArray()) return tlv(0x06, out.toByteArray())
} }
private fun buildGetRequest(community: String, oidStr: String): ByteArray { private fun buildGetRequest(community: String, oidStr: String, requestId: Int): ByteArray {
val varbind = tlv(0x30, oid(oidStr) + tlv(0x05, ByteArray(0))) // OID + NULL val varbind = tlv(0x30, oid(oidStr) + tlv(0x05, ByteArray(0))) // OID + NULL
val varbindList = tlv(0x30, varbind) val varbindList = tlv(0x30, varbind)
val requestId = (System.currentTimeMillis() and 0x7FFF).toInt()
val pdu = tlv( val pdu = tlv(
0xA0, // GetRequest-PDU 0xA0, // GetRequest-PDU
integer(requestId) + integer(0) + integer(0) + varbindList, integer(requestId) + integer(0) + integer(0) + varbindList,
) )
val message = tlv( return tlv(
0x30, 0x30,
integer(1) + octetString(community) + pdu, // version 1 = SNMPv2c integer(1) + octetString(community) + pdu, // version 1 = SNMPv2c
) )
return message
} }
/* ---- Antwort parsen: ersten Variablen-Wert herausziehen ---- */ /* ---- Antwort parsen ---- */
private fun parseFirstValue(buf: ByteArray, len: Int): String? { /** Kleiner BER-Leser mit Bereichsprüfung. */
private class Reader(val buf: ByteArray, val len: Int) {
var i = 0 var i = 0
// Durch die Struktur navigieren bis zum ersten primitiven Wert nach einer OID
var lastWasOid = false fun tag(): Int {
while (i < len) { require(i < len) { "unerwartetes Paketende" }
val tag = buf[i].toInt() and 0xFF return (buf[i++].toInt() and 0xFF)
i++ }
if (i >= len) break
var l = buf[i].toInt() and 0xFF fun length(): Int {
i++ require(i < len) { "unerwartetes Paketende" }
var l = buf[i++].toInt() and 0xFF
if (l and 0x80 != 0) { if (l and 0x80 != 0) {
val n = l and 0x7F val n = l and 0x7F
require(n in 1..4 && i + n <= len) { "ungueltige Laenge" }
l = 0 l = 0
for (k in 0 until n) { l = (l shl 8) or (buf[i].toInt() and 0xFF); i++ } for (k in 0 until n) l = (l shl 8) or (buf[i++].toInt() and 0xFF)
} }
// Konstruierte Typen (SEQUENCE, PDU) aufsteigen require(l >= 0 && i + l <= len) { "Laenge zeigt ueber das Paket hinaus" }
if (tag == 0x30 || tag == 0xA2 || tag == 0xA0) continue return l
if (tag == 0x06) { lastWasOid = true; i += l; continue }
if (lastWasOid) {
return when (tag) {
0x02, 0x41, 0x42, 0x43, 0x44, 0x46 -> { // INTEGER, Counter, Gauge, TimeTicks ...
var v = 0L
for (k in 0 until l) v = (v shl 8) or (buf[i + k].toLong() and 0xFF)
v.toString()
}
0x04 -> String(buf, i, l) // OCTET STRING
0x05 -> "" // NULL -> kein Wert
else -> {
val sb = StringBuilder()
for (k in 0 until l) sb.append("%02X".format(buf[i + k]))
sb.toString()
}
}
}
i += l
} }
return null
fun intValue(l: Int): Long {
var v = 0L
for (k in 0 until l) v = (v shl 8) or (buf[i + k].toLong() and 0xFF)
i += l
return v
}
fun text(l: Int): String {
val s = String(buf, i, l)
i += l
return s
}
fun hex(l: Int): String {
val sb = StringBuilder()
for (k in 0 until l) sb.append("%02X".format(buf[i + k]))
i += l
return sb.toString()
}
fun ipv4(l: Int): String {
val s = (0 until l).joinToString(".") { (buf[i + it].toInt() and 0xFF).toString() }
i += l
return s
}
fun skip(l: Int) { i += l }
}
/**
* Die Antwortstruktur wird gezielt durchlaufen statt heuristisch nach dem
* ersten primitiven Wert gesucht nur so lassen sich Request-ID und
* error-status überhaupt prüfen.
*
* SEQUENCE { version, community, GetResponse[0xA2] { req-id, err-status,
* err-index, SEQUENCE { SEQUENCE { OID, Wert } } } }
*/
private fun parseResponse(buf: ByteArray, len: Int, expectedRequestId: Int): Result {
return try {
val r = Reader(buf, len)
if (r.tag() != 0x30) return Result.Failure("kein SNMP-Paket")
r.length()
if (r.tag() != 0x02) return Result.Failure("Version fehlt")
r.skip(r.length())
if (r.tag() != 0x04) return Result.Failure("Community fehlt")
r.skip(r.length())
val pduTag = r.tag()
if (pduTag != 0xA2) return Result.Failure("keine GetResponse (Tag 0x%02X)".format(pduTag))
r.length()
if (r.tag() != 0x02) return Result.Failure("Request-ID fehlt")
val reqId = r.intValue(r.length())
if (reqId != expectedRequestId.toLong()) {
return Result.Failure("Antwort gehoert zu einer anderen Abfrage")
}
if (r.tag() != 0x02) return Result.Failure("error-status fehlt")
val errStatus = r.intValue(r.length())
if (errStatus != 0L) return Result.Failure(errorStatusText(errStatus.toInt()))
if (r.tag() != 0x02) return Result.Failure("error-index fehlt")
r.skip(r.length())
if (r.tag() != 0x30) return Result.Failure("Variablenliste fehlt")
r.length()
if (r.tag() != 0x30) return Result.Failure("Variable fehlt")
r.length()
if (r.tag() != 0x06) return Result.Failure("OID fehlt")
r.skip(r.length())
val valueTag = r.tag()
val valueLen = r.length()
when (valueTag) {
// INTEGER, Counter32, Gauge32, TimeTicks, Counter64
0x02, 0x41, 0x42, 0x43, 0x46 -> Result.Value(r.intValue(valueLen).toString())
0x04 -> Result.Value(r.text(valueLen)) // OCTET STRING
0x40 -> Result.Value(r.ipv4(valueLen)) // IpAddress
0x05 -> Result.Failure("Agent liefert keinen Wert (NULL)")
// v2c-Ausnahmen: der Agent kennt die OID nicht. Fruehler kam hier
// ein Leerstring heraus, der als 0 weiterverarbeitet wurde.
0x80 -> Result.Failure("OID auf diesem Geraet nicht vorhanden")
0x81 -> Result.Failure("OID-Instanz nicht vorhanden (Port-Index falsch?)")
0x82 -> Result.Failure("Ende der MIB erreicht")
else -> Result.Value(r.hex(valueLen))
}
} catch (e: Exception) {
Result.Failure("Antwort nicht lesbar: ${e.message}")
}
}
/** error-status aus RFC 3416 in Klartext. */
private fun errorStatusText(code: Int): String = when (code) {
1 -> "Antwort zu gross (tooBig)"
2 -> "OID unbekannt (noSuchName)"
3 -> "Wert passt nicht (badValue)"
4 -> "nur lesbar (readOnly)"
5 -> "allgemeiner Agent-Fehler (genErr)"
6 -> "Zugriff verweigert — Community pruefen (noAccess)"
else -> "Agent meldet Fehler $code"
} }
} }

View file

@ -36,3 +36,10 @@ body {
.ampel-fail { .ampel-fail {
color: #f85149; color: #f85149;
} }
/*
* Nicht messbar" bewusst neutral-grau statt rot. Der Test konnte nicht
* durchgeführt werden; das ist keine Aussage über das Kundennetz.
*/
.ampel-unmess {
color: #8b949e;
}

View file

@ -29,8 +29,9 @@
onrename?: () => void; onrename?: () => void;
} = $props(); } = $props();
const ampel = ['ampel-ok', 'ampel-warn', 'ampel-fail']; // Index = MeasureStatus (0 ok, 1 warn, 2 fail, 3 nicht messbar)
const ampelDot = ['bg-emerald-500', 'bg-amber-400', 'bg-red-500']; const ampel = ['ampel-ok', 'ampel-warn', 'ampel-fail', 'ampel-unmess'];
const ampelDot = ['bg-emerald-500', 'bg-amber-400', 'bg-red-500', 'bg-zinc-500'];
/** Anzeigename: eigener Name vor mDNS-/Host-/NetBIOS-Name, sonst IP */ /** Anzeigename: eigener Name vor mDNS-/Host-/NetBIOS-Name, sonst IP */
const title = $derived( const title = $derived(

View file

@ -65,11 +65,18 @@ export interface OpenPort {
export interface PingQuality { export interface PingQuality {
sent: number; sent: number;
received: number; received: number;
/** Verlust in Prozent (Kommazahl — 1 von 20 Paketen = 5,0) */
lossPct: number; lossPct: number;
minMs: number; minMs: number;
avgMs: number; avgMs: number;
/** Median der Laufzeiten — robuster gegen einzelne Ausreisser als avgMs */
medianMs: number;
maxMs: number; maxMs: number;
jitterMs: number; jitterMs: number;
/** avg - min: Anteil, der vom Messverfahren stammt und nicht vom Netz */
messaufschlagMs: number;
/** Klartext des benutzten Messverfahrens (fuer das Protokoll) */
verfahren: string;
} }
export interface WifiNetwork { export interface WifiNetwork {
ssid: string; ssid: string;
@ -90,20 +97,44 @@ export interface DhcpLease {
} }
export interface TracerouteHop { export interface TracerouteHop {
ttl: number; ttl: number;
/** antwortende IP oder '*' wenn niemand geantwortet hat */
ip: string; ip: string;
ms: number; /** Mittelwert der Proben in ms, null wenn keine Antwort kam (NICHT 0) */
ms: number | null;
minMs?: number;
/** wie viele der 3 Proben geantwortet haben */
antworten?: number;
} }
export interface ThroughputResult { export interface ThroughputResult {
downMbps: number; downMbps: number;
upMbps: number; upMbps: number;
downBytes?: number;
upBytes?: number;
/** false = es wurde kein einziges Byte uebertragen, also keine Messung */
measured?: boolean;
} }
/** 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") */
getLocalSubnet(): Promise<{ subnet: string; ip: string; gateway: string }>; getLocalSubnet(): Promise<{
subnet: string;
ip: string;
gateway: string;
prefix?: number;
/** 'adapter' = vom System gemeldet, 'angenommen' = /24 als Rueckfallwert */
prefixQuelle?: string;
}>;
/** IP-Scan: Geräte im Subnetz finden (ARP + Ping-Sweep + Namensauflösung) */ /** IP-Scan: Geräte im Subnetz finden (ARP + Ping-Sweep + Namensauflösung) */
ipScan(opts: { subnet: string }): Promise<{ devices: ScannedDevice[] }>; ipScan(opts: { subnet: string }): Promise<{
devices: ScannedDevice[];
/** wie viele Adressen geprueft wurden */
probed?: number;
/** wie viele davon geantwortet haben */
answered?: number;
/** war die ARP-Tabelle lesbar? (ab Android 10 meist nicht) */
arpAvailable?: boolean;
}>;
/** mDNS/Bonjour-Dienstsuche: Drucker, Kameras, Chromecast, AirPlay … */ /** mDNS/Bonjour-Dienstsuche: Drucker, Kameras, Chromecast, AirPlay … */
mdnsScan(opts: { timeoutMs?: number }): Promise<{ devices: MdnsDevice[] }>; mdnsScan(opts: { timeoutMs?: number }): Promise<{ devices: MdnsDevice[] }>;
/** IP-Konflikt-Prüfung: findet IP-Adressen, die zwei Geräte gleichzeitig benutzen */ /** IP-Konflikt-Prüfung: findet IP-Adressen, die zwei Geräte gleichzeitig benutzen */
@ -123,9 +154,17 @@ export interface NetDiagScannerPlugin {
/** SNMP v2c Abfrage (Switch: Link-Speed, Fehlerzähler) */ /** SNMP v2c Abfrage (Switch: Link-Speed, Fehlerzähler) */
snmpGet(opts: { host: string; community: string; oids: string[] }): Promise<{ snmpGet(opts: { host: string; community: string; oids: string[] }): Promise<{
values: Record<string, string>; values: Record<string, string>;
/** false = der Switch hat ueberhaupt nicht geantwortet */
answered: boolean;
/** Klartextgrund, wenn etwas nicht gelesen werden konnte */
error: string;
}>; }>;
/** Traceroute zu einem Host */ /** Traceroute zu einem Host */
traceroute(opts: { host: string }): Promise<{ hops: TracerouteHop[] }>; traceroute(opts: { host: string }): Promise<{
hops: TracerouteHop[];
/** hat das Ziel selbst geantwortet? */
reachedTarget: boolean;
}>;
/** Durchsatztest gegen eine TCP-Sink/Source-Gegenstelle (KEIN iperf3-Protokoll) */ /** Durchsatztest gegen eine TCP-Sink/Source-Gegenstelle (KEIN iperf3-Protokoll) */
throughput(opts: { host: string; port: number; durationSec: number }): Promise<ThroughputResult>; throughput(opts: { host: string; port: number; durationSec: number }): Promise<ThroughputResult>;
/** Dauer-/Stresstest starten (läuft NICHT im Foreground-Service, s. stresstest.ts) */ /** Dauer-/Stresstest starten (läuft NICHT im Foreground-Service, s. stresstest.ts) */
@ -197,7 +236,13 @@ let mockWifiTrackRssi = -55;
const mock: NetDiagScannerPlugin = { const mock: NetDiagScannerPlugin = {
async getLocalSubnet() { async getLocalSubnet() {
return { subnet: '192.168.1.0/24', ip: '192.168.1.50', gateway: '192.168.1.1' }; return {
subnet: '192.168.1.0/24',
ip: '192.168.1.50',
gateway: '192.168.1.1',
prefix: 24,
prefixQuelle: 'adapter',
};
}, },
async ipScan() { async ipScan() {
return { return {
@ -274,14 +319,19 @@ const mock: NetDiagScannerPlugin = {
async pingQuality(opts) { async pingQuality(opts) {
const sent = opts.count; const sent = opts.count;
const received = sent - (Math.random() < 0.3 ? 1 : 0); const received = sent - (Math.random() < 0.3 ? 1 : 0);
const min = rnd(1, 4);
const avg = rnd(4, 12);
return { return {
sent, sent,
received, received,
lossPct: Math.round(((sent - received) / sent) * 100), lossPct: Math.round(((sent - received) / sent) * 1000) / 10,
minMs: rnd(1, 4), minMs: min,
avgMs: rnd(4, 12), avgMs: avg,
medianMs: rnd(min, avg),
maxMs: rnd(12, 40), maxMs: rnd(12, 40),
jitterMs: rnd(0.5, 5), jitterMs: rnd(0.5, 5),
messaufschlagMs: Math.round((avg - min) * 10) / 10,
verfahren: 'Mock (Browser-Entwicklung)',
}; };
}, },
async wifiScan() { async wifiScan() {
@ -304,19 +354,26 @@ const mock: NetDiagScannerPlugin = {
async snmpGet(opts) { async snmpGet(opts) {
const values: Record<string, string> = {}; const values: Record<string, string> = {};
for (const oid of opts.oids) values[oid] = String(Math.floor(Math.random() * 1000)); for (const oid of opts.oids) values[oid] = String(Math.floor(Math.random() * 1000));
return { values }; return { values, answered: true, error: '' };
}, },
async traceroute() { async traceroute() {
return { return {
hops: [ hops: [
{ ttl: 1, ip: '192.168.1.1', ms: rnd(1, 3) }, { ttl: 1, ip: '192.168.1.1', ms: rnd(1, 3), antworten: 3 },
{ ttl: 2, ip: '10.0.0.1', ms: rnd(8, 15) }, { ttl: 2, ip: '*', ms: null, antworten: 0 },
{ ttl: 3, ip: '8.8.8.8', ms: rnd(15, 30) }, { ttl: 3, ip: '8.8.8.8', ms: rnd(15, 30), antworten: 3 },
], ],
reachedTarget: true,
}; };
}, },
async throughput() { async throughput() {
return { downMbps: rnd(80, 940), upMbps: rnd(40, 500) }; return {
downMbps: rnd(80, 940),
upMbps: rnd(40, 500),
downBytes: 12_000_000,
upBytes: 6_000_000,
measured: true,
};
}, },
async startStressTest() { async startStressTest() {
return { runId: 'mock-run' }; return { runId: 'mock-run' };

View file

@ -42,6 +42,23 @@ export const iperfTool: Tool = {
const res = await scanner.throughput({ host, port, durationSec }); const res = await scanner.throughput({ host, port, durationSec });
// Kein einziges Byte übertragen = es gab keine Messung. Daraus „0 Mbit/s"
// in Rot zu machen wäre eine Aussage über das Kundennetz, die die Messung
// gar nicht hergibt — meist fehlt schlicht die Gegenstelle.
if (res.measured === false) {
return {
label: `${host}:${port} — keine Gegenstelle`,
result: {
gegenstelle: `${host}:${port}`,
hinweis:
'Es wurden keine Daten übertragen. Der Test braucht eine Gegenstelle, ' +
'die auf diesem Port Daten annimmt und sendet. Ein echter iperf3-Server ' +
'funktioniert hier NICHT (siehe Modul-Doku).',
},
measureStatus: 3, // nicht messbar
};
}
let status: MeasureStatus = 0; let status: MeasureStatus = 0;
if (res.downMbps < 100) status = 1; if (res.downMbps < 100) status = 1;
if (res.downMbps < 10) status = 2; if (res.downMbps < 10) status = 2;

View file

@ -23,22 +23,46 @@ export const pingTool: Tool = {
const q = await scanner.pingQuality({ host, count }); const q = await scanner.pingQuality({ host, count });
// Bewertung: Verlust und Jitter // Gar keine Antwort: das Ziel ist nicht erreichbar. Latenzwerte gibt es
// dann nicht — 0 ms auszugeben wäre irreführend.
if (q.received === 0) {
return {
label: `${host}: keine Antwort`,
result: {
host,
gesendet: q.sent,
empfangen: 0,
verlustProzent: 100,
hinweis: 'Ziel antwortet nicht. Falsche Adresse, Gerät aus oder Firewall?',
},
measureStatus: 2,
};
}
// Bewertung auf den Median statt den Mittelwert: ein einzelner Ausreißer
// (WLAN-Aufwachen, kurzer Funkstörer) verschiebt den Mittelwert stark, der
// Median bleibt bei dem, was das Netz üblicherweise liefert.
const leitwert = q.medianMs ?? q.avgMs;
let status: MeasureStatus = 0; let status: MeasureStatus = 0;
if (q.lossPct > 0 || q.jitterMs > 10) status = 1; if (q.lossPct > 0 || q.jitterMs > 10) status = 1;
if (q.lossPct >= 10 || q.avgMs > 100) status = 2; if (q.lossPct >= 10 || leitwert > 100) status = 2;
return { return {
label: `${host}: ${q.avgMs} ms ø, ${q.lossPct}% Verlust`, label: `${host}: ${leitwert} ms (Median), ${q.lossPct}% Verlust`,
result: { result: {
host, host,
gesendet: q.sent, gesendet: q.sent,
empfangen: q.received, empfangen: q.received,
verlustProzent: q.lossPct, verlustProzent: q.lossPct,
minMs: q.minMs, minMs: q.minMs,
medianMs: q.medianMs,
avgMs: q.avgMs, avgMs: q.avgMs,
maxMs: q.maxMs, maxMs: q.maxMs,
jitterMs: q.jitterMs, jitterMs: q.jitterMs,
// Macht sichtbar, wie viel Messverfahren im Wert steckt: ein großer
// Abstand zwischen Mittelwert und Minimum kommt vom Messweg, nicht vom Netz.
messaufschlagMs: q.messaufschlagMs,
verfahren: q.verfahren,
}, },
measureStatus: status, measureStatus: status,
}; };

View file

@ -32,26 +32,81 @@ export const snmpTool: Tool = {
if (!host) throw new Error('Keine Switch-IP angegeben'); if (!host) throw new Error('Keine Switch-IP angegeben');
const community = String(ctx.params.community || 'public'); const community = String(ctx.params.community || 'public');
const { values } = await scanner.snmpGet({ host, community, oids: Object.values(OIDS) }); const { values, answered, error } = await scanner.snmpGet({
host,
community,
oids: Object.values(OIDS),
});
// OID-Werte den lesbaren Namen zuordnen // Der Switch hat überhaupt nicht geantwortet -> keine Messung.
const named: Record<string, string> = {}; // Früher wurde daraus über parseInt('-') || 0 ein grünes „0 Fehler"
for (const [name, oid] of Object.entries(OIDS)) named[name] = values[oid] ?? '-'; // (siehe Prod-Messungen #95/#96) — ein Totalausfall sah damit aus wie ein
// fehlerfreier Switch.
if (!answered) {
return {
label: `${host}: keine SNMP-Antwort`,
result: {
host,
hinweis:
error ||
'Keine Antwort auf Port 161. Mögliche Ursachen: SNMP am Gerät nicht ' +
'aktiviert, andere Community, SNMPv3 statt v2c, oder Firewall.',
},
measureStatus: 3, // nicht messbar — keine Aussage über das Netz
};
}
const inErr = parseInt(named['ifInErrors'], 10) || 0; // Zahlenwerte nur übernehmen, wenn sie wirklich geliefert wurden
const outErr = parseInt(named['ifOutErrors'], 10) || 0; const num = (key: string): number | null => {
const raw = values[OIDS[key]];
if (raw === undefined || raw === null || raw === '') return null;
const n = Number.parseInt(raw, 10);
return Number.isNaN(n) ? null : n;
};
const text = (key: string): string | null => {
const raw = values[OIDS[key]];
return raw === undefined || raw === '' ? null : raw;
};
const inErr = num('ifInErrors');
const outErr = num('ifOutErrors');
// Agent erreichbar, aber die Fehlerzähler gibt es nicht (falscher Port-Index,
// eingeschränkte MIB-Sicht). Auch das ist „nicht messbar", nicht „0 Fehler".
if (inErr === null && outErr === null) {
return {
label: `${host}: Fehlerzähler nicht lesbar`,
result: {
host,
port: text('ifDescr'),
linkSpeed: text('ifSpeed'),
hinweis:
error ||
'Der Switch antwortet, liefert für diesen Port-Index aber keine ' +
'Fehlerzähler. Standardmäßig wird Interface-Index 1 abgefragt — das ist ' +
'oft nicht der gesuchte Port.',
},
measureStatus: 3,
};
}
const sum = (inErr ?? 0) + (outErr ?? 0);
let status: MeasureStatus = 0; let status: MeasureStatus = 0;
if (inErr + outErr > 0) status = 1; if (sum > 0) status = 1;
if (inErr + outErr > 100) status = 2; if (sum > 100) status = 2;
const speedBits = num('ifSpeed');
return { return {
label: `${host}: ${inErr + outErr} Fehler`, label: `${host}: ${sum} Fehler`,
result: { result: {
host, host,
port: named['ifDescr'], port: text('ifDescr'),
linkSpeed: named['ifSpeed'], linkSpeed: speedBits !== null ? `${Math.round(speedBits / 1_000_000)} Mbit/s` : null,
eingangsFehler: inErr, eingangsFehler: inErr,
ausgangsFehler: outErr, ausgangsFehler: outErr,
hinweis:
'Absolute Zähler seit dem letzten Neustart des Switches — sie sagen nichts ' +
'darüber, ob die Fehler heute oder vor zwei Jahren aufgetreten sind.',
}, },
measureStatus: status, measureStatus: status,
}; };

View file

@ -23,7 +23,16 @@ export const stressTestTool: Tool = {
description: 'Langzeitmessung: Paketverlust und Latenz über einen Zeitraum.', description: 'Langzeitmessung: Paketverlust und Latenz über einen Zeitraum.',
scope: 'protocol', scope: 'protocol',
params: [ params: [
{ key: 'host', label: 'Ziel', type: 'text', default: '192.168.1.1' }, // KEIN fester Vorgabewert mehr. Die alte Vorgabe 192.168.1.1 hat im
// Kundennetz 192.168.178.0/24 fünf Minuten lang ins Leere gemessen und das
// Ergebnis als „100 % Verlust" rot ins Protokoll geschrieben (Prod #126).
// Leer lassen -> es wird das Gateway des aktiven Adapters genommen.
{
key: 'host',
label: 'Ziel — leer = Gateway des aktiven Netzes',
type: 'text',
placeholder: 'leer lassen → Gateway',
},
{ {
key: 'duration', key: 'duration',
label: 'Dauer', label: 'Dauer',
@ -38,9 +47,47 @@ export const stressTestTool: Tool = {
}, },
], ],
async run(ctx) { async run(ctx) {
const host = String(ctx.params.host || '192.168.1.1'); let host = String(ctx.params.host ?? '').trim();
const durationSec = Number(ctx.params.duration || 300); const durationSec = Number(ctx.params.duration || 300);
// Kein Ziel angegeben -> Gateway des aktiven Adapters
if (!host) {
try {
host = String((await scanner.getLocalSubnet()).gateway ?? '').trim();
} catch {
/* unten abgefangen */
}
}
if (!host) {
return {
label: 'Kein Ziel — WLAN/LAN nicht aktiv?',
result: {
hinweis:
'Es konnte kein Gateway ermittelt werden. Bitte ein Ziel angeben ' +
'(z.B. den Router des Kundennetzes).',
},
measureStatus: 3,
};
}
// Vorlaufprobe: antwortet das Ziel überhaupt? Sonst misst man minuten- bis
// stundenlang gegen etwas, das gar nicht da ist, und bekommt am Ende
// „100 % Verlust" — was wie ein kaputtes Kundennetz aussieht.
const probe = await scanner.pingQuality({ host, count: 3 });
if (probe.received === 0) {
return {
label: `${host} antwortet nicht — Test nicht gestartet`,
result: {
host,
hinweis:
`${host} hat auf 3 Vorabproben nicht geantwortet. Ein Dauertest gegen ` +
'ein nicht erreichbares Ziel liefert nur 100 % Verlust und sagt nichts ' +
'über das Netz aus. Ziel prüfen und erneut starten.',
},
measureStatus: 3,
};
}
const { runId } = await scanner.startStressTest({ host, durationSec }); const { runId } = await scanner.startStressTest({ host, durationSec });
// Auf das Ende des Laufs warten. Der Timer läuft in der WebView und wird von // Auf das Ende des Laufs warten. Der Timer läuft in der WebView und wird von
// Chromium gedrosselt, sobald die Seite in den Hintergrund gerät — das Ergebnis // Chromium gedrosselt, sobald die Seite in den Hintergrund gerät — das Ergebnis

View file

@ -3,7 +3,7 @@
*/ */
import { scanner } from '../../scanner'; import { scanner } from '../../scanner';
import type { Tool } from '../types'; import type { MeasureStatus, Tool } from '../types';
export const tracerouteTool: Tool = { export const tracerouteTool: Tool = {
id: 'traceroute', id: 'traceroute',
@ -15,14 +15,36 @@ export const tracerouteTool: Tool = {
params: [{ key: 'host', label: 'Ziel', type: 'text', default: '8.8.8.8' }], params: [{ key: 'host', label: 'Ziel', type: 'text', default: '8.8.8.8' }],
async run(ctx) { async run(ctx) {
const host = String(ctx.params.host || '8.8.8.8'); const host = String(ctx.params.host || '8.8.8.8');
const { hops } = await scanner.traceroute({ host }); const { hops, reachedTarget } = await scanner.traceroute({ host });
const antwortend = hops.filter((h) => h.ip !== '*').length;
// Bewertung an „Ziel erreicht" hängen, nicht an „Liste nicht leer".
// Vorher war jede Ausgabe grün — auch eine Liste aus lauter Sternchen.
let status: MeasureStatus = 0;
if (!reachedTarget) status = 2;
if (antwortend === 0) status = 3; // gar keine Antwort -> nicht messbar
return { return {
label: `${hops.length} Hops bis ${host}`, label: reachedTarget
? `${hops.length} Hops bis ${host}`
: antwortend === 0
? `${host}: keine Antwort auf dem Weg`
: `${host} nicht erreicht (${antwortend} Hops geantwortet)`,
result: { result: {
ziel: host, ziel: host,
hops: hops.map((h) => `${h.ttl}. ${h.ip} (${h.ms} ms)`), zielErreicht: reachedTarget,
hops: hops.map((h) =>
h.ip === '*'
? `${h.ttl}. * (keine Antwort)`
: `${h.ttl}. ${h.ip} (${h.ms === null || h.ms === undefined ? '—' : h.ms + ' ms'})`,
),
hinweis: reachedTarget
? undefined
: 'Viele Router antworten auf abgelaufene TTL grundsätzlich nicht — ' +
'Sternchen bedeuten nicht zwingend eine Störung.',
}, },
measureStatus: hops.length > 0 ? 0 : 2, measureStatus: status,
}; };
}, },
}; };

View file

@ -163,7 +163,17 @@ export interface WifiTrackSession {
} }
/** Ampel-Bewertung einer Messung */ /** Ampel-Bewertung einer Messung */
export type MeasureStatus = 0 | 1 | 2; // 0=ok, 1=warn, 2=fail /**
* Bewertung einer Messung.
*
* 3 ist bewusst kein schlechter als rot", sondern eine eigene Aussage:
* die Messung konnte gar nicht durchgeführt werden (Switch antwortet nicht
* auf SNMP, kein Netz, Gegenstelle fehlt). Ohne diesen Zustand wird ein
* fehlgeschlagener Test entweder als alles gut" (grün, weil 0 Fehler
* gezählt wurden) oder als Netzfehler (rot) protokolliert beides ist
* gegenüber dem Kunden falsch.
*/
export type MeasureStatus = 0 | 1 | 2 | 3; // 0=ok, 1=warn, 2=fail, 3=nicht messbar
/** Ergebnis eines Tool-Laufs */ /** Ergebnis eines Tool-Laufs */
export interface Measurement { export interface Measurement {

View file

@ -49,8 +49,9 @@
), ),
); );
const ampel = ['ampel-ok', 'ampel-warn', 'ampel-fail']; // Index = MeasureStatus (0 ok, 1 warn, 2 fail, 3 nicht messbar)
const ampelDot = ['bg-emerald-500', 'bg-amber-400', 'bg-red-500']; const ampel = ['ampel-ok', 'ampel-warn', 'ampel-fail', 'ampel-unmess'];
const ampelDot = ['bg-emerald-500', 'bg-amber-400', 'bg-red-500', 'bg-zinc-500'];
onMount(async () => { onMount(async () => {
const uuid = $page.params.id ?? ''; const uuid = $page.params.id ?? '';