Compare commits
No commits in common. "85f1c246188378eb817148c48d49ed9d8a191798" and "6330c78d1b54cdcc48f989f7416c0c51a8e35bda" have entirely different histories.
85f1c24618
...
6330c78d1b
12 changed files with 163 additions and 1471 deletions
|
|
@ -12,17 +12,12 @@ import android.os.IBinder
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Schlanker Vordergrund-Dienst, der die App am Leben hält, solange mindestens
|
* Schlanker Vordergrund-Dienst für den Geräte-Monitor.
|
||||||
* 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.
|
|
||||||
*
|
*
|
||||||
* Mehrere Jobs gleichzeitig: es gibt nur EINE Dienst-Instanz mit EINER
|
* Er hält den App-Prozess am Leben, solange die Überwachung läuft — damit
|
||||||
* Benachrichtigung, aber ein Job darf beim Beenden nicht die Benachrichtigung
|
* Android die Mess-Schleife bei ausgeschaltetem Display oder App-Wechsel nicht
|
||||||
* für einen anderen, noch laufenden Job wegreißen. Deshalb führt der Dienst
|
* beendet. Die eigentliche Ping-Logik läuft im NetDiagScannerPlugin; dieser
|
||||||
* eine kleine Registry aktiver Jobs (`start`/`stop` je mit eigener jobId) und
|
* Dienst zeigt nur die dauerhafte Benachrichtigung.
|
||||||
* beendet sich selbst erst, wenn keiner mehr übrig ist.
|
|
||||||
*/
|
*/
|
||||||
class MonitorService : Service() {
|
class MonitorService : Service() {
|
||||||
|
|
||||||
|
|
@ -55,10 +50,6 @@ class MonitorService : Service() {
|
||||||
const val NOTIF_ID = 4711
|
const val NOTIF_ID = 4711
|
||||||
const val EXTRA_TEXT = "text"
|
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) */
|
/** Benachrichtigungskanal anlegen (idempotent) */
|
||||||
fun ensureChannel(ctx: Context) {
|
fun ensureChannel(ctx: Context) {
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||||
|
|
@ -67,22 +58,15 @@ class MonitorService : Service() {
|
||||||
mgr.createNotificationChannel(
|
mgr.createNotificationChannel(
|
||||||
NotificationChannel(
|
NotificationChannel(
|
||||||
CHANNEL_ID,
|
CHANNEL_ID,
|
||||||
"Langzeitmessungen",
|
"Geräte-Monitor",
|
||||||
NotificationManager.IMPORTANCE_LOW,
|
NotificationManager.IMPORTANCE_LOW,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
fun start(ctx: Context, text: String) {
|
||||||
* Job anmelden und den Dienst (neu) starten bzw. seine Benachrichtigung
|
val i = Intent(ctx, MonitorService::class.java).putExtra(EXTRA_TEXT, text)
|
||||||
* 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) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
ctx.startForegroundService(i)
|
ctx.startForegroundService(i)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -90,26 +74,8 @@ class MonitorService : Service() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
fun stop(ctx: Context) {
|
||||||
* 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))
|
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,15 +26,10 @@ import com.getcapacitor.PluginMethod
|
||||||
import com.getcapacitor.annotation.CapacitorPlugin
|
import com.getcapacitor.annotation.CapacitorPlugin
|
||||||
import com.getcapacitor.annotation.Permission
|
import com.getcapacitor.annotation.Permission
|
||||||
import com.getcapacitor.annotation.PermissionCallback
|
import com.getcapacitor.annotation.PermissionCallback
|
||||||
import kotlinx.coroutines.CoroutineExceptionHandler
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.SupervisorJob
|
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.awaitAll
|
import kotlinx.coroutines.awaitAll
|
||||||
import kotlinx.coroutines.cancelAndJoin
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.TimeoutCancellationException
|
import kotlinx.coroutines.TimeoutCancellationException
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
@ -51,7 +46,6 @@ import java.net.InetAddress
|
||||||
import java.net.InetSocketAddress
|
import java.net.InetSocketAddress
|
||||||
import java.net.Socket
|
import java.net.Socket
|
||||||
import java.net.URL
|
import java.net.URL
|
||||||
import org.json.JSONObject
|
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
import java.util.concurrent.ConcurrentLinkedQueue
|
import java.util.concurrent.ConcurrentLinkedQueue
|
||||||
import java.util.concurrent.CountDownLatch
|
import java.util.concurrent.CountDownLatch
|
||||||
|
|
@ -77,24 +71,7 @@ import java.util.concurrent.TimeUnit
|
||||||
)
|
)
|
||||||
class NetDiagScannerPlugin : Plugin() {
|
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>()
|
private val stressRuns = ConcurrentHashMap<String, StressRun>()
|
||||||
|
|
||||||
/* --------------------------------------------------------------------- */
|
/* --------------------------------------------------------------------- */
|
||||||
|
|
@ -960,15 +937,7 @@ class NetDiagScannerPlugin : Plugin() {
|
||||||
} else {
|
} else {
|
||||||
// Kein Wert ist etwas anderes als 0 ms — sonst steht im
|
// Kein Wert ist etwas anderes als 0 ms — sonst steht im
|
||||||
// Protokoll "0 ms" für einen Hop, der nie geantwortet hat.
|
// Protokoll "0 ms" für einen Hop, der nie geantwortet hat.
|
||||||
// WICHTIG: explizit JSONObject.NULL statt bloss `null` —
|
hop.put("ms", null as Any?)
|
||||||
// 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)
|
hop.put("antworten", times.size)
|
||||||
hops.put(hop)
|
hops.put(hop)
|
||||||
|
|
@ -1083,256 +1052,59 @@ class NetDiagScannerPlugin : Plugin() {
|
||||||
|
|
||||||
/* --------------------------------------------------------------------- */
|
/* --------------------------------------------------------------------- */
|
||||||
/* Dauer-/Stresstest */
|
/* 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
|
@PluginMethod
|
||||||
fun startStressTest(call: PluginCall) {
|
fun startStressTest(call: PluginCall) {
|
||||||
val host = call.getString("host") ?: return call.reject("host fehlt")
|
val host = call.getString("host") ?: return call.reject("host fehlt")
|
||||||
val durationSec = (call.getInt("durationSec") ?: 300).coerceIn(30, 4 * 3600)
|
val durationSec = call.getInt("durationSec") ?: 300
|
||||||
val intervalSec = (call.getInt("intervalSec") ?: 1).coerceIn(1, 10)
|
val runId = "run-${System.currentTimeMillis()}"
|
||||||
val runId = "stress-${System.currentTimeMillis()}"
|
val run = StressRun(host, durationSec)
|
||||||
val run = StressRun(host, durationSec, intervalSec)
|
|
||||||
stressRuns[runId] = run
|
stressRuns[runId] = run
|
||||||
|
// Hinweis: für Läufe > einige Minuten sollte ein Foreground-Service
|
||||||
MonitorService.start(context, runId, "Dauertest zu $host läuft")
|
// gestartet werden, sonst kann Android den Prozess beenden.
|
||||||
|
io.launch {
|
||||||
run.job = 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)
|
val locks = acquireRadioLocks("stress", (durationSec * 1000L) + 60_000L)
|
||||||
try {
|
try {
|
||||||
val addr = try {
|
val end = System.currentTimeMillis() + durationSec * 1000L
|
||||||
InetAddress.getByName(host)
|
while (System.currentTimeMillis() < end && run.active) {
|
||||||
} catch (e: Exception) {
|
val q = measurePing(host, 5)
|
||||||
run.resolveError = "Ziel nicht aufloesbar: ${e.message}"
|
run.samples++
|
||||||
return@launch
|
// 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 {
|
} 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)
|
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
|
@PluginMethod
|
||||||
fun stopStressTest(call: PluginCall) {
|
fun stopStressTest(call: PluginCall) {
|
||||||
val runId = call.getString("runId") ?: return call.reject("runId fehlt")
|
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.active = false
|
||||||
run.stoppedExplicitly = true
|
val n = Math.max(1, run.samples)
|
||||||
io.launch {
|
resolve(call, JSObject()
|
||||||
// cancelAndJoin wartet, bis die Messschleife (inkl. ihres finally-
|
.put("samples", run.samples)
|
||||||
// Blocks: Ausfall schliessen, Sperren freigeben, Dienst abmelden,
|
.put("lossPct", run.lossSum / n)
|
||||||
// stressFinished-Event) VOLLSTAENDIG durchgelaufen ist — danach ist
|
.put("avgMs", round1(run.avgSum / n))
|
||||||
// buildStressResult garantiert der endgueltige Stand.
|
.put("maxMs", round1(run.maxMs)))
|
||||||
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
|
@Volatile var active = true
|
||||||
@Volatile var stoppedExplicitly = false
|
var samples = 0
|
||||||
@Volatile var resolveError: String? = null
|
var lossSum = 0.0
|
||||||
var job: Job? = null
|
var avgSum = 0.0
|
||||||
|
var maxMs = 0.0
|
||||||
@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))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --------------------------------------------------------------------- */
|
/* --------------------------------------------------------------------- */
|
||||||
|
|
@ -1375,11 +1147,7 @@ class NetDiagScannerPlugin : Plugin() {
|
||||||
val runId = "mon-${System.currentTimeMillis()}"
|
val runId = "mon-${System.currentTimeMillis()}"
|
||||||
val run = MonitorRun(targets, intervalSec)
|
val run = MonitorRun(targets, intervalSec)
|
||||||
monitorRuns[runId] = run
|
monitorRuns[runId] = run
|
||||||
// Eigene Job-ID je Lauf (nicht nur "monitor") — sonst wuerden zwei
|
MonitorService.start(context, "${targets.size} Geräte · alle ${intervalSec}s")
|
||||||
// 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")
|
@Suppress("DEPRECATION")
|
||||||
val wifiLock = (context.applicationContext
|
val wifiLock = (context.applicationContext
|
||||||
|
|
@ -1434,7 +1202,7 @@ class NetDiagScannerPlugin : Plugin() {
|
||||||
val runId = call.getString("runId") ?: return call.reject("runId fehlt")
|
val runId = call.getString("runId") ?: return call.reject("runId fehlt")
|
||||||
val run = monitorRuns.remove(runId) ?: return call.reject("Lauf nicht gefunden")
|
val run = monitorRuns.remove(runId) ?: return call.reject("Lauf nicht gefunden")
|
||||||
run.active = false
|
run.active = false
|
||||||
MonitorService.stop(context, runId)
|
if (monitorRuns.isEmpty()) MonitorService.stop(context)
|
||||||
val events = JSArray()
|
val events = JSArray()
|
||||||
run.events.forEach { events.put(it) }
|
run.events.forEach { events.put(it) }
|
||||||
resolve(call, JSObject().put("stopped", true).put("events", events))
|
resolve(call, JSObject().put("stopped", true).put("events", events))
|
||||||
|
|
|
||||||
|
|
@ -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}
|
|
||||||
|
|
@ -29,7 +29,6 @@ function normalizeProtocol(p: Protocol): Protocol {
|
||||||
p.savedScans ??= [];
|
p.savedScans ??= [];
|
||||||
p.monitorSessions ??= [];
|
p.monitorSessions ??= [];
|
||||||
p.wifiTrackSessions ??= [];
|
p.wifiTrackSessions ??= [];
|
||||||
p.stressTestSessions ??= [];
|
|
||||||
return p;
|
return p;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -58,60 +58,6 @@ export interface WifiSignalEvent {
|
||||||
rssi: number;
|
rssi: number;
|
||||||
source: 'connected' | 'scan';
|
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 {
|
export interface OpenPort {
|
||||||
port: number;
|
port: number;
|
||||||
service?: string;
|
service?: string;
|
||||||
|
|
@ -221,28 +167,15 @@ export interface NetDiagScannerPlugin {
|
||||||
}>;
|
}>;
|
||||||
/** Durchsatztest gegen eine TCP-Sink/Source-Gegenstelle (KEIN iperf3-Protokoll) */
|
/** Durchsatztest gegen eine TCP-Sink/Source-Gegenstelle (KEIN iperf3-Protokoll) */
|
||||||
throughput(opts: { host: string; port: number; durationSec: number }): Promise<ThroughputResult>;
|
throughput(opts: { host: string; port: number; durationSec: number }): Promise<ThroughputResult>;
|
||||||
/**
|
/** Dauer-/Stresstest starten (läuft NICHT im Foreground-Service, s. stresstest.ts) */
|
||||||
* Dauer-/Stresstest starten — läuft im Foreground-Service (wie der
|
startStressTest(opts: { host: string; durationSec: number }): Promise<{ runId: string }>;
|
||||||
* Geräte-Monitor), taktet fest im `intervalSec`-Abstand (Standard 1 s) und
|
/** Laufenden Stresstest beenden und Ergebnis holen */
|
||||||
* meldet jede Probe live über das `stressSample`-Event.
|
stopStressTest(opts: { runId: string }): Promise<{
|
||||||
*/
|
samples: number;
|
||||||
startStressTest(opts: {
|
lossPct: number;
|
||||||
host: string;
|
avgMs: number;
|
||||||
durationSec: number;
|
maxMs: 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 */
|
/** Geräte-Monitor starten: mehrere Geräte im Intervall auf Erreichbarkeit prüfen */
|
||||||
startMonitor(opts: {
|
startMonitor(opts: {
|
||||||
hosts: { ip: string; label: string }[];
|
hosts: { ip: string; label: string }[];
|
||||||
|
|
@ -301,65 +234,6 @@ let mockWifiTrackMode: 'connected' | 'scan' = 'connected';
|
||||||
/** simulierter RSSI-Random-Walk fürs Browser-Mock */
|
/** simulierter RSSI-Random-Walk fürs Browser-Mock */
|
||||||
let mockWifiTrackRssi = -55;
|
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 = {
|
const mock: NetDiagScannerPlugin = {
|
||||||
async getLocalSubnet() {
|
async getLocalSubnet() {
|
||||||
return {
|
return {
|
||||||
|
|
@ -501,43 +375,11 @@ const mock: NetDiagScannerPlugin = {
|
||||||
measured: true,
|
measured: true,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
async startStressTest(opts) {
|
async startStressTest() {
|
||||||
const runId = 'mock-stress-' + Date.now();
|
return { runId: 'mock-run' };
|
||||||
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() {
|
async stopStressTest() {
|
||||||
return finishMockStress('stopped');
|
return { samples: 120, lossPct: rnd(0, 2), avgMs: rnd(3, 10), maxMs: rnd(20, 90) };
|
||||||
},
|
|
||||||
async getStressStatus() {
|
|
||||||
if (!mockStressTimer) return { running: false };
|
|
||||||
return { ...buildMockStressResult('completed'), running: true };
|
|
||||||
},
|
|
||||||
async dismissStressRun() {
|
|
||||||
return { ok: true };
|
|
||||||
},
|
},
|
||||||
async startMonitor(opts) {
|
async startMonitor(opts) {
|
||||||
const runId = 'mock-mon-' + Date.now();
|
const runId = 'mock-mon-' + Date.now();
|
||||||
|
|
@ -698,49 +540,3 @@ export function onWifiSignal(cb: (e: WifiSignalEvent) => void): () => void {
|
||||||
wifiSignalListeners.delete(cb);
|
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);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -3,8 +3,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { scanner } from '../../scanner';
|
import { scanner } from '../../scanner';
|
||||||
import { rateLatency } from '../rating';
|
import type { MeasureStatus, Tool } from '../types';
|
||||||
import type { Tool } from '../types';
|
|
||||||
|
|
||||||
export const pingTool: Tool = {
|
export const pingTool: Tool = {
|
||||||
id: 'ping',
|
id: 'ping',
|
||||||
|
|
@ -44,9 +43,9 @@ export const pingTool: Tool = {
|
||||||
// (WLAN-Aufwachen, kurzer Funkstörer) verschiebt den Mittelwert stark, der
|
// (WLAN-Aufwachen, kurzer Funkstörer) verschiebt den Mittelwert stark, der
|
||||||
// Median bleibt bei dem, was das Netz üblicherweise liefert.
|
// Median bleibt bei dem, was das Netz üblicherweise liefert.
|
||||||
const leitwert = q.medianMs ?? q.avgMs;
|
const leitwert = q.medianMs ?? q.avgMs;
|
||||||
// Gemeinsame Bewertung mit dem Dauertest (rating.ts) — vorher konnte
|
let status: MeasureStatus = 0;
|
||||||
// derselbe Netzzustand hier Rot, im Dauertest aber Gelb ergeben.
|
if (q.lossPct > 0 || q.jitterMs > 10) status = 1;
|
||||||
const status = rateLatency({ lossPct: q.lossPct, latencyMs: leitwert, jitterMs: q.jitterMs });
|
if (q.lossPct >= 10 || leitwert > 100) status = 2;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
label: `${host}: ${leitwert} ms (Median), ${q.lossPct}% Verlust`,
|
label: `${host}: ${leitwert} ms (Median), ${q.lossPct}% Verlust`,
|
||||||
|
|
|
||||||
|
|
@ -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
|
* ACHTUNG, bekannte Schwächen (Umbau in ROADMAP_UMSETZUNG.md Phase 2):
|
||||||
* eine Live-Anzeige, während er läuft (Fortschritt, laufende Kennzahlen,
|
* - Der Lauf hängt an einer nackten Coroutine im Plugin, NICHT an einem
|
||||||
* Abbrechen, Ausfallliste). Das passt nicht in den generischen Kurzdialog
|
* Foreground-Service. Bei ausgeschaltetem Display friert Android ihn ein.
|
||||||
* aller anderen Werkzeuge (`ToolDialog.svelte`). Er hat deshalb seit
|
* - Die native Schleife hat kein festes Intervall — die Zahl der Messpunkte
|
||||||
* ROADMAP_UMSETZUNG.md Phase 2 eine eigene Route
|
* hängt davon ab, wie schnell das Ziel antwortet (guter Link ~250 Punkte in
|
||||||
* (`/protokoll/{id}/stresstest/`, siehe dort) — genau wie IP-Test,
|
* 5 min, Totalausfall ~38). Sie ist damit keine Messgröße.
|
||||||
* WLAN-Empfang und der Geräte-Monitor.
|
* - Es wird keine Zeitreihe gespeichert, nur Summen. „Wann war die Störung?"
|
||||||
*
|
* lässt sich aus dem Ergebnis nicht beantworten.
|
||||||
* Dieser Eintrag bleibt trotzdem in der TOOLS-Registry (`tools/index.ts`),
|
* Die Dauer wird als Parameter vorgegeben; run() wartet auf das Ergebnis.
|
||||||
* 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 type { Tool } from '../types';
|
import { scanner } from '../../scanner';
|
||||||
|
import type { MeasureStatus, Tool } from '../types';
|
||||||
|
|
||||||
export const stressTestTool: Tool = {
|
export const stressTestTool: Tool = {
|
||||||
id: 'stresstest',
|
id: 'stresstest',
|
||||||
|
|
@ -25,15 +22,94 @@ export const stressTestTool: Tool = {
|
||||||
icon: 'gauge',
|
icon: 'gauge',
|
||||||
description: 'Langzeitmessung: Paketverlust und Latenz über einen Zeitraum.',
|
description: 'Langzeitmessung: Paketverlust und Latenz über einen Zeitraum.',
|
||||||
scope: 'protocol',
|
scope: 'protocol',
|
||||||
// Keine Parameter mehr hier — die eigene Route fragt Ziel/Dauer/Intervall
|
params: [
|
||||||
// in ihrer eigenen Einrichtungsansicht ab, nicht im generischen Dialog.
|
// KEIN fester Vorgabewert mehr. Die alte Vorgabe 192.168.1.1 hat im
|
||||||
params: [],
|
// Kundennetz 192.168.178.0/24 fünf Minuten lang ins Leere gemessen und das
|
||||||
async run() {
|
// Ergebnis als „100 % Verlust" rot ins Protokoll geschrieben (Prod #126).
|
||||||
throw new Error(
|
// Leer lassen -> es wird das Gateway des aktiven Adapters genommen.
|
||||||
'Der Dauer-/Stresstest läuft über die eigene Seite ' +
|
{
|
||||||
'(/protokoll/{id}/stresstest/), nicht über den Werkzeug-Dialog. ' +
|
key: 'host',
|
||||||
'Diese Funktion sollte nie aufgerufen werden — falls doch, verweist ' +
|
label: 'Ziel — leer = Gateway des aktiven Netzes',
|
||||||
'irgendwo im Code noch der alte Aufrufweg.',
|
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,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -162,57 +162,6 @@ export interface WifiTrackSession {
|
||||||
mode?: 'connected' | 'scan';
|
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 */
|
/** Ampel-Bewertung einer Messung */
|
||||||
/**
|
/**
|
||||||
* Bewertung einer Messung.
|
* Bewertung einer Messung.
|
||||||
|
|
@ -265,8 +214,6 @@ export interface Protocol {
|
||||||
monitorSessions?: DeviceMonitorSession[];
|
monitorSessions?: DeviceMonitorSession[];
|
||||||
/** WLAN-Empfangstracker-Sessions (nur lokal, wird nicht synchronisiert) */
|
/** WLAN-Empfangstracker-Sessions (nur lokal, wird nicht synchronisiert) */
|
||||||
wifiTrackSessions?: WifiTrackSession[];
|
wifiTrackSessions?: WifiTrackSession[];
|
||||||
/** Dauer-/Stresstest-Sessions (nur lokal — das Ergebnis geht als Measurement raus) */
|
|
||||||
stressTestSessions?: StressTestSession[];
|
|
||||||
/** true solange noch nicht zum Server synchronisiert */
|
/** true solange noch nicht zum Server synchronisiert */
|
||||||
dirty: boolean;
|
dirty: boolean;
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,6 @@
|
||||||
saveScan,
|
saveScan,
|
||||||
deleteScan,
|
deleteScan,
|
||||||
} from '$lib/protocols';
|
} from '$lib/protocols';
|
||||||
import { resumeFinishedStressSessions } from '$lib/stresstest';
|
|
||||||
import { sync } from '$lib/sync.svelte';
|
import { sync } from '$lib/sync.svelte';
|
||||||
import { toast } from '$lib/toast.svelte';
|
import { toast } from '$lib/toast.svelte';
|
||||||
import { pushOverlay } from '$lib/overlay.svelte';
|
import { pushOverlay } from '$lib/overlay.svelte';
|
||||||
|
|
@ -40,10 +39,7 @@
|
||||||
|
|
||||||
let appStateListener: PluginListenerHandle | null = null;
|
let appStateListener: PluginListenerHandle | null = null;
|
||||||
|
|
||||||
// Stresstest ist seit Phase 2 KEIN Dialog-Werkzeug mehr — eigene Route mit
|
const protocolTools = TOOLS.filter((t) => t.scope === 'protocol');
|
||||||
// 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');
|
const deviceTools = TOOLS.filter((t) => t.scope === 'device');
|
||||||
|
|
||||||
/** Geräte mit Favoriten zuerst */
|
/** Geräte mit Favoriten zuerst */
|
||||||
|
|
@ -67,15 +63,6 @@
|
||||||
}
|
}
|
||||||
protocol = p;
|
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) →
|
// App wechselt in den Hintergrund (anderer App-Wechsel, Display aus) →
|
||||||
// sofort sichern, bevor Android den Prozess evtl. beendet.
|
// sofort sichern, bevor Android den Prozess evtl. beendet.
|
||||||
appStateListener = await App.addListener('appStateChange', ({ isActive }) => {
|
appStateListener = await App.addListener('appStateChange', ({ isActive }) => {
|
||||||
|
|
@ -281,17 +268,6 @@
|
||||||
<span class="text-[11px] leading-tight text-zinc-500">{tool.description}</span>
|
<span class="text-[11px] leading-tight text-zinc-500">{tool.description}</span>
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/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) -->
|
<!-- Geräte-Monitor: eigene Seite (Mehrfachauswahl + Dauerlauf) -->
|
||||||
<a
|
<a
|
||||||
class="flex flex-col items-start gap-1 rounded-lg bg-zinc-800 p-3 active:bg-zinc-700"
|
class="flex flex-col items-start gap-1 rounded-lg bg-zinc-800 p-3 active:bg-zinc-700"
|
||||||
|
|
|
||||||
|
|
@ -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}
|
|
||||||
Loading…
Reference in a new issue