Geraete-Monitor: Auswertung als Messung, drei Rechenfehler vorher behoben

Die Ueberwachung endete bisher mit einem Toast und verschwand — fuer Kunde,
PDF und Server existierte sie nicht (Sitzungen werden nicht synchronisiert).
Damit fehlte ausgerechnet der Nachweis fuer die haeufigste Reklamation
("die Kamera faellt staendig aus"). Genau dafuer wird ueberwacht.

Vor der Auswertung mussten drei Luecken in der Datengrundlage geschlossen
werden. Alle drei haetten das Ergebnis systematisch beschoenigt:

1. Der Startzustand wurde gemessen, aber nicht gemeldet. Ein Geraet, das vom
   Anfang bis zum Ende des Termins tot war, hatte NULL Ereignisse — die
   Auswertung haette ihm 100 % Verfuegbarkeit ins Kundendokument geschrieben.
   Das Plugin erzeugt dafuer jetzt beim Start ein down-Ereignis und setzt
   downSince, damit ein spaeteres up eine echte Ausfalldauer bekommt statt 0.
2. Ein beim Beenden noch offener Ausfall wurde nirgends abgeschlossen und
   zaehlte gar nicht. stopMonitor liefert jetzt startedAt/endedAt/zustand,
   die Auswertung rechnet ihn bis zum Sitzungsende zu.
3. Die Gesamtaussage ist das SCHLECHTESTE Geraet, nicht der Mittelwert — ein
   Totalausfall verschwindet sonst zwischen neun gesunden Geraeten.

Ausserdem am Monitor:
- PARTIAL_WAKE_LOCK (12 h Grenze). Der Monitor war der einzige Langzeitpfad
  ohne CPU-Sperre; der WifiLock allein haelt nur das Funkmodul wach. Bei
  ausgeschaltetem Display streckt Android die Schleife, und es fehlen genau
  die Messpunkte, auf denen die Aussage "ueber sechs Stunden stabil" beruht.
- Thread.sleep -> delay(): blockierte den Dispatcher-Thread ueber die gesamte
  Wartezeit und nahm ihn parallelen Messungen weg.
- Reisst der Lauf ab (Prozess tot, App beendet), wird trotzdem ein
  Teilergebnis gespeichert und als unvollstaendig gekennzeichnet, mit dem
  letzten echten Messpunkt als Endzeit. Vorher wurde die Endzeit auf "jetzt"
  gesetzt — eine Verfuegbarkeit ueber Stunden auszuweisen, in denen gar nicht
  gemessen wurde, waere eine Falschaussage im Abnahmeprotokoll.
- Der Browser-Mock ist jetzt eine echte Zustandsmaschine. Vorher wuerfelte er
  je Tick unabhaengig up oder down und erzeugte Folgen wie up,up,down,down,
  die es in der Wirklichkeit nicht gibt (ein Ereignis IST ein Wechsel) — eine
  Auswertung liess sich damit nicht sinnvoll pruefen.

Dolibarr-Seite gleich mit: die sechs neuen Schluessel in BEIDE Feldtabellen
(netdiagKundenfelder + messfelder.ts). Ohne das stuende im Kunden-PDF nur die
Ampel — der Fehler, der bei der Whitelist schon einmal passiert ist.

Geprueft: neues Werkzeug tools/pruefe-monitor.mjs rechnet sechs konstruierte
Faelle gegen von Hand ermittelte Sollwerte durch, darunter die drei kritischen
(durchgehend totes Geraet -> 0 % statt 100 %, offener Ausfall am Ende,
ein totes unter neun gesunden). Alle korrekt. Danach im Emulator ueber eine
echte Ueberwachung von 59 s: Messung entsteht, alle Felder korrekt beschriftet.
This commit is contained in:
Eduard Wisch 2026-08-16 19:20:08 +02:00
parent 85e68ca528
commit 6483589592
6 changed files with 536 additions and 21 deletions

View file

@ -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<String, Boolean>()
/** 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 {

View file

@ -42,6 +42,16 @@ const FELDER: Record<string, Feld> = {
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' },

249
src/lib/monitor.ts Normal file
View file

@ -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<string, unknown>; 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<GeraetAuswertung | null>(
(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,
});
}

View file

@ -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<string, boolean>;
}>;
/** 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<string, boolean>;
}>;
/** 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<typeof setInterval> | 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<string, boolean> = {};
let mockMonitorDownSince: Record<string, number> = {};
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

View file

@ -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<Protocol | null>(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;
}

89
tools/pruefe-monitor.mjs Normal file
View file

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