Compare commits
No commits in common. "7236017e87ec47facf0bcb0d74571fe2fbefe34f" and "bbf24c06b8322a29d09c161488aa00e2b38f9d3d" have entirely different histories.
7236017e87
...
bbf24c06b8
20 changed files with 199 additions and 1842 deletions
|
|
@ -1,196 +0,0 @@
|
||||||
package de.data_it_solution.netdiag
|
|
||||||
|
|
||||||
import java.io.ByteArrayOutputStream
|
|
||||||
import java.net.DatagramPacket
|
|
||||||
import java.net.DatagramSocket
|
|
||||||
import java.net.InetAddress
|
|
||||||
import java.net.SocketTimeoutException
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Minimaler DNS-Client über UDP/53 — fragt einen BESTIMMTEN Server.
|
|
||||||
*
|
|
||||||
* Warum überhaupt: `InetAddress.getByName()` (die Grundlage von `dnsResolve`
|
|
||||||
* im Plugin) benutzt den System-Resolver. Welcher Server antwortet, ist dort
|
|
||||||
* nicht wählbar, und der Systemcache liegt dazwischen — eine zweite Abfrage
|
|
||||||
* misst den Cache, nicht das Netz. Für die Frage, die beim Kunden zählt
|
|
||||||
* („antwortet die Fritzbox / der Domaincontroller überhaupt, und antwortet sie
|
|
||||||
* richtig?"), braucht es genau das: einen Server, ein Paket, eine Antwort.
|
|
||||||
*
|
|
||||||
* Aufbau bewusst wie `Snmp.kt`: eigener UDP-Client, eigener Ergebnistyp statt
|
|
||||||
* `null`. Der Grund steht dort ausführlich — kurz: ein nicht erreichbarer
|
|
||||||
* Server darf nie wie ein fehlerfreier aussehen. Ein DNS-Server, der „keine
|
|
||||||
* Antwort" gibt, und einer, der „diesen Namen kenne ich nicht" sagt, sind
|
|
||||||
* zwei völlig verschiedene Befunde: der eine ist ein Ausfall, der andere ein
|
|
||||||
* korrekt arbeitender Server.
|
|
||||||
*
|
|
||||||
* Implementiert nur so viel vom Protokoll (RFC 1035) wie nötig: eine Frage
|
|
||||||
* je Paket, A-Records aus dem Antwortteil, Namenskompression beim Überspringen.
|
|
||||||
*/
|
|
||||||
object Dns {
|
|
||||||
|
|
||||||
/** Ergebnis einer Abfrage bei EINEM bestimmten Server. */
|
|
||||||
sealed class Result {
|
|
||||||
/** Server hat geantwortet und Adressen geliefert. */
|
|
||||||
data class Addresses(val ips: List<String>, val ms: Double, val autoritativ: Boolean) : Result()
|
|
||||||
|
|
||||||
/** Server hat geantwortet, kennt den Namen aber nicht (RCODE 3, NXDOMAIN). */
|
|
||||||
data class Unbekannt(val ms: Double) : Result()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Server hat geantwortet, aber ohne Adressen und ohne NXDOMAIN —
|
|
||||||
* z.B. RCODE 2 (SERVFAIL) oder eine Antwort ganz ohne A-Record.
|
|
||||||
*/
|
|
||||||
data class OhneAntwort(val rcode: Int, val ms: Double) : Result()
|
|
||||||
|
|
||||||
/** Keine Antwort in der vorgegebenen Zeit (Server aus, Firewall, falsche Adresse). */
|
|
||||||
object Timeout : Result()
|
|
||||||
|
|
||||||
/** Paket kam an, war aber nicht verwertbar. */
|
|
||||||
data class Failure(val reason: String) : Result()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Einen Namen bei einem bestimmten Server abfragen.
|
|
||||||
*
|
|
||||||
* @param server IP des zu befragenden DNS-Servers
|
|
||||||
* @param name abzufragender Name (A-Record)
|
|
||||||
* @param timeoutMs Wartezeit je Versuch
|
|
||||||
* @param attempts Versuche — UDP darf einmal verloren gehen
|
|
||||||
*/
|
|
||||||
fun query(
|
|
||||||
server: String,
|
|
||||||
name: String,
|
|
||||||
timeoutMs: Int = 2000,
|
|
||||||
attempts: Int = 2,
|
|
||||||
): Result {
|
|
||||||
var last: Result = Result.Timeout
|
|
||||||
repeat(attempts) {
|
|
||||||
last = queryOnce(server, name, timeoutMs)
|
|
||||||
// Nur bei Zeitüberschreitung erneut fragen — eine inhaltliche
|
|
||||||
// Antwort (NXDOMAIN, SERVFAIL) wiederholt sich sowieso.
|
|
||||||
if (last !is Result.Timeout) return last
|
|
||||||
}
|
|
||||||
return last
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun queryOnce(server: String, name: String, timeoutMs: Int): Result {
|
|
||||||
// Zufällige ID, damit eine verspätete Antwort auf eine frühere Frage
|
|
||||||
// nicht als Antwort auf diese durchgeht (dasselbe Motiv wie in Snmp.kt).
|
|
||||||
val id = (1..0xFFFE).random()
|
|
||||||
val t0 = System.nanoTime()
|
|
||||||
return try {
|
|
||||||
val request = buildQuery(id, name)
|
|
||||||
DatagramSocket().use { sock ->
|
|
||||||
sock.soTimeout = timeoutMs
|
|
||||||
sock.send(DatagramPacket(request, request.size, InetAddress.getByName(server), 53))
|
|
||||||
val buf = ByteArray(1500)
|
|
||||||
val resp = DatagramPacket(buf, buf.size)
|
|
||||||
sock.receive(resp)
|
|
||||||
val ms = (System.nanoTime() - t0) / 1e6
|
|
||||||
parseResponse(buf, resp.length, id, ms)
|
|
||||||
}
|
|
||||||
} catch (e: SocketTimeoutException) {
|
|
||||||
Result.Timeout
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Result.Failure(e.message ?: "DNS-Fehler")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- Anfrage bauen (RFC 1035, Abschnitt 4) ---- */
|
|
||||||
|
|
||||||
private fun buildQuery(id: Int, name: String): ByteArray {
|
|
||||||
val out = ByteArrayOutputStream()
|
|
||||||
// Kopf: ID, Flags (RD=1 „bitte rekursiv auflösen"), 1 Frage, kein Rest
|
|
||||||
out.write(id shr 8 and 0xFF); out.write(id and 0xFF)
|
|
||||||
out.write(0x01); out.write(0x00)
|
|
||||||
out.write(0x00); out.write(0x01)
|
|
||||||
out.write(0x00); out.write(0x00)
|
|
||||||
out.write(0x00); out.write(0x00)
|
|
||||||
out.write(0x00); out.write(0x00)
|
|
||||||
// Frage: Name als Folge von Längen-Präfix-Labels, dann Nullbyte
|
|
||||||
for (label in name.trim().trim('.').split('.')) {
|
|
||||||
val bytes = label.toByteArray(Charsets.US_ASCII)
|
|
||||||
// Ein Label darf höchstens 63 Zeichen haben; längere würden das
|
|
||||||
// Längenbyte in die Flags hineinlaufen lassen.
|
|
||||||
if (bytes.size > 63) throw IllegalArgumentException("Namensteil zu lang: $label")
|
|
||||||
out.write(bytes.size)
|
|
||||||
out.write(bytes)
|
|
||||||
}
|
|
||||||
out.write(0x00)
|
|
||||||
out.write(0x00); out.write(0x01) // QTYPE A
|
|
||||||
out.write(0x00); out.write(0x01) // QCLASS IN
|
|
||||||
return out.toByteArray()
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- Antwort lesen ---- */
|
|
||||||
|
|
||||||
private fun parseResponse(buf: ByteArray, len: Int, erwarteteId: Int, ms: Double): Result {
|
|
||||||
if (len < 12) return Result.Failure("Antwort zu kurz ($len Byte)")
|
|
||||||
val id = (buf[0].toInt() and 0xFF shl 8) or (buf[1].toInt() and 0xFF)
|
|
||||||
if (id != erwarteteId) {
|
|
||||||
return Result.Failure("Antwort gehört zu einer anderen Anfrage")
|
|
||||||
}
|
|
||||||
val flags2 = buf[3].toInt() and 0xFF
|
|
||||||
val rcode = flags2 and 0x0F
|
|
||||||
val autoritativ = (buf[2].toInt() and 0x04) != 0
|
|
||||||
val qdCount = (buf[4].toInt() and 0xFF shl 8) or (buf[5].toInt() and 0xFF)
|
|
||||||
val anCount = (buf[6].toInt() and 0xFF shl 8) or (buf[7].toInt() and 0xFF)
|
|
||||||
|
|
||||||
if (rcode == 3) return Result.Unbekannt(ms) // NXDOMAIN: Server kennt den Namen nicht
|
|
||||||
|
|
||||||
var pos = 12
|
|
||||||
// Fragen überspringen
|
|
||||||
repeat(qdCount) {
|
|
||||||
pos = skipName(buf, len, pos)
|
|
||||||
pos += 4 // QTYPE + QCLASS
|
|
||||||
}
|
|
||||||
val ips = ArrayList<String>()
|
|
||||||
repeat(anCount) {
|
|
||||||
if (pos >= len) return@repeat
|
|
||||||
pos = skipName(buf, len, pos)
|
|
||||||
if (pos + 10 > len) return@repeat
|
|
||||||
val type = (buf[pos].toInt() and 0xFF shl 8) or (buf[pos + 1].toInt() and 0xFF)
|
|
||||||
val rdLen = (buf[pos + 8].toInt() and 0xFF shl 8) or (buf[pos + 9].toInt() and 0xFF)
|
|
||||||
val rdPos = pos + 10
|
|
||||||
if (type == 1 && rdLen == 4 && rdPos + 4 <= len) {
|
|
||||||
ips.add(
|
|
||||||
"${buf[rdPos].toInt() and 0xFF}.${buf[rdPos + 1].toInt() and 0xFF}." +
|
|
||||||
"${buf[rdPos + 2].toInt() and 0xFF}.${buf[rdPos + 3].toInt() and 0xFF}",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
pos = rdPos + rdLen
|
|
||||||
}
|
|
||||||
if (ips.isNotEmpty()) return Result.Addresses(ips, ms, autoritativ)
|
|
||||||
return Result.OhneAntwort(rcode, ms)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Über einen Namen hinweglesen und die Position dahinter liefern.
|
|
||||||
*
|
|
||||||
* Namen können komprimiert sein: zwei Bits gesetzt (0xC0) bedeutet „hier
|
|
||||||
* folgt ein Zeiger auf eine frühere Stelle im Paket". Der Zeiger ist zwei
|
|
||||||
* Byte lang und beendet den Namen — ihm zu folgen ist hier unnötig, weil
|
|
||||||
* wir nur die Adressen aus dem Antwortteil brauchen.
|
|
||||||
*/
|
|
||||||
private fun skipName(buf: ByteArray, len: Int, start: Int): Int {
|
|
||||||
var pos = start
|
|
||||||
while (pos < len) {
|
|
||||||
val l = buf[pos].toInt() and 0xFF
|
|
||||||
if (l == 0) return pos + 1
|
|
||||||
if (l and 0xC0 == 0xC0) return pos + 2
|
|
||||||
pos += 1 + l
|
|
||||||
}
|
|
||||||
return pos
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Klartext des Antwortcodes fürs Protokoll (RFC 1035, Abschnitt 4.1.1) */
|
|
||||||
fun rcodeText(rcode: Int): String = when (rcode) {
|
|
||||||
0 -> "kein Fehler"
|
|
||||||
1 -> "Anfrage vom Server nicht verstanden (FORMERR)"
|
|
||||||
2 -> "Serverfehler (SERVFAIL)"
|
|
||||||
3 -> "Name unbekannt (NXDOMAIN)"
|
|
||||||
4 -> "Anfrageart wird nicht unterstützt (NOTIMP)"
|
|
||||||
5 -> "Anfrage abgelehnt (REFUSED)"
|
|
||||||
else -> "Antwortcode $rcode"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1923,16 +1923,6 @@ class NetDiagScannerPlugin : Plugin() {
|
||||||
.put("iface", iface)
|
.put("iface", iface)
|
||||||
.put("type", type)
|
.put("type", type)
|
||||||
.put("isDefault", iface == defaultIface)
|
.put("isDefault", iface == defaultIface)
|
||||||
// Androids eigenes Urteil über die Verbindung. Das System
|
|
||||||
// prüft nach jedem Verbindungsaufbau selbst gegen einen
|
|
||||||
// Erreichbarkeitsdienst — dieses Ergebnis kostet uns hier
|
|
||||||
// keine einzige Messung und ist die verlässlichste Auskunft
|
|
||||||
// über ein Gäste-WLAN mit Anmeldeseite, die ohne Root zu
|
|
||||||
// bekommen ist.
|
|
||||||
obj.put("validated", caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED))
|
|
||||||
if (caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_CAPTIVE_PORTAL)) {
|
|
||||||
obj.put("captivePortal", true)
|
|
||||||
}
|
|
||||||
if (ipv4 != null) obj.put("ipv4", ipv4)
|
if (ipv4 != null) obj.put("ipv4", ipv4)
|
||||||
if (prefix > 0) obj.put("prefixLength", prefix)
|
if (prefix > 0) obj.put("prefixLength", prefix)
|
||||||
if (gateway != null) obj.put("gateway", gateway)
|
if (gateway != null) obj.put("gateway", gateway)
|
||||||
|
|
@ -1946,40 +1936,6 @@ class NetDiagScannerPlugin : Plugin() {
|
||||||
(type == "ethernet" && ifLower.matches(Regex("eth[1-9].*")))
|
(type == "ethernet" && ifLower.matches(Regex("eth[1-9].*")))
|
||||||
if (isUsb) obj.put("isUsbEthernet", true)
|
if (isUsb) obj.put("isUsbEthernet", true)
|
||||||
|
|
||||||
/*
|
|
||||||
* DHCP-Server: moderne API zuerst, für JEDEN Anschlusstyp.
|
|
||||||
*
|
|
||||||
* `WifiManager.dhcpInfo` (weiter unten) gibt es nur für WLAN und
|
|
||||||
* ist deprecated — an einer Netzwerkdose blieb die Angabe deshalb
|
|
||||||
* leer, obwohl das Gerät seine Adresse sichtbar per DHCP bezogen
|
|
||||||
* hat. Für ein Abnahmeprotokoll ist „von welchem Server kommt die
|
|
||||||
* Adresse" aber gerade an der Dose die interessante Frage (zweiter
|
|
||||||
* Router im Netz, vergessener Testserver).
|
|
||||||
*
|
|
||||||
* `getDhcpServerAddress()` gibt es ab API 30. Bewusst VOR dem
|
|
||||||
* WLAN-Pfad: so läuft der Normalfall über die unterstützte API,
|
|
||||||
* und der alte Pfad füllt nur noch, was diese nicht liefert.
|
|
||||||
*
|
|
||||||
* Die Lease-DAUER gibt es hier nicht — sie steckt nur im
|
|
||||||
* deprecated DhcpInfo und nur für WLAN. Sie zu erfinden oder als
|
|
||||||
* 0 zu schreiben wäre eine Falschaussage im Protokoll; „0" hat
|
|
||||||
* genau so schon einmal als „0 DHCP-Server (!)" in Rot in einem
|
|
||||||
* ausgelieferten Kundendokument gestanden.
|
|
||||||
*/
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
|
||||||
val srvIp = lp.dhcpServerAddress?.hostAddress
|
|
||||||
if (!srvIp.isNullOrEmpty() && srvIp != "0.0.0.0") {
|
|
||||||
obj.put("dhcpServer", srvIp)
|
|
||||||
// Quelle mitliefern wie bei `prefixQuelle` in
|
|
||||||
// getLocalSubnet(): sonst ist später nicht mehr
|
|
||||||
// nachvollziehbar, ob die Angabe von der
|
|
||||||
// unterstützten API oder aus dem alten WLAN-Pfad
|
|
||||||
// stammt — und damit auch nicht, ob sie an einer
|
|
||||||
// Kabeldose überhaupt zu erwarten war.
|
|
||||||
obj.put("dhcpQuelle", "system")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Link-Speed-Quellen
|
// Link-Speed-Quellen
|
||||||
if (type == "wifi") {
|
if (type == "wifi") {
|
||||||
try {
|
try {
|
||||||
|
|
@ -1999,11 +1955,7 @@ class NetDiagScannerPlugin : Plugin() {
|
||||||
}
|
}
|
||||||
@Suppress("DEPRECATION") val dhcp = wifi.dhcpInfo
|
@Suppress("DEPRECATION") val dhcp = wifi.dhcpInfo
|
||||||
if (dhcp != null && dhcp.serverAddress != 0) {
|
if (dhcp != null && dhcp.serverAddress != 0) {
|
||||||
// Nur füllen, was die moderne API oben nicht liefert
|
obj.put("dhcpServer", intToIp(dhcp.serverAddress))
|
||||||
if (!obj.has("dhcpServer")) {
|
|
||||||
obj.put("dhcpServer", intToIp(dhcp.serverAddress))
|
|
||||||
obj.put("dhcpQuelle", "wlan")
|
|
||||||
}
|
|
||||||
if (dhcp.leaseDuration > 0) obj.put("leaseSec", dhcp.leaseDuration)
|
if (dhcp.leaseDuration > 0) obj.put("leaseSec", dhcp.leaseDuration)
|
||||||
}
|
}
|
||||||
} catch (_: Exception) { /* WLAN-Info nicht greifbar */ }
|
} catch (_: Exception) { /* WLAN-Info nicht greifbar */ }
|
||||||
|
|
@ -2011,7 +1963,6 @@ class NetDiagScannerPlugin : Plugin() {
|
||||||
val mbps = readEthernetSpeed(iface)
|
val mbps = readEthernetSpeed(iface)
|
||||||
if (mbps != null) obj.put("linkSpeedMbps", mbps)
|
if (mbps != null) obj.put("linkSpeedMbps", mbps)
|
||||||
}
|
}
|
||||||
|
|
||||||
arr.put(obj)
|
arr.put(obj)
|
||||||
}
|
}
|
||||||
resolve(call, JSObject().put("links", arr))
|
resolve(call, JSObject().put("links", arr))
|
||||||
|
|
@ -2038,171 +1989,6 @@ class NetDiagScannerPlugin : Plugin() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --------------------------------------------------------------------- */
|
|
||||||
/* Internet-Kette — Namensauflösung und HTTPS-Abruf */
|
|
||||||
/* --------------------------------------------------------------------- */
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Namen auflösen über den System-Resolver.
|
|
||||||
*
|
|
||||||
* GRENZE, die im Ergebnis stehen muss: welcher DNS-Server befragt wird, ist
|
|
||||||
* hier NICHT wählbar, und zwischen Aufruf und Server liegt der Cache des
|
|
||||||
* Systems. Eine zweite Abfrage desselben Namens misst deshalb den Cache,
|
|
||||||
* nicht das Netz. Für „antwortet dieser bestimmte Server" braucht es einen
|
|
||||||
* eigenen UDP-Client auf Port 53 (eigener Roadmap-Punkt) — für die Frage
|
|
||||||
* „funktioniert Namensauflösung überhaupt" reicht dieser Weg.
|
|
||||||
*/
|
|
||||||
@PluginMethod
|
|
||||||
fun dnsResolve(call: PluginCall) {
|
|
||||||
val host = call.getString("host") ?: return call.reject("host fehlt")
|
|
||||||
val timeoutMs = (call.getInt("timeoutMs") ?: 5000).coerceIn(500, 30_000)
|
|
||||||
io.launch {
|
|
||||||
val t0 = System.nanoTime()
|
|
||||||
try {
|
|
||||||
val addrs = withTimeout(timeoutMs.toLong()) {
|
|
||||||
withContext(Dispatchers.IO) { InetAddress.getAllByName(host) }
|
|
||||||
}
|
|
||||||
val ms = (System.nanoTime() - t0) / 1e6
|
|
||||||
val arr = JSArray()
|
|
||||||
for (a in addrs) a.hostAddress?.let { arr.put(it) }
|
|
||||||
resolve(call, JSObject()
|
|
||||||
.put("ok", arr.length() > 0)
|
|
||||||
.put("ips", arr)
|
|
||||||
.put("latenzMs", round1(ms)))
|
|
||||||
} catch (e: TimeoutCancellationException) {
|
|
||||||
resolve(call, JSObject()
|
|
||||||
.put("ok", false)
|
|
||||||
.put("ips", JSArray())
|
|
||||||
.put("fehler", "Keine Antwort innerhalb von $timeoutMs ms"))
|
|
||||||
} catch (e: Exception) {
|
|
||||||
// UnknownHostException ist der Normalfall bei kaputtem DNS und
|
|
||||||
// keine Ausnahmesituation — deshalb aufgelöst statt reject().
|
|
||||||
resolve(call, JSObject()
|
|
||||||
.put("ok", false)
|
|
||||||
.put("ips", JSArray())
|
|
||||||
.put("fehler", e.message ?: e.javaClass.simpleName))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Einen Namen bei EINEM bestimmten DNS-Server abfragen (eigener UDP-Client).
|
|
||||||
*
|
|
||||||
* Unterschied zu `dnsResolve`: dort antwortet, wer das System gerade für
|
|
||||||
* richtig hält, und der Systemcache liegt dazwischen. Hier geht genau ein
|
|
||||||
* Paket an genau einen Server — nur so ist die Frage „antwortet die
|
|
||||||
* Fritzbox / der Domaincontroller überhaupt" zu beantworten.
|
|
||||||
*
|
|
||||||
* Die Antwort unterscheidet ausdrücklich zwischen „keine Antwort" (Ausfall)
|
|
||||||
* und „Name unbekannt" (Server arbeitet korrekt). Das sind zwei völlig
|
|
||||||
* verschiedene Befunde beim Kunden.
|
|
||||||
*/
|
|
||||||
@PluginMethod
|
|
||||||
fun dnsQuery(call: PluginCall) {
|
|
||||||
val server = call.getString("server") ?: return call.reject("server fehlt")
|
|
||||||
val name = call.getString("name") ?: return call.reject("name fehlt")
|
|
||||||
val timeoutMs = (call.getInt("timeoutMs") ?: 2000).coerceIn(300, 20_000)
|
|
||||||
io.launch {
|
|
||||||
try {
|
|
||||||
val r = withContext(Dispatchers.IO) { Dns.query(server, name, timeoutMs) }
|
|
||||||
val obj = JSObject().put("server", server).put("name", name)
|
|
||||||
when (r) {
|
|
||||||
is Dns.Result.Addresses -> {
|
|
||||||
val arr = JSArray()
|
|
||||||
for (ip in r.ips) arr.put(ip)
|
|
||||||
obj.put("status", "ok")
|
|
||||||
obj.put("ips", arr)
|
|
||||||
obj.put("latenzMs", round1(r.ms))
|
|
||||||
obj.put("autoritativ", r.autoritativ)
|
|
||||||
}
|
|
||||||
is Dns.Result.Unbekannt -> {
|
|
||||||
obj.put("status", "unbekannt")
|
|
||||||
obj.put("ips", JSArray())
|
|
||||||
obj.put("latenzMs", round1(r.ms))
|
|
||||||
obj.put("meldung", Dns.rcodeText(3))
|
|
||||||
}
|
|
||||||
is Dns.Result.OhneAntwort -> {
|
|
||||||
obj.put("status", "ohneAdresse")
|
|
||||||
obj.put("ips", JSArray())
|
|
||||||
obj.put("latenzMs", round1(r.ms))
|
|
||||||
obj.put("meldung", Dns.rcodeText(r.rcode))
|
|
||||||
}
|
|
||||||
is Dns.Result.Timeout -> {
|
|
||||||
obj.put("status", "keineAntwort")
|
|
||||||
obj.put("ips", JSArray())
|
|
||||||
obj.put("meldung", "Keine Antwort innerhalb von $timeoutMs ms")
|
|
||||||
}
|
|
||||||
is Dns.Result.Failure -> {
|
|
||||||
obj.put("status", "fehler")
|
|
||||||
obj.put("ips", JSArray())
|
|
||||||
obj.put("meldung", r.reason)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
resolve(call, obj)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
call.reject("dnsQuery: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Eine URL abrufen und Statuszeile plus Laufzeit melden.
|
|
||||||
*
|
|
||||||
* `instanceFollowRedirects = false` ist hier keine Feinheit, sondern der
|
|
||||||
* Kern der Aussage: ein Gäste-WLAN mit Anmeldeseite beantwortet JEDE
|
|
||||||
* Anfrage mit einer Umleitung auf sein Portal. Folgt man ihr, kommt am Ende
|
|
||||||
* ein sauberes 200 zurück und die Messung meldet „Internet in Ordnung",
|
|
||||||
* obwohl ohne Anmeldung nichts geht. Ohne Weiterleitung steht die 302 mit
|
|
||||||
* ihrem Ziel im Protokoll und ist als Portal erkennbar.
|
|
||||||
*/
|
|
||||||
@PluginMethod
|
|
||||||
fun httpsCheck(call: PluginCall) {
|
|
||||||
val url = call.getString("url") ?: return call.reject("url fehlt")
|
|
||||||
val timeoutMs = (call.getInt("timeoutMs") ?: 8000).coerceIn(500, 60_000)
|
|
||||||
io.launch {
|
|
||||||
var conn: HttpURLConnection? = null
|
|
||||||
val t0 = System.nanoTime()
|
|
||||||
try {
|
|
||||||
val res = withTimeout(timeoutMs.toLong() + 2000L) {
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
val c = URL(url).openConnection() as HttpURLConnection
|
|
||||||
conn = c
|
|
||||||
c.instanceFollowRedirects = false
|
|
||||||
c.connectTimeout = timeoutMs
|
|
||||||
c.readTimeout = timeoutMs
|
|
||||||
c.requestMethod = "GET"
|
|
||||||
c.setRequestProperty("User-Agent", "NetDiag")
|
|
||||||
// Kein Zwischenspeicher: sonst misst der zweite Aufruf
|
|
||||||
// beim selben Kunden die eigene Festplatte.
|
|
||||||
c.useCaches = false
|
|
||||||
c.setRequestProperty("Cache-Control", "no-cache")
|
|
||||||
val code = c.responseCode
|
|
||||||
val ort = c.getHeaderField("Location")
|
|
||||||
Triple(code, ort, c.responseMessage ?: "")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val ms = (System.nanoTime() - t0) / 1e6
|
|
||||||
val obj = JSObject()
|
|
||||||
.put("erreichbar", true)
|
|
||||||
.put("statusCode", res.first)
|
|
||||||
.put("statusText", res.third)
|
|
||||||
.put("latenzMs", round1(ms))
|
|
||||||
if (!res.second.isNullOrEmpty()) obj.put("weiterleitungAuf", res.second)
|
|
||||||
resolve(call, obj)
|
|
||||||
} catch (e: TimeoutCancellationException) {
|
|
||||||
resolve(call, JSObject()
|
|
||||||
.put("erreichbar", false)
|
|
||||||
.put("fehler", "Keine Antwort innerhalb von $timeoutMs ms"))
|
|
||||||
} catch (e: Exception) {
|
|
||||||
resolve(call, JSObject()
|
|
||||||
.put("erreichbar", false)
|
|
||||||
.put("fehler", e.message ?: e.javaClass.simpleName))
|
|
||||||
} finally {
|
|
||||||
try { conn?.disconnect() } catch (_: Exception) { }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --------------------------------------------------------------------- */
|
/* --------------------------------------------------------------------- */
|
||||||
/* WLAN-Empfangstracker — RSSI live beim Durchgehen durchs Gebäude */
|
/* WLAN-Empfangstracker — RSSI live beim Durchgehen durchs Gebäude */
|
||||||
/* --------------------------------------------------------------------- */
|
/* --------------------------------------------------------------------- */
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,6 @@
|
||||||
import AmpelBadge from './AmpelBadge.svelte';
|
import AmpelBadge from './AmpelBadge.svelte';
|
||||||
import type { Device, Measurement } from '$lib/types';
|
import type { Device, Measurement } from '$lib/types';
|
||||||
import type { Tool } from '$lib/tools/types';
|
import type { Tool } from '$lib/tools/types';
|
||||||
import { teileNachGeraetetyp } from '$lib/tools/geraeteaktionen';
|
|
||||||
|
|
||||||
let {
|
let {
|
||||||
device,
|
device,
|
||||||
|
|
@ -31,9 +30,6 @@
|
||||||
onrename?: () => void;
|
onrename?: () => void;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
// Werkzeuge nach Gerätetyp sortieren (passend vorn, Rest nachrangig)
|
|
||||||
const aufgeteilt = $derived(teileNachGeraetetyp(tools, device.deviceType));
|
|
||||||
|
|
||||||
// Index = MeasureStatus (0 ok, 1 warn, 2 fail, 3 nicht messbar)
|
// Index = MeasureStatus (0 ok, 1 warn, 2 fail, 3 nicht messbar)
|
||||||
const ampel = ['ampel-ok', 'ampel-warn', 'ampel-fail', 'ampel-unmess'];
|
const ampel = ['ampel-ok', 'ampel-warn', 'ampel-fail', 'ampel-unmess'];
|
||||||
const ampelDot = ['bg-emerald-500', 'bg-amber-400', 'bg-red-500', 'bg-zinc-500'];
|
const ampelDot = ['bg-emerald-500', 'bg-amber-400', 'bg-red-500', 'bg-zinc-500'];
|
||||||
|
|
@ -136,14 +132,8 @@
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
{#if tools.length && onrun}
|
{#if tools.length && onrun}
|
||||||
<!--
|
|
||||||
Nach Gerätetyp getrennt: passende Werkzeuge vorn, unpassende blasser
|
|
||||||
dahinter. Bewusst NICHT ausgeblendet — `deviceType` ist geraten (MAC-
|
|
||||||
Hersteller, mDNS, offene Ports), und ein als „Linux-Gerät" erkannter
|
|
||||||
Switch verlöre sonst ausgerechnet SNMP. Siehe tools/geraeteaktionen.ts.
|
|
||||||
-->
|
|
||||||
<div class="mt-2 flex flex-wrap gap-1.5">
|
<div class="mt-2 flex flex-wrap gap-1.5">
|
||||||
{#each aufgeteilt.passend as tool (tool.id)}
|
{#each tools as tool (tool.id)}
|
||||||
<button
|
<button
|
||||||
class="rounded bg-zinc-800 px-2 py-1 text-xs text-sky-300 active:bg-zinc-700"
|
class="rounded bg-zinc-800 px-2 py-1 text-xs text-sky-300 active:bg-zinc-700"
|
||||||
onclick={() => onrun?.(tool)}
|
onclick={() => onrun?.(tool)}
|
||||||
|
|
@ -151,15 +141,6 @@
|
||||||
{tool.name}
|
{tool.name}
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
{#each aufgeteilt.nachrangig as tool (tool.id)}
|
|
||||||
<button
|
|
||||||
class="rounded bg-zinc-900 px-2 py-1 text-xs text-zinc-500 active:bg-zinc-700"
|
|
||||||
title="Passt selten zu diesem Gerätetyp — trotzdem ausführbar"
|
|
||||||
onclick={() => onrun?.(tool)}
|
|
||||||
>
|
|
||||||
{tool.name}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Tool, ToolParamField, ToolPreview } from '$lib/tools/types';
|
import type { Tool, ToolPreview } from '$lib/tools/types';
|
||||||
import type { Device, Protocol } from '$lib/types';
|
import type { Device, Protocol } from '$lib/types';
|
||||||
import { debugLog } from '$lib/debuglog.svelte';
|
import { debugLog } from '$lib/debuglog.svelte';
|
||||||
import { scanner } from '$lib/scanner';
|
import { scanner } from '$lib/scanner';
|
||||||
|
|
@ -41,22 +41,6 @@
|
||||||
...Object.fromEntries(tool.params.map((p) => [p.key, p.default ?? ''])),
|
...Object.fromEntries(tool.params.map((p) => [p.key, p.default ?? ''])),
|
||||||
...(startParams ?? {}),
|
...(startParams ?? {}),
|
||||||
});
|
});
|
||||||
/**
|
|
||||||
* Passende Bildschirmtastatur je Eingabefeld.
|
|
||||||
*
|
|
||||||
* `decimal` statt `numeric`, wo Punkte vorkommen: die numerische Tastatur
|
|
||||||
* hat keinen Punkt, und ohne den lässt sich weder eine IP-Adresse noch ein
|
|
||||||
* Netzbereich eingeben. Reine Anzahlen (Pakete, Sekunden, Runden) bekommen
|
|
||||||
* `numeric`, Namen und URLs bleiben bei der Volltastatur — `url` wäre hier
|
|
||||||
* falsch, weil dieselben Felder auch reine Hostnamen aufnehmen.
|
|
||||||
*/
|
|
||||||
function tastaturModus(field: ToolParamField): 'text' | 'numeric' | 'decimal' {
|
|
||||||
if (field.type === 'number') return 'numeric';
|
|
||||||
const k = field.key.toLowerCase();
|
|
||||||
if (/(host|ziel|ip|subnet|netz|gateway|server|adresse)/.test(k)) return 'decimal';
|
|
||||||
return 'text';
|
|
||||||
}
|
|
||||||
|
|
||||||
let busy = $state(false);
|
let busy = $state(false);
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
|
|
||||||
|
|
@ -206,22 +190,9 @@
|
||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
{:else}
|
{:else}
|
||||||
<!--
|
|
||||||
Tastatur passend zum Feld. Auf dem Handy vor Ort ist das kein
|
|
||||||
Feinschliff: für „192.168.30.1" oder eine Portnummer die
|
|
||||||
Buchstabentastatur zu bekommen, kostet jedes Mal zwei
|
|
||||||
Fehleingaben. `inputmode="decimal"` bringt Ziffern UND Punkt —
|
|
||||||
bei „numeric" fehlt der Punkt und damit jede IP-Eingabe.
|
|
||||||
Zusätzlich Autokorrektur und Großschreibung aus: sonst wird aus
|
|
||||||
„fritz.box" beim Tippen „Fritz.Box".
|
|
||||||
-->
|
|
||||||
<input
|
<input
|
||||||
class="rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-2"
|
class="rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-2"
|
||||||
type={field.type === 'number' ? 'number' : 'text'}
|
type={field.type === 'number' ? 'number' : 'text'}
|
||||||
inputmode={tastaturModus(field)}
|
|
||||||
autocapitalize="off"
|
|
||||||
autocorrect="off"
|
|
||||||
spellcheck="false"
|
|
||||||
placeholder={field.placeholder ?? ''}
|
placeholder={field.placeholder ?? ''}
|
||||||
bind:value={params[field.key]}
|
bind:value={params[field.key]}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -54,23 +54,9 @@ const FELDER: Record<string, Feld> = {
|
||||||
geraete: { label: 'Je Gerät' },
|
geraete: { label: 'Je Gerät' },
|
||||||
ausfaelle: { label: 'Einzelne Ausfälle' },
|
ausfaelle: { label: 'Einzelne Ausfälle' },
|
||||||
anzahlGeraete: { label: 'Überwachte Geräte' },
|
anzahlGeraete: { label: 'Überwachte Geräte' },
|
||||||
// Internet-Kette. `fehlerglied` ist die eigentliche Aussage des Werkzeugs —
|
|
||||||
// fehlt der Schlüssel hier oder in netdiagKundenfelder(), steht im
|
|
||||||
// Kundendokument nur noch die Ampel.
|
|
||||||
fehlerglied: { label: 'Fehlerglied' },
|
|
||||||
stufen: { label: 'Geprüfte Stufen' },
|
|
||||||
systemUrteil: { label: 'Urteil des Betriebssystems' },
|
|
||||||
validated: { label: 'Internetverbindung bestätigt' },
|
|
||||||
captivePortal: { label: 'Anmeldeseite erkannt' },
|
|
||||||
// DNS-Prüfung (je ein Paket an je einen Server)
|
|
||||||
gefragteServer: { label: 'Befragte DNS-Server' },
|
|
||||||
antworten: { label: 'Antworten der Server' },
|
|
||||||
// Netz / DHCP / Dose
|
// Netz / DHCP / Dose
|
||||||
server: { label: 'DHCP-Server' },
|
server: { label: 'DHCP-Server' },
|
||||||
dhcpServer: { label: 'DHCP-Server' },
|
dhcpServer: { label: 'DHCP-Server' },
|
||||||
// 'system' = vom Betriebssystem gemeldet (jeder Anschlusstyp), 'wlan' = alter
|
|
||||||
// WLAN-Weg. Steht im Protokoll, damit ein fehlender Wert einordenbar bleibt.
|
|
||||||
dhcpQuelle: { label: 'Quelle der DHCP-Angabe' },
|
|
||||||
lease: { label: 'Lease-Dauer', einheit: 's' },
|
lease: { label: 'Lease-Dauer', einheit: 's' },
|
||||||
leaseSec: { label: 'Lease-Dauer', einheit: 's' },
|
leaseSec: { label: 'Lease-Dauer', einheit: 's' },
|
||||||
gateway: { label: 'Gateway' },
|
gateway: { label: 'Gateway' },
|
||||||
|
|
|
||||||
|
|
@ -1,199 +0,0 @@
|
||||||
/**
|
|
||||||
* Rechnung des Geräte-Monitors — bewusst OHNE jeden Laufzeit-Import.
|
|
||||||
*
|
|
||||||
* Ausgelagert aus `monitor.ts`, damit `tools/pruefe-monitor.mjs` die Funktion
|
|
||||||
* direkt laden kann. Vorher importierte der Prüfer eine von Hand erzeugte
|
|
||||||
* .mjs-Kopie aus einem Sitzungsverzeichnis unter /tmp; nach dessen Wegfall
|
|
||||||
* lief er nicht mehr — im Repo stand trotzdem „alle Fälle korrekt". Eine
|
|
||||||
* Zusicherung, die man nicht nachrechnen kann, ist keine.
|
|
||||||
*
|
|
||||||
* Alles hier ist reine Rechnung ohne Netz- oder Datenbankzugriff. Was die
|
|
||||||
* Zahlen bedeuten und welche drei Fehler sie vorher systematisch zugunsten
|
|
||||||
* des Dokuments verfälscht haben, steht im Kopf von `monitor.ts`.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { DeviceMonitorSession, MeasureStatus, MonitorEvent } from './types';
|
|
||||||
|
|
||||||
function fmtTime(ts: number): string {
|
|
||||||
return new Date(ts).toLocaleTimeString('de-DE', {
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
second: '2-digit',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function fmtDuration(sec: number): string {
|
|
||||||
if (sec < 60) return Math.round(sec) + ' s';
|
|
||||||
const m = Math.floor(sec / 60);
|
|
||||||
if (m < 60) {
|
|
||||||
const s = Math.round(sec % 60);
|
|
||||||
return s ? `${m} min ${s} s` : `${m} min`;
|
|
||||||
}
|
|
||||||
const h = Math.floor(m / 60);
|
|
||||||
const restM = m % 60;
|
|
||||||
return restM ? `${h} Std ${restM} min` : `${h} Std`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Prozent mit einer Nachkommastelle, deutsches Komma */
|
|
||||||
function fmtProzent(p: number): string {
|
|
||||||
return p.toFixed(1).replace('.', ',') + ' %';
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GeraetAuswertung {
|
|
||||||
ip: string;
|
|
||||||
label: string;
|
|
||||||
ausfallzeitSek: number;
|
|
||||||
aussetzer: number;
|
|
||||||
laengsterAusfallSek: number;
|
|
||||||
verfuegbarkeitProzent: number;
|
|
||||||
/** einzelne Ausfälle als lesbare Zeilen */
|
|
||||||
ausfaelle: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ausfallsegmente eines Geräts aus seinen Ereignissen bilden.
|
|
||||||
*
|
|
||||||
* Ein Ausfall beginnt mit 'down' und endet mit dem nächsten 'up'. Ist am Ende
|
|
||||||
* noch einer offen, wird er bis `bis` zugerechnet — er ist ja nicht dadurch
|
|
||||||
* verschwunden, dass die Messung aufhörte.
|
|
||||||
*
|
|
||||||
* `durationSec` aus dem up-Ereignis wird bewusst NICHT verwendet, sondern die
|
|
||||||
* Differenz der Zeitstempel: die native Dauer war in einem Fall nachweislich
|
|
||||||
* 0 (nämlich wenn der Ausfall schon vor dem Start bestand), und zwei Quellen
|
|
||||||
* für dieselbe Zahl gehen irgendwann auseinander.
|
|
||||||
*/
|
|
||||||
function segmenteFuer(events: MonitorEvent[], von: number, bis: number): number[][] {
|
|
||||||
const segmente: number[][] = [];
|
|
||||||
let offen: number | null = null;
|
|
||||||
for (const e of [...events].sort((a, b) => a.ts - b.ts)) {
|
|
||||||
if (e.type === 'down') {
|
|
||||||
if (offen === null) offen = e.ts;
|
|
||||||
} else if (offen !== null) {
|
|
||||||
segmente.push([offen, e.ts]);
|
|
||||||
offen = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (offen !== null) segmente.push([offen, bis]);
|
|
||||||
// Auf den Messzeitraum begrenzen — ein Ereignis vor dem Start (Wiederauf-
|
|
||||||
// nahme einer älteren Sitzung) darf die Ausfallzeit nicht aufblähen.
|
|
||||||
return segmente
|
|
||||||
.map(([a, b]) => [Math.max(a, von), Math.min(b, bis)])
|
|
||||||
.filter(([a, b]) => b > a);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Aus einer Überwachungssitzung die Felder für die gespeicherte Messung bauen.
|
|
||||||
*
|
|
||||||
* @param sitzung die Sitzung mit ihren Ereignissen
|
|
||||||
* @param endeTs Ende des Messzeitraums (aus dem Plugin, sonst jetzt)
|
|
||||||
* @param abgerissen true = der Lauf wurde nicht sauber beendet (Prozess tot,
|
|
||||||
* nativer Lauf verschwunden) — dann steht das im Hinweis,
|
|
||||||
* statt eine Verfügbarkeit über eine Zeitspanne auszuweisen,
|
|
||||||
* in der gar nicht gemessen wurde.
|
|
||||||
*/
|
|
||||||
export function buildMonitorMeasurement(
|
|
||||||
sitzung: DeviceMonitorSession,
|
|
||||||
endeTs: number,
|
|
||||||
abgerissen = false,
|
|
||||||
): { label: string; result: Record<string, unknown>; measureStatus: MeasureStatus } {
|
|
||||||
const von = sitzung.startedAt;
|
|
||||||
const bis = Math.max(von, endeTs);
|
|
||||||
const dauerSek = Math.max(1, Math.round((bis - von) / 1000));
|
|
||||||
|
|
||||||
const auswertung: GeraetAuswertung[] = sitzung.targets.map((t) => {
|
|
||||||
const eigene = sitzung.events.filter((e) => e.ip === t.ip);
|
|
||||||
const segmente = segmenteFuer(eigene, von, bis);
|
|
||||||
const ausfallzeitSek = Math.round(
|
|
||||||
segmente.reduce((s, [a, b]) => s + (b - a), 0) / 1000,
|
|
||||||
);
|
|
||||||
const laengster = segmente.length
|
|
||||||
? Math.round(Math.max(...segmente.map(([a, b]) => b - a)) / 1000)
|
|
||||||
: 0;
|
|
||||||
return {
|
|
||||||
ip: t.ip,
|
|
||||||
label: t.label || t.ip,
|
|
||||||
ausfallzeitSek,
|
|
||||||
aussetzer: segmente.length,
|
|
||||||
laengsterAusfallSek: laengster,
|
|
||||||
verfuegbarkeitProzent: Math.max(
|
|
||||||
0,
|
|
||||||
Math.min(100, (1 - ausfallzeitSek / dauerSek) * 100),
|
|
||||||
),
|
|
||||||
ausfaelle: segmente.map(
|
|
||||||
([a, b]) =>
|
|
||||||
`${t.label || t.ip} ${fmtTime(a)} – ${fmtTime(b)} (${fmtDuration((b - a) / 1000)})`,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Schlechtestes Gerät bestimmt Label und Ampel (siehe Kopfkommentar)
|
|
||||||
const schlechtestes = auswertung.reduce<GeraetAuswertung | null>(
|
|
||||||
(min, g) => (min === null || g.verfuegbarkeitProzent < min.verfuegbarkeitProzent ? g : min),
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
const gesamtAussetzer = auswertung.reduce((s, g) => s + g.aussetzer, 0);
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Bewertung. Die Schwellen sind bewusst streng: Für eine Kamera oder eine
|
|
||||||
* Wallbox ist ein Aussetzer pro Termin bereits das gemeldete Problem, nicht
|
|
||||||
* ein Schönheitsfehler. 100 % über den ganzen Zeitraum = grün, jeder
|
|
||||||
* Aussetzer = gelb, unter 95 % oder ein Gerät durchgehend tot = rot.
|
|
||||||
*/
|
|
||||||
let status: MeasureStatus = 0;
|
|
||||||
if (gesamtAussetzer > 0) status = 1;
|
|
||||||
if ((schlechtestes?.verfuegbarkeitProzent ?? 100) < 95) status = 2;
|
|
||||||
if (abgerissen) status = 3; // nicht messbar — die Grundlage stimmt nicht
|
|
||||||
|
|
||||||
const hinweise: string[] = [];
|
|
||||||
if (abgerissen) {
|
|
||||||
hinweise.push(
|
|
||||||
'Die Überwachung wurde nicht regulär beendet (App oder Gerät zwischendurch ' +
|
|
||||||
'gestoppt). Der ausgewiesene Zeitraum endet beim letzten aufgezeichneten ' +
|
|
||||||
'Ereignis; danach wurde nicht mehr gemessen.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// Messverfahren offenlegen: das gehört zu einer belastbaren Aussage dazu.
|
|
||||||
hinweise.push(
|
|
||||||
'Gemessen durch regelmäßige Erreichbarkeitsprüfung alle ' +
|
|
||||||
`${sitzung.intervalSec} Sekunden. Ein Gerät, das Ping-Anfragen ` +
|
|
||||||
'grundsätzlich nicht beantwortet, erscheint dabei als nicht erreichbar.',
|
|
||||||
);
|
|
||||||
|
|
||||||
const label = schlechtestes
|
|
||||||
? gesamtAussetzer === 0
|
|
||||||
? `${auswertung.length} Geräte über ${fmtDuration(dauerSek)} durchgehend erreichbar`
|
|
||||||
: `${schlechtestes.label}: ${fmtProzent(schlechtestes.verfuegbarkeitProzent)} · ` +
|
|
||||||
`${gesamtAussetzer} ${gesamtAussetzer === 1 ? 'Aussetzer' : 'Aussetzer'} in ${fmtDuration(dauerSek)}`
|
|
||||||
: 'Überwachung ohne Geräte';
|
|
||||||
|
|
||||||
return {
|
|
||||||
label,
|
|
||||||
measureStatus: status,
|
|
||||||
result: {
|
|
||||||
dauerSekunden: dauerSek,
|
|
||||||
intervallSek: sitzung.intervalSec,
|
|
||||||
geraeteAnzahl: auswertung.length,
|
|
||||||
verfuegbarkeitProzent: Number((schlechtestes?.verfuegbarkeitProzent ?? 100).toFixed(1)),
|
|
||||||
ausfallzeitSek: schlechtestes?.ausfallzeitSek ?? 0,
|
|
||||||
aussetzer: gesamtAussetzer,
|
|
||||||
laengsterAusfallSek: Math.max(0, ...auswertung.map((g) => g.laengsterAusfallSek)),
|
|
||||||
// Vorformatierte Zeilen statt Objekte: MeasurementResult rendert Arrays
|
|
||||||
// zeilenweise, und dieselben Zeichenketten werden im PDF wiederverwendet.
|
|
||||||
geraete: auswertung.map(
|
|
||||||
(g) =>
|
|
||||||
// Nur benannte Geräte bekommen die IP in Klammern dahinter — ohne
|
|
||||||
// eigenen Namen stand sonst „10.0.2.2 (10.0.2.2)" im Kundendokument.
|
|
||||||
`${g.label === g.ip ? g.ip : `${g.label} (${g.ip})`}: ` +
|
|
||||||
`${fmtProzent(g.verfuegbarkeitProzent)}` +
|
|
||||||
(g.aussetzer
|
|
||||||
? ` · ${g.aussetzer} ${g.aussetzer === 1 ? 'Aussetzer' : 'Aussetzer'}` +
|
|
||||||
` · ${fmtDuration(g.ausfallzeitSek)} Ausfall` +
|
|
||||||
` · längster ${fmtDuration(g.laengsterAusfallSek)}`
|
|
||||||
: ' · durchgehend erreichbar'),
|
|
||||||
),
|
|
||||||
ausfaelle: auswertung.flatMap((g) => g.ausfaelle),
|
|
||||||
hinweis: hinweise.join(' '),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -24,13 +24,196 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { addMeasurement } from './protocols';
|
import { addMeasurement } from './protocols';
|
||||||
import { buildMonitorMeasurement } from './monitor-auswertung';
|
import type {
|
||||||
import type { DeviceMonitorSession, Measurement, Protocol } from './types';
|
DeviceMonitorSession,
|
||||||
|
MeasureStatus,
|
||||||
|
Measurement,
|
||||||
|
MonitorEvent,
|
||||||
|
Protocol,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
// Die Rechnung selbst liegt in monitor-auswertung.ts, damit sie ohne
|
function fmtTime(ts: number): string {
|
||||||
// Capacitor-Abhängigkeiten geladen und geprüft werden kann (tools/pruefe-monitor.mjs).
|
return new Date(ts).toLocaleTimeString('de-DE', {
|
||||||
export { buildMonitorMeasurement } from './monitor-auswertung';
|
hour: '2-digit',
|
||||||
export type { GeraetAuswertung } from './monitor-auswertung';
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDuration(sec: number): string {
|
||||||
|
if (sec < 60) return Math.round(sec) + ' s';
|
||||||
|
const m = Math.floor(sec / 60);
|
||||||
|
if (m < 60) {
|
||||||
|
const s = Math.round(sec % 60);
|
||||||
|
return s ? `${m} min ${s} s` : `${m} min`;
|
||||||
|
}
|
||||||
|
const h = Math.floor(m / 60);
|
||||||
|
const restM = m % 60;
|
||||||
|
return restM ? `${h} Std ${restM} min` : `${h} Std`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Prozent mit einer Nachkommastelle, deutsches Komma */
|
||||||
|
function fmtProzent(p: number): string {
|
||||||
|
return p.toFixed(1).replace('.', ',') + ' %';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GeraetAuswertung {
|
||||||
|
ip: string;
|
||||||
|
label: string;
|
||||||
|
ausfallzeitSek: number;
|
||||||
|
aussetzer: number;
|
||||||
|
laengsterAusfallSek: number;
|
||||||
|
verfuegbarkeitProzent: number;
|
||||||
|
/** einzelne Ausfälle als lesbare Zeilen */
|
||||||
|
ausfaelle: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ausfallsegmente eines Geräts aus seinen Ereignissen bilden.
|
||||||
|
*
|
||||||
|
* Ein Ausfall beginnt mit 'down' und endet mit dem nächsten 'up'. Ist am Ende
|
||||||
|
* noch einer offen, wird er bis `bis` zugerechnet — er ist ja nicht dadurch
|
||||||
|
* verschwunden, dass die Messung aufhörte.
|
||||||
|
*
|
||||||
|
* `durationSec` aus dem up-Ereignis wird bewusst NICHT verwendet, sondern die
|
||||||
|
* Differenz der Zeitstempel: die native Dauer war in einem Fall nachweislich
|
||||||
|
* 0 (nämlich wenn der Ausfall schon vor dem Start bestand), und zwei Quellen
|
||||||
|
* für dieselbe Zahl gehen irgendwann auseinander.
|
||||||
|
*/
|
||||||
|
function segmenteFuer(events: MonitorEvent[], von: number, bis: number): number[][] {
|
||||||
|
const segmente: number[][] = [];
|
||||||
|
let offen: number | null = null;
|
||||||
|
for (const e of [...events].sort((a, b) => a.ts - b.ts)) {
|
||||||
|
if (e.type === 'down') {
|
||||||
|
if (offen === null) offen = e.ts;
|
||||||
|
} else if (offen !== null) {
|
||||||
|
segmente.push([offen, e.ts]);
|
||||||
|
offen = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (offen !== null) segmente.push([offen, bis]);
|
||||||
|
// Auf den Messzeitraum begrenzen — ein Ereignis vor dem Start (Wiederauf-
|
||||||
|
// nahme einer älteren Sitzung) darf die Ausfallzeit nicht aufblähen.
|
||||||
|
return segmente
|
||||||
|
.map(([a, b]) => [Math.max(a, von), Math.min(b, bis)])
|
||||||
|
.filter(([a, b]) => b > a);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aus einer Überwachungssitzung die Felder für die gespeicherte Messung bauen.
|
||||||
|
*
|
||||||
|
* @param sitzung die Sitzung mit ihren Ereignissen
|
||||||
|
* @param endeTs Ende des Messzeitraums (aus dem Plugin, sonst jetzt)
|
||||||
|
* @param abgerissen true = der Lauf wurde nicht sauber beendet (Prozess tot,
|
||||||
|
* nativer Lauf verschwunden) — dann steht das im Hinweis,
|
||||||
|
* statt eine Verfügbarkeit über eine Zeitspanne auszuweisen,
|
||||||
|
* in der gar nicht gemessen wurde.
|
||||||
|
*/
|
||||||
|
export function buildMonitorMeasurement(
|
||||||
|
sitzung: DeviceMonitorSession,
|
||||||
|
endeTs: number,
|
||||||
|
abgerissen = false,
|
||||||
|
): { label: string; result: Record<string, unknown>; measureStatus: MeasureStatus } {
|
||||||
|
const von = sitzung.startedAt;
|
||||||
|
const bis = Math.max(von, endeTs);
|
||||||
|
const dauerSek = Math.max(1, Math.round((bis - von) / 1000));
|
||||||
|
|
||||||
|
const auswertung: GeraetAuswertung[] = sitzung.targets.map((t) => {
|
||||||
|
const eigene = sitzung.events.filter((e) => e.ip === t.ip);
|
||||||
|
const segmente = segmenteFuer(eigene, von, bis);
|
||||||
|
const ausfallzeitSek = Math.round(
|
||||||
|
segmente.reduce((s, [a, b]) => s + (b - a), 0) / 1000,
|
||||||
|
);
|
||||||
|
const laengster = segmente.length
|
||||||
|
? Math.round(Math.max(...segmente.map(([a, b]) => b - a)) / 1000)
|
||||||
|
: 0;
|
||||||
|
return {
|
||||||
|
ip: t.ip,
|
||||||
|
label: t.label || t.ip,
|
||||||
|
ausfallzeitSek,
|
||||||
|
aussetzer: segmente.length,
|
||||||
|
laengsterAusfallSek: laengster,
|
||||||
|
verfuegbarkeitProzent: Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(100, (1 - ausfallzeitSek / dauerSek) * 100),
|
||||||
|
),
|
||||||
|
ausfaelle: segmente.map(
|
||||||
|
([a, b]) =>
|
||||||
|
`${t.label || t.ip} ${fmtTime(a)} – ${fmtTime(b)} (${fmtDuration((b - a) / 1000)})`,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Schlechtestes Gerät bestimmt Label und Ampel (siehe Kopfkommentar)
|
||||||
|
const schlechtestes = auswertung.reduce<GeraetAuswertung | null>(
|
||||||
|
(min, g) => (min === null || g.verfuegbarkeitProzent < min.verfuegbarkeitProzent ? g : min),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const gesamtAussetzer = auswertung.reduce((s, g) => s + g.aussetzer, 0);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Bewertung. Die Schwellen sind bewusst streng: Für eine Kamera oder eine
|
||||||
|
* Wallbox ist ein Aussetzer pro Termin bereits das gemeldete Problem, nicht
|
||||||
|
* ein Schönheitsfehler. 100 % über den ganzen Zeitraum = grün, jeder
|
||||||
|
* Aussetzer = gelb, unter 95 % oder ein Gerät durchgehend tot = rot.
|
||||||
|
*/
|
||||||
|
let status: MeasureStatus = 0;
|
||||||
|
if (gesamtAussetzer > 0) status = 1;
|
||||||
|
if ((schlechtestes?.verfuegbarkeitProzent ?? 100) < 95) status = 2;
|
||||||
|
if (abgerissen) status = 3; // nicht messbar — die Grundlage stimmt nicht
|
||||||
|
|
||||||
|
const hinweise: string[] = [];
|
||||||
|
if (abgerissen) {
|
||||||
|
hinweise.push(
|
||||||
|
'Die Überwachung wurde nicht regulär beendet (App oder Gerät zwischendurch ' +
|
||||||
|
'gestoppt). Der ausgewiesene Zeitraum endet beim letzten aufgezeichneten ' +
|
||||||
|
'Ereignis; danach wurde nicht mehr gemessen.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Messverfahren offenlegen: das gehört zu einer belastbaren Aussage dazu.
|
||||||
|
hinweise.push(
|
||||||
|
'Gemessen durch regelmäßige Erreichbarkeitsprüfung alle ' +
|
||||||
|
`${sitzung.intervalSec} Sekunden. Ein Gerät, das Ping-Anfragen ` +
|
||||||
|
'grundsätzlich nicht beantwortet, erscheint dabei als nicht erreichbar.',
|
||||||
|
);
|
||||||
|
|
||||||
|
const label = schlechtestes
|
||||||
|
? gesamtAussetzer === 0
|
||||||
|
? `${auswertung.length} Geräte über ${fmtDuration(dauerSek)} durchgehend erreichbar`
|
||||||
|
: `${schlechtestes.label}: ${fmtProzent(schlechtestes.verfuegbarkeitProzent)} · ` +
|
||||||
|
`${gesamtAussetzer} ${gesamtAussetzer === 1 ? 'Aussetzer' : 'Aussetzer'} in ${fmtDuration(dauerSek)}`
|
||||||
|
: 'Überwachung ohne Geräte';
|
||||||
|
|
||||||
|
return {
|
||||||
|
label,
|
||||||
|
measureStatus: status,
|
||||||
|
result: {
|
||||||
|
dauerSekunden: dauerSek,
|
||||||
|
intervallSek: sitzung.intervalSec,
|
||||||
|
geraeteAnzahl: auswertung.length,
|
||||||
|
verfuegbarkeitProzent: Number((schlechtestes?.verfuegbarkeitProzent ?? 100).toFixed(1)),
|
||||||
|
ausfallzeitSek: schlechtestes?.ausfallzeitSek ?? 0,
|
||||||
|
aussetzer: gesamtAussetzer,
|
||||||
|
laengsterAusfallSek: Math.max(0, ...auswertung.map((g) => g.laengsterAusfallSek)),
|
||||||
|
// Vorformatierte Zeilen statt Objekte: MeasurementResult rendert Arrays
|
||||||
|
// zeilenweise, und dieselben Zeichenketten werden im PDF wiederverwendet.
|
||||||
|
geraete: auswertung.map(
|
||||||
|
(g) =>
|
||||||
|
// Nur benannte Geräte bekommen die IP in Klammern dahinter — ohne
|
||||||
|
// eigenen Namen stand sonst „10.0.2.2 (10.0.2.2)" im Kundendokument.
|
||||||
|
`${g.label === g.ip ? g.ip : `${g.label} (${g.ip})`}: ` +
|
||||||
|
`${fmtProzent(g.verfuegbarkeitProzent)}` +
|
||||||
|
(g.aussetzer
|
||||||
|
? ` · ${g.aussetzer} ${g.aussetzer === 1 ? 'Aussetzer' : 'Aussetzer'}` +
|
||||||
|
` · ${fmtDuration(g.ausfallzeitSek)} Ausfall` +
|
||||||
|
` · längster ${fmtDuration(g.laengsterAusfallSek)}`
|
||||||
|
: ' · durchgehend erreichbar'),
|
||||||
|
),
|
||||||
|
ausfaelle: auswertung.flatMap((g) => g.ausfaelle),
|
||||||
|
hinweis: hinweise.join(' '),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sitzung abschließen und als Messung ins Protokoll legen.
|
* Sitzung abschließen und als Messung ins Protokoll legen.
|
||||||
|
|
|
||||||
|
|
@ -206,43 +206,6 @@ export interface ThroughputResult {
|
||||||
measured?: boolean;
|
measured?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Ergebnis einer Namensauflösung */
|
|
||||||
export interface DnsResult {
|
|
||||||
ok: boolean;
|
|
||||||
ips: string[];
|
|
||||||
latenzMs?: number;
|
|
||||||
fehler?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Ergebnis eines HTTP(S)-Abrufs ohne Weiterleitungsverfolgung */
|
|
||||||
export interface HttpsResult {
|
|
||||||
erreichbar: boolean;
|
|
||||||
statusCode?: number;
|
|
||||||
statusText?: string;
|
|
||||||
latenzMs?: number;
|
|
||||||
/** Ziel einer 30x-Antwort — bei einem Gäste-WLAN die Adresse der Anmeldeseite */
|
|
||||||
weiterleitungAuf?: string;
|
|
||||||
fehler?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Antwort eines einzelnen, namentlich befragten DNS-Servers */
|
|
||||||
export interface DnsQueryResult {
|
|
||||||
server: string;
|
|
||||||
name: string;
|
|
||||||
/**
|
|
||||||
* 'ok' = Adressen geliefert · 'unbekannt' = Server antwortet, kennt den
|
|
||||||
* Namen nicht (NXDOMAIN — ein FUNKTIONIERENDER Server) · 'ohneAdresse' =
|
|
||||||
* antwortet ohne brauchbares Ergebnis (SERVFAIL/REFUSED) · 'keineAntwort' =
|
|
||||||
* gar nichts kam zurück · 'fehler' = Paket nicht auswertbar.
|
|
||||||
*/
|
|
||||||
status: 'ok' | 'unbekannt' | 'ohneAdresse' | 'keineAntwort' | 'fehler';
|
|
||||||
ips: string[];
|
|
||||||
latenzMs?: number;
|
|
||||||
/** Server ist für diese Zone zuständig (AA-Flag) */
|
|
||||||
autoritativ?: boolean;
|
|
||||||
meldung?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Schnittstelle des nativen Plugins */
|
/** Schnittstelle des nativen Plugins */
|
||||||
export interface NetDiagScannerPlugin {
|
export interface NetDiagScannerPlugin {
|
||||||
/** Aktuelles Subnetz des Geräts ermitteln (z.B. "192.168.1.0/24") */
|
/** Aktuelles Subnetz des Geräts ermitteln (z.B. "192.168.1.0/24") */
|
||||||
|
|
@ -369,26 +332,6 @@ export interface NetDiagScannerPlugin {
|
||||||
}>;
|
}>;
|
||||||
/** Alle aktiven Netzwerk-Interfaces auflisten (WLAN, Ethernet incl. USB-RJ45, Mobilfunk) */
|
/** Alle aktiven Netzwerk-Interfaces auflisten (WLAN, Ethernet incl. USB-RJ45, Mobilfunk) */
|
||||||
linkInfo(): Promise<{ links: LinkInfo[] }>;
|
linkInfo(): Promise<{ links: LinkInfo[] }>;
|
||||||
/**
|
|
||||||
* Namen über den System-Resolver auflösen.
|
|
||||||
*
|
|
||||||
* GRENZE: welcher DNS-Server antwortet, ist nicht wählbar, und der
|
|
||||||
* System-Cache liegt dazwischen — eine zweite Abfrage desselben Namens misst
|
|
||||||
* den Cache. Für „antwortet dieser bestimmte Server" braucht es einen eigenen
|
|
||||||
* UDP-Client (eigener Roadmap-Punkt).
|
|
||||||
*/
|
|
||||||
dnsResolve(opts: { host: string; timeoutMs?: number }): Promise<DnsResult>;
|
|
||||||
/**
|
|
||||||
* Eine URL abrufen, ohne Weiterleitungen zu folgen. Das Nichtfolgen ist der
|
|
||||||
* Kern: ein Gäste-WLAN mit Anmeldeseite beantwortet jede Anfrage mit einer
|
|
||||||
* Umleitung, und wer ihr folgt, bekommt ein sauberes 200 zurück.
|
|
||||||
*/
|
|
||||||
httpsCheck(opts: { url: string; timeoutMs?: number }): Promise<HttpsResult>;
|
|
||||||
/**
|
|
||||||
* Einen Namen bei EINEM bestimmten DNS-Server abfragen (eigener UDP-Client
|
|
||||||
* auf Port 53, ohne den Zwischenspeicher des Systems).
|
|
||||||
*/
|
|
||||||
dnsQuery(opts: { server: string; name: string; timeoutMs?: number }): Promise<DnsQueryResult>;
|
|
||||||
/** Aktiven WLAN-Scan triggern (System-Cache nachladen); `triggered=false` bei Throttling */
|
/** Aktiven WLAN-Scan triggern (System-Cache nachladen); `triggered=false` bei Throttling */
|
||||||
startWifiScan(): Promise<{ triggered: boolean }>;
|
startWifiScan(): Promise<{ triggered: boolean }>;
|
||||||
/** WLAN-Empfangstracker für ein BSSID starten — live RSSI per `wifiSignal`-Event */
|
/** WLAN-Empfangstracker für ein BSSID starten — live RSSI per `wifiSignal`-Event */
|
||||||
|
|
@ -892,7 +835,6 @@ const mock: NetDiagScannerPlugin = {
|
||||||
gateway: '192.168.1.1',
|
gateway: '192.168.1.1',
|
||||||
dns: ['192.168.1.1', '1.1.1.1'],
|
dns: ['192.168.1.1', '1.1.1.1'],
|
||||||
linkSpeedMbps: 1000,
|
linkSpeedMbps: 1000,
|
||||||
validated: true,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
iface: 'wlan0',
|
iface: 'wlan0',
|
||||||
|
|
@ -909,39 +851,10 @@ const mock: NetDiagScannerPlugin = {
|
||||||
ssid: 'AllesWattLaeuft',
|
ssid: 'AllesWattLaeuft',
|
||||||
bssid: 'AA:BB:CC:11:22:33',
|
bssid: 'AA:BB:CC:11:22:33',
|
||||||
rssi: -52,
|
rssi: -52,
|
||||||
validated: true,
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
/*
|
|
||||||
* Der Mock liefert für DNS und HTTPS BEWUSST nur den guten Fall — im Browser
|
|
||||||
* gibt es kein Kundennetz, gegen das sich ein Ausfall ehrlich nachstellen
|
|
||||||
* ließe. Wer die Internet-Kette hier durchklickt, sieht deshalb immer Grün.
|
|
||||||
* Die Fehlerpfade (kein Gateway, DNS tot, Anmeldeseite, HTTPS blockiert)
|
|
||||||
* werden nicht über den Mock geprüft, sondern über `tools/pruefe-kette.mjs`
|
|
||||||
* gegen konstruierte Stufen mit von Hand ermitteltem Sollwert. Dieselbe
|
|
||||||
* Falle wie beim geplanten Geräte-Diff: der Mock sieht perfekt aus und
|
|
||||||
* beweist nichts.
|
|
||||||
*/
|
|
||||||
async dnsResolve(opts) {
|
|
||||||
return { ok: true, ips: ['142.250.185.67'], latenzMs: rnd(8, 40) };
|
|
||||||
},
|
|
||||||
async httpsCheck() {
|
|
||||||
return { erreichbar: true, statusCode: 204, statusText: 'No Content', latenzMs: rnd(30, 120) };
|
|
||||||
},
|
|
||||||
async dnsQuery(opts) {
|
|
||||||
// Auch hier nur der Gutfall — siehe Kommentar oben. Die Fehlerpfade der
|
|
||||||
// DNS-Prüfung rechnet tools/pruefe-dns.mjs nach.
|
|
||||||
return {
|
|
||||||
server: opts.server,
|
|
||||||
name: opts.name,
|
|
||||||
status: 'ok' as const,
|
|
||||||
ips: ['142.250.185.67'],
|
|
||||||
latenzMs: rnd(3, 25),
|
|
||||||
autoritativ: false,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
async startWifiScan() {
|
async startWifiScan() {
|
||||||
return { triggered: true };
|
return { triggered: true };
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,86 +0,0 @@
|
||||||
/**
|
|
||||||
* Welche Geräte-Werkzeuge passen zu welchem Gerätetyp — bewusst OHNE
|
|
||||||
* Laufzeit-Import, damit `tools/pruefe-geraeteaktionen.mjs` die Regeln
|
|
||||||
* nachrechnen kann.
|
|
||||||
*
|
|
||||||
* Anlass: „SNMP-Switch" stand an jeder IP-Kamera und erzeugte dort zuverlässig
|
|
||||||
* eine Messung „nicht messbar" im Kundenprotokoll — eine Zeile, die niemandem
|
|
||||||
* hilft und den Abnahmebeleg verwässert.
|
|
||||||
*
|
|
||||||
* WICHTIG — warum hier nichts verschwindet: `Device.deviceType` ist freier Text
|
|
||||||
* aus zwei unabhängigen Quellen (Kotlin `guessDeviceType`, TS `typeFromMdns`)
|
|
||||||
* und rät anhand von MAC-Hersteller, mDNS-Diensten und offenen Ports. Ein
|
|
||||||
* managed Switch, der als „Linux-Gerät" durchgeht, verlöre bei hartem Filtern
|
|
||||||
* ausgerechnet SNMP — das wäre schlimmer als die überflüssige Zeile, die wir
|
|
||||||
* loswerden wollen. Unpassende Werkzeuge werden deshalb **nachrangig**
|
|
||||||
* einsortiert (die Karte zeigt sie kleiner und blasser), nicht entfernt.
|
|
||||||
* Unbekannter oder leerer Typ ⇒ alles gleichrangig, nie weniger.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** Werkzeuge, die nur bei bestimmten Gerätetypen vorn stehen sollen */
|
|
||||||
const NUR_BEI: Record<string, string[]> = {
|
|
||||||
/*
|
|
||||||
* SNMP beantwortet praktisch nur verwaltbare Netz- und Bürotechnik. Die Liste
|
|
||||||
* ist absichtlich großzügig: lieber ein Werkzeug zu viel vorn als eines, das
|
|
||||||
* der Techniker beim Kunden suchen muss. Drucker gehören dazu — sie sind in
|
|
||||||
* der Praxis die häufigste SNMP-Quelle überhaupt (Tonerstand, Zählerstand).
|
|
||||||
*/
|
|
||||||
snmp: [
|
|
||||||
'switch',
|
|
||||||
'router',
|
|
||||||
'gateway',
|
|
||||||
'nas',
|
|
||||||
'drucker',
|
|
||||||
'printer',
|
|
||||||
'server',
|
|
||||||
'usv',
|
|
||||||
'accesspoint',
|
|
||||||
'access point',
|
|
||||||
'firewall',
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Gerätetyp auf einen Vergleichswert bringen (Kleinschreibung, ohne Beiwerk) */
|
|
||||||
function normiere(typ: string | undefined | null): string {
|
|
||||||
return (typ ?? '').toLowerCase().trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Passt ein Werkzeug zum Gerätetyp?
|
|
||||||
*
|
|
||||||
* @param toolId ID aus der Werkzeug-Registry
|
|
||||||
* @param deviceType freier Text aus `Device.deviceType` (darf leer sein)
|
|
||||||
* @returns true = gleichrangig anzeigen, false = nachrangig
|
|
||||||
*/
|
|
||||||
export function passtZuGeraet(toolId: string, deviceType: string | undefined | null): boolean {
|
|
||||||
const nurBei = NUR_BEI[toolId];
|
|
||||||
if (!nurBei) {
|
|
||||||
return true; // keine Einschränkung hinterlegt
|
|
||||||
}
|
|
||||||
const typ = normiere(deviceType);
|
|
||||||
if (typ === '') {
|
|
||||||
return true; // unbekannt ⇒ nie weniger zeigen
|
|
||||||
}
|
|
||||||
// Teilstring-Vergleich: die Quellen liefern u.a. „SNMP-Switch", „IP-Kamera",
|
|
||||||
// „Windows-PC" — ein Gleichheitsvergleich ginge an der Wirklichkeit vorbei.
|
|
||||||
return nurBei.some((k) => typ.includes(k));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Werkzeugliste in „passend" und „nachrangig" teilen, Reihenfolge bleibt sonst
|
|
||||||
* unverändert.
|
|
||||||
*
|
|
||||||
* @param tools Werkzeuge mit `scope: 'device'`
|
|
||||||
* @param deviceType freier Text aus `Device.deviceType`
|
|
||||||
*/
|
|
||||||
export function teileNachGeraetetyp<T extends { id: string }>(
|
|
||||||
tools: T[],
|
|
||||||
deviceType: string | undefined | null,
|
|
||||||
): { passend: T[]; nachrangig: T[] } {
|
|
||||||
const passend: T[] = [];
|
|
||||||
const nachrangig: T[] = [];
|
|
||||||
for (const t of tools) {
|
|
||||||
(passtZuGeraet(t.id, deviceType) ? passend : nachrangig).push(t);
|
|
||||||
}
|
|
||||||
return { passend, nachrangig };
|
|
||||||
}
|
|
||||||
|
|
@ -9,7 +9,6 @@
|
||||||
|
|
||||||
import type { Tool, ToolCategory } from './types';
|
import type { Tool, ToolCategory } from './types';
|
||||||
|
|
||||||
import { dnsCheckTool } from './netzwerk/dnscheck';
|
|
||||||
import { ipConflictTool } from './netzwerk/ipconflict';
|
import { ipConflictTool } from './netzwerk/ipconflict';
|
||||||
import { ipScanTool } from './netzwerk/ipscan';
|
import { ipScanTool } from './netzwerk/ipscan';
|
||||||
import { pingTool } from './netzwerk/ping';
|
import { pingTool } from './netzwerk/ping';
|
||||||
|
|
@ -18,7 +17,6 @@ import { snmpTool } from './netzwerk/snmp';
|
||||||
import { stressTestTool } from './netzwerk/stresstest';
|
import { stressTestTool } from './netzwerk/stresstest';
|
||||||
import { tracerouteTool } from './netzwerk/traceroute';
|
import { tracerouteTool } from './netzwerk/traceroute';
|
||||||
|
|
||||||
import { internetKetteTool } from './internet/internetkette';
|
|
||||||
import { iperfTool } from './internet/iperf';
|
import { iperfTool } from './internet/iperf';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -37,11 +35,8 @@ export const TOOLS: Tool[] = [
|
||||||
ipConflictTool,
|
ipConflictTool,
|
||||||
snmpTool,
|
snmpTool,
|
||||||
tracerouteTool,
|
tracerouteTool,
|
||||||
dnsCheckTool,
|
|
||||||
stressTestTool,
|
stressTestTool,
|
||||||
// Internet — die Kette steht bewusst vorn: sie ist die Erstdiagnose,
|
// Internet
|
||||||
// wenn der Kunde „Internet geht nicht" sagt.
|
|
||||||
internetKetteTool,
|
|
||||||
iperfTool,
|
iperfTool,
|
||||||
// Telefonie: folgt (SIP-Registrierung, FreePBX-Check, RTP-Qualität)
|
// Telefonie: folgt (SIP-Registrierung, FreePBX-Check, RTP-Qualität)
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -1,156 +0,0 @@
|
||||||
/**
|
|
||||||
* Tool: Internet-Kette — Anschluss → Gateway → Namensauflösung → HTTPS.
|
|
||||||
*
|
|
||||||
* Die Standard-Erstdiagnose vor Ort. Die häufigste Aussage beim Kunden ist
|
|
||||||
* „Internet geht nicht"; bisher musste der Techniker Ping, Traceroute und
|
|
||||||
* Browser einzeln bedienen und selbst schlussfolgern, welches Glied hakt.
|
|
||||||
* Dieses Werkzeug misst alle Stufen in einem Lauf und benennt das Fehlerglied.
|
|
||||||
*
|
|
||||||
* Zwei Entwurfsentscheidungen, die für die Ehrlichkeit der Aussage wesentlich
|
|
||||||
* sind und deshalb hier stehen:
|
|
||||||
*
|
|
||||||
* 1. **Die Kette bricht nicht ab.** Auch nach einer fehlgeschlagenen Stufe
|
|
||||||
* laufen die restlichen weiter. Sonst wäre der häufigste Fall überhaupt
|
|
||||||
* nicht auswertbar: sehr viele Router beantworten Ping grundsätzlich nicht.
|
|
||||||
* Ein Abbruch dort meldete „Gateway tot" bei völlig gesundem Internet.
|
|
||||||
* 2. **Eine stille Stufe ist kein Fehler, wenn eine spätere Stufe antwortet.**
|
|
||||||
* Wer eine Antwort aus dem Internet bekommt, hat sein Gateway benutzt —
|
|
||||||
* dann ist dessen Schweigen eine Eigenheit, keine Störung, und wird als
|
|
||||||
* solche formuliert.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { scanner } from '../../scanner';
|
|
||||||
import type { Tool } from '../types';
|
|
||||||
import { bewerteKette, type KettenStufe } from './kette-bewertung';
|
|
||||||
|
|
||||||
export { bewerteKette } from './kette-bewertung';
|
|
||||||
export type { KettenStufe, KettenUrteil } from './kette-bewertung';
|
|
||||||
|
|
||||||
export const internetKetteTool: Tool = {
|
|
||||||
id: 'internetkette',
|
|
||||||
category: 'internet',
|
|
||||||
name: 'Internet-Kette',
|
|
||||||
icon: 'link',
|
|
||||||
description: 'Anschluss → Gateway → Namensauflösung → HTTPS in einem Lauf, mit benanntem Fehlerglied.',
|
|
||||||
scope: 'protocol',
|
|
||||||
quickRun: true,
|
|
||||||
params: [
|
|
||||||
{ key: 'dnsName', label: 'Name zum Auflösen', type: 'text', default: 'www.google.de' },
|
|
||||||
{
|
|
||||||
key: 'url',
|
|
||||||
label: 'Adresse für den HTTPS-Abruf',
|
|
||||||
type: 'text',
|
|
||||||
default: 'https://connectivitycheck.gstatic.com/generate_204',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
async run(ctx) {
|
|
||||||
const dnsName = String(ctx.params.dnsName || 'www.google.de').trim();
|
|
||||||
const url = String(ctx.params.url || 'https://connectivitycheck.gstatic.com/generate_204').trim();
|
|
||||||
|
|
||||||
/* Stufe 1 — eigener Anschluss */
|
|
||||||
const { links } = await scanner.linkInfo();
|
|
||||||
const link =
|
|
||||||
links.find((l) => l.isDefault && l.ipv4) ??
|
|
||||||
links.find((l) => l.ipv4 && l.type !== 'cellular') ??
|
|
||||||
links.find((l) => l.ipv4);
|
|
||||||
|
|
||||||
const anschlussText = link
|
|
||||||
? `${anschlussName(link.type)} ${link.iface}, IP ${link.ipv4}/${link.prefixLength ?? '?'}`
|
|
||||||
: 'keine IP-Adresse auf keinem Anschluss';
|
|
||||||
|
|
||||||
const stufen: KettenStufe[] = [
|
|
||||||
{ name: 'Anschluss', ok: link ? true : null, detail: anschlussText },
|
|
||||||
];
|
|
||||||
|
|
||||||
/* Stufe 2 — Gateway */
|
|
||||||
const gateway = link?.gateway;
|
|
||||||
if (!gateway) {
|
|
||||||
stufen.push({
|
|
||||||
name: 'Gateway',
|
|
||||||
ok: null,
|
|
||||||
detail: 'kein Standard-Gateway gemeldet',
|
|
||||||
stillErlaubt: true,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
const q = await scanner.pingQuality({ host: gateway, count: 4 });
|
|
||||||
stufen.push({
|
|
||||||
name: 'Gateway',
|
|
||||||
ok: q.received > 0,
|
|
||||||
detail:
|
|
||||||
q.received > 0
|
|
||||||
? `${gateway} antwortet in ${q.medianMs ?? q.avgMs} ms (${q.received}/${q.sent} Pakete)`
|
|
||||||
: `${gateway} antwortet auf keines von ${q.sent} Paketen`,
|
|
||||||
stillErlaubt: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Stufe 3 — Namensauflösung */
|
|
||||||
const dns = await scanner.dnsResolve({ host: dnsName, timeoutMs: 5000 });
|
|
||||||
stufen.push({
|
|
||||||
name: 'Namensauflösung',
|
|
||||||
ok: dns.ok,
|
|
||||||
detail: dns.ok
|
|
||||||
? `${dnsName} → ${dns.ips.slice(0, 2).join(', ')}${dns.latenzMs !== undefined ? ` (${dns.latenzMs} ms)` : ''}`
|
|
||||||
: `${dnsName} nicht auflösbar${dns.fehler ? ` (${dns.fehler})` : ''}`,
|
|
||||||
});
|
|
||||||
|
|
||||||
/* Stufe 4 — HTTPS */
|
|
||||||
const https = await scanner.httpsCheck({ url, timeoutMs: 8000 });
|
|
||||||
// 2xx zählt als Erfolg, 204 ist die erwartete Antwort des Prüfdienstes.
|
|
||||||
// Eine Weiterleitung ist KEIN Erfolg — genau dort sitzt die Anmeldeseite.
|
|
||||||
const code = https.statusCode ?? 0;
|
|
||||||
const httpsOk = https.erreichbar && code >= 200 && code < 300;
|
|
||||||
const weiterleitung = https.erreichbar && code >= 300 && code < 400;
|
|
||||||
stufen.push({
|
|
||||||
name: 'HTTPS-Abruf',
|
|
||||||
ok: https.erreichbar ? httpsOk : false,
|
|
||||||
detail: https.erreichbar
|
|
||||||
? `HTTP ${code}${https.statusText ? ' ' + https.statusText : ''}` +
|
|
||||||
(https.latenzMs !== undefined ? ` in ${https.latenzMs} ms` : '') +
|
|
||||||
(https.weiterleitungAuf ? ` → Umleitung auf ${https.weiterleitungAuf}` : '')
|
|
||||||
: `kein Verbindungsaufbau${https.fehler ? ` (${https.fehler})` : ''}`,
|
|
||||||
});
|
|
||||||
|
|
||||||
const portalErkannt = weiterleitung || link?.captivePortal === true;
|
|
||||||
const urteil = bewerteKette(stufen, portalErkannt, link?.validated);
|
|
||||||
|
|
||||||
// Systemurteil als eigene, klar als solche gekennzeichnete Zeile: es ist
|
|
||||||
// eine Fremdaussage, keine eigene Messung.
|
|
||||||
const systemText =
|
|
||||||
link?.validated === undefined
|
|
||||||
? undefined
|
|
||||||
: link.validated
|
|
||||||
? 'Android stuft diese Verbindung als bestätigt ein.'
|
|
||||||
: 'Android stuft diese Verbindung NICHT als bestätigt ein.';
|
|
||||||
|
|
||||||
return {
|
|
||||||
label: urteil.label,
|
|
||||||
measureStatus: urteil.status,
|
|
||||||
result: {
|
|
||||||
fehlerglied: urteil.fehlerglied || 'keines',
|
|
||||||
stufen: stufen.map(
|
|
||||||
(s, i) =>
|
|
||||||
`${i + 1}. ${s.name}: ${s.detail}` +
|
|
||||||
(s.ok === true ? '' : s.ok === false ? ' — keine Antwort' : ' — nicht messbar'),
|
|
||||||
),
|
|
||||||
gateway: gateway ?? undefined,
|
|
||||||
systemUrteil: systemText,
|
|
||||||
hinweis: urteil.hinweis,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Anschlussart auf Deutsch — dieselbe Sprache wie netdiagKundentext() im Modul */
|
|
||||||
function anschlussName(type: string): string {
|
|
||||||
switch (type) {
|
|
||||||
case 'ethernet':
|
|
||||||
return 'LAN (Kabel)';
|
|
||||||
case 'wifi':
|
|
||||||
return 'WLAN';
|
|
||||||
case 'cellular':
|
|
||||||
return 'Mobilfunk';
|
|
||||||
default:
|
|
||||||
return 'Anschluss';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,162 +0,0 @@
|
||||||
/**
|
|
||||||
* Bewertung der Internet-Kette — bewusst OHNE jeden Laufzeit-Import.
|
|
||||||
*
|
|
||||||
* Warum eine eigene Datei: die Fehlerpfade dieses Werkzeugs (kein Gateway,
|
|
||||||
* DNS tot, Anmeldeseite, HTTPS blockiert) lassen sich im Browser-Mock nicht
|
|
||||||
* ehrlich nachstellen — dort ist immer alles grün. Geprüft wird deshalb mit
|
|
||||||
* `tools/pruefe-kette.mjs` gegen konstruierte Stufen mit von Hand ermitteltem
|
|
||||||
* Sollwert. Damit dieser Prüfer die Funktion laden kann, darf hier nichts
|
|
||||||
* stehen, was `scanner.ts` (und damit Capacitor) nachzieht: nur `import type`,
|
|
||||||
* das beim Ausführen mit Node restlos verschwindet.
|
|
||||||
*
|
|
||||||
* Der Ausweg „Datei von Hand nach .mjs kopieren" wurde beim Monitor gewählt
|
|
||||||
* und hat sich als Sackgasse erwiesen: die Kopie lag in einem
|
|
||||||
* Sitzungsverzeichnis, das Prüfwerkzeug lief nach dessen Wegfall nicht mehr,
|
|
||||||
* und im Repo stand trotzdem „alle Fälle korrekt".
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { MeasureStatus } from '../types';
|
|
||||||
|
|
||||||
/** Eine Stufe der Kette, so wie sie gemessen wurde */
|
|
||||||
export interface KettenStufe {
|
|
||||||
/** Kurzname für das Kundendokument („Gateway", „Namensauflösung" …) */
|
|
||||||
name: string;
|
|
||||||
/**
|
|
||||||
* true = Stufe hat geantwortet
|
|
||||||
* false = Stufe hat nicht geantwortet
|
|
||||||
* null = nicht messbar (die Voraussetzung fehlte, z.B. kein Gateway bekannt)
|
|
||||||
*/
|
|
||||||
ok: boolean | null;
|
|
||||||
/** Messwert/Antwort als Klartext für das Protokoll */
|
|
||||||
detail: string;
|
|
||||||
/**
|
|
||||||
* true = diese Stufe darf allein kein Rot auslösen, solange eine spätere
|
|
||||||
* Stufe geantwortet hat. Sehr viele Router beantworten Ping grundsätzlich
|
|
||||||
* nicht; wer eine Antwort aus dem Internet bekommt, hat sein Gateway aber
|
|
||||||
* nachweislich benutzt.
|
|
||||||
*/
|
|
||||||
stillErlaubt?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Ergebnis der Kettenbewertung */
|
|
||||||
export interface KettenUrteil {
|
|
||||||
status: MeasureStatus;
|
|
||||||
label: string;
|
|
||||||
/** benanntes Fehlerglied, leer wenn die Kette durchgeht */
|
|
||||||
fehlerglied: string;
|
|
||||||
hinweis?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Aus den gemessenen Stufen das Urteil bilden.
|
|
||||||
*
|
|
||||||
* @param stufen die Stufen in Reihenfolge (Anschluss zuerst, HTTPS zuletzt)
|
|
||||||
* @param portalErkannt Anmeldeseite erkannt (Weiterleitung oder Systemurteil)
|
|
||||||
* @param systemUrteil Androids NET_CAPABILITY_VALIDATED, falls bekannt
|
|
||||||
*/
|
|
||||||
export function bewerteKette(
|
|
||||||
stufen: KettenStufe[],
|
|
||||||
portalErkannt: boolean,
|
|
||||||
systemUrteil?: boolean,
|
|
||||||
): KettenUrteil {
|
|
||||||
// Gar nichts messbar (kein Anschluss) — Status 3 statt einer Falschaussage
|
|
||||||
// über das Kundennetz. Ohne eigene IP haben wir schlicht nicht gemessen.
|
|
||||||
const anschluss = stufen[0];
|
|
||||||
if (!anschluss || anschluss.ok === null) {
|
|
||||||
return {
|
|
||||||
status: 3,
|
|
||||||
label: 'Nicht messbar — kein aktiver Netzwerkanschluss',
|
|
||||||
fehlerglied: 'Anschluss',
|
|
||||||
hinweis:
|
|
||||||
'Das Messgerät hatte keine IP-Adresse. Es wurde nichts über das Kundennetz gemessen.',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Die Anmeldeseite wird VOR der Fehlerglied-Suche entschieden. Sie erklärt
|
|
||||||
* sämtliche anderen Symptome: ein Gäste-WLAN-Portal beantwortet den
|
|
||||||
* HTTPS-Abruf mit einer Umleitung (die Stufe gilt damit als nicht bestanden)
|
|
||||||
* und biegt oft auch die Namensauflösung um. Ohne diesen Vorrang stand im
|
|
||||||
* Kundendokument „Kein Internet — HTTPS-Abruf antwortet nicht", obwohl der
|
|
||||||
* Server sauber geantwortet hat und der Techniker nur auf „Anmelden" tippen
|
|
||||||
* muss. Das ist der Unterschied zwischen einem Anruf beim Provider und
|
|
||||||
* einem erledigten Termin.
|
|
||||||
*/
|
|
||||||
if (portalErkannt) {
|
|
||||||
return {
|
|
||||||
status: 1,
|
|
||||||
label: 'Anmeldeseite im Weg — Internet erst nach Anmeldung',
|
|
||||||
fehlerglied: 'Anmeldeseite',
|
|
||||||
hinweis:
|
|
||||||
'Die Anfrage wurde auf eine Anmeldeseite umgeleitet (typisch für ein ' +
|
|
||||||
'Gäste-WLAN). Ohne Anmeldung kommt kein Gerät ins Internet — Kameras, ' +
|
|
||||||
'Wechselrichter und Wallboxen können sich dort grundsätzlich nicht melden.',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Index der letzten Stufe, die geantwortet hat — entscheidet darüber, ob das
|
|
||||||
// Schweigen einer früheren Stufe entschuldigt ist.
|
|
||||||
const letzteAntwortIdx = stufen.reduce((idx, s, i) => (s.ok === true ? i : idx), -1);
|
|
||||||
|
|
||||||
// Fehlerglied: erste Stufe ohne Antwort, deren Schweigen nicht durch eine
|
|
||||||
// spätere Antwort erklärt ist.
|
|
||||||
let fehlerglied = '';
|
|
||||||
for (let i = 0; i < stufen.length; i++) {
|
|
||||||
const s = stufen[i];
|
|
||||||
if (s.ok !== false) continue;
|
|
||||||
if (s.stillErlaubt && letzteAntwortIdx > i) continue;
|
|
||||||
fehlerglied = s.name;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fehlerglied !== '') {
|
|
||||||
return {
|
|
||||||
status: 2,
|
|
||||||
label: `Kein Internet — ${fehlerglied} antwortet nicht`,
|
|
||||||
fehlerglied,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const httpsStufe = stufen[stufen.length - 1];
|
|
||||||
const internetGeht = httpsStufe?.ok === true;
|
|
||||||
|
|
||||||
// Alles geantwortet, aber Android hält die Verbindung nicht für bestätigt:
|
|
||||||
// „HTTPS geht, irgendetwas anderes klemmt trotzdem" — gelb, nicht grün.
|
|
||||||
if (internetGeht && systemUrteil === false) {
|
|
||||||
return {
|
|
||||||
status: 1,
|
|
||||||
label: 'Internet erreichbar, vom System aber nicht bestätigt',
|
|
||||||
fehlerglied: '',
|
|
||||||
hinweis:
|
|
||||||
'Alle Stufen haben geantwortet, Android stuft die Verbindung dennoch ' +
|
|
||||||
'nicht als bestätigt ein. Das deutet auf eine teilweise gefilterte ' +
|
|
||||||
'Verbindung hin (Jugendschutz, Firewall, Portsperre).',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Eine Stufe, die nicht messbar war (z.B. kein Gateway gemeldet), ist kein
|
|
||||||
// Erfolg — sie darf aber auch nicht als Störung ausgewiesen werden.
|
|
||||||
const stille = stufen.filter((s) => s.ok === false && s.stillErlaubt);
|
|
||||||
const unbekannt = stufen.filter((s) => s.ok === null && s.stillErlaubt);
|
|
||||||
const hinweise: string[] = [];
|
|
||||||
if (stille.length) {
|
|
||||||
hinweise.push(
|
|
||||||
`${stille.map((s) => s.name).join(' und ')} antwortet nicht auf Ping, ` +
|
|
||||||
'leitet den Verkehr aber weiter — das ist bei vielen Routern die ' +
|
|
||||||
'Werkseinstellung und keine Störung.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (unbekannt.length) {
|
|
||||||
hinweise.push(
|
|
||||||
`${unbekannt.map((s) => s.name).join(' und ')} konnte nicht geprüft werden ` +
|
|
||||||
'(vom System nicht gemeldet).',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
status: 0,
|
|
||||||
label: 'Internet erreichbar — Gateway, Namensauflösung und HTTPS in Ordnung',
|
|
||||||
fehlerglied: '',
|
|
||||||
hinweis: hinweise.length ? hinweise.join(' ') : undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,181 +0,0 @@
|
||||||
/**
|
|
||||||
* Bewertung der DNS-Prüfung — ohne Laufzeit-Import, damit
|
|
||||||
* `tools/pruefe-dns.mjs` sie nachrechnen kann.
|
|
||||||
*
|
|
||||||
* Kernaussage des Werkzeugs: **welcher** Server hat geantwortet und **wie**.
|
|
||||||
* Die vier Ausgänge sind fachlich verschieden und dürfen nie zu „geht/geht
|
|
||||||
* nicht" verkürzt werden:
|
|
||||||
*
|
|
||||||
* - `ok` — Server antwortet mit Adressen. Alles in Ordnung.
|
|
||||||
* - `unbekannt` — Server antwortet, kennt den Namen aber nicht (NXDOMAIN).
|
|
||||||
* Das ist ein **funktionierender** Server. Bei einem
|
|
||||||
* internen Namen heißt es „falscher Server gefragt", bei
|
|
||||||
* einem externen „der Name existiert wirklich nicht".
|
|
||||||
* - `ohneAdresse` — Server antwortet, liefert aber nichts Brauchbares
|
|
||||||
* (SERVFAIL, REFUSED). Das ist eine Störung.
|
|
||||||
* - `keineAntwort` — gar nichts kam zurück: Server aus, falsche Adresse,
|
|
||||||
* Firewall. Die häufigste echte Ursache vor Ort.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { MeasureStatus } from '../types';
|
|
||||||
|
|
||||||
/** Antwort eines einzelnen befragten Servers */
|
|
||||||
export interface DnsAntwort {
|
|
||||||
server: string;
|
|
||||||
name: string;
|
|
||||||
status: 'ok' | 'unbekannt' | 'ohneAdresse' | 'keineAntwort' | 'fehler';
|
|
||||||
ips: string[];
|
|
||||||
latenzMs?: number;
|
|
||||||
autoritativ?: boolean;
|
|
||||||
meldung?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DnsUrteil {
|
|
||||||
status: MeasureStatus;
|
|
||||||
label: string;
|
|
||||||
zeilen: string[];
|
|
||||||
hinweis?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Private IPv4-Adresse nach RFC 1918 (plus Link-Local 169.254.x)?
|
|
||||||
*
|
|
||||||
* Entscheidet, ob zwei Antworten „von verschiedener Sorte" sind — siehe
|
|
||||||
* Kommentar in `bewerteDns()`. Bewusst ohne Regex auf Zeichenketten wie
|
|
||||||
* „192.168.": 172.16–172.31 lässt sich so nicht sauber prüfen, und genau
|
|
||||||
* dieser Bereich kommt in Firmennetzen häufig vor.
|
|
||||||
*/
|
|
||||||
export function istPrivat(ip: string): boolean {
|
|
||||||
const t = ip.split('.').map((n) => Number(n));
|
|
||||||
if (t.length !== 4 || t.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return false;
|
|
||||||
if (t[0] === 10) return true;
|
|
||||||
if (t[0] === 192 && t[1] === 168) return true;
|
|
||||||
if (t[0] === 172 && t[1] >= 16 && t[1] <= 31) return true;
|
|
||||||
if (t[0] === 169 && t[1] === 254) return true;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Eine Antwort als Protokollzeile */
|
|
||||||
export function antwortZeile(a: DnsAntwort): string {
|
|
||||||
const kopf = `${a.server}: `;
|
|
||||||
switch (a.status) {
|
|
||||||
case 'ok':
|
|
||||||
return (
|
|
||||||
kopf +
|
|
||||||
`${a.ips.slice(0, 3).join(', ')}` +
|
|
||||||
(a.latenzMs !== undefined ? ` (${a.latenzMs} ms)` : '') +
|
|
||||||
(a.autoritativ ? ' · zuständig für diese Zone' : '')
|
|
||||||
);
|
|
||||||
case 'unbekannt':
|
|
||||||
return kopf + `antwortet, kennt „${a.name}" aber nicht (NXDOMAIN)`;
|
|
||||||
case 'ohneAdresse':
|
|
||||||
return kopf + `antwortet ohne Adresse${a.meldung ? ` — ${a.meldung}` : ''}`;
|
|
||||||
case 'keineAntwort':
|
|
||||||
return kopf + 'keine Antwort';
|
|
||||||
default:
|
|
||||||
return kopf + `nicht auswertbar${a.meldung ? ` — ${a.meldung}` : ''}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Aus den Antworten aller befragten Server das Urteil bilden.
|
|
||||||
*
|
|
||||||
* @param antworten in der Reihenfolge, in der gefragt wurde (eigene Server zuerst)
|
|
||||||
*/
|
|
||||||
export function bewerteDns(antworten: DnsAntwort[]): DnsUrteil {
|
|
||||||
const zeilen = antworten.map(antwortZeile);
|
|
||||||
if (antworten.length === 0) {
|
|
||||||
return {
|
|
||||||
status: 3,
|
|
||||||
label: 'Nicht messbar — kein DNS-Server bekannt',
|
|
||||||
zeilen,
|
|
||||||
hinweis:
|
|
||||||
'Das Gerät hat vom Netz keinen DNS-Server gemeldet bekommen. Es wurde nichts geprüft.',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const ok = antworten.filter((a) => a.status === 'ok');
|
|
||||||
const stumm = antworten.filter((a) => a.status === 'keineAntwort');
|
|
||||||
const gestoert = antworten.filter((a) => a.status === 'ohneAdresse' || a.status === 'fehler');
|
|
||||||
const unbekannt = antworten.filter((a) => a.status === 'unbekannt');
|
|
||||||
|
|
||||||
// Kein einziger Server hat brauchbar geantwortet
|
|
||||||
if (ok.length === 0 && unbekannt.length === 0) {
|
|
||||||
return {
|
|
||||||
status: 2,
|
|
||||||
label:
|
|
||||||
stumm.length === antworten.length
|
|
||||||
? `Kein DNS-Server antwortet (${antworten.length} geprüft)`
|
|
||||||
: 'DNS-Server antworten, liefern aber keine Adressen',
|
|
||||||
zeilen,
|
|
||||||
hinweis:
|
|
||||||
'Ohne Namensauflösung funktioniert im Netz praktisch nichts — weder ' +
|
|
||||||
'Internetzugang noch Updates, und Kameras oder Wechselrichter erreichen ' +
|
|
||||||
'ihren Herstellerdienst nicht.',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ein Server antwortet, kennt den Namen aber nicht — und keiner kennt ihn
|
|
||||||
if (ok.length === 0) {
|
|
||||||
return {
|
|
||||||
status: 1,
|
|
||||||
label: `Name „${antworten[0].name}" ist keinem befragten Server bekannt`,
|
|
||||||
zeilen,
|
|
||||||
hinweis:
|
|
||||||
'Die Server arbeiten (sie antworten mit „Name unbekannt"), der Name ' +
|
|
||||||
'existiert dort nur nicht. Bei einem internen Namen wurde vermutlich der ' +
|
|
||||||
'falsche Server gefragt, bei einem externen gibt es den Namen wirklich nicht.',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mindestens einer liefert Adressen — Rest bewerten
|
|
||||||
if (stumm.length > 0 || gestoert.length > 0) {
|
|
||||||
const betroffen = [...stumm, ...gestoert].map((a) => a.server).join(', ');
|
|
||||||
return {
|
|
||||||
status: 1,
|
|
||||||
label: `Namensauflösung geht, aber ${stumm.length + gestoert.length} von ${antworten.length} Servern versagen`,
|
|
||||||
zeilen,
|
|
||||||
hinweis:
|
|
||||||
`Nicht erreichbar oder gestört: ${betroffen}. Solange ein anderer Server ` +
|
|
||||||
'antwortet, merkt der Kunde davon nichts — bis dieser ausfällt. Bei einem ' +
|
|
||||||
'ausgefallenen ersten Server dauert außerdem jeder Verbindungsaufbau ' +
|
|
||||||
'spürbar länger, weil erst dessen Zeitüberschreitung abgewartet wird.',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Widersprüchliche Antworten — aber nur die aussagekräftige Sorte.
|
|
||||||
*
|
|
||||||
* Ein reiner Adressunterschied taugt NICHT als Befund: große Dienste
|
|
||||||
* (Google, Microsoft, jedes CDN) antworten standortabhängig, zwei Resolver
|
|
||||||
* liefern dort fast immer verschiedene IPs. Im Emulator gegen `www.google.de`
|
|
||||||
* nachgestellt: 10.0.2.3 → 192.178.24.195, 9.9.9.9 → 192.178.183.94, beides
|
|
||||||
* völlig in Ordnung. Eine Warnung darauf käme bei praktisch jeder Messung und
|
|
||||||
* würde im Kundendokument eine gelbe Ampel ohne Befund erzeugen.
|
|
||||||
*
|
|
||||||
* Aussagekräftig ist der Sortenunterschied: privat gegen öffentlich. Genau so
|
|
||||||
* sieht ein Split-Horizon-Eintrag aus (gewollt, im Firmennetz normal) — oder
|
|
||||||
* ein zweiter, nicht gewollter DNS-Dienst, der auf sich selbst umbiegt.
|
|
||||||
* Beide Antworten stehen ohnehin einzeln im Protokoll; der Techniker sieht
|
|
||||||
* den Unterschied also auch ohne Ampel.
|
|
||||||
*/
|
|
||||||
const sorten = new Set(ok.map((a) => (a.ips.some(istPrivat) ? 'privat' : 'oeffentlich')));
|
|
||||||
if (sorten.size > 1) {
|
|
||||||
return {
|
|
||||||
status: 1,
|
|
||||||
label: 'DNS-Server antworten mit unterschiedlichen Adressarten',
|
|
||||||
zeilen,
|
|
||||||
hinweis:
|
|
||||||
'Ein Server liefert für denselben Namen eine Adresse aus dem lokalen ' +
|
|
||||||
'Netz, ein anderer eine öffentliche. Im Firmennetz ist das gewollt, wenn ' +
|
|
||||||
'ein interner Server eigene Einträge pflegt — sonst ein Hinweis auf einen ' +
|
|
||||||
'zweiten, nicht gewollten DNS-Dienst.',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
status: 0,
|
|
||||||
label: `Namensauflösung in Ordnung (${ok.length} von ${antworten.length} Servern befragt)`,
|
|
||||||
zeilen,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,80 +0,0 @@
|
||||||
/**
|
|
||||||
* Tool: DNS-Prüfung — fragt die DNS-Server des Netzes einzeln und namentlich.
|
|
||||||
*
|
|
||||||
* Abgrenzung zur Internet-Kette: dort wird über den System-Resolver geprüft,
|
|
||||||
* ob Namensauflösung überhaupt funktioniert. Hier geht je ein UDP-Paket an je
|
|
||||||
* einen bestimmten Server. Nur so lässt sich sagen, WELCHER Server hakt — und
|
|
||||||
* beim Kunden ist genau das die Reparaturanweisung („die Fritzbox antwortet,
|
|
||||||
* der zweite Eintrag im DHCP zeigt ins Leere").
|
|
||||||
*
|
|
||||||
* Die Server kommen aus der Netzkonfiguration des Geräts (`LinkInfo.dns`),
|
|
||||||
* also aus dem, was der Kunde per DHCP verteilt. Zusätzlich lässt sich ein
|
|
||||||
* Vergleichsserver angeben: antwortet der, während die eigenen schweigen,
|
|
||||||
* liegt es am Server und nicht an der Leitung.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { scanner } from '../../scanner';
|
|
||||||
import type { Tool } from '../types';
|
|
||||||
import { bewerteDns, type DnsAntwort } from './dns-bewertung';
|
|
||||||
|
|
||||||
export { bewerteDns, antwortZeile } from './dns-bewertung';
|
|
||||||
export type { DnsAntwort, DnsUrteil } from './dns-bewertung';
|
|
||||||
|
|
||||||
export const dnsCheckTool: Tool = {
|
|
||||||
id: 'dnscheck',
|
|
||||||
category: 'netzwerk',
|
|
||||||
name: 'DNS-Prüfung',
|
|
||||||
icon: 'globe',
|
|
||||||
description: 'Fragt jeden DNS-Server des Netzes einzeln — zeigt, welcher antwortet und welcher nicht.',
|
|
||||||
scope: 'protocol',
|
|
||||||
quickRun: true,
|
|
||||||
params: [
|
|
||||||
{ key: 'name', label: 'Name zum Auflösen', type: 'text', default: 'www.google.de' },
|
|
||||||
{
|
|
||||||
key: 'vergleich',
|
|
||||||
label: 'Vergleichsserver (leer = keiner)',
|
|
||||||
type: 'text',
|
|
||||||
default: '9.9.9.9',
|
|
||||||
placeholder: '9.9.9.9',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
async run(ctx) {
|
|
||||||
const name = String(ctx.params.name || 'www.google.de').trim();
|
|
||||||
const vergleich = String(ctx.params.vergleich ?? '').trim();
|
|
||||||
|
|
||||||
// DNS-Server aus der Netzkonfiguration — bevorzugt vom Standard-Anschluss.
|
|
||||||
const { links } = await scanner.linkInfo();
|
|
||||||
const link = links.find((l) => l.isDefault && l.ipv4) ?? links.find((l) => l.ipv4);
|
|
||||||
const eigene = (link?.dns ?? []).filter((s) => !!s);
|
|
||||||
|
|
||||||
// Vergleichsserver ans Ende, aber nie doppelt fragen.
|
|
||||||
const serverListe = [...eigene];
|
|
||||||
if (vergleich && !serverListe.includes(vergleich)) {
|
|
||||||
serverListe.push(vergleich);
|
|
||||||
}
|
|
||||||
|
|
||||||
const antworten: DnsAntwort[] = [];
|
|
||||||
for (const server of serverListe) {
|
|
||||||
const r = await scanner.dnsQuery({ server, name, timeoutMs: 2000 });
|
|
||||||
antworten.push(r);
|
|
||||||
}
|
|
||||||
|
|
||||||
const urteil = bewerteDns(antworten);
|
|
||||||
|
|
||||||
return {
|
|
||||||
label: urteil.label,
|
|
||||||
measureStatus: urteil.status,
|
|
||||||
result: {
|
|
||||||
ziel: name,
|
|
||||||
dns: eigene.length ? eigene : undefined,
|
|
||||||
gefragteServer: serverListe,
|
|
||||||
antworten: urteil.zeilen,
|
|
||||||
hinweis: urteil.hinweis,
|
|
||||||
// Die Grenze gehört ins Kundendokument: gemessen wurde je ein Paket an
|
|
||||||
// je einen Server, nicht die Namensauflösung des ganzen Netzes.
|
|
||||||
verfahren:
|
|
||||||
'Je eine direkte Anfrage (UDP/53) an jeden Server, ohne den Zwischenspeicher des Geräts.',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
@ -121,15 +121,8 @@ export interface LinkInfo {
|
||||||
prefixLength?: number;
|
prefixLength?: number;
|
||||||
gateway?: string;
|
gateway?: string;
|
||||||
dns?: string[];
|
dns?: string[];
|
||||||
/** DHCP-Server, von dem dieser Anschluss seine Adresse bezogen hat */
|
/** DHCP-Server (nur für WLAN ermittelbar) */
|
||||||
dhcpServer?: string;
|
dhcpServer?: string;
|
||||||
/**
|
|
||||||
* Woher die DHCP-Angabe stammt: 'system' = LinkProperties (ab API 30, gilt
|
|
||||||
* für jeden Anschlusstyp), 'wlan' = das deprecated DhcpInfo (nur WLAN, dafür
|
|
||||||
* mit Lease-Dauer). Ohne diese Angabe ist im Nachhinein nicht zu klären, ob
|
|
||||||
* ein fehlender Wert an der Leitung lag oder an der Android-Version.
|
|
||||||
*/
|
|
||||||
dhcpQuelle?: 'system' | 'wlan';
|
|
||||||
leaseSec?: number;
|
leaseSec?: number;
|
||||||
/** Verbindungsgeschwindigkeit in Mbit/s — WLAN: linkSpeed; Ethernet: /sys/class/net/<iface>/speed */
|
/** Verbindungsgeschwindigkeit in Mbit/s — WLAN: linkSpeed; Ethernet: /sys/class/net/<iface>/speed */
|
||||||
linkSpeedMbps?: number;
|
linkSpeedMbps?: number;
|
||||||
|
|
@ -141,15 +134,6 @@ export interface LinkInfo {
|
||||||
bssid?: string;
|
bssid?: string;
|
||||||
/** Empfangsstärke in dBm (nur wenn type='wifi') */
|
/** Empfangsstärke in dBm (nur wenn type='wifi') */
|
||||||
rssi?: number;
|
rssi?: number;
|
||||||
/**
|
|
||||||
* Androids eigenes Urteil: das System hat über diese Verbindung erfolgreich
|
|
||||||
* einen Erreichbarkeitsdienst abgerufen (NET_CAPABILITY_VALIDATED). Kostet
|
|
||||||
* uns keine Messung und ist die verlässlichste Auskunft über ein Gäste-WLAN
|
|
||||||
* mit Anmeldeseite, die ohne Root zu bekommen ist.
|
|
||||||
*/
|
|
||||||
validated?: boolean;
|
|
||||||
/** System hat eine Anmeldeseite erkannt (NET_CAPABILITY_CAPTIVE_PORTAL) */
|
|
||||||
captivePortal?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Einzelne RSSI-Probe des WLAN-Empfangstrackers */
|
/** Einzelne RSSI-Probe des WLAN-Empfangstrackers */
|
||||||
|
|
|
||||||
|
|
@ -1,128 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import AppHeader from '$lib/components/AppHeader.svelte';
|
import AppHeader from '$lib/components/AppHeader.svelte';
|
||||||
import { debugLog } from '$lib/debuglog.svelte';
|
import { debugLog } from '$lib/debuglog.svelte';
|
||||||
import { internetKetteTool } from '$lib/tools/internet/internetkette';
|
|
||||||
import { dnsCheckTool } from '$lib/tools/netzwerk/dnscheck';
|
|
||||||
import { scanner } from '$lib/scanner';
|
|
||||||
import type { Protocol } from '$lib/types';
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Selbsttest der Internet-Kette — misst, ohne irgendetwas zu speichern.
|
|
||||||
*
|
|
||||||
* Zwei Gründe, warum das hier sitzt und nicht nur im Werkzeug-Grid:
|
|
||||||
* - Vor Ort beantwortet er die erste aller Fragen („kommt das Messgerät
|
|
||||||
* selbst überhaupt ins Netz?"), bevor man ein Protokoll anlegt.
|
|
||||||
* - Er ist der einzige Weg, die nativen Methoden im Emulator zu prüfen,
|
|
||||||
* solange die Testinstanz nur HTTP spricht: der WebView blockiert aus
|
|
||||||
* der Origin `https://localhost` jede HTTP-Anfrage (Mixed Content),
|
|
||||||
* ohne Server gibt es keine Aufträge und damit kein Protokoll.
|
|
||||||
*/
|
|
||||||
let selbsttest = $state<{ laeuft: boolean; label: string; status: number; zeilen: string[] } | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ein Werkzeug ohne Protokoll laufen lassen und das Ergebnis anzeigen.
|
|
||||||
* Gemeinsam für beide Selbsttests — sie unterscheiden sich nur im Werkzeug
|
|
||||||
* und darin, welche Ergebnisfelder als Zeilen taugen.
|
|
||||||
*/
|
|
||||||
async function laufeOhneSpeichern(
|
|
||||||
tool: typeof internetKetteTool,
|
|
||||||
zeilenAus: (res: Record<string, unknown>) => string[],
|
|
||||||
) {
|
|
||||||
selbsttest = { laeuft: true, label: 'misst …', status: -1, zeilen: [] };
|
|
||||||
try {
|
|
||||||
/*
|
|
||||||
* Vorgabewerte des Werkzeugs übernehmen. Sie stehen in `tool.params` und
|
|
||||||
* werden sonst erst vom ToolDialog gesetzt — ein direkter Aufruf mit `{}`
|
|
||||||
* misst deshalb etwas anderes als der Techniker vor Ort. Aufgefallen im
|
|
||||||
* Emulator: die DNS-Prüfung fragte „1 von 1 Servern", weil der
|
|
||||||
* Vergleichsserver aus dem Vorgabewert fehlte.
|
|
||||||
*/
|
|
||||||
const params: Record<string, string | number> = {};
|
|
||||||
for (const f of tool.params) {
|
|
||||||
if (f.default !== undefined) params[f.key] = f.default;
|
|
||||||
}
|
|
||||||
const r = await tool.run({
|
|
||||||
params,
|
|
||||||
// Die Werkzeuge lesen das Protokoll nicht — hier wird nichts gespeichert.
|
|
||||||
protocol: {} as unknown as Protocol,
|
|
||||||
});
|
|
||||||
selbsttest = {
|
|
||||||
laeuft: false,
|
|
||||||
label: r.label,
|
|
||||||
status: r.measureStatus,
|
|
||||||
zeilen: zeilenAus(r.result as Record<string, unknown>),
|
|
||||||
};
|
|
||||||
} catch (e) {
|
|
||||||
selbsttest = {
|
|
||||||
laeuft: false,
|
|
||||||
label: 'Fehler beim Messen',
|
|
||||||
status: 2,
|
|
||||||
zeilen: [e instanceof Error ? e.message : String(e)],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function dnsTesten() {
|
|
||||||
return laufeOhneSpeichern(dnsCheckTool, (res) => [
|
|
||||||
...((res.antworten as string[]) ?? []),
|
|
||||||
...(res.gefragteServer ? [`Befragt: ${(res.gefragteServer as string[]).join(', ')}`] : []),
|
|
||||||
...(res.hinweis ? [String(res.hinweis)] : []),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function ketteTesten() {
|
|
||||||
selbsttest = { laeuft: true, label: 'misst …', status: -1, zeilen: [] };
|
|
||||||
try {
|
|
||||||
const r = await internetKetteTool.run({
|
|
||||||
params: {},
|
|
||||||
// Das Werkzeug liest das Protokoll nicht — hier wird nichts gespeichert.
|
|
||||||
protocol: {} as unknown as Protocol,
|
|
||||||
});
|
|
||||||
const res = r.result as Record<string, unknown>;
|
|
||||||
// Anschlussdaten dazu: DHCP-Server, Lease und Link-Speed sieht man sonst
|
|
||||||
// nur im IP-Test, und der braucht ein Protokoll (also einen Server).
|
|
||||||
const { links } = await scanner.linkInfo();
|
|
||||||
const anschluesse = links.map((l) =>
|
|
||||||
[
|
|
||||||
`${l.iface} (${l.type})${l.isDefault ? ' [Standardroute]' : ''}`,
|
|
||||||
l.ipv4 ? `IP ${l.ipv4}/${l.prefixLength ?? '?'}` : 'keine IP',
|
|
||||||
l.gateway ? `Gateway ${l.gateway}` : 'kein Gateway',
|
|
||||||
`DHCP-Server ${l.dhcpServer ?? 'nicht ermittelbar'}${l.dhcpQuelle ? ` (${l.dhcpQuelle})` : ''}`,
|
|
||||||
`Lease ${l.leaseSec !== undefined ? l.leaseSec + ' s' : 'nicht ermittelbar'}`,
|
|
||||||
l.linkSpeedMbps ? `${l.linkSpeedMbps} Mbit/s` : 'Geschwindigkeit unbekannt',
|
|
||||||
].join(' · '),
|
|
||||||
);
|
|
||||||
selbsttest = {
|
|
||||||
laeuft: false,
|
|
||||||
label: r.label,
|
|
||||||
status: r.measureStatus,
|
|
||||||
zeilen: [
|
|
||||||
...((res.stufen as string[]) ?? []),
|
|
||||||
`Fehlerglied: ${res.fehlerglied}`,
|
|
||||||
...(res.systemUrteil ? [String(res.systemUrteil)] : []),
|
|
||||||
...(res.hinweis ? [String(res.hinweis)] : []),
|
|
||||||
'— Anschlüsse —',
|
|
||||||
...anschluesse,
|
|
||||||
],
|
|
||||||
};
|
|
||||||
} catch (e) {
|
|
||||||
selbsttest = {
|
|
||||||
laeuft: false,
|
|
||||||
label: 'Fehler beim Messen',
|
|
||||||
status: 2,
|
|
||||||
zeilen: [e instanceof Error ? e.message : String(e)],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const statusFarbe: Record<number, string> = {
|
|
||||||
0: 'text-emerald-400',
|
|
||||||
1: 'text-amber-400',
|
|
||||||
2: 'text-red-400',
|
|
||||||
3: 'text-zinc-400',
|
|
||||||
};
|
|
||||||
|
|
||||||
const levelColor: Record<string, string> = {
|
const levelColor: Record<string, string> = {
|
||||||
info: 'text-zinc-400',
|
info: 'text-zinc-400',
|
||||||
|
|
@ -170,33 +48,6 @@
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="border-b border-zinc-800 bg-zinc-900 px-3 py-2">
|
|
||||||
<button
|
|
||||||
class="w-full rounded bg-zinc-800 px-3 py-2 text-sm active:bg-zinc-700 disabled:opacity-50"
|
|
||||||
onclick={ketteTesten}
|
|
||||||
disabled={selbsttest?.laeuft}
|
|
||||||
>
|
|
||||||
Internet-Kette prüfen (misst nur, speichert nichts)
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="mt-1.5 w-full rounded bg-zinc-800 px-3 py-2 text-sm active:bg-zinc-700 disabled:opacity-50"
|
|
||||||
onclick={dnsTesten}
|
|
||||||
disabled={selbsttest?.laeuft}
|
|
||||||
>
|
|
||||||
DNS-Server prüfen (misst nur, speichert nichts)
|
|
||||||
</button>
|
|
||||||
{#if selbsttest}
|
|
||||||
<p class="mt-2 text-sm {statusFarbe[selbsttest.status] ?? 'text-zinc-400'}">
|
|
||||||
{selbsttest.label}
|
|
||||||
</p>
|
|
||||||
<ul class="mt-1 space-y-0.5 font-mono text-xs text-zinc-400">
|
|
||||||
{#each selbsttest.zeilen as z, i (i)}
|
|
||||||
<li class="break-words">{z}</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex-1 overflow-y-auto p-2 font-mono text-xs">
|
<div class="flex-1 overflow-y-auto p-2 font-mono text-xs">
|
||||||
{#if debugLog.entries.length === 0}
|
{#if debugLog.entries.length === 0}
|
||||||
<p class="p-4 text-center text-zinc-500">Keine Einträge.</p>
|
<p class="p-4 text-center text-zinc-500">Keine Einträge.</p>
|
||||||
|
|
|
||||||
|
|
@ -1,68 +0,0 @@
|
||||||
/**
|
|
||||||
* Prueft die Bewertung der DNS-Pruefung gegen konstruierte Antworten.
|
|
||||||
*
|
|
||||||
* Der Browser-Mock liefert auch hier nur den Gutfall. Entscheidend ist, dass
|
|
||||||
* "Server antwortet, kennt den Namen nicht" (NXDOMAIN) NICHT wie ein Ausfall
|
|
||||||
* behandelt wird - das ist ein funktionierender Server.
|
|
||||||
*
|
|
||||||
* Start: node tools/pruefe-dns.mjs
|
|
||||||
*/
|
|
||||||
const { bewerteDns, istPrivat } = await import('../src/lib/tools/netzwerk/dns-bewertung.ts');
|
|
||||||
|
|
||||||
const ok = (server, ips = ['142.250.185.67']) =>
|
|
||||||
({ server, name: 'www.google.de', status: 'ok', ips, latenzMs: 12 });
|
|
||||||
const stumm = (server) =>
|
|
||||||
({ server, name: 'www.google.de', status: 'keineAntwort', ips: [], meldung: 'Keine Antwort innerhalb von 2000 ms' });
|
|
||||||
const nxdomain = (server) =>
|
|
||||||
({ server, name: 'intern.firma.local', status: 'unbekannt', ips: [], latenzMs: 4 });
|
|
||||||
const servfail = (server) =>
|
|
||||||
({ server, name: 'www.google.de', status: 'ohneAdresse', ips: [], latenzMs: 9, meldung: 'Serverfehler (SERVFAIL)' });
|
|
||||||
|
|
||||||
const faelle = [
|
|
||||||
{ name: 'Alle Server antworten gleich -> GRUEN',
|
|
||||||
a: [ok('192.168.1.1'), ok('9.9.9.9')], soll: 0 },
|
|
||||||
{ name: 'Kein Server antwortet -> ROT',
|
|
||||||
a: [stumm('192.168.1.1'), stumm('192.168.1.2')], soll: 2 },
|
|
||||||
{ name: 'Erster Server stumm, Vergleichsserver geht -> GELB (faellt sonst nicht auf)',
|
|
||||||
a: [stumm('192.168.1.2'), ok('9.9.9.9')], soll: 1 },
|
|
||||||
{ name: 'SERVFAIL ueberall -> ROT',
|
|
||||||
a: [servfail('192.168.1.1')], soll: 2 },
|
|
||||||
{ name: 'NXDOMAIN ueberall -> GELB, KEIN Ausfall (Server arbeiten)',
|
|
||||||
a: [nxdomain('192.168.1.1'), nxdomain('9.9.9.9')], soll: 1 },
|
|
||||||
{ name: 'Privat vs. oeffentlich -> GELB (Split-Horizon oder Fremd-DNS)',
|
|
||||||
a: [ok('192.168.1.1', ['10.0.0.5']), ok('9.9.9.9', ['142.250.185.67'])], soll: 1 },
|
|
||||||
{ name: 'Zwei verschiedene OEFFENTLICHE Adressen -> GRUEN (CDN, kein Befund)',
|
|
||||||
a: [ok('10.0.2.3', ['192.178.24.195']), ok('9.9.9.9', ['192.178.183.94'])], soll: 0 },
|
|
||||||
{ name: 'Zwei verschiedene PRIVATE Adressen -> GRUEN (zwei interne Server, beide intern)',
|
|
||||||
a: [ok('192.168.1.1', ['192.168.1.50']), ok('192.168.1.2', ['192.168.1.51'])], soll: 0 },
|
|
||||||
{ name: 'Gar kein DNS-Server bekannt -> NICHT MESSBAR',
|
|
||||||
a: [], soll: 3 },
|
|
||||||
{ name: 'Ein Server ok, einer SERVFAIL -> GELB',
|
|
||||||
a: [ok('192.168.1.1'), servfail('192.168.1.2')], soll: 1 },
|
|
||||||
];
|
|
||||||
|
|
||||||
let fehler = 0;
|
|
||||||
for (const f of faelle) {
|
|
||||||
const r = bewerteDns(f.a);
|
|
||||||
const okk = r.status === f.soll;
|
|
||||||
if (!okk) fehler++;
|
|
||||||
console.log(`${okk ? 'OK ' : 'FEHL'} ${f.name}`);
|
|
||||||
console.log(` status=${r.status} (soll ${f.soll}) ${r.label}`);
|
|
||||||
}
|
|
||||||
// Adressbereiche einzeln nachrechnen - 172.16-31 ist der Bereich, den eine
|
|
||||||
// naive Zeichenkettenpruefung falsch macht.
|
|
||||||
const privatFaelle = [
|
|
||||||
['10.0.0.5', true], ['192.168.1.1', true], ['172.16.0.1', true],
|
|
||||||
['172.31.255.254', true], ['169.254.1.1', true],
|
|
||||||
['172.15.0.1', false], ['172.32.0.1', false], ['8.8.8.8', false],
|
|
||||||
['142.250.185.67', false], ['193.168.1.1', false],
|
|
||||||
];
|
|
||||||
for (const [ip, soll] of privatFaelle) {
|
|
||||||
const ist = istPrivat(ip);
|
|
||||||
const okk = ist === soll;
|
|
||||||
if (!okk) fehler++;
|
|
||||||
console.log(`${okk ? 'OK ' : 'FEHL'} istPrivat("${ip}") = ${ist} (soll ${soll})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(fehler === 0 ? 'ALLE FAELLE KORREKT' : `${fehler} FEHLER`);
|
|
||||||
process.exit(fehler === 0 ? 0 : 1);
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
/**
|
|
||||||
* Prueft die Zuordnung Geraetetyp -> Werkzeuge.
|
|
||||||
*
|
|
||||||
* Anlass: "SNMP-Switch" stand an jeder IP-Kamera und erzeugte dort eine
|
|
||||||
* Messung "nicht messbar" im Kundenprotokoll. Wichtiger als das Wegsortieren
|
|
||||||
* ist aber, dass NICHTS verschwindet: deviceType ist geraten.
|
|
||||||
*
|
|
||||||
* Start: node tools/pruefe-geraeteaktionen.mjs
|
|
||||||
*/
|
|
||||||
const { passtZuGeraet, teileNachGeraetetyp } =
|
|
||||||
await import('../src/lib/tools/geraeteaktionen.ts');
|
|
||||||
|
|
||||||
const WERKZEUGE = [{ id: 'ping' }, { id: 'portscan' }, { id: 'snmp' }];
|
|
||||||
|
|
||||||
const faelle = [
|
|
||||||
{ typ: 'Kamera', snmpVorn: false, name: 'IP-Kamera -> SNMP nachrangig' },
|
|
||||||
{ typ: 'IP-Kamera', snmpVorn: false, name: 'Schreibweise mit Praefix' },
|
|
||||||
{ typ: 'Switch', snmpVorn: true, name: 'Switch -> SNMP vorn' },
|
|
||||||
{ typ: 'SNMP-Switch', snmpVorn: true, name: 'Teilstring trifft' },
|
|
||||||
{ typ: 'Drucker', snmpVorn: true, name: 'Drucker koennen SNMP (Toner/Zaehler)' },
|
|
||||||
{ typ: 'NAS', snmpVorn: true, name: 'NAS -> SNMP vorn' },
|
|
||||||
{ typ: 'Router', snmpVorn: true, name: 'Router -> SNMP vorn' },
|
|
||||||
{ typ: 'Windows-PC', snmpVorn: false, name: 'PC -> SNMP nachrangig' },
|
|
||||||
{ typ: 'Wallbox', snmpVorn: false, name: 'Wallbox spricht Modbus, nicht SNMP' },
|
|
||||||
{ typ: 'IoT/SPS', snmpVorn: false, name: 'SPS spricht Modbus, nicht SNMP' },
|
|
||||||
{ typ: '', snmpVorn: true, name: 'LEER -> alles gleichrangig (nie weniger)' },
|
|
||||||
{ typ: undefined, snmpVorn: true, name: 'UNBEKANNT -> alles gleichrangig' },
|
|
||||||
{ typ: 'Linux-Gerät', snmpVorn: false, name: 'Linux-Geraet -> nachrangig, aber erreichbar' },
|
|
||||||
];
|
|
||||||
|
|
||||||
let fehler = 0;
|
|
||||||
for (const f of faelle) {
|
|
||||||
const { passend, nachrangig } = teileNachGeraetetyp(WERKZEUGE, f.typ);
|
|
||||||
const snmpVorn = passend.some((t) => t.id === 'snmp');
|
|
||||||
// Zwei harte Zusicherungen: die erwartete Einsortierung UND dass kein
|
|
||||||
// Werkzeug verschwindet.
|
|
||||||
const vollzaehlig = passend.length + nachrangig.length === WERKZEUGE.length;
|
|
||||||
const pingVorn = passend.some((t) => t.id === 'ping');
|
|
||||||
const portVorn = passend.some((t) => t.id === 'portscan');
|
|
||||||
const ok = snmpVorn === f.snmpVorn && vollzaehlig && pingVorn && portVorn;
|
|
||||||
if (!ok) fehler++;
|
|
||||||
console.log(`${ok ? 'OK ' : 'FEHL'} ${f.name}`);
|
|
||||||
console.log(` typ="${f.typ}" vorn=[${passend.map((t) => t.id)}] nachrangig=[${nachrangig.map((t) => t.id)}]`);
|
|
||||||
}
|
|
||||||
console.log(fehler === 0 ? 'ALLE FAELLE KORREKT' : `${fehler} FEHLER`);
|
|
||||||
process.exit(fehler === 0 ? 0 : 1);
|
|
||||||
|
|
@ -1,119 +0,0 @@
|
||||||
/**
|
|
||||||
* Prueft die Bewertung der Internet-Kette gegen konstruierte Faelle mit von
|
|
||||||
* Hand ermitteltem Sollwert.
|
|
||||||
*
|
|
||||||
* Warum ueberhaupt: der Browser-Mock liefert fuer DNS und HTTPS immer den
|
|
||||||
* guten Fall, dort ist die Kette also IMMER gruen. Genau die Faelle, wegen
|
|
||||||
* derer das Werkzeug gebaut wurde (Gateway still, DNS tot, Anmeldeseite),
|
|
||||||
* kommen im Mock nicht vor.
|
|
||||||
*
|
|
||||||
* Start: node tools/pruefe-kette.mjs
|
|
||||||
* (laedt die TS-Datei direkt — Node 22.6+ entfernt die Typannotationen selbst;
|
|
||||||
* deshalb darf kette-bewertung.ts keinen Laufzeit-Import enthalten)
|
|
||||||
*/
|
|
||||||
|
|
||||||
const { bewerteKette } = await import('../src/lib/tools/internet/kette-bewertung.ts');
|
|
||||||
|
|
||||||
const anschlussOk = { name: 'Anschluss', ok: true, detail: 'LAN (Kabel) eth0, IP 192.168.1.42/24' };
|
|
||||||
const gwOk = { name: 'Gateway', ok: true, detail: '192.168.1.1 antwortet in 2 ms', stillErlaubt: true };
|
|
||||||
const gwStill = { name: 'Gateway', ok: false, detail: 'antwortet auf keines von 4 Paketen', stillErlaubt: true };
|
|
||||||
const gwUnbekannt = { name: 'Gateway', ok: null, detail: 'kein Standard-Gateway gemeldet', stillErlaubt: true };
|
|
||||||
const dnsOk = { name: 'Namensauflösung', ok: true, detail: 'www.google.de → 142.250.185.67 (23 ms)' };
|
|
||||||
const dnsTot = { name: 'Namensauflösung', ok: false, detail: 'nicht auflösbar (UnknownHostException)' };
|
|
||||||
const httpsOk = { name: 'HTTPS-Abruf', ok: true, detail: 'HTTP 204 in 58 ms' };
|
|
||||||
const httpsTot = { name: 'HTTPS-Abruf', ok: false, detail: 'kein Verbindungsaufbau (timeout)' };
|
|
||||||
|
|
||||||
const faelle = [
|
|
||||||
{
|
|
||||||
name: 'Alles in Ordnung',
|
|
||||||
stufen: [anschlussOk, gwOk, dnsOk, httpsOk],
|
|
||||||
portal: false,
|
|
||||||
system: true,
|
|
||||||
soll: { status: 0, glied: '' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Gateway antwortet nicht auf Ping, Internet geht -> GRUEN mit Hinweis',
|
|
||||||
stufen: [anschlussOk, gwStill, dnsOk, httpsOk],
|
|
||||||
portal: false,
|
|
||||||
system: true,
|
|
||||||
soll: { status: 0, glied: '', hinweisEnthaelt: 'Werkseinstellung' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Gateway tot UND alles danach tot -> Fehlerglied Gateway',
|
|
||||||
stufen: [anschlussOk, gwStill, dnsTot, httpsTot],
|
|
||||||
portal: false,
|
|
||||||
system: false,
|
|
||||||
soll: { status: 2, glied: 'Gateway' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Gateway ok, DNS tot -> Fehlerglied Namensaufloesung',
|
|
||||||
stufen: [anschlussOk, gwOk, dnsTot, httpsTot],
|
|
||||||
portal: false,
|
|
||||||
system: false,
|
|
||||||
soll: { status: 2, glied: 'Namensauflösung' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'DNS ok, HTTPS tot -> Fehlerglied HTTPS-Abruf',
|
|
||||||
stufen: [anschlussOk, gwOk, dnsOk, httpsTot],
|
|
||||||
portal: false,
|
|
||||||
system: false,
|
|
||||||
soll: { status: 2, glied: 'HTTPS-Abruf' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Umleitung auf Anmeldeseite -> GELB "Anmeldeseite", NICHT rot "HTTPS tot"',
|
|
||||||
stufen: [anschlussOk, gwOk, dnsOk, { name: 'HTTPS-Abruf', ok: false, detail: 'HTTP 302 → Portal' }],
|
|
||||||
portal: true,
|
|
||||||
system: false,
|
|
||||||
soll: { status: 1, glied: 'Anmeldeseite', hinweisEnthaelt: 'Gäste-WLAN' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Portal nur ueber das Systemflag erkannt -> GELB Anmeldeseite',
|
|
||||||
stufen: [anschlussOk, gwOk, dnsOk, httpsOk],
|
|
||||||
portal: true,
|
|
||||||
system: false,
|
|
||||||
soll: { status: 1, glied: 'Anmeldeseite' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Alles ok, aber Android bestaetigt die Verbindung nicht -> GELB',
|
|
||||||
stufen: [anschlussOk, gwOk, dnsOk, httpsOk],
|
|
||||||
portal: false,
|
|
||||||
system: false,
|
|
||||||
soll: { status: 1, glied: '', hinweisEnthaelt: 'gefilterte' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Kein Anschluss (keine IP) -> NICHT MESSBAR, keine Aussage ueber das Kundennetz',
|
|
||||||
stufen: [{ name: 'Anschluss', ok: null, detail: 'keine IP-Adresse' }],
|
|
||||||
portal: false,
|
|
||||||
system: undefined,
|
|
||||||
soll: { status: 3, glied: 'Anschluss' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Kein Gateway gemeldet, Rest geht -> GRUEN mit Hinweis "nicht geprueft"',
|
|
||||||
stufen: [anschlussOk, gwUnbekannt, dnsOk, httpsOk],
|
|
||||||
portal: false,
|
|
||||||
system: true,
|
|
||||||
soll: { status: 0, glied: '', hinweisEnthaelt: 'nicht geprüft' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Systemurteil unbekannt (aeltere Android-Version) -> GRUEN, kein Gelb aus Unwissen',
|
|
||||||
stufen: [anschlussOk, gwOk, dnsOk, httpsOk],
|
|
||||||
portal: false,
|
|
||||||
system: undefined,
|
|
||||||
soll: { status: 0, glied: '' },
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
let fehler = 0;
|
|
||||||
for (const f of faelle) {
|
|
||||||
const r = bewerteKette(f.stufen, f.portal, f.system);
|
|
||||||
let ok = r.status === f.soll.status && r.fehlerglied === f.soll.glied;
|
|
||||||
if (f.soll.hinweisEnthaelt && !(r.hinweis || '').includes(f.soll.hinweisEnthaelt)) ok = false;
|
|
||||||
if (!ok) fehler++;
|
|
||||||
console.log(`${ok ? 'OK ' : 'FEHL'} ${f.name}`);
|
|
||||||
console.log(` status=${r.status} (soll ${f.soll.status}) glied="${r.fehlerglied}" (soll "${f.soll.glied}")`);
|
|
||||||
console.log(` Label: ${r.label}`);
|
|
||||||
if (r.hinweis) console.log(` Hinweis: ${r.hinweis.slice(0, 120)}`);
|
|
||||||
console.log();
|
|
||||||
}
|
|
||||||
console.log(fehler === 0 ? 'ALLE FAELLE KORREKT' : `${fehler} FEHLER`);
|
|
||||||
process.exit(fehler === 0 ? 0 : 1);
|
|
||||||
|
|
@ -3,11 +3,11 @@
|
||||||
* ausgerechnetem Sollwert. Die Rechnung landet als Verfuegbarkeitsaussage im
|
* ausgerechnetem Sollwert. Die Rechnung landet als Verfuegbarkeitsaussage im
|
||||||
* Kundendokument — sie muss stimmen, nicht plausibel aussehen.
|
* Kundendokument — sie muss stimmen, nicht plausibel aussehen.
|
||||||
*/
|
*/
|
||||||
// Laedt die TS-Datei direkt (Node 22.6+ entfernt die Typannotationen selbst).
|
import { readFileSync } from 'node:fs';
|
||||||
// Vorher stand hier der Pfad einer von Hand erzeugten .mjs-Kopie in einem
|
|
||||||
// Sitzungsverzeichnis unter /tmp — nach dessen Wegfall lief dieser Pruefer
|
|
||||||
// nicht mehr, waehrend im Repo "alle Faelle korrekt" stand.
|
|
||||||
const { buildMonitorMeasurement } = await import('../src/lib/monitor-auswertung.ts');
|
const { buildMonitorMeasurement } = await import('/tmp/claude-1000/-mnt-17---Entwicklungen-20---Projekte-NetzwerkDiagnose/c1dc785b-f713-4aae-a06d-1fe09a25cd1a/scratchpad/monitor.mjs');
|
||||||
|
|
||||||
const T0 = new Date('2026-08-16T09:00:00').getTime();
|
const T0 = new Date('2026-08-16T09:00:00').getTime();
|
||||||
const min = (m) => T0 + m * 60000;
|
const min = (m) => T0 + m * 60000;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue