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:
parent
881ca7956f
commit
240d65d534
13 changed files with 906 additions and 162 deletions
|
|
@ -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" />
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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' };
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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 ?? '';
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue