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>
This commit is contained in:
parent
6330c78d1b
commit
34589f12d1
12 changed files with 1471 additions and 163 deletions
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
107
src/lib/components/StressChart.svelte
Normal file
107
src/lib/components/StressChart.svelte
Normal 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}
|
||||
|
|
@ -29,6 +29,7 @@ function normalizeProtocol(p: Protocol): Protocol {
|
|||
p.savedScans ??= [];
|
||||
p.monitorSessions ??= [];
|
||||
p.wifiTrackSessions ??= [];
|
||||
p.stressTestSessions ??= [];
|
||||
return p;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
172
src/lib/stresstest.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -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
54
src/lib/tools/rating.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
502
src/routes/protokoll/[id]/stresstest/+page.svelte
Normal file
502
src/routes/protokoll/[id]/stresstest/+page.svelte
Normal 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}
|
||||
Loading…
Reference in a new issue