Compare commits

..

No commits in common. "85f1c246188378eb817148c48d49ed9d8a191798" and "6330c78d1b54cdcc48f989f7416c0c51a8e35bda" have entirely different histories.

12 changed files with 163 additions and 1471 deletions

View file

@ -12,17 +12,12 @@ import android.os.IBinder
import androidx.core.app.NotificationCompat
/**
* 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.
* Schlanker Vordergrund-Dienst für den Geräte-Monitor.
*
* 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.
* 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.
*/
class MonitorService : Service() {
@ -55,10 +50,6 @@ 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
@ -67,22 +58,15 @@ class MonitorService : Service() {
mgr.createNotificationChannel(
NotificationChannel(
CHANNEL_ID,
"Langzeitmessungen",
"Geräte-Monitor",
NotificationManager.IMPORTANCE_LOW,
),
)
}
}
/**
* 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(" · "))
fun start(ctx: Context, text: String) {
val i = Intent(ctx, MonitorService::class.java).putExtra(EXTRA_TEXT, text)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
ctx.startForegroundService(i)
} else {
@ -90,26 +74,8 @@ class MonitorService : Service() {
}
}
/**
* 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()) {
fun stop(ctx: Context) {
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,15 +26,10 @@ 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
@ -51,7 +46,6 @@ 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
@ -77,24 +71,7 @@ import java.util.concurrent.TimeUnit
)
class NetDiagScannerPlugin : Plugin() {
/**
* 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 io = CoroutineScope(Dispatchers.IO)
private val stressRuns = ConcurrentHashMap<String, StressRun>()
/* --------------------------------------------------------------------- */
@ -960,15 +937,7 @@ 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.
// 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("ms", null as Any?)
}
hop.put("antworten", times.size)
hops.put(hop)
@ -1083,256 +1052,59 @@ 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).coerceIn(30, 4 * 3600)
val intervalSec = (call.getInt("intervalSec") ?: 1).coerceIn(1, 10)
val runId = "stress-${System.currentTimeMillis()}"
val run = StressRun(host, durationSec, intervalSec)
val durationSec = call.getInt("durationSec") ?: 300
val runId = "run-${System.currentTimeMillis()}"
val run = StressRun(host, durationSec)
stressRuns[runId] = run
MonitorService.start(context, runId, "Dauertest zu $host läuft")
run.job = io.launch {
// 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.
val locks = acquireRadioLocks("stress", (durationSec * 1000L) + 60_000L)
try {
val addr = try {
InetAddress.getByName(host)
} catch (e: Exception) {
run.resolveError = "Ziel nicht aufloesbar: ${e.message}"
return@launch
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"))
}
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).put("intervalSec", intervalSec))
resolve(call, JSObject().put("runId", runId))
}
@PluginMethod
fun stopStressTest(call: PluginCall) {
val runId = call.getString("runId") ?: return call.reject("runId fehlt")
val run = stressRuns[runId] ?: return call.reject("Lauf nicht gefunden")
val run = stressRuns.remove(runId) ?: return call.reject("Lauf nicht gefunden")
run.active = false
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)
}
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)))
}
/**
* 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) {
private class StressRun(val host: String, val durationSec: Int) {
@Volatile var active = true
@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))
}
var samples = 0
var lossSum = 0.0
var avgSum = 0.0
var maxMs = 0.0
}
/* --------------------------------------------------------------------- */
@ -1375,11 +1147,7 @@ class NetDiagScannerPlugin : Plugin() {
val runId = "mon-${System.currentTimeMillis()}"
val run = MonitorRun(targets, intervalSec)
monitorRuns[runId] = run
// 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")
MonitorService.start(context, "${targets.size} Geräte · alle ${intervalSec}s")
@Suppress("DEPRECATION")
val wifiLock = (context.applicationContext
@ -1434,7 +1202,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
MonitorService.stop(context, runId)
if (monitorRuns.isEmpty()) MonitorService.stop(context)
val events = JSArray()
run.events.forEach { events.put(it) }
resolve(call, JSObject().put("stopped", true).put("events", events))

View file

@ -1,107 +0,0 @@
<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,7 +29,6 @@ function normalizeProtocol(p: Protocol): Protocol {
p.savedScans ??= [];
p.monitorSessions ??= [];
p.wifiTrackSessions ??= [];
p.stressTestSessions ??= [];
return p;
}

View file

@ -58,60 +58,6 @@ 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;
@ -221,28 +167,15 @@ 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 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 }>;
/** 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;
}>;
/** Geräte-Monitor starten: mehrere Geräte im Intervall auf Erreichbarkeit prüfen */
startMonitor(opts: {
hosts: { ip: string; label: string }[];
@ -301,65 +234,6 @@ 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 {
@ -501,43 +375,11 @@ const mock: NetDiagScannerPlugin = {
measured: true,
};
},
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 startStressTest() {
return { runId: 'mock-run' };
},
async stopStressTest() {
return finishMockStress('stopped');
},
async getStressStatus() {
if (!mockStressTimer) return { running: false };
return { ...buildMockStressResult('completed'), running: true };
},
async dismissStressRun() {
return { ok: true };
return { samples: 120, lossPct: rnd(0, 2), avgMs: rnd(3, 10), maxMs: rnd(20, 90) };
},
async startMonitor(opts) {
const runId = 'mock-mon-' + Date.now();
@ -698,49 +540,3 @@ 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);
};
}

View file

@ -1,172 +0,0 @@
/**
* 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,8 +3,7 @@
*/
import { scanner } from '../../scanner';
import { rateLatency } from '../rating';
import type { Tool } from '../types';
import type { MeasureStatus, Tool } from '../types';
export const pingTool: Tool = {
id: 'ping',
@ -44,9 +43,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;
// 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 });
let status: MeasureStatus = 0;
if (q.lossPct > 0 || q.jitterMs > 10) status = 1;
if (q.lossPct >= 10 || leitwert > 100) status = 2;
return {
label: `${host}: ${leitwert} ms (Median), ${q.lossPct}% Verlust`,

View file

@ -1,22 +1,19 @@
/**
* Werkzeug-Metadaten des Dauer-/Stresstests.
* Tool: Dauer-/Stresstest Langzeitmessung von Verlust und Latenz.
*
* 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.
* 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.
*/
import type { Tool } from '../types';
import { scanner } from '../../scanner';
import type { MeasureStatus, Tool } from '../types';
export const stressTestTool: Tool = {
id: 'stresstest',
@ -25,15 +22,94 @@ export const stressTestTool: Tool = {
icon: 'gauge',
description: 'Langzeitmessung: Paketverlust und Latenz über einen Zeitraum.',
scope: 'protocol',
// 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.',
);
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,
};
},
};

View file

@ -1,54 +0,0 @@
/**
* 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,57 +162,6 @@ 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.
@ -265,8 +214,6 @@ 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,7 +19,6 @@
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';
@ -40,10 +39,7 @@
let appStateListener: PluginListenerHandle | null = null;
// 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 protocolTools = TOOLS.filter((t) => t.scope === 'protocol');
const deviceTools = TOOLS.filter((t) => t.scope === 'device');
/** Geräte mit Favoriten zuerst */
@ -67,15 +63,6 @@
}
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 }) => {
@ -281,17 +268,6 @@
<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

@ -1,502 +0,0 @@
<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}