Compare commits

...

2 commits

Author SHA1 Message Date
85f1c24618 Trigger: APK-Rebuild [apk]
All checks were successful
Build APK / build-apk (push) Successful in 2m33s
Baut Phase 2 (Dauertest neu: Live-Ansicht, fester Takt, Foreground-Service,
NULL-Serialisierungsfix) als installierbare APK.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 06:54:41 +02:00
34589f12d1 Phase 2: Dauertest komplett neu — Live-Ansicht, fester Takt, Foreground-Service
Kompletter Umbau (ROADMAP_UMSETZUNG.md Phase 2). Die alte Fassung hatte vier
Probleme, die den Test praktisch wertlos machten: kein festes Intervall (ein
"Messpunkt" dauerte so lange wie das Ziel zufaellig antwortete — Prod #126:
38 Punkte in 300 s statt der ueblichen ~250-300), keine Zeitreihe (nur
Summen), kein Foreground-Service trotz gegenteiliger Doku-Behauptung, kein
Abbruchpfad. Vor allem aber: waehrend der 1-60 Minuten sah man nichts ausser
"Messung laeuft ...".

Nativ (NetDiagScannerPlugin.kt, MonitorService.kt)
  StressRun komplett neu: feste Taktung per naechstem Fixpunkt (nextTick +=
  intervalSec*1000, kein Zeitverzug ueber eine Stunde), Rohzaehler
  (sentTotal/receivedTotal/rttSum/Min/Max) + echte Zeitreihe (Liste aus
  {ts,rtt}), Ausfallsegmente werden waehrend des Laufs erkannt und
  geschlossen. Laeuft im MonitorService (Foreground, wie der Geraete-Monitor)
  — dafuer wurde der Dienst auf eine Job-Registry umgestellt (jobId -> Text),
  damit ein gleichzeitig laufender Geraete-Monitor und Dauertest sich beim
  Beenden nicht gegenseitig die Benachrichtigung wegnehmen.
  Events stressSample (live, pro Probe) und stressFinished (einmalig, Grund
  completed/stopped) + getStressStatus zur Wiederaufnahme nach Seitenwechsel.
  WICHTIG geloest: laeuft der Test natuerlich aus, waehrend niemand zuhoert
  (App im Hintergrund, andere Seite offen), bleibt der Lauf-Eintrag in
  stressRuns bestehen (wie monitorRuns) statt sofort geloescht zu werden —
  sonst waere das Ergebnis unwiederbringlich weg. Neuer Aufraeum-Call
  dismissStressRun.
  Nebenbei: der geteilte io-Scope aller Plugin-Methoden lief bisher auf einem
  gewoehnlichen Job — eine unbehandelte Exception in EINER Coroutine (z.B.
  IP-Scan mit kaputtem Host) haette strukturell alle Geschwister-Coroutinen
  mitgerissen, auch einen parallel laufenden Monitor/Dauertest. Jetzt
  SupervisorJob + CoroutineExceptionHandler.

Kritischer Bug gefunden UND gefixt (im Emulator, echter Netzausfall-Test):
  `.put("rtt", rtt as Any?)` mit Kotlin null wird von org.json.JSONObject
  behandelt wie `remove(key)` — der Schluessel verschwindet komplett statt
  als JSON null anzukommen. Die App bekam damit `undefined` statt `null` fuer
  verlorene Proben; `!== null`-Filter hielten das faelschlich fuer "erhalten".
  Ergebnis: ein per Flugmodus ausgeloester echter Ausfall zeigte live "0 %
  Verlust" und "NaN ms" bei Min/Max/Oe. Betraf NUR die Live-Anzeige — die
  gespeicherte Messung war unabhaengig davon korrekt, weil buildStressResult()
  ausschliesslich aus dem nativen Kotlin-Zustand rechnet, nie ueber die
  Pro-Probe-JSON-Bruecke. Fix: ueberall explizit JSONObject.NULL statt bloss
  `null` (betraf auch die Traceroute-Korrektur aus Phase 1). Zusaetzlich
  TS-seitig als zweite Verteidigungslinie `!= null` statt `!== null` (deckt
  sowohl null als auch undefined ab).

Neu: gemeinsame Bewertung (tools/rating.ts)
  ping.ts und der alte Dauertest bewerteten denselben Netzzustand
  unterschiedlich (ping.ts: avgMs>100 -> Rot; Dauertest: nur lossPct/maxMs).
  rateLatency() ist jetzt die einzige Stelle: Verlust, Jitter, Latenz nach
  Zielklasse (LAN/WLAN/WAN) UND eine eigene Regel "jeder Ausfall > 5 s = Rot"
  unabhaengig vom Gesamt-Verlustprozentsatz. ping.ts umgestellt, unveraendertes
  Verhalten fuer die bisherigen Schwellen (inkl. Jitter>10ms, das beim
  Portieren fast uebersehen wurde).

Neue Route statt Dialog (routes/protokoll/[id]/stresstest/)
  Wie IP-Test/WLAN/Monitor eine eigene Seite statt des blockierenden
  Werkzeug-Dialogs. Einrichtung (Ziel leer=Gateway, Dauer, Takt) mit
  Vorlaufprobe wie zuvor in stresstest.ts. Laufend: grosse Live-Zahl mit
  Ampelfarbe (rateLatency), Fortschrittsbalken mit Restzeit, Streifendiagramm
  (StressChart.svelte, live rollierendes 120s-Fenster), Zaehler
  gesendet/verloren/Ø/Min/Max, offener-Ausfall-Banner, Ereignisliste mit
  Uhrzeit ("Ausfall — wieder da nach 16 s"), Abbrechen. Abgeschlossene Laeufe
  darunter aufklappbar mit demselben Diagramm (kompletter Verlauf statt
  Fenster). stresstest.ts (Tool-Registry-Eintrag) ist jetzt nur noch
  Namens-Lookup fuer die Messungen-Liste, run() wirft bewusst einen Fehler,
  falls doch noch etwas den alten Weg aufruft.

Sicherheitsnetz gegen verpasste Events (lib/stresstest.ts)
  Ein natuerlich beendeter Test, waehrend niemand auf der Stresstest-Seite
  ist, verpasst dort das stressFinished-Event. Die Hauptprotokollseite prueft
  deshalb beim Oeffnen zusaetzlich per resumeFinishedStressSessions() nach
  (praktisch immer besucht, bevor ein Protokoll abgeschlossen wird) und
  traegt das Ergebnis nach. Bekannter Rest-Fall (dokumentiert): verlaesst der
  Techniker das Protokoll komplett ohne eine der beiden Seiten noch einmal zu
  oeffnen, bleibt der fertige Lauf bis zum naechsten Besuch liegen.

Ergebnis-Speicherung: Zeitreihe in 1-Minuten-Buckets + Ausfallsegmente +
laengster Ausfall als Measurement (tool=stresstest, sync-faehig) — Ausfaelle
werden beim Abschliessen mit dem nativen (autoritativen) Ergebnis abgeglichen,
nicht nur mit der lokal live mitgefuehrten Kopie.

Getestet im Emulator (Pixel 6 / Android 14):
- 1-Minuten-Lauf komplett bei ausgeschaltetem Display durchgelaufen
  (Foreground-Service haelt den Prozess), automatischer Abschluss + Toast
- Echter Netzausfall per Flugmodus (15s) waehrend laufendem 5-Minuten-Test:
  korrekt 23,2%/16,7% Verlust, Min/Max ohne NaN, Ereignisliste mit exakter
  Uhrzeit und Dauer — nach dem NULL-Fix verifiziert
- Abbrechen-Pfad: sofortiger Stopp, korrektes Teil-Ergebnis
- Wiederaufnahme: laufender Test nach APK-Neuinstallation (Prozess-Kill)
  korrekt als beendet erkannt
- gradle compileDebugKotlin ok, svelte-check 0 Fehler

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 00:39:36 +02:00
12 changed files with 1471 additions and 163 deletions

View file

@ -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<String, String>()
/** 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-<runId>".
*/
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)
}
}
}
}

View file

@ -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<String, StressRun>()
/* --------------------------------------------------------------------- */
@ -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<StressSample>()
val outages = java.util.concurrent.CopyOnWriteArrayList<StressOutage>()
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<StressSample>, bucketMs: Long): List<JSObject> {
if (samples.isEmpty()) return emptyList()
val groups = LinkedHashMap<Long, MutableList<Double?>>()
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))

View file

@ -0,0 +1,107 @@
<script lang="ts">
/**
* Streifendiagramm eines Dauer-/Stresstests — Laufzeit über die Zeit, mit
* Ausfällen als rote Bänder statt als glatte Linie durch die Lücke. Wird
* sowohl für den laufenden Test (windowSec gesetzt = rollierendes Fenster)
* als auch für einen abgeschlossenen Lauf (windowSec leer = ganzer Verlauf)
* verwendet.
*
* Bewusst OHNE Achsenbeschriftung im SVG: `preserveAspectRatio="none"` zieht
* die Zeichenfläche auf die Container-Breite — bei eingebettetem `<text>`
* würde das die Schrift verzerren. Kennzahlen (Max, Ø, …) zeigt die
* aufrufende Seite als eigene Kacheln daneben.
*/
import type { StressOutage, StressSample } from '$lib/types';
let {
samples,
outages,
intervalSec = 1,
windowSec = undefined,
}: {
samples: StressSample[];
outages: StressOutage[];
intervalSec?: number;
/** rollierendes Fenster in Sekunden; weglassen = kompletter Verlauf */
windowSec?: number;
} = $props();
const W = 300;
const H = 90;
const view = $derived.by(() => {
const now = Date.now();
const cutoff = windowSec ? now - windowSec * 1000 : -Infinity;
const tail = samples.filter((s) => s.ts >= cutoff);
if (tail.length === 0) return null;
const t0 = windowSec ? cutoff : tail[0].ts;
const t1 = windowSec ? now : tail[tail.length - 1].ts;
const spanMs = Math.max(1000, t1 - t0);
const receivedVals = tail.map((s) => s.rtt).filter((v): v is number => v != null);
const maxRtt = Math.max(20, ...receivedVals, 0);
const x = (ts: number) => ((ts - t0) / spanMs) * W;
const y = (rtt: number) => H - (rtt / maxRtt) * H;
// In Segmente teilen: eine Lücke (verlorene Probe oder größere Zeit-
// Unterbrechung) darf NICHT als gerade Linie überbrückt werden — das
// würde eine Messung vortäuschen, die es nicht gab.
const gapMs = Math.max(intervalSec, 1) * 1500;
const segments: string[] = [];
let current: string[] = [];
let prevTs: number | null = null;
for (const s of tail) {
if (s.rtt == null) {
if (current.length > 1) segments.push(current.join(' '));
current = [];
prevTs = null;
continue;
}
if (prevTs !== null && s.ts - prevTs > gapMs) {
if (current.length > 1) segments.push(current.join(' '));
current = [];
}
current.push(`${x(s.ts).toFixed(1)},${y(s.rtt).toFixed(1)}`);
prevTs = s.ts;
}
if (current.length > 1) segments.push(current.join(' '));
// Ausfallbänder — laufender Ausfall (kein endTs) reicht bis "jetzt"
const bands = outages
.filter((o) => (o.endTs ?? now) >= t0)
.map((o) => {
const xs = x(Math.max(o.startTs, t0));
const xe = x(Math.min(o.endTs ?? now, t1));
return { x: xs, w: Math.max(1.5, xe - xs) };
});
// Letzter Punkt hervorgehoben, wenn er zum sichtbaren Fenster gehört
const last = tail[tail.length - 1];
const lastPoint = last && last.rtt != null ? { x: x(last.ts), y: y(last.rtt) } : null;
return { segments, bands, lastPoint, maxRtt };
});
</script>
{#if view}
<svg viewBox="0 0 {W} {H}" class="w-full" preserveAspectRatio="none" role="img" aria-label="Verlauf der Laufzeit">
<!-- Nulllinie -->
<line x1="0" y1={H - 0.5} x2={W} y2={H - 0.5} stroke="currentColor" class="text-zinc-800" stroke-width="1" />
{#each view.bands as b (b.x)}
<rect x={b.x} y="0" width={b.w} height={H} class="fill-red-500/20" />
{/each}
{#each view.segments as seg, i (i)}
<polyline points={seg} fill="none" stroke="currentColor" stroke-width="2" class="text-sky-400" />
{/each}
{#if view.lastPoint}
<circle cx={view.lastPoint.x} cy={view.lastPoint.y} r="2.5" class="fill-sky-300" />
{/if}
</svg>
{:else}
<div class="flex h-[90px] items-center justify-center text-xs text-zinc-600">Noch keine Daten</div>
{/if}

View file

@ -29,6 +29,7 @@ function normalizeProtocol(p: Protocol): Protocol {
p.savedScans ??= [];
p.monitorSessions ??= [];
p.wifiTrackSessions ??= [];
p.stressTestSessions ??= [];
return p;
}

View file

@ -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<ThroughputResult>;
/** 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<StressResult>;
/**
* 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<StressResult>;
/** 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<typeof setInterval> | 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<PluginListenerHandle>;
}
).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<PluginListenerHandle>;
}
).addListener('stressFinished', cb);
return () => {
void handle.then((h) => h.remove());
};
}
stressFinishedListeners.add(cb);
return () => {
stressFinishedListeners.delete(cb);
};
}

172
src/lib/stresstest.ts Normal file
View file

@ -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<string, unknown>; 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<boolean> {
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;
}

View file

@ -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`,

View file

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

54
src/lib/tools/rating.ts Normal file
View file

@ -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<TargetClass, { warnMs: number; failMs: number }> = {
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;
}

View file

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

View file

@ -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 @@
<span class="text-[11px] leading-tight text-zinc-500">{tool.description}</span>
</button>
{/each}
<!-- Dauer-/Stresstest: eigene Seite (Live-Diagramm + Foreground-Service) -->
<a
class="flex flex-col items-start gap-1 rounded-lg bg-zinc-800 p-3 active:bg-zinc-700"
href="/protokoll/{protocol.clientUuid}/stresstest/"
>
<Icons.Gauge size={20} class="text-sky-400" />
<span class="text-sm font-medium">Dauer-/Stresstest</span>
<span class="text-[11px] leading-tight text-zinc-500">
Langzeitmessung: Paketverlust und Latenz über einen Zeitraum, live sichtbar.
</span>
</a>
<!-- Geräte-Monitor: eigene Seite (Mehrfachauswahl + Dauerlauf) -->
<a
class="flex flex-col items-start gap-1 rounded-lg bg-zinc-800 p-3 active:bg-zinc-700"

View file

@ -0,0 +1,502 @@
<script lang="ts">
/**
* 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<Protocol | null>(null);
let session = $state<StressTestSession | null>(null);
let busy = $state(false);
let expanded = $state<string | null>(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`;
}
</script>
{#snippet outageRow(o: StressOutage)}
<div class="mb-1 flex items-center gap-2 text-xs">
<span class="h-2 w-2 shrink-0 rounded-full {o.endTs ? 'bg-red-500' : 'animate-pulse bg-red-500'}"
></span>
<span class="w-16 shrink-0 text-zinc-500">{fmtTime(o.startTs)}</span>
<span class="min-w-0 flex-1 text-red-400">
{o.endTs
? `Ausfall — wieder da nach ${fmtDuration(o.durationSec ?? (o.endTs - o.startTs) / 1000)}`
: 'Ausfall läuft noch …'}
</span>
</div>
{/snippet}
{#if protocol}
<AppHeader title="Dauer-/Stresstest" subtitle={protocol.label} back />
<div class="flex-1 overflow-y-auto p-3">
{#if running && session}
<!-- Laufender Test -->
<div class="rounded-lg bg-zinc-900 p-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="h-2.5 w-2.5 animate-pulse rounded-full bg-emerald-500"></span>
<span class="text-sm font-medium">Test läuft — Ziel {session.host}</span>
</div>
<span class="text-xs text-zinc-500">
noch {fmtDuration(remainingSec)}
</span>
</div>
<div class="mt-2 h-1.5 overflow-hidden rounded bg-zinc-800">
<div class="h-full rounded bg-sky-500 transition-all" style="width:{progressPct}%"></div>
</div>
<!-- Live-Zahl -->
<div class="mt-3 flex items-baseline gap-2">
<span class="text-5xl font-bold tabular-nums {statusColor}">
{lastSample?.rtt ?? '—'}
</span>
<span class="text-lg text-zinc-400">ms</span>
<span class="text-sm {statusColor}">
{lastSample && lastSample.rtt == null ? 'keine Antwort' : ''}
</span>
</div>
{#if openOutage}
<p class="mt-1 text-xs text-red-400">
Ausfall läuft seit {fmtTime(openOutage.startTs)} ({fmtDuration(openOutageSec)})
</p>
{/if}
<!-- Streifendiagramm der letzten 2 Minuten -->
<div class="mt-3">
<StressChart samples={session.samples} outages={session.outages} intervalSec={session.intervalSec} windowSec={120} />
</div>
<div class="mt-3 grid grid-cols-3 gap-2 text-xs">
<div>
<div class="text-[10px] uppercase tracking-wide text-zinc-500">Gesendet</div>
<div>{sentCount}</div>
</div>
<div>
<div class="text-[10px] uppercase tracking-wide text-zinc-500">Verlust</div>
<div class={lossPct > 0 ? 'text-red-400' : 'text-emerald-400'}>{lossPct}%</div>
</div>
<div>
<div class="text-[10px] uppercase tracking-wide text-zinc-500">Ø Laufzeit</div>
<div>{liveStats.avgMs ?? '—'} ms</div>
</div>
<div>
<div class="text-[10px] uppercase tracking-wide text-zinc-500">Min</div>
<div>{liveStats.minMs ?? '—'} ms</div>
</div>
<div>
<div class="text-[10px] uppercase tracking-wide text-zinc-500">Max</div>
<div>{liveStats.maxMs ?? '—'} ms</div>
</div>
<div>
<div class="text-[10px] uppercase tracking-wide text-zinc-500">Ausfälle</div>
<div>{session.outages.filter((o) => o.endTs).length}</div>
</div>
</div>
<button
class="mt-3 w-full rounded-lg bg-red-600 py-2 text-sm font-semibold text-white active:bg-red-700 disabled:opacity-50"
onclick={stop}
disabled={busy}
>
Test abbrechen
</button>
</div>
<h2 class="mb-1 mt-3 text-sm font-semibold text-zinc-300">
Ereignisse ({session.outages.length})
</h2>
{#if session.outages.length === 0}
<p class="text-xs text-zinc-500">Noch kein Ausfall — Ziel durchgehend erreichbar.</p>
{/if}
{#each [...session.outages].reverse() as o (o.startTs)}
{@render outageRow(o)}
{/each}
{:else}
<!-- Einrichtung -->
<div class="rounded-lg bg-zinc-900 p-4">
<label class="flex flex-col gap-1 text-sm">
<span class="text-zinc-400">Ziel — leer = Gateway des aktiven Netzes</span>
<input
class="rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-2"
type="text"
inputmode="decimal"
autocapitalize="off"
autocorrect="off"
placeholder="leer lassen → Gateway"
bind:value={hostInput}
/>
</label>
<label class="mt-3 flex flex-col gap-1 text-sm">
<span class="text-zinc-400">Dauer</span>
<select
class="rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-2 text-zinc-100"
bind:value={durationSec}
>
<option value={60}>1 Minute</option>
<option value={300}>5 Minuten</option>
<option value={900}>15 Minuten</option>
<option value={3600}>1 Stunde</option>
</select>
</label>
<label class="mt-3 flex flex-col gap-1 text-sm">
<span class="text-zinc-400">Messabstand</span>
<select
class="rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-2 text-zinc-100"
bind:value={intervalSec}
>
<option value={1}>jede Sekunde</option>
<option value={2}>alle 2 Sekunden</option>
<option value={5}>alle 5 Sekunden</option>
</select>
</label>
<button
class="mt-4 w-full rounded-lg bg-emerald-600 py-2.5 font-semibold text-white active:bg-emerald-700 disabled:opacity-50"
onclick={start}
disabled={busy}
>
{busy ? 'Wird gestartet …' : 'Test starten'}
</button>
<p class="mt-2 text-[11px] leading-tight text-zinc-500">
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.
</p>
</div>
{/if}
<!-- Frühere Läufe -->
{#if pastSessions.length > 0}
<h2 class="mb-1 mt-4 text-sm font-semibold text-zinc-300">Frühere Läufe</h2>
{#each pastSessions as s (s.id)}
<div class="mb-1.5 rounded-lg bg-zinc-900">
<button
class="flex w-full items-center justify-between gap-2 p-2.5 text-left"
onclick={() => (expanded = expanded === s.id ? null : s.id)}
>
<div class="min-w-0">
<div class="truncate text-sm font-medium">{s.host}</div>
<div class="text-[11px] text-zinc-500">
{Math.round(s.durationSec / 60)} min · {s.outages.length} Ausfälle · {fmtDateTime(
s.startedAt,
)}
</div>
</div>
<ChevronDown
size={16}
class="shrink-0 text-zinc-500 {expanded === s.id ? 'rotate-180' : ''}"
/>
</button>
{#if expanded === s.id}
<div class="border-t border-zinc-800 p-2.5">
<StressChart samples={s.samples} outages={s.outages} intervalSec={s.intervalSec} />
{#if s.outages.length === 0}
<p class="mt-2 text-xs text-zinc-500">Kein Ausfall während des Laufs.</p>
{:else}
<div class="mt-2">
{#each s.outages as o (o.startTs)}
{@render outageRow(o)}
{/each}
</div>
{/if}
</div>
{/if}
</div>
{/each}
{/if}
</div>
{:else}
<div class="flex min-h-screen items-center justify-center text-zinc-500">Lädt …</div>
{/if}