diff --git a/android/app/src/main/java/de/data_it_solution/netdiag/NetDiagScannerPlugin.kt b/android/app/src/main/java/de/data_it_solution/netdiag/NetDiagScannerPlugin.kt index 1ff8f62..5458228 100644 --- a/android/app/src/main/java/de/data_it_solution/netdiag/NetDiagScannerPlugin.kt +++ b/android/app/src/main/java/de/data_it_solution/netdiag/NetDiagScannerPlugin.kt @@ -1645,6 +1645,8 @@ class NetDiagScannerPlugin : Plugin() { val intervalSec: Int, ) { @Volatile var active = true + /** Beginn der Ueberwachung — Bezugsgroesse jeder Verfuegbarkeitsrechnung */ + val startedAt = System.currentTimeMillis() /** je IP: true = erreichbar */ val state = ConcurrentHashMap() /** je IP: Beginn des aktuellen Ausfalls (für die Ausfalldauer) */ @@ -1685,13 +1687,64 @@ class NetDiagScannerPlugin : Plugin() { .getSystemService(Context.WIFI_SERVICE) as WifiManager) .createWifiLock(WifiManager.WIFI_MODE_FULL_HIGH_PERF, "netdiag-monitor") try { wifiLock.acquire() } catch (_: Exception) { } + /* + * Zusaetzlich die CPU wachhalten. Der Monitor war der einzige + * Langzeitpfad ohne PARTIAL_WAKE_LOCK — der WifiLock allein haelt nur + * das Funkmodul wach. Bei ausgeschaltetem Display streckt Android die + * Schleife, und es fehlen ausgerechnet die Messpunkte, auf denen die + * Aussage "ueber sechs Stunden stabil" im Abnahmeprotokoll beruht. + * Der Dauertest macht das seit Phase 1 so (acquireRadioLocks). + * + * Zeitgrenze grosszuegig, aber endlich: ein vergessener WakeLock saugt + * den Akku leer. 12 Stunden decken jeden realen Termin ab. + */ + val cpuLock = try { + val pm = context.applicationContext + .getSystemService(Context.POWER_SERVICE) as android.os.PowerManager + pm.newWakeLock(android.os.PowerManager.PARTIAL_WAKE_LOCK, "netdiag:monitor") + .also { it.acquire(12 * 60 * 60 * 1000L) } + } catch (e: Exception) { + android.util.Log.w(TAG, "WakeLock (monitor) nicht moeglich: ${e.message}") + null + } io.launch { try { - // Ausgangslage erfassen — erzeugt noch kein Ereignis - for ((ip, _) in targets) run.state[ip] = isReachable(ip) + /* + * Ausgangslage erfassen — und fuer jedes bereits ausgefallene + * Geraet SOFORT ein down-Ereignis erzeugen. + * + * Vorher wurde der Startzustand nur vermerkt. Ein Geraet, das + * vom Anfang bis zum Ende des Termins tot war, hatte damit NULL + * Ereignisse — und eine Auswertung haette ihm 100 % Verfueg- + * barkeit ins Kundendokument geschrieben. Genau die Kamera, um + * derentwillen ueberwacht wird, waere als einwandfrei + * ausgewiesen worden. + * + * downSince gleich mitsetzen, sonst bekommt ein spaeteres + * up-Ereignis die Ausfalldauer 0 statt der echten Zeit. + */ + for ((ip, label) in targets) { + val up = isReachable(ip) + run.state[ip] = up + if (!up) { + run.downSince[ip] = run.startedAt + val ev = JSObject() + .put("runId", runId) + .put("ip", ip) + .put("label", label) + .put("ts", run.startedAt) + .put("type", "down") + run.events.add(ev) + notifyListeners("monitorEvent", ev) + notifyDown(label, ip) + } + } while (run.active) { - Thread.sleep(intervalSec * 1000L) + // delay statt Thread.sleep: Thread.sleep blockiert den + // Coroutine-Dispatcher-Thread ueber die gesamte Wartezeit + // und nimmt ihn parallelen Messungen weg. + kotlinx.coroutines.delay(intervalSec * 1000L) if (!run.active) break for ((ip, label) in targets) { if (!run.active) break @@ -1723,6 +1776,7 @@ class NetDiagScannerPlugin : Plugin() { } } finally { try { if (wifiLock.isHeld) wifiLock.release() } catch (_: Exception) { } + try { if (cpuLock?.isHeld == true) cpuLock.release() } catch (_: Exception) { } } } resolve(call, JSObject().put("runId", runId)) @@ -1736,7 +1790,15 @@ class NetDiagScannerPlugin : Plugin() { MonitorService.stop(context, runId) val events = JSArray() run.events.forEach { events.put(it) } - resolve(call, JSObject().put("stopped", true).put("events", events)) + // startedAt/endedAt/zustand mitgeben: ohne sie kann die Auswertung + // weder die Messdauer noch einen beim Beenden NOCH OFFENEN Ausfall + // richtig zurechnen — der faellt sonst komplett unter den Tisch. + resolve(call, JSObject() + .put("stopped", true) + .put("events", events) + .put("startedAt", run.startedAt) + .put("endedAt", System.currentTimeMillis()) + .put("zustand", zustandAlsObjekt(run))) } /** Status eines Monitor-Laufs abfragen (UI-Wiederaufnahme nach Seitenwechsel) */ @@ -1748,7 +1810,16 @@ class NetDiagScannerPlugin : Plugin() { run?.events?.forEach { events.put(it) } resolve(call, JSObject() .put("running", run != null && run.active) - .put("events", events)) + .put("events", events) + .put("startedAt", run?.startedAt ?: 0L) + .put("zustand", if (run != null) zustandAlsObjekt(run) else JSObject())) + } + + /** aktueller Zustand je IP (true = erreichbar) fuer die Auswertung */ + private fun zustandAlsObjekt(run: MonitorRun): JSObject { + val o = JSObject() + for ((ip, up) in run.state) o.put(ip, up) + return o } private fun isReachable(ip: String): Boolean = try { diff --git a/src/lib/messfelder.ts b/src/lib/messfelder.ts index 59fbafe..c45af76 100644 --- a/src/lib/messfelder.ts +++ b/src/lib/messfelder.ts @@ -42,6 +42,16 @@ const FELDER: Record = { dauerSekunden: { label: 'Messdauer', einheit: 's' }, intervallSek: { label: 'Messabstand', einheit: 's' }, laengsterAusfallSek: { label: 'Längster Ausfall', einheit: 's' }, + // Geräte-Monitor — Gegenstück zu netdiagKundenfelder() im Dolibarr-Modul. + // Beide Tabellen müssen dieselben Schlüssel kennen, sonst steht der Wert in + // der App oder im PDF als roher Schlüsselname da. + geraeteAnzahl: { label: 'Überwachte Geräte' }, + verfuegbarkeitProzent: { label: 'Verfügbarkeit (schlechtestes Gerät)', einheit: '%' }, + ausfallzeitSek: { label: 'Ausfallzeit', einheit: 's' }, + aussetzer: { label: 'Aussetzer' }, + geraete: { label: 'Je Gerät' }, + ausfaelle: { label: 'Einzelne Ausfälle' }, + anzahlGeraete: { label: 'Überwachte Geräte' }, // Netz / DHCP / Dose server: { label: 'DHCP-Server' }, dhcpServer: { label: 'DHCP-Server' }, diff --git a/src/lib/monitor.ts b/src/lib/monitor.ts new file mode 100644 index 0000000..85081c8 --- /dev/null +++ b/src/lib/monitor.ts @@ -0,0 +1,249 @@ +/** + * Auswertung des Geräte-Monitors — aus einer Überwachungssitzung wird eine + * richtige Messung im Protokoll. + * + * Warum das nötig war: Der Monitor lief, zeigte live Ausfälle an, und beim + * Beenden verschwand das Ganze mit einem Toast. Für Kunde, PDF und Server + * existierte die Überwachung nicht — die Sitzungen werden nicht synchronisiert. + * Damit fehlte ausgerechnet der Nachweis für die häufigste Reklamation: + * „die Kamera fällt ständig aus". Genau dafür wird überwacht. + * + * Aufbau bewusst wie `stresstest.ts`: dort ist dasselbe Problem (Langzeit- + * messung, überlebt App-Wechsel, Ergebnis muss ins Protokoll) bereits gelöst, + * und zwei Muster für dieselbe Sache wären eine Fehlerquelle. + * + * ZUR RECHNUNG — drei Punkte, die vorher systematisch falsch gewesen wären, + * und zwar jedes Mal ZUGUNSTEN des Dokuments: + * 1. Ein Gerät, das von Anfang bis Ende tot war, hatte null Ereignisse und + * wäre mit 100 % Verfügbarkeit ausgewiesen worden. Das Plugin erzeugt + * dafür jetzt beim Start ein down-Ereignis. + * 2. Ein beim Beenden noch offener Ausfall wurde nirgends abgeschlossen und + * zählte gar nicht. Er wird hier bis `endedAt` zugerechnet. + * 3. Die Gesamtaussage ist das SCHLECHTESTE Gerät, nicht der Mittelwert — + * ein Totalausfall verschwindet sonst zwischen neun gesunden Geräten. + */ + +import { addMeasurement } from './protocols'; +import type { + DeviceMonitorSession, + MeasureStatus, + Measurement, + MonitorEvent, + Protocol, +} 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); + if (m < 60) { + const s = Math.round(sec % 60); + return s ? `${m} min ${s} s` : `${m} min`; + } + const h = Math.floor(m / 60); + const restM = m % 60; + return restM ? `${h} Std ${restM} min` : `${h} Std`; +} + +/** Prozent mit einer Nachkommastelle, deutsches Komma */ +function fmtProzent(p: number): string { + return p.toFixed(1).replace('.', ',') + ' %'; +} + +interface GeraetAuswertung { + ip: string; + label: string; + ausfallzeitSek: number; + aussetzer: number; + laengsterAusfallSek: number; + verfuegbarkeitProzent: number; + /** einzelne Ausfälle als lesbare Zeilen */ + ausfaelle: string[]; +} + +/** + * Ausfallsegmente eines Geräts aus seinen Ereignissen bilden. + * + * Ein Ausfall beginnt mit 'down' und endet mit dem nächsten 'up'. Ist am Ende + * noch einer offen, wird er bis `bis` zugerechnet — er ist ja nicht dadurch + * verschwunden, dass die Messung aufhörte. + * + * `durationSec` aus dem up-Ereignis wird bewusst NICHT verwendet, sondern die + * Differenz der Zeitstempel: die native Dauer war in einem Fall nachweislich + * 0 (nämlich wenn der Ausfall schon vor dem Start bestand), und zwei Quellen + * für dieselbe Zahl gehen irgendwann auseinander. + */ +function segmenteFuer(events: MonitorEvent[], von: number, bis: number): number[][] { + const segmente: number[][] = []; + let offen: number | null = null; + for (const e of [...events].sort((a, b) => a.ts - b.ts)) { + if (e.type === 'down') { + if (offen === null) offen = e.ts; + } else if (offen !== null) { + segmente.push([offen, e.ts]); + offen = null; + } + } + if (offen !== null) segmente.push([offen, bis]); + // Auf den Messzeitraum begrenzen — ein Ereignis vor dem Start (Wiederauf- + // nahme einer älteren Sitzung) darf die Ausfallzeit nicht aufblähen. + return segmente + .map(([a, b]) => [Math.max(a, von), Math.min(b, bis)]) + .filter(([a, b]) => b > a); +} + +/** + * Aus einer Überwachungssitzung die Felder für die gespeicherte Messung bauen. + * + * @param sitzung die Sitzung mit ihren Ereignissen + * @param endeTs Ende des Messzeitraums (aus dem Plugin, sonst jetzt) + * @param abgerissen true = der Lauf wurde nicht sauber beendet (Prozess tot, + * nativer Lauf verschwunden) — dann steht das im Hinweis, + * statt eine Verfügbarkeit über eine Zeitspanne auszuweisen, + * in der gar nicht gemessen wurde. + */ +export function buildMonitorMeasurement( + sitzung: DeviceMonitorSession, + endeTs: number, + abgerissen = false, +): { label: string; result: Record; measureStatus: MeasureStatus } { + const von = sitzung.startedAt; + const bis = Math.max(von, endeTs); + const dauerSek = Math.max(1, Math.round((bis - von) / 1000)); + + const auswertung: GeraetAuswertung[] = sitzung.targets.map((t) => { + const eigene = sitzung.events.filter((e) => e.ip === t.ip); + const segmente = segmenteFuer(eigene, von, bis); + const ausfallzeitSek = Math.round( + segmente.reduce((s, [a, b]) => s + (b - a), 0) / 1000, + ); + const laengster = segmente.length + ? Math.round(Math.max(...segmente.map(([a, b]) => b - a)) / 1000) + : 0; + return { + ip: t.ip, + label: t.label || t.ip, + ausfallzeitSek, + aussetzer: segmente.length, + laengsterAusfallSek: laengster, + verfuegbarkeitProzent: Math.max( + 0, + Math.min(100, (1 - ausfallzeitSek / dauerSek) * 100), + ), + ausfaelle: segmente.map( + ([a, b]) => + `${t.label || t.ip} ${fmtTime(a)} – ${fmtTime(b)} (${fmtDuration((b - a) / 1000)})`, + ), + }; + }); + + // Schlechtestes Gerät bestimmt Label und Ampel (siehe Kopfkommentar) + const schlechtestes = auswertung.reduce( + (min, g) => (min === null || g.verfuegbarkeitProzent < min.verfuegbarkeitProzent ? g : min), + null, + ); + const gesamtAussetzer = auswertung.reduce((s, g) => s + g.aussetzer, 0); + + /* + * Bewertung. Die Schwellen sind bewusst streng: Für eine Kamera oder eine + * Wallbox ist ein Aussetzer pro Termin bereits das gemeldete Problem, nicht + * ein Schönheitsfehler. 100 % über den ganzen Zeitraum = grün, jeder + * Aussetzer = gelb, unter 95 % oder ein Gerät durchgehend tot = rot. + */ + let status: MeasureStatus = 0; + if (gesamtAussetzer > 0) status = 1; + if ((schlechtestes?.verfuegbarkeitProzent ?? 100) < 95) status = 2; + if (abgerissen) status = 3; // nicht messbar — die Grundlage stimmt nicht + + const hinweise: string[] = []; + if (abgerissen) { + hinweise.push( + 'Die Überwachung wurde nicht regulär beendet (App oder Gerät zwischendurch ' + + 'gestoppt). Der ausgewiesene Zeitraum endet beim letzten aufgezeichneten ' + + 'Ereignis; danach wurde nicht mehr gemessen.', + ); + } + // Messverfahren offenlegen: das gehört zu einer belastbaren Aussage dazu. + hinweise.push( + 'Gemessen durch regelmäßige Erreichbarkeitsprüfung alle ' + + `${sitzung.intervalSec} Sekunden. Ein Gerät, das Ping-Anfragen ` + + 'grundsätzlich nicht beantwortet, erscheint dabei als nicht erreichbar.', + ); + + const label = schlechtestes + ? gesamtAussetzer === 0 + ? `${auswertung.length} Geräte über ${fmtDuration(dauerSek)} durchgehend erreichbar` + : `${schlechtestes.label}: ${fmtProzent(schlechtestes.verfuegbarkeitProzent)} · ` + + `${gesamtAussetzer} ${gesamtAussetzer === 1 ? 'Aussetzer' : 'Aussetzer'} in ${fmtDuration(dauerSek)}` + : 'Überwachung ohne Geräte'; + + return { + label, + measureStatus: status, + result: { + dauerSekunden: dauerSek, + intervallSek: sitzung.intervalSec, + geraeteAnzahl: auswertung.length, + verfuegbarkeitProzent: Number((schlechtestes?.verfuegbarkeitProzent ?? 100).toFixed(1)), + ausfallzeitSek: schlechtestes?.ausfallzeitSek ?? 0, + aussetzer: gesamtAussetzer, + laengsterAusfallSek: Math.max(0, ...auswertung.map((g) => g.laengsterAusfallSek)), + // Vorformatierte Zeilen statt Objekte: MeasurementResult rendert Arrays + // zeilenweise, und dieselben Zeichenketten werden im PDF wiederverwendet. + geraete: auswertung.map( + (g) => + // Nur benannte Geräte bekommen die IP in Klammern dahinter — ohne + // eigenen Namen stand sonst „10.0.2.2 (10.0.2.2)" im Kundendokument. + `${g.label === g.ip ? g.ip : `${g.label} (${g.ip})`}: ` + + `${fmtProzent(g.verfuegbarkeitProzent)}` + + (g.aussetzer + ? ` · ${g.aussetzer} ${g.aussetzer === 1 ? 'Aussetzer' : 'Aussetzer'}` + + ` · ${fmtDuration(g.ausfallzeitSek)} Ausfall` + + ` · längster ${fmtDuration(g.laengsterAusfallSek)}` + : ' · durchgehend erreichbar'), + ), + ausfaelle: auswertung.flatMap((g) => g.ausfaelle), + hinweis: hinweise.join(' '), + }, + }; +} + +/** + * Sitzung abschließen und als Messung ins Protokoll legen. + * + * Speichert bewusst NICHT selbst — der Aufrufer kennt seinen Speicherzeitpunkt + * (gleiches Vorgehen wie in `stresstest.ts`). + */ +export function finalizeMonitorSession( + protocol: Protocol, + sitzung: DeviceMonitorSession, + endeTs?: number, + abgerissen = false, +): Measurement { + const ende = endeTs ?? Date.now(); + const { label, result, measureStatus } = buildMonitorMeasurement(sitzung, ende, abgerissen); + + sitzung.status = 'stopped'; + sitzung.endedAt = ende; + + return addMeasurement(protocol, { + deviceClientId: null, + tool: 'monitor', + category: 'netzwerk', + label, + params: { + anzahlGeraete: sitzung.targets.length, + intervallSek: sitzung.intervalSec, + }, + result, + measureStatus, + dateMeasure: ende, + }); +} diff --git a/src/lib/scanner.ts b/src/lib/scanner.ts index d6ad2e1..5732dd6 100644 --- a/src/lib/scanner.ts +++ b/src/lib/scanner.ts @@ -306,12 +306,30 @@ export interface NetDiagScannerPlugin { hosts: { ip: string; label: string }[]; intervalSec: number; }): Promise<{ runId: string }>; - /** Geräte-Monitor beenden — liefert alle gesammelten Ereignisse */ - stopMonitor(opts: { runId: string }): Promise<{ stopped: boolean; events: MonitorEventData[] }>; + /** + * Geräte-Monitor beenden — liefert alle gesammelten Ereignisse. + * + * `startedAt`/`endedAt` und `zustand` sind für die Auswertung unverzichtbar: + * ohne sie lässt sich weder die Messdauer bestimmen noch ein beim Beenden + * NOCH OFFENER Ausfall zurechnen — der fiele sonst komplett unter den Tisch. + */ + stopMonitor(opts: { runId: string }): Promise<{ + stopped: boolean; + events: MonitorEventData[]; + startedAt: number; + endedAt: number; + /** je IP: true = zuletzt erreichbar */ + zustand: Record; + }>; /** Status eines Monitor-Laufs abfragen (Wiederaufnahme nach Seitenwechsel) */ getMonitorStatus(opts: { runId: string; - }): Promise<{ running: boolean; events: MonitorEventData[] }>; + }): Promise<{ + running: boolean; + events: MonitorEventData[]; + startedAt: number; + zustand: Record; + }>; /** Alle aktiven Netzwerk-Interfaces auflisten (WLAN, Ethernet incl. USB-RJ45, Mobilfunk) */ linkInfo(): Promise<{ links: LinkInfo[] }>; /** Aktiven WLAN-Scan triggern (System-Cache nachladen); `triggered=false` bei Throttling */ @@ -350,6 +368,16 @@ function rnd(min: number, max: number): number { const monitorListeners = new Set<(e: MonitorEventData) => void>(); let mockMonitorTimer: ReturnType | undefined; let mockMonitorEvents: MonitorEventData[] = []; +/** + * Zustand je Host im Mock. Die frühere Fassung würfelte je Tick unabhängig + * „up" oder „down" — dabei entstanden Folgen wie up,up,down,down, die es in + * der Wirklichkeit nicht gibt (ein Ereignis IST ein Zustandswechsel). Eine + * Auswertung, die daraus Ausfallzeiten rechnet, ließe sich damit nicht + * sinnvoll prüfen. + */ +let mockMonitorState: Record = {}; +let mockMonitorDownSince: Record = {}; +let mockMonitorStartedAt = 0; /* --- WLAN-Empfangstracker: Ereignis-Verteilung + Mock-Simulation --- */ const wifiSignalListeners = new Set<(e: WifiSignalEvent) => void>(); @@ -726,18 +754,49 @@ const mock: NetDiagScannerPlugin = { async startMonitor(opts) { const runId = 'mock-mon-' + Date.now(); mockMonitorEvents = []; + mockMonitorState = {}; + mockMonitorDownSince = {}; + mockMonitorStartedAt = Date.now(); + + // Ausgangslage wie nativ: das letzte Ziel gilt als von Anfang an + // ausgefallen. Genau dieser Fall (Gerät durchgehend tot) muss sich prüfen + // lassen — er hätte ohne Start-Ereignis 100 % Verfügbarkeit ergeben. + opts.hosts.forEach((h, i) => { + const up = i < opts.hosts.length - 1; + mockMonitorState[h.ip] = up; + if (!up) { + mockMonitorDownSince[h.ip] = mockMonitorStartedAt; + const ev: MonitorEventData = { + runId, ip: h.ip, label: h.label, ts: mockMonitorStartedAt, type: 'down', + }; + mockMonitorEvents.push(ev); + monitorListeners.forEach((cb) => cb(ev)); + } + }); + mockMonitorTimer = setInterval(() => { const host = opts.hosts[Math.floor(Math.random() * opts.hosts.length)]; if (!host) return; - const type: 'up' | 'down' = Math.random() < 0.5 ? 'down' : 'up'; + // Zustandswechsel nur gelegentlich — ein Ereignis IST ein Wechsel + if (Math.random() > 0.35) return; + const vorher = mockMonitorState[host.ip] ?? true; + const jetzt = !vorher; + mockMonitorState[host.ip] = jetzt; + const ts = Date.now(); const ev: MonitorEventData = { runId, ip: host.ip, label: host.label, - ts: Date.now(), - type, - durationSec: type === 'up' ? Math.floor(rnd(20, 180)) : undefined, + ts, + type: jetzt ? 'up' : 'down', }; + if (jetzt) { + const seit = mockMonitorDownSince[host.ip]; + delete mockMonitorDownSince[host.ip]; + ev.durationSec = seit ? Math.round((ts - seit) / 1000) : 0; + } else { + mockMonitorDownSince[host.ip] = ts; + } mockMonitorEvents.push(ev); monitorListeners.forEach((cb) => cb(ev)); }, 6000); @@ -746,10 +805,21 @@ const mock: NetDiagScannerPlugin = { async stopMonitor() { if (mockMonitorTimer) clearInterval(mockMonitorTimer); mockMonitorTimer = undefined; - return { stopped: true, events: mockMonitorEvents }; + return { + stopped: true, + events: mockMonitorEvents, + startedAt: mockMonitorStartedAt, + endedAt: Date.now(), + zustand: { ...mockMonitorState }, + }; }, async getMonitorStatus() { - return { running: mockMonitorTimer !== undefined, events: mockMonitorEvents }; + return { + running: mockMonitorTimer !== undefined, + events: mockMonitorEvents, + startedAt: mockMonitorStartedAt, + zustand: { ...mockMonitorState }, + }; }, async linkInfo() { // Mock: ein verbundenes WLAN + ein simulierter USB-RJ45-Adapter mit 1 Gbit diff --git a/src/routes/protokoll/[id]/monitor/+page.svelte b/src/routes/protokoll/[id]/monitor/+page.svelte index 08b5888..c20dfd4 100644 --- a/src/routes/protokoll/[id]/monitor/+page.svelte +++ b/src/routes/protokoll/[id]/monitor/+page.svelte @@ -19,6 +19,7 @@ import { scanner, onMonitorEvent, type MonitorEventData } from '$lib/scanner'; import { sync } from '$lib/sync.svelte'; import { toast } from '$lib/toast.svelte'; + import { finalizeMonitorSession } from '$lib/monitor'; import type { DeviceMonitorSession, MonitorEvent, Protocol } from '$lib/types'; let protocol = $state(null); @@ -53,12 +54,19 @@ session = live; attachListener(live.runId); } else { - live.status = 'stopped'; - live.endedAt = Date.now(); + // Lauf ist nativ nicht mehr da (Prozess gestorben o.ae.). Als + // Endzeit den letzten aufgezeichneten Messpunkt nehmen, NICHT jetzt: + // der Prozess kann Stunden vorher gestorben sein, und eine + // Verfuegbarkeit ueber eine Zeitspanne auszuweisen, in der gar nicht + // gemessen wurde, waere eine Falschaussage im Kundendokument. + live.events = st.events.length ? st.events.map(toStored) : live.events; + const letztes = live.events.at(-1)?.ts; + finalizeMonitorSession(p, live, letztes ?? live.startedAt, true); await persist(); } } catch { - live.status = 'stopped'; + const letztes = live.events.at(-1)?.ts; + finalizeMonitorSession(p, live, letztes ?? live.startedAt, true); await persist(); } } @@ -131,14 +139,32 @@ try { const res = await scanner.stopMonitor({ runId: session.runId }); session.events = res.events.map(toStored); - session.status = 'stopped'; - session.endedAt = Date.now(); offEvent?.(); offEvent = undefined; + // Auswertung als richtige Messung ablegen — vorher endete die + // Ueberwachung mit einem Toast und war fuer Kunde, PDF und Server nie + // vorhanden (Sitzungen werden nicht synchronisiert). Damit fehlte + // ausgerechnet der Nachweis fuer die haeufigste Reklamation. + // endedAt kommt vom Plugin: der Zeitpunkt der letzten echten Pruefung + // ist genauer als der Moment, in dem der Techniker auf Stopp tippt. + const messung = finalizeMonitorSession(protocol!, session, res.endedAt); await persist(); - toast.show('Überwachung beendet', 'success'); + toast.show(messung.label, messung.measureStatus >= 2 ? 'error' : 'success'); } catch (e) { - toast.show(e instanceof Error ? e.message : 'Monitor-Stopp fehlgeschlagen', 'error'); + // Auch wenn der native Lauf verschwunden ist (Prozess gestorben, App + // zwischendurch beendet): lokal abschliessen statt die Messung ersatzlos + // zu verlieren. Sie wird dabei als unvollstaendig gekennzeichnet. + if (protocol && session) { + const letztes = session.events.at(-1)?.ts; + finalizeMonitorSession(protocol, session, letztes ?? Date.now(), true); + await persist(); + } + toast.show( + e instanceof Error + ? `Überwachung abgerissen — Teilergebnis gespeichert (${e.message})` + : 'Überwachung abgerissen — Teilergebnis gespeichert', + 'error', + ); } finally { busy = false; } diff --git a/tools/pruefe-monitor.mjs b/tools/pruefe-monitor.mjs new file mode 100644 index 0000000..b867471 --- /dev/null +++ b/tools/pruefe-monitor.mjs @@ -0,0 +1,89 @@ +/** + * Prueft die Monitor-Auswertung gegen konstruierte Faelle mit von Hand + * ausgerechnetem Sollwert. Die Rechnung landet als Verfuegbarkeitsaussage im + * Kundendokument — sie muss stimmen, nicht plausibel aussehen. + */ +import { readFileSync } from 'node:fs'; + + + +const { buildMonitorMeasurement } = await import('/tmp/claude-1000/-mnt-17---Entwicklungen-20---Projekte-NetzwerkDiagnose/c1dc785b-f713-4aae-a06d-1fe09a25cd1a/scratchpad/monitor.mjs'); + +const T0 = new Date('2026-08-16T09:00:00').getTime(); +const min = (m) => T0 + m * 60000; + +const faelle = [ + { + name: 'Alles stabil, 60 min, 2 Geraete', + s: { startedAt: T0, intervalSec: 30, + targets: [{ip:'192.168.1.10',label:'Router'},{ip:'192.168.1.51',label:'Kamera Hof'}], + events: [] }, + ende: min(60), + soll: { verf: 100, aussetzer: 0, status: 0 }, + }, + { + name: 'Kamera 6 min weg (2 Ausfaelle) in 60 min -> 90 %', + s: { startedAt: T0, intervalSec: 30, + targets: [{ip:'192.168.1.51',label:'Kamera Hof'}], + events: [ + {ip:'192.168.1.51',ts:min(10),type:'down'}, + {ip:'192.168.1.51',ts:min(13),type:'up'}, + {ip:'192.168.1.51',ts:min(40),type:'down'}, + {ip:'192.168.1.51',ts:min(43),type:'up'}, + ]}, + ende: min(60), + soll: { verf: 90, aussetzer: 2, status: 2 }, // 6 von 60 min weg + }, + { + name: 'DURCHGEHEND TOT (Start-Ereignis) -> 0 %, nicht 100 %', + s: { startedAt: T0, intervalSec: 30, + targets: [{ip:'192.168.1.51',label:'Kamera Hof'}], + events: [{ip:'192.168.1.51',ts:T0,type:'down'}]}, + ende: min(60), + soll: { verf: 0, aussetzer: 1, status: 2 }, + }, + { + name: 'Beim Beenden NOCH OFFENER Ausfall (letzte 15 von 60 min)', + s: { startedAt: T0, intervalSec: 30, + targets: [{ip:'192.168.1.51',label:'Kamera Hof'}], + events: [{ip:'192.168.1.51',ts:min(45),type:'down'}]}, + ende: min(60), + soll: { verf: 75, aussetzer: 1, status: 2 }, + }, + { + name: 'Ein totes Geraet unter neun gesunden (schlechtestes zaehlt)', + s: { startedAt: T0, intervalSec: 30, + targets: [...Array(9)].map((_,i)=>({ip:`192.168.1.${20+i}`,label:`PC ${i+1}`})) + .concat([{ip:'192.168.1.51',label:'Kamera Hof'}]), + events: [{ip:'192.168.1.51',ts:T0,type:'down'}]}, + ende: min(60), + soll: { verf: 0, aussetzer: 1, status: 2 }, + }, + { + name: 'Ein kurzer Aussetzer (30 s in 60 min) -> gelb, nicht rot', + s: { startedAt: T0, intervalSec: 30, + targets: [{ip:'192.168.1.51',label:'Kamera Hof'}], + events: [ + {ip:'192.168.1.51',ts:min(20),type:'down'}, + {ip:'192.168.1.51',ts:min(20.5),type:'up'}, + ]}, + ende: min(60), + soll: { verf: 99.2, aussetzer: 1, status: 1 }, + }, +]; + +let fehler = 0; +for (const f of faelle) { + const r = buildMonitorMeasurement(f.s, f.ende, false); + const verf = r.result.verfuegbarkeitProzent; + const aus = r.result.aussetzer; + const st = r.measureStatus; + const ok = Math.abs(verf - f.soll.verf) < 0.15 && aus === f.soll.aussetzer && st === f.soll.status; + if (!ok) fehler++; + console.log(`${ok ? 'OK ' : 'FEHL'} ${f.name}`); + console.log(` verf=${verf} (soll ${f.soll.verf}) aussetzer=${aus} (soll ${f.soll.aussetzer}) status=${st} (soll ${f.soll.status})`); + console.log(` Label: ${r.label}`); + if (r.result.geraete.length <= 3) r.result.geraete.forEach(g => console.log(' ', g)); + console.log(); +} +console.log(fehler === 0 ? 'ALLE FAELLE KORREKT' : `${fehler} FEHLER`);