diff --git a/android/app/src/main/java/de/data_it_solution/netdiag/MonitorService.kt b/android/app/src/main/java/de/data_it_solution/netdiag/MonitorService.kt index 9bd98bf..dfcbed8 100644 --- a/android/app/src/main/java/de/data_it_solution/netdiag/MonitorService.kt +++ b/android/app/src/main/java/de/data_it_solution/netdiag/MonitorService.kt @@ -12,12 +12,17 @@ import android.os.IBinder import androidx.core.app.NotificationCompat /** - * Schlanker Vordergrund-Dienst für den Geräte-Monitor. + * Schlanker Vordergrund-Dienst, der die App am Leben hält, solange mindestens + * eine Langzeitmessung läuft (Geräte-Monitor UND/ODER Dauer-/Stresstest) — + * damit Android die Mess-Schleife bei ausgeschaltetem Display oder App-Wechsel + * nicht beendet. Die eigentliche Mess-Logik läuft im NetDiagScannerPlugin; + * dieser Dienst zeigt nur die dauerhafte Benachrichtigung. * - * Er hält den App-Prozess am Leben, solange die Überwachung läuft — damit - * Android die Mess-Schleife bei ausgeschaltetem Display oder App-Wechsel nicht - * beendet. Die eigentliche Ping-Logik läuft im NetDiagScannerPlugin; dieser - * Dienst zeigt nur die dauerhafte Benachrichtigung. + * Mehrere Jobs gleichzeitig: es gibt nur EINE Dienst-Instanz mit EINER + * Benachrichtigung, aber ein Job darf beim Beenden nicht die Benachrichtigung + * für einen anderen, noch laufenden Job wegreißen. Deshalb führt der Dienst + * eine kleine Registry aktiver Jobs (`start`/`stop` je mit eigener jobId) und + * beendet sich selbst erst, wenn keiner mehr übrig ist. */ class MonitorService : Service() { @@ -50,6 +55,10 @@ class MonitorService : Service() { const val NOTIF_ID = 4711 const val EXTRA_TEXT = "text" + /** jobId -> Anzeigetext. Mehrere gleichzeitig laufende Jobs teilen sich + * eine Benachrichtigung (Texte durch " · " getrennt). */ + private val activeJobs = java.util.concurrent.ConcurrentHashMap() + /** Benachrichtigungskanal anlegen (idempotent) */ fun ensureChannel(ctx: Context) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return @@ -58,15 +67,22 @@ class MonitorService : Service() { mgr.createNotificationChannel( NotificationChannel( CHANNEL_ID, - "Geräte-Monitor", + "Langzeitmessungen", NotificationManager.IMPORTANCE_LOW, ), ) } } - fun start(ctx: Context, text: String) { - val i = Intent(ctx, MonitorService::class.java).putExtra(EXTRA_TEXT, text) + /** + * Job anmelden und den Dienst (neu) starten bzw. seine Benachrichtigung + * aktualisieren. `jobId` muss zum passenden `stop(ctx, jobId)` passen — + * z.B. "monitor" oder "stress-". + */ + fun start(ctx: Context, jobId: String, text: String) { + activeJobs[jobId] = text + val i = Intent(ctx, MonitorService::class.java) + .putExtra(EXTRA_TEXT, activeJobs.values.joinToString(" · ")) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { ctx.startForegroundService(i) } else { @@ -74,8 +90,26 @@ class MonitorService : Service() { } } - fun stop(ctx: Context) { - ctx.stopService(Intent(ctx, MonitorService::class.java)) + /** + * Job abmelden. Solange noch andere Jobs laufen, bleibt der Dienst (mit + * aktualisierter Benachrichtigung) aktiv — sonst würde z.B. das Beenden + * des Geräte-Monitors einen parallel laufenden Dauertest mit ausschalten. + */ + fun stop(ctx: Context, jobId: String) { + activeJobs.remove(jobId) + if (activeJobs.isEmpty()) { + ctx.stopService(Intent(ctx, MonitorService::class.java)) + return + } + // Andere Jobs laufen noch — Dienst am Leben lassen, nur die + // Benachrichtigung auf den verbleibenden Text aktualisieren. + val i = Intent(ctx, MonitorService::class.java) + .putExtra(EXTRA_TEXT, activeJobs.values.joinToString(" · ")) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + ctx.startForegroundService(i) + } else { + ctx.startService(i) + } } } } diff --git a/android/app/src/main/java/de/data_it_solution/netdiag/NetDiagScannerPlugin.kt b/android/app/src/main/java/de/data_it_solution/netdiag/NetDiagScannerPlugin.kt index ec61424..447116b 100644 --- a/android/app/src/main/java/de/data_it_solution/netdiag/NetDiagScannerPlugin.kt +++ b/android/app/src/main/java/de/data_it_solution/netdiag/NetDiagScannerPlugin.kt @@ -26,10 +26,15 @@ import com.getcapacitor.PluginMethod import com.getcapacitor.annotation.CapacitorPlugin import com.getcapacitor.annotation.Permission import com.getcapacitor.annotation.PermissionCallback +import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.delay import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -46,6 +51,7 @@ import java.net.InetAddress import java.net.InetSocketAddress import java.net.Socket import java.net.URL +import org.json.JSONObject import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentLinkedQueue import java.util.concurrent.CountDownLatch @@ -71,7 +77,24 @@ import java.util.concurrent.TimeUnit ) class NetDiagScannerPlugin : Plugin() { - private val io = CoroutineScope(Dispatchers.IO) + /** + * Gemeinsamer Hintergrund-Scope aller Plugin-Methoden. + * + * `CoroutineScope(Dispatchers.IO)` allein erzeugt einen GEWÖHNLICHEN Job: + * wirft eine damit gestartete Coroutine eine unbehandelte Exception (z.B. + * `InetAddress.getByName()` bei einem kaputten Hostnamen), reisst das laut + * Struktur-Konkurrenz-Regeln ALLE Geschwister-Coroutinen im selben Scope mit + * — ein IP-Scan, der stolpert, könnte so einen laufenden Geräte-Monitor oder + * Dauertest lautlos beenden. `SupervisorJob()` verhindert das: ein + * fehlgeschlagener Kind-Job storniert nur sich selbst. Der Handler fängt, + * was trotzdem durchrutscht (Coroutines ohne eigenes try/catch), damit es + * wenigstens im Log auftaucht statt nirgends. + */ + private val io = CoroutineScope( + SupervisorJob() + Dispatchers.IO + CoroutineExceptionHandler { _, e -> + android.util.Log.e(TAG, "Unbehandelter Fehler in einer Hintergrund-Coroutine", e) + }, + ) private val stressRuns = ConcurrentHashMap() /* --------------------------------------------------------------------- */ @@ -937,7 +960,15 @@ class NetDiagScannerPlugin : Plugin() { } else { // Kein Wert ist etwas anderes als 0 ms — sonst steht im // Protokoll "0 ms" für einen Hop, der nie geantwortet hat. - hop.put("ms", null as Any?) + // WICHTIG: explizit JSONObject.NULL statt bloss `null` — + // org.json.JSONObject.put(key, null) LÖSCHT den Schlüssel + // (Standardverhalten, kein Capacitor-Sonderfall), die App + // bekäme dann `undefined` statt `null` und Filter wie + // `!== null` würden den fehlenden Wert für "vorhanden" + // halten. Fehler live im Emulator gefunden (Phase 2): + // ein per Flugmodus simulierter Ausfall zeigte "0 % Verlust" + // und "NaN ms" statt des echten Ausfalls. + hop.put("ms", JSONObject.NULL) } hop.put("antworten", times.size) hops.put(hop) @@ -1052,59 +1083,256 @@ class NetDiagScannerPlugin : Plugin() { /* --------------------------------------------------------------------- */ /* Dauer-/Stresstest */ + /* */ + /* Kompletter Umbau (ROADMAP_UMSETZUNG.md Phase 2). Die alte Fassung hatte */ + /* vier Probleme, die zusammen den Test praktisch wertlos machten: */ + /* - kein festes Intervall: ein "Messpunkt" (measurePing(host,5)) dauerte */ + /* so lange, wie das Ziel zufaellig antwortete — bei Totalausfall kamen */ + /* dadurch WENIGER Punkte heraus als bei einem gesunden Netz (Prod #126:*/ + /* 38 Punkte in 300 s statt der ueblichen ~250-300). */ + /* - keine Zeitreihe, nur Summen — "wann war die Stoerung?" unbeantwortbar*/ + /* - kein Foreground-Service trotz gegenteiliger Behauptung im TS-Code — */ + /* bei ausgeschaltetem Display fror Android den Lauf ein. */ + /* - kein Abbruchpfad, `active` nicht volatile genug fuer sauberes Stopp. */ /* --------------------------------------------------------------------- */ @PluginMethod fun startStressTest(call: PluginCall) { val host = call.getString("host") ?: return call.reject("host fehlt") - val durationSec = call.getInt("durationSec") ?: 300 - val runId = "run-${System.currentTimeMillis()}" - val run = StressRun(host, durationSec) + val durationSec = (call.getInt("durationSec") ?: 300).coerceIn(30, 4 * 3600) + val intervalSec = (call.getInt("intervalSec") ?: 1).coerceIn(1, 10) + val runId = "stress-${System.currentTimeMillis()}" + val run = StressRun(host, durationSec, intervalSec) stressRuns[runId] = run - // Hinweis: für Läufe > einige Minuten sollte ein Foreground-Service - // gestartet werden, sonst kann Android den Prozess beenden. - io.launch { - // Funk/CPU wachhalten. Ersetzt den Foreground-Service NICHT — bei - // ausgeschaltetem Display kann Android den Lauf weiterhin einfrieren. - // Der richtige Umbau steht in ROADMAP_UMSETZUNG.md Phase 2. + + MonitorService.start(context, runId, "Dauertest zu $host läuft") + + run.job = io.launch { val locks = acquireRadioLocks("stress", (durationSec * 1000L) + 60_000L) try { - val end = System.currentTimeMillis() + durationSec * 1000L - while (System.currentTimeMillis() < end && run.active) { - val q = measurePing(host, 5) - run.samples++ - // lossPct ist seit der Ping-Korrektur eine Kommazahl — - // getInteger() haette hier still 0 geliefert. - run.lossSum += q.getDouble("lossPct") - run.avgSum += q.getDouble("avgMs") - run.maxMs = Math.max(run.maxMs, q.getDouble("maxMs")) + val addr = try { + InetAddress.getByName(host) + } catch (e: Exception) { + run.resolveError = "Ziel nicht aufloesbar: ${e.message}" + return@launch } + + var nextTick = System.currentTimeMillis() + val endAt = System.currentTimeMillis() + durationSec * 1000L + while (run.active && System.currentTimeMillis() < endAt) { + val t0 = System.nanoTime() + val ok = try { addr.isReachable(1000) } catch (_: Exception) { false } + val rtt = if (ok) round1((System.nanoTime() - t0) / 1_000_000.0) else null + val now = System.currentTimeMillis() + + run.sentTotal++ + if (rtt != null) { + run.receivedTotal++ + run.rttSumMs += rtt + run.rttMinMs = minOf(run.rttMinMs ?: rtt, rtt) + run.rttMaxMs = maxOf(run.rttMaxMs ?: rtt, rtt) + run.currentOutageStart?.let { startTs -> + run.outages.add( + StressOutage(startTs, now, round1((now - startTs) / 1000.0)), + ) + run.currentOutageStart = null + } + } else if (run.currentOutageStart == null) { + run.currentOutageStart = now + } + run.samples.add(StressSample(now, rtt)) + + notifyListeners( + "stressSample", + JSObject() + .put("runId", runId) + .put("ts", now) + .put("rtt", (rtt as Any?) ?: JSONObject.NULL) + .put("sent", run.sentTotal) + .put("received", run.receivedTotal), + ) + + // Feste Taktung statt "so lange wie die Messung zufaellig dauert": + // naechster Zeitpunkt ist FIX, nicht "jetzt + Intervall" — sonst + // haeuft sich der Zeitverzug ueber eine Stunde spuerbar an. + nextTick += intervalSec * 1000L + val sleepMs = nextTick - System.currentTimeMillis() + if (sleepMs > 0) delay(sleepMs) + } + } catch (e: Exception) { + android.util.Log.e(TAG, "Stresstest-Schleife ($runId): ${e.message}", e) } finally { + // Einen zum Laufzeitende noch offenen Ausfall abschliessen, sonst + // fehlt das letzte Segment in der Auswertung. + run.currentOutageStart?.let { startTs -> + val now = System.currentTimeMillis() + run.outages.add(StressOutage(startTs, now, round1((now - startTs) / 1000.0))) + run.currentOutageStart = null + } releaseRadioLocks(locks) + MonitorService.stop(context, runId) + // WICHTIG: hier NICHT aus stressRuns entfernen. Laeuft der Test + // natuerlich ab, waehrend die App im Hintergrund oder auf einer + // anderen Seite ist, hoert dort niemand auf stressFinished — das + // Ergebnis waere sonst unwiederbringlich weg. Der Eintrag bleibt + // (wie bei monitorRuns) bis zu einem expliziten stopStressTest + // ODER dismissStressRun stehen; getStressStatus liefert dann beim + // naechsten Seitenaufruf noch das vollstaendige Ergebnis. + notifyListeners("stressFinished", buildStressResult(run, runId)) } } - resolve(call, JSObject().put("runId", runId)) + resolve(call, JSObject().put("runId", runId).put("intervalSec", intervalSec)) } @PluginMethod fun stopStressTest(call: PluginCall) { val runId = call.getString("runId") ?: return call.reject("runId fehlt") - val run = stressRuns.remove(runId) ?: return call.reject("Lauf nicht gefunden") + val run = stressRuns[runId] ?: return call.reject("Lauf nicht gefunden") run.active = false - val n = Math.max(1, run.samples) - resolve(call, JSObject() - .put("samples", run.samples) - .put("lossPct", run.lossSum / n) - .put("avgMs", round1(run.avgSum / n)) - .put("maxMs", round1(run.maxMs))) + run.stoppedExplicitly = true + io.launch { + // cancelAndJoin wartet, bis die Messschleife (inkl. ihres finally- + // Blocks: Ausfall schliessen, Sperren freigeben, Dienst abmelden, + // stressFinished-Event) VOLLSTAENDIG durchgelaufen ist — danach ist + // buildStressResult garantiert der endgueltige Stand. + run.job?.cancelAndJoin() + val result = buildStressResult(run, runId).put("stopped", true) + stressRuns.remove(runId) + resolve(call, result) + } } - private class StressRun(val host: String, val durationSec: Int) { + /** + * Status eines Stresstest-Laufs abfragen (UI-Wiederaufnahme nach Seitenwechsel). + * Liefert auch fuer einen bereits BEENDETEN Lauf noch das volle Ergebnis + * (running=false) — der Eintrag bleibt bis dismissStressRun/stopStressTest + * bestehen, siehe Kommentar in startStressTest. + */ + @PluginMethod + fun getStressStatus(call: PluginCall) { + val runId = call.getString("runId") ?: return call.reject("runId fehlt") + val run = stressRuns[runId] + if (run == null) { + resolve(call, JSObject().put("running", false)) + return + } + resolve(call, buildStressResult(run, runId).put("running", run.active)) + } + + /** + * Einen abgeschlossenen Lauf aus dem Speicher entfernen, nachdem die App + * ihn (per getStressStatus) erfolgreich uebernommen hat. Ohne diesen Aufruf + * blieben ueber eine lange App-Sitzung viele beendete Laeufe im Speicher. + */ + @PluginMethod + fun dismissStressRun(call: PluginCall) { + val runId = call.getString("runId") ?: return call.reject("runId fehlt") + stressRuns.remove(runId) + resolve(call, JSObject().put("ok", true)) + } + + /** Einzelne Probe der Zeitreihe eines Dauertests. */ + private data class StressSample(val ts: Long, val rtt: Double?) + + /** Ein zusammenhaengender Ausfall (aufeinanderfolgende verlorene Proben). */ + private data class StressOutage(val startTs: Long, val endTs: Long, val durationSec: Double) + + private class StressRun(val host: String, val durationSec: Int, val intervalSec: Int) { @Volatile var active = true - var samples = 0 - var lossSum = 0.0 - var avgSum = 0.0 - var maxMs = 0.0 + @Volatile var stoppedExplicitly = false + @Volatile var resolveError: String? = null + var job: Job? = null + + @Volatile var sentTotal = 0 + @Volatile var receivedTotal = 0 + @Volatile var rttSumMs = 0.0 + @Volatile var rttMinMs: Double? = null + @Volatile var rttMaxMs: Double? = null + /** Startzeit eines gerade laufenden Ausfalls, oder null wenn keiner offen ist. */ + @Volatile var currentOutageStart: Long? = null + + val samples = java.util.concurrent.CopyOnWriteArrayList() + val outages = java.util.concurrent.CopyOnWriteArrayList() + val startedAtMs = System.currentTimeMillis() + } + + /** + * Aggregiertes Ergebnis eines Laufs — sowohl fuer den laufenden Zwischenstand + * (stressSample begleitend, getStressStatus) als auch fuer den Abschluss + * (stopStressTest, stressFinished) verwendet. + */ + private fun buildStressResult(run: StressRun, runId: String): JSObject { + run.resolveError?.let { err -> + return JSObject() + .put("runId", runId) + .put("host", run.host) + .put("error", err) + .put("sentTotal", 0) + .put("receivedTotal", 0) + } + + val sent = run.sentTotal + val received = run.receivedTotal + val lossPct = if (sent > 0) round1((sent - received) * 100.0 / sent) else 0.0 + val avgMs = if (received > 0) round1(run.rttSumMs / received) else null + + val sortedRtts = run.samples.mapNotNull { it.rtt }.sorted() + val p95Ms = if (sortedRtts.isNotEmpty()) { + sortedRtts[((sortedRtts.size - 1) * 0.95).toInt()] + } else { + null + } + val laengsterAusfallSek = run.outages.maxOfOrNull { it.durationSec } + + val ausfaelle = JSArray() + run.outages.forEach { o -> + ausfaelle.put( + JSObject().put("startTs", o.startTs).put("endTs", o.endTs).put("durationSec", o.durationSec), + ) + } + + // Auf 1-Minuten-Buckets verdichten — bei 1 Probe/s und einer Stunde + // waeren es sonst 3600 Rohpunkte, die weder Chart noch PDF sinnvoll + // darstellen und die dol_trunc() im PDF ohnehin abschneiden wuerde. + val verlauf = JSArray() + bucketize(run.samples, 60_000L).forEach { verlauf.put(it) } + + return JSObject() + .put("runId", runId) + .put("host", run.host) + .put("intervalSec", run.intervalSec) + .put("startedAt", run.startedAtMs) + .put("sentTotal", sent) + .put("receivedTotal", received) + .put("lossPct", lossPct) + .put("avgMs", (avgMs as Any?) ?: JSONObject.NULL) + .put("minMs", (run.rttMinMs as Any?) ?: JSONObject.NULL) + .put("maxMs", (run.rttMaxMs as Any?) ?: JSONObject.NULL) + .put("p95Ms", (p95Ms as Any?) ?: JSONObject.NULL) + .put("laengsterAusfallSek", (laengsterAusfallSek as Any?) ?: JSONObject.NULL) + .put("ausfaelle", ausfaelle) + .put("verlauf", verlauf) + .put("reason", if (run.stoppedExplicitly) "stopped" else "completed") + } + + /** Proben in feste Zeit-Buckets verdichten (Mittel/Max/Verlust je Bucket). */ + private fun bucketize(samples: List, bucketMs: Long): List { + if (samples.isEmpty()) return emptyList() + val groups = LinkedHashMap>() + for (s in samples) { + val bucket = s.ts - (s.ts % bucketMs) + groups.getOrPut(bucket) { mutableListOf() }.add(s.rtt) + } + return groups.map { (bucketTs, vals) -> + val received = vals.filterNotNull() + JSObject() + .put("t", bucketTs) + .put("sent", vals.size) + .put("received", received.size) + .put("avgMs", (if (received.isNotEmpty()) round1(received.average()) else null) ?: JSONObject.NULL) + .put("maxMs", (if (received.isNotEmpty()) round1(received.max()) else null) ?: JSONObject.NULL) + .put("lossPct", round1((vals.size - received.size) * 100.0 / vals.size)) + } } /* --------------------------------------------------------------------- */ @@ -1147,7 +1375,11 @@ class NetDiagScannerPlugin : Plugin() { val runId = "mon-${System.currentTimeMillis()}" val run = MonitorRun(targets, intervalSec) monitorRuns[runId] = run - MonitorService.start(context, "${targets.size} Geräte · alle ${intervalSec}s") + // Eigene Job-ID je Lauf (nicht nur "monitor") — sonst wuerden zwei + // gleichzeitige Monitor-Sitzungen sich beim Stoppen gegenseitig die + // Benachrichtigung wegnehmen (monitorRuns ist eine Map, mehrere Läufe + // sind moeglich). + MonitorService.start(context, runId, "${targets.size} Geräte · alle ${intervalSec}s") @Suppress("DEPRECATION") val wifiLock = (context.applicationContext @@ -1202,7 +1434,7 @@ class NetDiagScannerPlugin : Plugin() { val runId = call.getString("runId") ?: return call.reject("runId fehlt") val run = monitorRuns.remove(runId) ?: return call.reject("Lauf nicht gefunden") run.active = false - if (monitorRuns.isEmpty()) MonitorService.stop(context) + MonitorService.stop(context, runId) val events = JSArray() run.events.forEach { events.put(it) } resolve(call, JSObject().put("stopped", true).put("events", events)) diff --git a/src/lib/components/StressChart.svelte b/src/lib/components/StressChart.svelte new file mode 100644 index 0000000..71ecbcb --- /dev/null +++ b/src/lib/components/StressChart.svelte @@ -0,0 +1,107 @@ + + +{#if view} + + + + + {#each view.bands as b (b.x)} + + {/each} + + {#each view.segments as seg, i (i)} + + {/each} + + {#if view.lastPoint} + + {/if} + +{:else} +
Noch keine Daten
+{/if} diff --git a/src/lib/db.ts b/src/lib/db.ts index 5fd0c8b..bd65bc9 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -29,6 +29,7 @@ function normalizeProtocol(p: Protocol): Protocol { p.savedScans ??= []; p.monitorSessions ??= []; p.wifiTrackSessions ??= []; + p.stressTestSessions ??= []; return p; } diff --git a/src/lib/scanner.ts b/src/lib/scanner.ts index 7a1d1e2..b444f1a 100644 --- a/src/lib/scanner.ts +++ b/src/lib/scanner.ts @@ -58,6 +58,60 @@ export interface WifiSignalEvent { rssi: number; source: 'connected' | 'scan'; } +/** Eine einzelne Probe des Dauer-/Stresstests (Live-Event `stressSample`) */ +export interface StressSampleEvent { + runId: string; + ts: number; + /** Laufzeit in ms, oder null wenn die Probe verloren ging */ + rtt: number | null; + /** bisher gesendete/empfangene Proben — spart der UI eigenes Mitzählen */ + sent: number; + received: number; +} +/** Ausfallsegment im aggregierten Stresstest-Ergebnis */ +export interface StressOutageResult { + startTs: number; + endTs: number; + durationSec: number; +} +/** 1-Minuten-Bucket im aggregierten Stresstest-Ergebnis */ +export interface StressBucketResult { + t: number; + sent: number; + received: number; + avgMs: number | null; + maxMs: number | null; + lossPct: number; +} +/** + * Aggregiertes Ergebnis eines Stresstest-Laufs (Zwischenstand ODER Endstand). + * Fast alles ist optional: `getStressStatus` für einen unbekannten Lauf + * liefert nur `{running: false}`, ein Namensauflösungsfehler nur `{error}`. + */ +export interface StressResult { + runId?: string; + host?: string; + /** gesetzt statt der Messfelder, wenn das Ziel nicht aufgelöst werden konnte */ + error?: string; + intervalSec?: number; + startedAt?: number; + sentTotal?: number; + receivedTotal?: number; + lossPct?: number; + avgMs?: number | null; + minMs?: number | null; + maxMs?: number | null; + p95Ms?: number | null; + laengsterAusfallSek?: number | null; + ausfaelle?: StressOutageResult[]; + verlauf?: StressBucketResult[]; + /** nur bei getStressStatus/stopStressTest gesetzt */ + running?: boolean; + /** nur bei stopStressTest gesetzt */ + stopped?: boolean; + /** nur im stressFinished-Event bzw. stopStressTest gesetzt */ + reason?: 'completed' | 'stopped'; +} export interface OpenPort { port: number; service?: string; @@ -167,15 +221,28 @@ export interface NetDiagScannerPlugin { }>; /** Durchsatztest gegen eine TCP-Sink/Source-Gegenstelle (KEIN iperf3-Protokoll) */ throughput(opts: { host: string; port: number; durationSec: number }): Promise; - /** Dauer-/Stresstest starten (läuft NICHT im Foreground-Service, s. stresstest.ts) */ - startStressTest(opts: { host: string; durationSec: number }): Promise<{ runId: string }>; - /** Laufenden Stresstest beenden und Ergebnis holen */ - stopStressTest(opts: { runId: string }): Promise<{ - samples: number; - lossPct: number; - avgMs: number; - maxMs: number; - }>; + /** + * Dauer-/Stresstest starten — läuft im Foreground-Service (wie der + * Geräte-Monitor), taktet fest im `intervalSec`-Abstand (Standard 1 s) und + * meldet jede Probe live über das `stressSample`-Event. + */ + startStressTest(opts: { + host: string; + durationSec: number; + intervalSec?: number; + }): Promise<{ runId: string; intervalSec: number }>; + /** Laufenden Stresstest abbrechen — liefert das (abschließende) Ergebnis */ + stopStressTest(opts: { runId: string }): Promise; + /** + * Status eines Stresstest-Laufs abfragen (Wiederaufnahme nach Seitenwechsel). + * Liefert auch für einen bereits beendeten Lauf noch das volle Ergebnis + * (running=false) — wichtig, falls der Test natürlich auslief, während + * niemand zuhörte (App im Hintergrund/andere Seite). Danach mit + * `dismissStressRun` abräumen. + */ + getStressStatus(opts: { runId: string }): Promise; + /** Beendeten Lauf aus dem nativen Speicher entfernen, nachdem er übernommen wurde */ + dismissStressRun(opts: { runId: string }): Promise<{ ok: boolean }>; /** Geräte-Monitor starten: mehrere Geräte im Intervall auf Erreichbarkeit prüfen */ startMonitor(opts: { hosts: { ip: string; label: string }[]; @@ -234,6 +301,65 @@ let mockWifiTrackMode: 'connected' | 'scan' = 'connected'; /** simulierter RSSI-Random-Walk fürs Browser-Mock */ let mockWifiTrackRssi = -55; +/* --- Dauer-/Stresstest: Ereignis-Verteilung + Mock-Simulation --- */ +const stressSampleListeners = new Set<(e: StressSampleEvent) => void>(); +const stressFinishedListeners = new Set<(e: StressResult) => void>(); +let mockStressTimer: ReturnType | undefined; +let mockStressSamples: { ts: number; rtt: number | null }[] = []; +let mockStressRunId = ''; +let mockStressHost = ''; + +/** Aggregiertes Mock-Ergebnis aus den bisher gesammelten Mock-Proben bauen. */ +function buildMockStressResult(reason: 'completed' | 'stopped'): StressResult { + const received = mockStressSamples.filter((s) => s.rtt !== null); + const rtts = received.map((s) => s.rtt as number).sort((a, b) => a - b); + const avgMs = rtts.length ? rtts.reduce((a, b) => a + b, 0) / rtts.length : null; + const p95 = rtts.length ? rtts[Math.floor((rtts.length - 1) * 0.95)] : null; + + // Ausfallsegmente aus den Lücken rekonstruieren (einfache Mock-Heuristik) + const ausfaelle: StressOutageResult[] = []; + let outageStart: number | null = null; + for (const s of mockStressSamples) { + if (s.rtt === null) { + if (outageStart === null) outageStart = s.ts; + } else if (outageStart !== null) { + ausfaelle.push({ + startTs: outageStart, + endTs: s.ts, + durationSec: Math.round(((s.ts - outageStart) / 1000) * 10) / 10, + }); + outageStart = null; + } + } + + return { + runId: mockStressRunId, + host: mockStressHost, + sentTotal: mockStressSamples.length, + receivedTotal: received.length, + lossPct: mockStressSamples.length + ? Math.round(((mockStressSamples.length - received.length) / mockStressSamples.length) * 1000) / 10 + : 0, + avgMs: avgMs !== null ? Math.round(avgMs * 10) / 10 : null, + minMs: rtts.length ? rtts[0] : null, + maxMs: rtts.length ? rtts[rtts.length - 1] : null, + p95Ms: p95, + laengsterAusfallSek: ausfaelle.length ? Math.max(...ausfaelle.map((a) => a.durationSec ?? 0)) : null, + ausfaelle, + verlauf: [], + reason, + }; +} + +/** Mock-Lauf beenden: Timer stoppen, Ergebnis bauen, stressFinished feuern. */ +function finishMockStress(reason: 'completed' | 'stopped'): StressResult { + if (mockStressTimer) clearInterval(mockStressTimer); + mockStressTimer = undefined; + const result = buildMockStressResult(reason); + stressFinishedListeners.forEach((cb) => cb(result)); + return result; +} + const mock: NetDiagScannerPlugin = { async getLocalSubnet() { return { @@ -375,11 +501,43 @@ const mock: NetDiagScannerPlugin = { measured: true, }; }, - async startStressTest() { - return { runId: 'mock-run' }; + async startStressTest(opts) { + const runId = 'mock-stress-' + Date.now(); + mockStressRunId = runId; + mockStressHost = opts.host; + mockStressSamples = []; + const intervalSec = opts.intervalSec ?? 1; + let sent = 0; + let received = 0; + const endAt = Date.now() + opts.durationSec * 1000; + + const tick = () => { + if (Date.now() >= endAt) { + finishMockStress('completed'); + return; + } + sent++; + // Alle ~12 Proben ein kurzer simulierter Ausfall, sonst 3-15 ms + const lost = sent % 12 === 0 || sent % 13 === 0; + const rtt = lost ? null : rnd(3, 15); + if (!lost) received++; + const ts = Date.now(); + mockStressSamples.push({ ts, rtt }); + const ev: StressSampleEvent = { runId, ts, rtt, sent, received }; + stressSampleListeners.forEach((cb) => cb(ev)); + }; + mockStressTimer = setInterval(tick, intervalSec * 1000); + return { runId, intervalSec }; }, async stopStressTest() { - return { samples: 120, lossPct: rnd(0, 2), avgMs: rnd(3, 10), maxMs: rnd(20, 90) }; + return finishMockStress('stopped'); + }, + async getStressStatus() { + if (!mockStressTimer) return { running: false }; + return { ...buildMockStressResult('completed'), running: true }; + }, + async dismissStressRun() { + return { ok: true }; }, async startMonitor(opts) { const runId = 'mock-mon-' + Date.now(); @@ -540,3 +698,49 @@ export function onWifiSignal(cb: (e: WifiSignalEvent) => void): () => void { wifiSignalListeners.delete(cb); }; } + +/** + * Auf einzelne Proben eines laufenden Dauer-/Stresstests hören (Live-Diagramm). + * Gibt die Abmeldefunktion zurück. + */ +export function onStressSample(cb: (e: StressSampleEvent) => void): () => void { + if (Capacitor.isNativePlatform()) { + const handle = ( + native as unknown as { + addListener( + name: string, + cb: (e: StressSampleEvent) => void, + ): Promise; + } + ).addListener('stressSample', cb); + return () => { + void handle.then((h) => h.remove()); + }; + } + stressSampleListeners.add(cb); + return () => { + stressSampleListeners.delete(cb); + }; +} + +/** + * Auf das Ende eines Dauer-/Stresstests hören — feuert genau einmal, egal ob + * der Lauf regulär durchlief oder per `stopStressTest` abgebrochen wurde. + * Gibt die Abmeldefunktion zurück. + */ +export function onStressFinished(cb: (e: StressResult) => void): () => void { + if (Capacitor.isNativePlatform()) { + const handle = ( + native as unknown as { + addListener(name: string, cb: (e: StressResult) => void): Promise; + } + ).addListener('stressFinished', cb); + return () => { + void handle.then((h) => h.remove()); + }; + } + stressFinishedListeners.add(cb); + return () => { + stressFinishedListeners.delete(cb); + }; +} diff --git a/src/lib/stresstest.ts b/src/lib/stresstest.ts new file mode 100644 index 0000000..58be07b --- /dev/null +++ b/src/lib/stresstest.ts @@ -0,0 +1,172 @@ +/** + * Gemeinsame Logik rund um den Dauer-/Stresstest — von der eigenen Seite + * (`routes/protokoll/[id]/stresstest/`) UND von der Hauptprotokollseite + * genutzt. Grund für die zweite Stelle: läuft ein Test natürlich aus, während + * niemand auf der Stresstest-Seite ist (App im Hintergrund, User bearbeitet + * gerade ein anderes Werkzeug), verpasst diese Seite das `stressFinished`- + * Event. Die Hauptprotokollseite ist der Ort, an dem der Techniker praktisch + * immer vorbeikommt, bevor er das Protokoll abschließt — sie prüft deshalb + * beim Öffnen ebenfalls kurz nach, ob ein Lauf fertig ist, und trägt das + * Ergebnis nach. (Verlässt der Techniker das Protokoll komplett, ohne eine + * der beiden Seiten noch einmal zu öffnen, bleibt der fertige Lauf bis zum + * nächsten Öffnen unverarbeitet liegen — ein bewusst in Kauf genommener + * Rest-Fall, siehe ROADMAP_UMSETZUNG.md Phase 2.) + */ + +import { addMeasurement } from './protocols'; +import { rateLatency } from './tools/rating'; +import { scanner, type StressResult } from './scanner'; +import type { MeasureStatus, Protocol, StressTestSession } 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); + const s = Math.round(sec % 60); + return s ? `${m} min ${s} s` : `${m} min`; +} + +/** + * Aus dem aggregierten Plugin-Ergebnis die Felder für die gespeicherte + * Messung bauen — Ausfälle und Verlauf als lesbare Zeilen (wie Traceroute + * seine Hops), nicht als rohe Objekte (die `MeasurementResult.svelte` sonst + * nur als "[object Object]" darstellen könnte). + */ +export function buildStressMeasurement( + session: StressTestSession, + r: StressResult, +): { label: string; result: Record; measureStatus: MeasureStatus } { + if (r.error) { + return { + label: `${session.host}: ${r.error}`, + result: { host: session.host, fehler: r.error }, + measureStatus: 3, + }; + } + + const sent = r.sentTotal ?? 0; + const received = r.receivedTotal ?? 0; + if (sent === 0) { + return { + label: `${session.host}: keine Messpunkte`, + result: { host: session.host, hinweis: 'Der Lauf endete, bevor eine Probe gesendet wurde.' }, + measureStatus: 3, + }; + } + + const status = rateLatency({ + lossPct: r.lossPct ?? 0, + latencyMs: r.avgMs ?? null, + longestOutageSec: r.laengsterAusfallSek ?? null, + }); + + const ausfaelle = (r.ausfaelle ?? []).map( + (o) => `${fmtTime(o.startTs)} – ${fmtTime(o.endTs)} (${fmtDuration(o.durationSec)})`, + ); + const verlauf = (r.verlauf ?? []).map((b) => { + const totalSuffix = b.sent > 0 && b.received === 0 ? ' — Totalausfall' : ''; + return `${fmtTime(b.t)}: ${b.received}/${b.sent} Proben, ø ${b.avgMs ?? '—'} ms${totalSuffix}`; + }); + + const minuten = Math.round(session.durationSec / 60); + return { + label: + r.lossPct && r.lossPct > 0 + ? `${session.host}: ${r.lossPct}% Verlust über ${minuten} min` + : `${session.host}: ø ${r.avgMs ?? '—'} ms über ${minuten} min, kein Verlust`, + result: { + host: session.host, + dauerSekunden: session.durationSec, + intervallSek: r.intervalSec ?? session.intervalSec, + gesendet: sent, + empfangen: received, + verlustProzent: r.lossPct ?? 0, + avgMs: r.avgMs ?? null, + minMs: r.minMs ?? null, + maxMs: r.maxMs ?? null, + p95Ms: r.p95Ms ?? null, + laengsterAusfallSek: r.laengsterAusfallSek ?? null, + ausfaelle, + verlauf, + }, + measureStatus: status, + }; +} + +/** + * Eine lokale Session anhand eines Plugin-Ergebnisses abschließen: Messung + * anlegen (sync-fähig) + Session-Status setzen. Persistiert NICHT selbst — + * das macht der Aufrufer (unterschiedlich je nach Seite). + */ +export function finalizeStressSession(protocol: Protocol, session: StressTestSession, r: StressResult): void { + const built = buildStressMeasurement(session, r); + addMeasurement(protocol, { + deviceClientId: null, + tool: 'stresstest', + category: 'netzwerk', + label: built.label, + params: { host: session.host, durationSec: session.durationSec, intervalSec: session.intervalSec }, + result: built.result, + measureStatus: built.measureStatus, + dateMeasure: Date.now(), + }); + + // Ausfallliste mit dem nativen (autoritativen) Ergebnis abgleichen statt nur + // die lokal live mitgeführte Kopie zu behalten — die native Seite führt ihre + // Buchhaltung unabhängig davon, ob gerade ein JS-Listener zugehört hat + // (App im Hintergrund, andere Seite offen), die lokale Kopie kann also + // Lücken haben. Die rohe Probenreihe (session.samples) bleibt dagegen bei + // dem Stand, den die App tatsächlich live mitbekommen hat — das betrifft + // nur die Detailgenauigkeit der historischen Grafik, keine Kennzahl. + if (r.ausfaelle) { + session.outages = r.ausfaelle.map((o) => ({ + startTs: o.startTs, + endTs: o.endTs, + durationSec: o.durationSec, + })); + } + + session.status = 'stopped'; + session.endedAt = Date.now(); +} + +/** + * Prüft, ob im Protokoll ein als "laufend" markierter Stresstest in + * Wirklichkeit schon fertig ist (natürlich ausgelaufen, während niemand + * zuhörte), und trägt ihn nach. Persistiert NICHT selbst. + * + * @return true, wenn etwas verändert wurde (Aufrufer sollte dann speichern) + */ +export async function resumeFinishedStressSessions(protocol: Protocol): Promise { + const running = (protocol.stressTestSessions ?? []).filter( + (s) => s.status === 'running' && s.runId, + ); + let changed = false; + for (const session of running) { + try { + const st = await scanner.getStressStatus({ runId: session.runId! }); + if (st.running) continue; // läuft noch — nichts zu tun, die Seite selbst dockt an + finalizeStressSession(protocol, session, st); + changed = true; + try { + await scanner.dismissStressRun({ runId: session.runId! }); + } catch { + /* nicht kritisch — Eintrag bleibt nativ liegen, stört nur den Speicher */ + } + } catch { + // Plugin nicht erreichbar (z.B. Browser-Dev) — Session als beendet + // markieren, ohne ein Messergebnis zu erfinden. + session.status = 'stopped'; + session.endedAt = Date.now(); + changed = true; + } + } + return changed; +} diff --git a/src/lib/tools/netzwerk/ping.ts b/src/lib/tools/netzwerk/ping.ts index f6c0863..7be751a 100644 --- a/src/lib/tools/netzwerk/ping.ts +++ b/src/lib/tools/netzwerk/ping.ts @@ -3,7 +3,8 @@ */ import { scanner } from '../../scanner'; -import type { MeasureStatus, Tool } from '../types'; +import { rateLatency } from '../rating'; +import type { Tool } from '../types'; export const pingTool: Tool = { id: 'ping', @@ -43,9 +44,9 @@ export const pingTool: Tool = { // (WLAN-Aufwachen, kurzer Funkstörer) verschiebt den Mittelwert stark, der // Median bleibt bei dem, was das Netz üblicherweise liefert. const leitwert = q.medianMs ?? q.avgMs; - let status: MeasureStatus = 0; - if (q.lossPct > 0 || q.jitterMs > 10) status = 1; - if (q.lossPct >= 10 || leitwert > 100) status = 2; + // Gemeinsame Bewertung mit dem Dauertest (rating.ts) — vorher konnte + // derselbe Netzzustand hier Rot, im Dauertest aber Gelb ergeben. + const status = rateLatency({ lossPct: q.lossPct, latencyMs: leitwert, jitterMs: q.jitterMs }); return { label: `${host}: ${leitwert} ms (Median), ${q.lossPct}% Verlust`, diff --git a/src/lib/tools/netzwerk/stresstest.ts b/src/lib/tools/netzwerk/stresstest.ts index 8cbbff0..bb8ffac 100644 --- a/src/lib/tools/netzwerk/stresstest.ts +++ b/src/lib/tools/netzwerk/stresstest.ts @@ -1,19 +1,22 @@ /** - * Tool: Dauer-/Stresstest — Langzeitmessung von Verlust und Latenz. + * Werkzeug-Metadaten des Dauer-/Stresstests. * - * ACHTUNG, bekannte Schwächen (Umbau in ROADMAP_UMSETZUNG.md Phase 2): - * - Der Lauf hängt an einer nackten Coroutine im Plugin, NICHT an einem - * Foreground-Service. Bei ausgeschaltetem Display friert Android ihn ein. - * - Die native Schleife hat kein festes Intervall — die Zahl der Messpunkte - * hängt davon ab, wie schnell das Ziel antwortet (guter Link ~250 Punkte in - * 5 min, Totalausfall ~38). Sie ist damit keine Messgröße. - * - Es wird keine Zeitreihe gespeichert, nur Summen. „Wann war die Störung?" - * lässt sich aus dem Ergebnis nicht beantworten. - * Die Dauer wird als Parameter vorgegeben; run() wartet auf das Ergebnis. + * ACHTUNG: `run()` ist NICHT mehr der Ausführungsweg — der Dauertest braucht + * eine Live-Anzeige, während er läuft (Fortschritt, laufende Kennzahlen, + * Abbrechen, Ausfallliste). Das passt nicht in den generischen Kurzdialog + * aller anderen Werkzeuge (`ToolDialog.svelte`). Er hat deshalb seit + * ROADMAP_UMSETZUNG.md Phase 2 eine eigene Route + * (`/protokoll/{id}/stresstest/`, siehe dort) — genau wie IP-Test, + * WLAN-Empfang und der Geräte-Monitor. + * + * Dieser Eintrag bleibt trotzdem in der TOOLS-Registry (`tools/index.ts`), + * damit `getTool('stresstest')` in der Messungen-Liste weiterhin den + * lesbaren Namen „Dauer-/Stresstest" liefert statt der rohen ID — siehe + * `routes/protokoll/[id]/+page.svelte`, wo Messungen unabhängig vom + * ausführenden Weg (Dialog oder eigene Route) einheitlich dargestellt werden. */ -import { scanner } from '../../scanner'; -import type { MeasureStatus, Tool } from '../types'; +import type { Tool } from '../types'; export const stressTestTool: Tool = { id: 'stresstest', @@ -22,94 +25,15 @@ export const stressTestTool: Tool = { icon: 'gauge', description: 'Langzeitmessung: Paketverlust und Latenz über einen Zeitraum.', scope: 'protocol', - params: [ - // KEIN fester Vorgabewert mehr. Die alte Vorgabe 192.168.1.1 hat im - // Kundennetz 192.168.178.0/24 fünf Minuten lang ins Leere gemessen und das - // Ergebnis als „100 % Verlust" rot ins Protokoll geschrieben (Prod #126). - // Leer lassen -> es wird das Gateway des aktiven Adapters genommen. - { - key: 'host', - label: 'Ziel — leer = Gateway des aktiven Netzes', - type: 'text', - placeholder: 'leer lassen → Gateway', - }, - { - key: 'duration', - label: 'Dauer', - type: 'select', - default: '300', - options: [ - { value: '60', label: '1 Minute' }, - { value: '300', label: '5 Minuten' }, - { value: '900', label: '15 Minuten' }, - { value: '3600', label: '1 Stunde' }, - ], - }, - ], - async run(ctx) { - let host = String(ctx.params.host ?? '').trim(); - const durationSec = Number(ctx.params.duration || 300); - - // Kein Ziel angegeben -> Gateway des aktiven Adapters - if (!host) { - try { - host = String((await scanner.getLocalSubnet()).gateway ?? '').trim(); - } catch { - /* unten abgefangen */ - } - } - if (!host) { - return { - label: 'Kein Ziel — WLAN/LAN nicht aktiv?', - result: { - hinweis: - 'Es konnte kein Gateway ermittelt werden. Bitte ein Ziel angeben ' + - '(z.B. den Router des Kundennetzes).', - }, - measureStatus: 3, - }; - } - - // Vorlaufprobe: antwortet das Ziel überhaupt? Sonst misst man minuten- bis - // stundenlang gegen etwas, das gar nicht da ist, und bekommt am Ende - // „100 % Verlust" — was wie ein kaputtes Kundennetz aussieht. - const probe = await scanner.pingQuality({ host, count: 3 }); - if (probe.received === 0) { - return { - label: `${host} antwortet nicht — Test nicht gestartet`, - result: { - host, - hinweis: - `${host} hat auf 3 Vorabproben nicht geantwortet. Ein Dauertest gegen ` + - 'ein nicht erreichbares Ziel liefert nur 100 % Verlust und sagt nichts ' + - 'über das Netz aus. Ziel prüfen und erneut starten.', - }, - measureStatus: 3, - }; - } - - const { runId } = await scanner.startStressTest({ host, durationSec }); - // Auf das Ende des Laufs warten. Der Timer läuft in der WebView und wird von - // Chromium gedrosselt, sobald die Seite in den Hintergrund gerät — das Ergebnis - // kann dadurch später kommen als die angegebene Dauer. - await new Promise((r) => setTimeout(r, durationSec * 1000)); - const res = await scanner.stopStressTest({ runId }); - - let status: MeasureStatus = 0; - if (res.lossPct > 0 || res.maxMs > 100) status = 1; - if (res.lossPct >= 5 || res.maxMs > 500) status = 2; - - return { - label: `${host}: ${res.lossPct}% Verlust über ${Math.round(durationSec / 60)} min`, - result: { - host, - dauerSekunden: durationSec, - messpunkte: res.samples, - verlustProzent: res.lossPct, - avgMs: res.avgMs, - maxMs: res.maxMs, - }, - measureStatus: status, - }; + // Keine Parameter mehr hier — die eigene Route fragt Ziel/Dauer/Intervall + // in ihrer eigenen Einrichtungsansicht ab, nicht im generischen Dialog. + params: [], + async run() { + throw new Error( + 'Der Dauer-/Stresstest läuft über die eigene Seite ' + + '(/protokoll/{id}/stresstest/), nicht über den Werkzeug-Dialog. ' + + 'Diese Funktion sollte nie aufgerufen werden — falls doch, verweist ' + + 'irgendwo im Code noch der alte Aufrufweg.', + ); }, }; diff --git a/src/lib/tools/rating.ts b/src/lib/tools/rating.ts new file mode 100644 index 0000000..a8376ec --- /dev/null +++ b/src/lib/tools/rating.ts @@ -0,0 +1,54 @@ +/** + * Gemeinsame Ampel-Bewertung für Latenz-/Verlustmessungen. + * + * Vorher bewerteten `ping.ts` und der alte Dauertest denselben Netzzustand + * unterschiedlich: `ping.ts` schlug schon bei `avgMs > 100` auf Rot, der + * Dauertest schaute nur auf `lossPct`/`maxMs`. Derselbe Zustand konnte damit + * einmal Rot, einmal Gelb sein — verwirrend für den Techniker und schlecht + * fürs Kundenprotokoll. Diese Funktion ist jetzt die einzige Stelle, die + * entscheidet, und wird von beiden Werkzeugen aufgerufen. + */ + +import type { MeasureStatus } from '../types'; + +/** Grobe Einordnung des Ziels — WAN-Latenzen sind strukturell höher als LAN. */ +export type TargetClass = 'lan' | 'wlan' | 'wan'; + +export interface LatencyRating { + /** Verlust in Prozent (Kommazahl) */ + lossPct: number; + /** Leitwert der Latenz — bevorzugt Median, sonst Mittelwert; null = nicht ermittelt */ + latencyMs: number | null; + /** längster einzelner Ausfall in Sekunden, falls bekannt (nur Dauertest) */ + longestOutageSec?: number | null; + /** Schwankung zwischen aufeinanderfolgenden Proben; > 10 ms zählt als Warnung */ + jitterMs?: number | null; +} + +const THRESHOLDS: Record = { + lan: { warnMs: 20, failMs: 100 }, + wlan: { warnMs: 50, failMs: 150 }, + wan: { warnMs: 150, failMs: 400 }, +}; + +/** + * Bewertet eine Latenz-/Verlustmessung. `targetClass` default 'lan', weil die + * meisten Ziele (Gateway, Switch, Gerät im Kundennetz) LAN sind — ein Anrufer + * ins Internet (Traceroute-Ziel, WAN-Speedtest) sollte 'wan' übergeben. + */ +export function rateLatency(r: LatencyRating, targetClass: TargetClass = 'lan'): MeasureStatus { + const t = THRESHOLDS[targetClass]; + + // Ein einzelner Ausfall über 5 s ist für den Kunden spürbar (Videocall bricht, + // Kasse hängt) — unabhängig davon, wie klein der Verlust über die Gesamtdauer + // gerechnet aussieht. Deshalb eigene, vorrangige Regel. + if ((r.longestOutageSec ?? 0) > 5) return 2; + if (r.lossPct >= 10) return 2; + if (r.latencyMs !== null && r.latencyMs > t.failMs) return 2; + + let status: MeasureStatus = 0; + if (r.lossPct > 0) status = 1; + if ((r.jitterMs ?? 0) > 10) status = 1; + if (r.latencyMs !== null && r.latencyMs > t.warnMs) status = 1; + return status; +} diff --git a/src/lib/types.ts b/src/lib/types.ts index fc48705..90f25c3 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -162,6 +162,57 @@ export interface WifiTrackSession { mode?: 'connected' | 'scan'; } +/** Einzelne Probe der Zeitreihe eines Dauer-/Stresstests */ +export interface StressSample { + ts: number; + /** Laufzeit in ms, oder null wenn diese Probe verloren ging (NICHT 0) */ + rtt: number | null; +} + +/** Ein zusammenhängendes Ausfallsegment (aufeinanderfolgende verlorene Proben) */ +export interface StressOutage { + startTs: number; + /** fehlt, solange der Ausfall noch andauert (nur bei laufender Session) */ + endTs?: number; + durationSec?: number; +} + +/** Auf 1-Minuten-Fenster verdichteter Ausschnitt der Zeitreihe (fürs Diagramm/PDF) */ +export interface StressBucket { + /** Beginn des Fensters (Unix-ms, auf die Minute gerundet) */ + t: number; + sent: number; + received: number; + avgMs?: number | null; + maxMs?: number | null; + lossPct: number; +} + +/** + * Laufende oder gerade beendete Dauer-/Stresstest-Sitzung — NUR LOKAL + * (nicht synchronisiert, wie DeviceMonitorSession/WifiTrackSession). Dient der + * Live-Anzeige und der Wiederaufnahme nach einem Seitenwechsel. Das fertige + * Ergebnis wird zusätzlich als `Measurement` (tool='stresstest') gespeichert — + * NUR der landet im Dolibarr-Protokoll. + */ +export interface StressTestSession { + id: string; + host: string; + /** geplante Gesamtdauer in Sekunden */ + durationSec: number; + /** Sondierungsabstand in Sekunden (Standard 1) */ + intervalSec: number; + startedAt: number; + endedAt?: number; + samples: StressSample[]; + outages: StressOutage[]; + status: 'running' | 'stopped'; + /** laufende Plugin-Lauf-ID (für Wiederaufnahme nach Seitenwechsel) */ + runId?: string; + /** vom Nativen gemeldeter Grund, falls das Ziel gar nicht aufgelöst werden konnte */ + resolveError?: string; +} + /** Ampel-Bewertung einer Messung */ /** * Bewertung einer Messung. @@ -214,6 +265,8 @@ export interface Protocol { monitorSessions?: DeviceMonitorSession[]; /** WLAN-Empfangstracker-Sessions (nur lokal, wird nicht synchronisiert) */ wifiTrackSessions?: WifiTrackSession[]; + /** Dauer-/Stresstest-Sessions (nur lokal — das Ergebnis geht als Measurement raus) */ + stressTestSessions?: StressTestSession[]; /** true solange noch nicht zum Server synchronisiert */ dirty: boolean; updatedAt: number; diff --git a/src/routes/protokoll/[id]/+page.svelte b/src/routes/protokoll/[id]/+page.svelte index 48e0695..4fcf767 100644 --- a/src/routes/protokoll/[id]/+page.svelte +++ b/src/routes/protokoll/[id]/+page.svelte @@ -19,6 +19,7 @@ saveScan, deleteScan, } from '$lib/protocols'; + import { resumeFinishedStressSessions } from '$lib/stresstest'; import { sync } from '$lib/sync.svelte'; import { toast } from '$lib/toast.svelte'; import { pushOverlay } from '$lib/overlay.svelte'; @@ -39,7 +40,10 @@ let appStateListener: PluginListenerHandle | null = null; - const protocolTools = TOOLS.filter((t) => t.scope === 'protocol'); + // Stresstest ist seit Phase 2 KEIN Dialog-Werkzeug mehr — eigene Route mit + // Live-Anzeige (siehe stresstest.ts oben). Bleibt in TOOLS nur für + // getTool()-Namenslookups in der Messungen-Liste. + const protocolTools = TOOLS.filter((t) => t.scope === 'protocol' && t.id !== 'stresstest'); const deviceTools = TOOLS.filter((t) => t.scope === 'device'); /** Geräte mit Favoriten zuerst */ @@ -63,6 +67,15 @@ } protocol = p; + // Sicherheitsnetz: ein Dauertest kann natürlich auslaufen, während niemand + // auf der Stresstest-Seite ist (App im Hintergrund, anderes Werkzeug in + // Arbeit) — dort verpasst dann niemand das stressFinished-Event. Diese + // Seite wird vor dem Abschließen praktisch immer noch einmal geöffnet, + // deshalb hier zusätzlich nachsehen und das Ergebnis nachtragen. + if (await resumeFinishedStressSessions(protocol)) { + await persist(); + } + // App wechselt in den Hintergrund (anderer App-Wechsel, Display aus) → // sofort sichern, bevor Android den Prozess evtl. beendet. appStateListener = await App.addListener('appStateChange', ({ isActive }) => { @@ -268,6 +281,17 @@ {tool.description} {/each} + + + + Dauer-/Stresstest + + Langzeitmessung: Paketverlust und Latenz über einen Zeitraum, live sichtbar. + + + /** + * Dauer-/Stresstest — Langzeitmessung von Latenz und Paketverlust mit + * Live-Anzeige (Streifendiagramm, Zähler, Ausfallliste). Läuft über einen + * Vordergrund-Dienst auch bei ausgeschaltetem Display weiter (wie der + * Geräte-Monitor). Verlässt man die Seite, läuft der Test weiter; beim + * Zurückkehren wird der Stand wieder aufgenommen. + * + * Ersetzt den alten Werkzeug-Dialog (ROADMAP_UMSETZUNG.md Phase 2): der + * blockierte die App für die volle Testdauer und zeigte nichts außer + * "Messung läuft …". + */ + import { onMount, onDestroy } from 'svelte'; + import { page } from '$app/stores'; + import { goto } from '$app/navigation'; + import { ChevronDown } from 'lucide-svelte'; + import AppHeader from '$lib/components/AppHeader.svelte'; + import StressChart from '$lib/components/StressChart.svelte'; + import { getProtocol, saveProtocol } from '$lib/db'; + import { uid } from '$lib/protocols'; + import { finalizeStressSession } from '$lib/stresstest'; + import { + scanner, + onStressSample, + onStressFinished, + type StressResult, + type StressSampleEvent, + } from '$lib/scanner'; + import { rateLatency } from '$lib/tools/rating'; + import { sync } from '$lib/sync.svelte'; + import { toast } from '$lib/toast.svelte'; + import type { Protocol, StressOutage, StressTestSession } from '$lib/types'; + + let protocol = $state(null); + let session = $state(null); + let busy = $state(false); + let expanded = $state(null); + let offSample: (() => void) | undefined; + let offFinished: (() => void) | undefined; + + // Einrichtung + let hostInput = $state(''); + let durationSec = $state(300); + let intervalSec = $state(1); + + // Sekunden-Takt für Fortschritt/Restzeit — Proben kommen selten öfter als + // 1x/s, ohne eigenen Takt würde die Anzeige bei intervalSec=5 ruckeln. + let nowTick = $state(Date.now()); + + const running = $derived(session?.status === 'running'); + const pastSessions = $derived( + (protocol?.stressTestSessions ?? []).filter((s) => s.status === 'stopped'), + ); + + $effect(() => { + if (!running) return; + const t = setInterval(() => (nowTick = Date.now()), 1000); + return () => clearInterval(t); + }); + + const elapsedSec = $derived( + session ? Math.max(0, Math.floor((nowTick - session.startedAt) / 1000)) : 0, + ); + const remainingSec = $derived(session ? Math.max(0, session.durationSec - elapsedSec) : 0); + const progressPct = $derived( + session && session.durationSec > 0 ? Math.min(100, (elapsedSec / session.durationSec) * 100) : 0, + ); + + const sentCount = $derived(session?.samples.length ?? 0); + const receivedCount = $derived(session?.samples.filter((s) => s.rtt != null).length ?? 0); + const lossPct = $derived( + sentCount > 0 ? Math.round(((sentCount - receivedCount) / sentCount) * 1000) / 10 : 0, + ); + const liveStats = $derived.by(() => { + const vals = (session?.samples ?? []) + .map((s) => s.rtt) + .filter((v): v is number => v != null); + if (!vals.length) return { avgMs: null as number | null, minMs: null as number | null, maxMs: null as number | null }; + return { + avgMs: Math.round((vals.reduce((a, b) => a + b, 0) / vals.length) * 10) / 10, + minMs: Math.min(...vals), + maxMs: Math.max(...vals), + }; + }); + const openOutage = $derived(session?.outages.find((o) => o.endTs === undefined)); + const openOutageSec = $derived(openOutage ? (nowTick - openOutage.startTs) / 1000 : 0); + + const liveStatus = $derived( + rateLatency({ + lossPct, + latencyMs: liveStats.avgMs, + longestOutageSec: Math.max( + openOutageSec, + ...(session?.outages ?? []).map((o) => o.durationSec ?? 0), + 0, + ), + }), + ); + const statusColor = $derived( + ['text-emerald-400', 'text-amber-400', 'text-red-400', 'text-zinc-500'][liveStatus], + ); + + const lastSample = $derived( + session && session.samples.length > 0 ? session.samples[session.samples.length - 1] : undefined, + ); + + onMount(async () => { + const p = await getProtocol($page.params.id ?? ''); + if (!p) { + toast.show('Protokoll nicht gefunden', 'error'); + goto('/auftraege/'); + return; + } + protocol = p; + + // Läuft bereits ein Test? → wieder andocken + const live = p.stressTestSessions?.find((s) => s.status === 'running'); + if (live?.runId) { + try { + const st = await scanner.getStressStatus({ runId: live.runId }); + if (st.running) { + session = live; + attachListeners(live.runId); + } else { + // Natürlich ausgelaufen, während niemand zuhörte — aus dem vollen + // Ergebnis fertigstellen, als wäre stressFinished gerade gefeuert. + finalizeStressSession(p, live, st); + try { + await scanner.dismissStressRun({ runId: live.runId }); + } catch { + /* nicht kritisch */ + } + await persist(); + toast.show('Dauertest war inzwischen fertig — Ergebnis übernommen', 'info', 5000); + } + } catch { + live.status = 'stopped'; + live.endedAt = Date.now(); + await persist(); + } + } + }); + + onDestroy(() => { + // Listener lösen, aber den Test NICHT stoppen — er läuft im Hintergrund weiter. + offSample?.(); + offFinished?.(); + void persist(); + }); + + async function persist() { + if (!protocol) return; + protocol.dirty = true; + await saveProtocol($state.snapshot(protocol) as Protocol); + await sync.refreshPending(); + } + + function attachListeners(runId: string) { + offSample?.(); + offFinished?.(); + offSample = onStressSample((e: StressSampleEvent) => { + if (e.runId !== runId || !session) return; + session.samples.push({ ts: e.ts, rtt: e.rtt }); + + // Ausfall lokal mitführen, damit die Ereignisliste sofort mitwächst — + // beim Abschließen wird das gegen das native (autoritative) Ergebnis + // abgeglichen, siehe finalizeStressSession. + const open = session.outages.find((o) => o.endTs === undefined); + if (e.rtt == null) { + if (!open) session.outages.push({ startTs: e.ts }); + } else if (open) { + open.endTs = e.ts; + open.durationSec = Math.round(((e.ts - open.startTs) / 1000) * 10) / 10; + } + + // Nicht bei jeder Probe schreiben — alle 10 reicht, onDestroy sichert final. + if (session.samples.length % 10 === 0) void persist(); + }); + offFinished = onStressFinished((r: StressResult) => { + if (r.runId !== runId) return; + void finishLive(r); + }); + } + + async function finishLive(r: StressResult) { + if (!protocol || !session) return; + offSample?.(); + offSample = undefined; + offFinished?.(); + offFinished = undefined; + finalizeStressSession(protocol, session, r); + if (session.runId) { + try { + await scanner.dismissStressRun({ runId: session.runId }); + } catch { + /* nicht kritisch */ + } + } + await persist(); + toast.show(r.reason === 'stopped' ? 'Dauertest abgebrochen' : 'Dauertest beendet', 'success'); + } + + async function start() { + if (!protocol || busy) return; + let host = hostInput.trim(); + busy = true; + try { + // Kein Ziel angegeben -> Gateway des aktiven Adapters. KEIN fester + // Vorgabewert mehr (die alte Vorgabe 192.168.1.1 hat im Kundennetz + // 192.168.178.0/24 fünf Minuten ins Leere gemessen, Prod #126). + if (!host) { + try { + host = String((await scanner.getLocalSubnet()).gateway ?? '').trim(); + } catch { + /* unten abgefangen */ + } + } + if (!host) { + toast.show('Kein Ziel — WLAN/LAN nicht aktiv? Bitte Ziel angeben.', 'error', 6000); + return; + } + + // Vorlaufprobe: antwortet das Ziel überhaupt? Sonst misst man minuten- + // bis stundenlang gegen etwas, das gar nicht da ist. + const probe = await scanner.pingQuality({ host, count: 3 }); + if (probe.received === 0) { + toast.show(`${host} antwortet nicht auf 3 Vorabproben — Test nicht gestartet.`, 'error', 6000); + return; + } + + const { runId, intervalSec: actualInterval } = await scanner.startStressTest({ + host, + durationSec, + intervalSec, + }); + const sessions = (protocol.stressTestSessions ??= []); + sessions.push({ + id: uid(), + host, + durationSec, + intervalSec: actualInterval, + startedAt: Date.now(), + samples: [], + outages: [], + status: 'running', + runId, + }); + session = sessions[sessions.length - 1]; + attachListeners(runId); + await persist(); + } catch (e) { + toast.show(e instanceof Error ? e.message : 'Start fehlgeschlagen', 'error'); + } finally { + busy = false; + } + } + + async function stop() { + if (!session?.runId || busy) return; + busy = true; + // Listener zuerst lösen — die native Seite feuert stressFinished ohnehin, + // sonst würde das Ergebnis zweimal fertiggestellt. + offSample?.(); + offSample = undefined; + offFinished?.(); + offFinished = undefined; + try { + const res = await scanner.stopStressTest({ runId: session.runId }); + if (protocol && session) { + finalizeStressSession(protocol, session, res); + await persist(); + } + toast.show('Dauertest abgebrochen', 'success'); + } catch (e) { + toast.show(e instanceof Error ? e.message : 'Abbruch fehlgeschlagen', 'error'); + } finally { + busy = false; + } + } + + /* --- Anzeige-Helfer --- */ + function fmtTime(ts: number): string { + return new Date(ts).toLocaleTimeString('de-DE', { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); + } + function fmtDateTime(ts: number): string { + return new Date(ts).toLocaleString('de-DE', { + day: '2-digit', + month: '2-digit', + hour: '2-digit', + minute: '2-digit', + }); + } + function fmtDuration(sec: number): string { + if (sec < 60) return Math.round(sec) + ' s'; + const m = Math.floor(sec / 60); + const s = Math.round(sec % 60); + return s ? `${m} min ${s} s` : `${m} min`; + } + + +{#snippet outageRow(o: StressOutage)} +
+ + {fmtTime(o.startTs)} + + {o.endTs + ? `Ausfall — wieder da nach ${fmtDuration(o.durationSec ?? (o.endTs - o.startTs) / 1000)}` + : 'Ausfall läuft noch …'} + +
+{/snippet} + +{#if protocol} + + +
+ {#if running && session} + +
+
+
+ + Test läuft — Ziel {session.host} +
+ + noch {fmtDuration(remainingSec)} + +
+
+
+
+ + +
+ + {lastSample?.rtt ?? '—'} + + ms + + {lastSample && lastSample.rtt == null ? 'keine Antwort' : ''} + +
+ + {#if openOutage} +

+ Ausfall läuft seit {fmtTime(openOutage.startTs)} ({fmtDuration(openOutageSec)}) +

+ {/if} + + +
+ +
+ +
+
+
Gesendet
+
{sentCount}
+
+
+
Verlust
+
0 ? 'text-red-400' : 'text-emerald-400'}>{lossPct}%
+
+
+
Ø Laufzeit
+
{liveStats.avgMs ?? '—'} ms
+
+
+
Min
+
{liveStats.minMs ?? '—'} ms
+
+
+
Max
+
{liveStats.maxMs ?? '—'} ms
+
+
+
Ausfälle
+
{session.outages.filter((o) => o.endTs).length}
+
+
+ + +
+ +

+ Ereignisse ({session.outages.length}) +

+ {#if session.outages.length === 0} +

Noch kein Ausfall — Ziel durchgehend erreichbar.

+ {/if} + {#each [...session.outages].reverse() as o (o.startTs)} + {@render outageRow(o)} + {/each} + {:else} + +
+ + + + + +

+ Läuft auch bei ausgeschaltetem Display weiter und zeigt live Verlauf, + Zähler und jeden Ausfall mit Uhrzeit. Vor dem Start wird kurz geprüft, + ob das Ziel überhaupt antwortet — sonst würde die volle Dauer nur + „100 % Verlust" liefern, ohne etwas über das Netz auszusagen. +

+
+ {/if} + + + {#if pastSessions.length > 0} +

Frühere Läufe

+ {#each pastSessions as s (s.id)} +
+ + {#if expanded === s.id} +
+ + {#if s.outages.length === 0} +

Kein Ausfall während des Laufs.

+ {:else} +
+ {#each s.outages as o (o.startTs)} + {@render outageRow(o)} + {/each} +
+ {/if} +
+ {/if} +
+ {/each} + {/if} +
+{:else} +
Lädt …
+{/if}