Compare commits

..

No commits in common. "a5ef9ff9a146c65ae71c6d6af37f612c1ce2ebe1" and "767861046c81038f6135df8c3435acbf6b8b9cf9" have entirely different histories.

19 changed files with 55 additions and 1034 deletions

View file

@ -499,21 +499,7 @@ class NetDiagScannerPlugin : Plugin() {
* Geräteart-Heuristik (z.B. 554 Kamera, 9100 Drucker). 500 ms Timeout. * Geräteart-Heuristik (z.B. 554 Kamera, 9100 Drucker). 500 ms Timeout.
*/ */
private suspend fun quickPortProbe(ip: String): List<Int> { private suspend fun quickPortProbe(ip: String): List<Int> {
/* val probe = listOf(22, 23, 80, 443, 445, 554, 1883, 3389, 8000, 9100)
* 502 (Modbus) und 515 (LPD) ergaenzt.
*
* 502 ist in diesem Betrieb der wichtigste Hinweis ueberhaupt:
* Wechselrichter, Batteriespeicher und Wallboxen sprechen Modbus/TCP.
* Die Geraeteart-Heuristik weiter unten wertet 502 laengst aus ("IoT/SPS")
* der Zweig war nur nachweislich unerreichbar, weil die Sonde den Port
* gar nicht geprueft hat. Dasselbe gilt fuer 515 (Drucker).
*
* 88 (Kerberos) und 8443 bewusst NICHT hier: sie kommen im Handwerksnetz
* praktisch nicht vor und dieser Pfad ist der teuerste im ganzen Scan
* (er laeuft je stiller Adresse in BEIDEN Runden plus in der
* Anreicherung). Wer sie braucht, nimmt den Port-Scan mit eigener Liste.
*/
val probe = listOf(22, 23, 80, 443, 445, 502, 515, 554, 1883, 3389, 8000, 9100)
return withContext(Dispatchers.IO) { return withContext(Dispatchers.IO) {
probe.map { port -> probe.map { port ->
async { async {
@ -1659,8 +1645,6 @@ class NetDiagScannerPlugin : Plugin() {
val intervalSec: Int, val intervalSec: Int,
) { ) {
@Volatile var active = true @Volatile var active = true
/** Beginn der Ueberwachung — Bezugsgroesse jeder Verfuegbarkeitsrechnung */
val startedAt = System.currentTimeMillis()
/** je IP: true = erreichbar */ /** je IP: true = erreichbar */
val state = ConcurrentHashMap<String, Boolean>() val state = ConcurrentHashMap<String, Boolean>()
/** je IP: Beginn des aktuellen Ausfalls (für die Ausfalldauer) */ /** je IP: Beginn des aktuellen Ausfalls (für die Ausfalldauer) */
@ -1701,64 +1685,13 @@ class NetDiagScannerPlugin : Plugin() {
.getSystemService(Context.WIFI_SERVICE) as WifiManager) .getSystemService(Context.WIFI_SERVICE) as WifiManager)
.createWifiLock(WifiManager.WIFI_MODE_FULL_HIGH_PERF, "netdiag-monitor") .createWifiLock(WifiManager.WIFI_MODE_FULL_HIGH_PERF, "netdiag-monitor")
try { wifiLock.acquire() } catch (_: Exception) { } 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 { io.launch {
try { try {
/* // Ausgangslage erfassen — erzeugt noch kein Ereignis
* Ausgangslage erfassen und fuer jedes bereits ausgefallene for ((ip, _) in targets) run.state[ip] = isReachable(ip)
* 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) { while (run.active) {
// delay statt Thread.sleep: Thread.sleep blockiert den Thread.sleep(intervalSec * 1000L)
// Coroutine-Dispatcher-Thread ueber die gesamte Wartezeit
// und nimmt ihn parallelen Messungen weg.
kotlinx.coroutines.delay(intervalSec * 1000L)
if (!run.active) break if (!run.active) break
for ((ip, label) in targets) { for ((ip, label) in targets) {
if (!run.active) break if (!run.active) break
@ -1790,7 +1723,6 @@ class NetDiagScannerPlugin : Plugin() {
} }
} finally { } finally {
try { if (wifiLock.isHeld) wifiLock.release() } catch (_: Exception) { } try { if (wifiLock.isHeld) wifiLock.release() } catch (_: Exception) { }
try { if (cpuLock?.isHeld == true) cpuLock.release() } catch (_: Exception) { }
} }
} }
resolve(call, JSObject().put("runId", runId)) resolve(call, JSObject().put("runId", runId))
@ -1804,15 +1736,7 @@ class NetDiagScannerPlugin : Plugin() {
MonitorService.stop(context, runId) MonitorService.stop(context, runId)
val events = JSArray() val events = JSArray()
run.events.forEach { events.put(it) } run.events.forEach { events.put(it) }
// startedAt/endedAt/zustand mitgeben: ohne sie kann die Auswertung resolve(call, JSObject().put("stopped", true).put("events", events))
// 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) */ /** Status eines Monitor-Laufs abfragen (UI-Wiederaufnahme nach Seitenwechsel) */
@ -1824,16 +1748,7 @@ class NetDiagScannerPlugin : Plugin() {
run?.events?.forEach { events.put(it) } run?.events?.forEach { events.put(it) }
resolve(call, JSObject() resolve(call, JSObject()
.put("running", run != null && run.active) .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 { private fun isReachable(ip: String): Boolean = try {
@ -2475,10 +2390,8 @@ class NetDiagScannerPlugin : Plugin() {
private fun serviceName(port: Int): String = when (port) { private fun serviceName(port: Int): String = when (port) {
21 -> "ftp"; 22 -> "ssh"; 23 -> "telnet"; 53 -> "dns"; 80 -> "http" 21 -> "ftp"; 22 -> "ssh"; 23 -> "telnet"; 53 -> "dns"; 80 -> "http"
88 -> "kerberos"; 139 -> "netbios"; 443 -> "https"; 445 -> "smb" 139 -> "netbios"; 443 -> "https"; 445 -> "smb"; 502 -> "modbus"
502 -> "modbus"; 515 -> "lpd"; 554 -> "rtsp"; 1883 -> "mqtt" 1883 -> "mqtt"; 3389 -> "rdp"; 8080 -> "http-alt"; 8443 -> "https-alt"
3389 -> "rdp"; 8000 -> "http-alt"; 8080 -> "http-alt"
8443 -> "https-alt"; 9100 -> "jetdirect"
else -> "" else -> ""
} }

View file

@ -6,18 +6,7 @@
title, title,
back = false, back = false,
subtitle = '', subtitle = '',
aktionen = undefined, }: { title: string; back?: boolean; subtitle?: string } = $props();
}: {
title: string;
back?: boolean;
subtitle?: string;
/**
* Zusätzliche Bedienelemente links vom Zahnrad (z.B. ein Überlaufmenü).
* Optional — die Kopfzeile wird auf zehn Seiten benutzt, und das Zahnrad
* darf durch nichts verdrängt werden.
*/
aktionen?: import('svelte').Snippet;
} = $props();
// Zahnrad auf der Einstellungs-Seite selbst ausblenden // Zahnrad auf der Einstellungs-Seite selbst ausblenden
const onSettings = $derived($page.url.pathname.startsWith('/einstellungen')); const onSettings = $derived($page.url.pathname.startsWith('/einstellungen'));
@ -35,7 +24,6 @@
<p class="truncate text-xs text-zinc-400">{subtitle}</p> <p class="truncate text-xs text-zinc-400">{subtitle}</p>
{/if} {/if}
</div> </div>
{@render aktionen?.()}
{#if !onSettings} {#if !onSettings}
<a <a
class="rounded p-1.5 text-zinc-300 active:bg-zinc-800" class="rounded p-1.5 text-zinc-300 active:bg-zinc-800"

View file

@ -11,24 +11,12 @@
tool, tool,
protocol, protocol,
device = undefined, device = undefined,
autostart = false,
startParams = undefined,
onclose, onclose,
onrun, onrun,
}: { }: {
tool: Tool; tool: Tool;
protocol: Protocol; protocol: Protocol;
device?: Device; device?: Device;
/**
* true = die Messung startet ohne Zutun (Schnellstart per kurzem Tap).
* Der Lauf bleibt trotzdem in diesem Dialog: hier sitzen die
* Bildschirmsperre, der Fortschritt mit Abbrechen-Knopf und die einzige
* Fehlerbehandlung eines Werkzeuglaufs. Ein Schnellstart, der daran
* vorbeiliefe, würde Fehler verschlucken und den Abbruch unmöglich machen.
*/
autostart?: boolean;
/** vorbelegte Werte (zuletzt benutzt), überschreiben die Vorgaben */
startParams?: Record<string, string | number>;
onclose: () => void; onclose: () => void;
onrun: ( onrun: (
params: Record<string, string | number>, params: Record<string, string | number>,
@ -36,11 +24,10 @@
) => Promise<void>; ) => Promise<void>;
} = $props(); } = $props();
// Parameter mit Vorgabewerten füllen, dann die zuletzt benutzten drüber // Parameter mit Vorgabewerten füllen
let params = $state<Record<string, string | number>>({ let params = $state<Record<string, string | number>>(
...Object.fromEntries(tool.params.map((p) => [p.key, p.default ?? ''])), Object.fromEntries(tool.params.map((p) => [p.key, p.default ?? ''])),
...(startParams ?? {}), );
});
let busy = $state(false); let busy = $state(false);
let error = $state(''); let error = $state('');
@ -124,31 +111,6 @@
cancelRequested = true; cancelRequested = true;
cancelling = true; cancelling = true;
} }
/**
* Schnellstart: sofort loslaufen, ohne dass jemand „Weiter" drücken muss.
*
* Der Vorschau-Schritt wird NICHT übersprungen, sondern nur dann automatisch
* durchgewinkt, wenn er keine Warnung enthält. Der IP-Scan warnt ab etwa
* 1000 Adressen — genau der Fall, in dem ein versehentlicher Tap sonst
* minutenlang das falsche Netz absuchen würde. Bei Warnung bleibt der Dialog
* in der Vorschau stehen und wartet auf eine bewusste Bestätigung.
*/
$effect(() => {
if (!autostart || autostartErledigt) return;
autostartErledigt = true;
void (async () => {
if (!tool.preview) {
await start();
return;
}
await showPreviewOrRun();
if (step === 'preview' && !previewInfo?.warning) await start();
})();
});
/** verhindert, dass der Effekt bei jeder Zustandsänderung erneut startet */
let autostartErledigt = false;
</script> </script>
<div <div

View file

@ -42,16 +42,6 @@ const FELDER: Record<string, Feld> = {
dauerSekunden: { label: 'Messdauer', einheit: 's' }, dauerSekunden: { label: 'Messdauer', einheit: 's' },
intervallSek: { label: 'Messabstand', einheit: 's' }, intervallSek: { label: 'Messabstand', einheit: 's' },
laengsterAusfallSek: { label: 'Längster Ausfall', 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 // Netz / DHCP / Dose
server: { label: 'DHCP-Server' }, server: { label: 'DHCP-Server' },
dhcpServer: { label: 'DHCP-Server' }, dhcpServer: { label: 'DHCP-Server' },

View file

@ -1,249 +0,0 @@
/**
* 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,30 +306,12 @@ export interface NetDiagScannerPlugin {
hosts: { ip: string; label: string }[]; hosts: { ip: string; label: string }[];
intervalSec: number; intervalSec: number;
}): Promise<{ runId: string }>; }): Promise<{ runId: string }>;
/** /** Geräte-Monitor beenden — liefert alle gesammelten Ereignisse */
* Geräte-Monitor beenden liefert alle gesammelten Ereignisse. stopMonitor(opts: { runId: string }): Promise<{ stopped: boolean; events: MonitorEventData[] }>;
*
* `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) */ /** Status eines Monitor-Laufs abfragen (Wiederaufnahme nach Seitenwechsel) */
getMonitorStatus(opts: { getMonitorStatus(opts: {
runId: string; runId: string;
}): Promise<{ }): Promise<{ running: boolean; events: MonitorEventData[] }>;
running: boolean;
events: MonitorEventData[];
startedAt: number;
zustand: Record<string, boolean>;
}>;
/** Alle aktiven Netzwerk-Interfaces auflisten (WLAN, Ethernet incl. USB-RJ45, Mobilfunk) */ /** Alle aktiven Netzwerk-Interfaces auflisten (WLAN, Ethernet incl. USB-RJ45, Mobilfunk) */
linkInfo(): Promise<{ links: LinkInfo[] }>; linkInfo(): Promise<{ links: LinkInfo[] }>;
/** Aktiven WLAN-Scan triggern (System-Cache nachladen); `triggered=false` bei Throttling */ /** Aktiven WLAN-Scan triggern (System-Cache nachladen); `triggered=false` bei Throttling */
@ -368,16 +350,6 @@ function rnd(min: number, max: number): number {
const monitorListeners = new Set<(e: MonitorEventData) => void>(); const monitorListeners = new Set<(e: MonitorEventData) => void>();
let mockMonitorTimer: ReturnType<typeof setInterval> | undefined; let mockMonitorTimer: ReturnType<typeof setInterval> | undefined;
let mockMonitorEvents: MonitorEventData[] = []; 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 --- */ /* --- WLAN-Empfangstracker: Ereignis-Verteilung + Mock-Simulation --- */
const wifiSignalListeners = new Set<(e: WifiSignalEvent) => void>(); const wifiSignalListeners = new Set<(e: WifiSignalEvent) => void>();
@ -754,49 +726,18 @@ const mock: NetDiagScannerPlugin = {
async startMonitor(opts) { async startMonitor(opts) {
const runId = 'mock-mon-' + Date.now(); const runId = 'mock-mon-' + Date.now();
mockMonitorEvents = []; 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(() => { mockMonitorTimer = setInterval(() => {
const host = opts.hosts[Math.floor(Math.random() * opts.hosts.length)]; const host = opts.hosts[Math.floor(Math.random() * opts.hosts.length)];
if (!host) return; if (!host) return;
// Zustandswechsel nur gelegentlich — ein Ereignis IST ein Wechsel const type: 'up' | 'down' = Math.random() < 0.5 ? 'down' : 'up';
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 = { const ev: MonitorEventData = {
runId, runId,
ip: host.ip, ip: host.ip,
label: host.label, label: host.label,
ts, ts: Date.now(),
type: jetzt ? 'up' : 'down', type,
durationSec: type === 'up' ? Math.floor(rnd(20, 180)) : undefined,
}; };
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); mockMonitorEvents.push(ev);
monitorListeners.forEach((cb) => cb(ev)); monitorListeners.forEach((cb) => cb(ev));
}, 6000); }, 6000);
@ -805,21 +746,10 @@ const mock: NetDiagScannerPlugin = {
async stopMonitor() { async stopMonitor() {
if (mockMonitorTimer) clearInterval(mockMonitorTimer); if (mockMonitorTimer) clearInterval(mockMonitorTimer);
mockMonitorTimer = undefined; mockMonitorTimer = undefined;
return { return { stopped: true, events: mockMonitorEvents };
stopped: true,
events: mockMonitorEvents,
startedAt: mockMonitorStartedAt,
endedAt: Date.now(),
zustand: { ...mockMonitorState },
};
}, },
async getMonitorStatus() { async getMonitorStatus() {
return { return { running: mockMonitorTimer !== undefined, events: mockMonitorEvents };
running: mockMonitorTimer !== undefined,
events: mockMonitorEvents,
startedAt: mockMonitorStartedAt,
zustand: { ...mockMonitorState },
};
}, },
async linkInfo() { async linkInfo() {
// Mock: ein verbundenes WLAN + ein simulierter USB-RJ45-Adapter mit 1 Gbit // Mock: ein verbundenes WLAN + ein simulierter USB-RJ45-Adapter mit 1 Gbit

View file

@ -1,88 +0,0 @@
/**
* Gedächtnis für zuletzt benutzte Werkzeug-Parameter.
*
* Zweck: Der Techniker stellt beim Kunden dieselben Werte immer wieder neu
* ein SNMP-Community, Anzahl Ping-Pakete, Portliste. Ohne Gedächtnis ist
* der Schnellstart (kurzer Tap startet sofort) nur für Werkzeuge nützlich,
* deren Vorgabewerte zufällig passen; mit Gedächtnis für alle.
*
* ORTSGEBUNDENE SCHLÜSSEL WERDEN BEWUSST NICHT GEMERKT (siehe FLUECHTIG).
* Ein aus dem vorigen Kundennetz übernommener Netzbereich wäre schlimmer als
* gar keiner: der Schnellstart würde stillschweigend das falsche Netz scannen
* und ein leeres Ergebnis ins Abnahmeprotokoll schreiben. Bleibt das Feld
* leer, lösen ipscan und ipconflict den Bereich ohnehin selbst über den
* aktiven Adapter auf das ist immer richtig.
*/
import { Preferences } from '@capacitor/preferences';
import type { Tool } from './tools/types';
const KEY = 'nd_toolparams';
/**
* Schlüssel, die NICHT über einen Kunden hinaus gemerkt werden dürfen, weil
* sie an Ort und Netz hängen. Bewusst eine Sperrliste statt einer Erlaubnis-
* liste: ein neuer, harmloser Parameter soll ohne Zutun gemerkt werden, ein
* neuer ortsgebundener fällt beim Hinzufügen auf.
*/
const FLUECHTIG = new Set(['subnet', 'ip', 'ziel', 'target']);
type ParamSatz = Record<string, string | number>;
let gemerkt: Record<string, ParamSatz> = {};
export async function ladeParameterGedaechtnis(): Promise<void> {
try {
const { value } = await Preferences.get({ key: KEY });
gemerkt = value ? JSON.parse(value) : {};
} catch {
// Beschädigter Eintrag darf die Seite nicht aufhalten — dann eben ohne
// Gedächtnis weiterarbeiten.
gemerkt = {};
}
}
/** Zuletzt benutzte Werte eines Werkzeugs (leer, wenn noch nie benutzt) */
export function letzteParameter(toolId: string): ParamSatz {
return gemerkt[toolId] ?? {};
}
export async function merkeParameterFuer(
toolId: string,
params: ParamSatz,
): Promise<void> {
const behalten: ParamSatz = {};
for (const [k, v] of Object.entries(params)) {
if (FLUECHTIG.has(k)) continue;
if (v === '' || v === null || v === undefined) continue;
behalten[k] = v;
}
if (Object.keys(behalten).length === 0) return;
gemerkt = { ...gemerkt, [toolId]: behalten };
try {
await Preferences.set({ key: KEY, value: JSON.stringify(gemerkt) });
} catch {
// Dann gilt das Gedächtnis nur für diese Sitzung.
}
}
/**
* Startwerte für den Parameterdialog: Vorgabe des Werkzeugs, überschrieben
* von den zuletzt benutzten Werten.
*
* Es werden nur Schlüssel übernommen, die das Werkzeug HEUTE noch kennt
* sonst schleppt ein einmal entfernter Parameter sich ewig mit und taucht in
* `Measurement.params` wieder auf, wo er im Kundendokument als unbekanntes
* Feld landet.
*/
export function startParameter(tool: Tool): ParamSatz {
const aus = Object.fromEntries(
(tool.params ?? []).map((p) => [p.key, p.default ?? '']),
) as ParamSatz;
const alt = letzteParameter(tool.id);
for (const p of tool.params ?? []) {
if (alt[p.key] !== undefined) aus[p.key] = alt[p.key];
}
return aus;
}

View file

@ -19,9 +19,6 @@ export const iperfTool: Tool = {
icon: 'gauge-circle', icon: 'gauge-circle',
description: 'Misst Down-/Upload-Bandbreite gegen eine Gegenstelle.', description: 'Misst Down-/Upload-Bandbreite gegen eine Gegenstelle.',
scope: 'protocol', scope: 'protocol',
// BEWUSST kein quickRun: `host` hat keinen sinnvollen Vorgabewert (die
// Gegenstelle ist je Einsatz eine andere) und run() bricht ohne ihn sofort
// ab. Ein Schnellstart würde beim Kunden nur eine Fehlermeldung erzeugen.
params: [ params: [
{ key: 'host', label: 'Gegenstelle (IP)', type: 'text', placeholder: '192.168.1.20' }, { key: 'host', label: 'Gegenstelle (IP)', type: 'text', placeholder: '192.168.1.20' },
{ key: 'port', label: 'Port', type: 'number', default: 5201 }, { key: 'port', label: 'Port', type: 'number', default: 5201 },

View file

@ -17,8 +17,6 @@ export const ipConflictTool: Tool = {
icon: 'alert-triangle', icon: 'alert-triangle',
description: 'Findet IP-Adressen, die zwei Geräte gleichzeitig benutzen.', description: 'Findet IP-Adressen, die zwei Geräte gleichzeitig benutzen.',
scope: 'protocol', scope: 'protocol',
// Netzbereich optional, Runden haben einen Vorgabewert
quickRun: true,
params: [ params: [
{ {
key: 'subnet', key: 'subnet',

View file

@ -159,8 +159,6 @@ export const ipScanTool: Tool = {
description: 'Sucht Geräte per Ping, Port-Probe, ARP-Abgleich und mDNS/Bonjour.', description: 'Sucht Geräte per Ping, Port-Probe, ARP-Abgleich und mDNS/Bonjour.',
scope: 'protocol', scope: 'protocol',
supportsProgress: true, supportsProgress: true,
// Netzbereich darf leer bleiben (wird ueber den Adapter aufgeloest)
quickRun: true,
params: [ params: [
{ {
key: 'subnet', key: 'subnet',

View file

@ -13,8 +13,6 @@ export const pingTool: Tool = {
icon: 'activity', icon: 'activity',
description: 'Misst Latenz, Jitter und Paketverlust.', description: 'Misst Latenz, Jitter und Paketverlust.',
scope: 'device', scope: 'device',
// Ziel kommt aus dem angetippten Geraet, Paketzahl hat Vorgabewert
quickRun: true,
params: [ params: [
{ key: 'host', label: 'Ziel (leer = Gerät)', type: 'text', placeholder: '192.168.1.1' }, { key: 'host', label: 'Ziel (leer = Gerät)', type: 'text', placeholder: '192.168.1.1' },
{ key: 'count', label: 'Anzahl Pakete', type: 'number', default: 20 }, { key: 'count', label: 'Anzahl Pakete', type: 'number', default: 20 },

View file

@ -6,17 +6,8 @@
import { scanner } from '../../scanner'; import { scanner } from '../../scanner';
import type { Tool } from '../types'; import type { Tool } from '../types';
/** /** Häufige Ports im Standard-Scan */
* Häufige Ports im Standard-Scan. const DEFAULT_PORTS = [21, 22, 23, 53, 80, 139, 443, 445, 502, 1883, 3389, 8080, 8443];
*
* Hier darf die Liste länger sein als bei der Schnellprobe des IP-Scanners:
* der Port-Scan läuft auf EIN vom Techniker ausgewähltes Gerät, nicht auf 254
* Adressen in zwei Runden. Ergänzt gegenüber der Schnellprobe: 515 (Drucker
* per LPD), 554 (Kamera per RTSP), 9100 (Drucker roh), 88 (Windows-Domäne).
*/
const DEFAULT_PORTS = [
21, 22, 23, 53, 80, 88, 139, 443, 445, 502, 515, 554, 1883, 3389, 8080, 8443, 9100,
];
export const portScanTool: Tool = { export const portScanTool: Tool = {
id: 'portscan', id: 'portscan',
@ -25,8 +16,6 @@ export const portScanTool: Tool = {
icon: 'scan-line', icon: 'scan-line',
description: 'Prüft offene TCP-Ports eines einzelnen Geräts.', description: 'Prüft offene TCP-Ports eines einzelnen Geräts.',
scope: 'device', scope: 'device',
// Leere Portliste bedeutet ausdruecklich DEFAULT_PORTS
quickRun: true,
params: [ params: [
{ {
key: 'ports', key: 'ports',

View file

@ -23,8 +23,6 @@ export const snmpTool: Tool = {
icon: 'network', icon: 'network',
description: 'Liest Link-Speed und Fehlerzähler eines Switches (SNMP v2c).', description: 'Liest Link-Speed und Fehlerzähler eines Switches (SNMP v2c).',
scope: 'device', scope: 'device',
// Community hat den Vorgabewert 'public', Host kommt aus dem Geraet
quickRun: true,
params: [ params: [
{ key: 'host', label: 'Switch-IP (leer = Gerät)', type: 'text', placeholder: '192.168.1.10' }, { key: 'host', label: 'Switch-IP (leer = Gerät)', type: 'text', placeholder: '192.168.1.10' },
{ key: 'community', label: 'SNMP Community', type: 'text', default: 'public' }, { key: 'community', label: 'SNMP Community', type: 'text', default: 'public' },

View file

@ -12,8 +12,6 @@ export const tracerouteTool: Tool = {
icon: 'route', icon: 'route',
description: 'Zeigt die Netzwerk-Hops bis zum Ziel.', description: 'Zeigt die Netzwerk-Hops bis zum Ziel.',
scope: 'protocol', scope: 'protocol',
// Ziel hat mit 8.8.8.8 einen brauchbaren Vorgabewert
quickRun: true,
params: [{ key: 'host', label: 'Ziel', type: 'text', default: '8.8.8.8' }], params: [{ key: 'host', label: 'Ziel', type: 'text', default: '8.8.8.8' }],
async run(ctx) { async run(ctx) {
const host = String(ctx.params.host || '8.8.8.8'); const host = String(ctx.params.host || '8.8.8.8');
@ -27,16 +25,12 @@ export const tracerouteTool: Tool = {
if (!reachedTarget) status = 2; if (!reachedTarget) status = 2;
if (antwortend === 0) status = 3; // gar keine Antwort -> nicht messbar if (antwortend === 0) status = 3; // gar keine Antwort -> nicht messbar
// Einzahl/Mehrzahl: bei einem direkt erreichbaren Ziel stand hier „1 Hops".
// Das Label landet unveraendert im Kundendokument.
const stationen = (n: number) => `${n} ${n === 1 ? 'Station' : 'Stationen'}`;
return { return {
label: reachedTarget label: reachedTarget
? `${stationen(hops.length)} bis ${host}` ? `${hops.length} Hops bis ${host}`
: antwortend === 0 : antwortend === 0
? `${host}: keine Antwort auf dem Weg` ? `${host}: keine Antwort auf dem Weg`
: `${host} nicht erreicht (${stationen(antwortend)} geantwortet)`, : `${host} nicht erreicht (${antwortend} Hops geantwortet)`,
result: { result: {
ziel: host, ziel: host,
zielErreicht: reachedTarget, zielErreicht: reachedTarget,

View file

@ -97,20 +97,6 @@ export interface Tool {
params: ToolParamField[]; params: ToolParamField[];
/** true = das Tool meldet Fortschritt über `ctx.onProgress` — ToolDialog zeigt dann Fortschrittsanzeige + Abbrechen statt des einfachen Spinners */ /** true = das Tool meldet Fortschritt über `ctx.onProgress` — ToolDialog zeigt dann Fortschrittsanzeige + Abbrechen statt des einfachen Spinners */
supportsProgress?: boolean; supportsProgress?: boolean;
/**
* true = ein kurzer Tap auf die Werkzeugkarte startet die Messung sofort mit
* den zuletzt benutzten Werten; der Parameterdialog kommt erst beim langen
* Drücken.
*
* MUSS je Werkzeug ausdrücklich gesetzt werden und darf NICHT aus den
* Parametern hergeleitet werden: `ToolParamField` kennt kein `required`, ob
* ein Parameter Pflicht ist, steht allein im `run()`-Rumpf. `portscan.ports`
* hat keinen Vorgabewert und ist trotzdem optional, `iperf.host` hat
* ebenfalls keinen und ist Pflicht eine Heuristik über hat Default"
* würde also genau falsch herum raten und beim Kunden eine Messung
* losschicken, die sofort mit Keine Gegenstelle angegeben" abbricht.
*/
quickRun?: boolean;
/** Optionale Vorschau vor dem Start (z.B. IP-Scan: Adapter/eigene IP/Maske/Gateway/Anzahl) */ /** Optionale Vorschau vor dem Start (z.B. IP-Scan: Adapter/eigene IP/Maske/Gateway/Anzahl) */
preview?(ctx: ToolContext): Promise<ToolPreview>; preview?(ctx: ToolContext): Promise<ToolPreview>;
run(ctx: ToolContext): Promise<ToolRunResult>; run(ctx: ToolContext): Promise<ToolRunResult>;

View file

@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { onMount, onDestroy, tick } from 'svelte'; import { onMount, onDestroy } from 'svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { App } from '@capacitor/app'; import { App } from '@capacitor/app';
@ -26,8 +26,7 @@
import { toast } from '$lib/toast.svelte'; import { toast } from '$lib/toast.svelte';
import { pushOverlay } from '$lib/overlay.svelte'; import { pushOverlay } from '$lib/overlay.svelte';
import { TOOLS, getTool } from '$lib/tools'; import { TOOLS, getTool } from '$lib/tools';
import { werkzeugName, feldLabel } from '$lib/messfelder'; import { werkzeugName } from '$lib/messfelder';
import { ladeParameterGedaechtnis, startParameter, merkeParameterFuer } from '$lib/toolparams';
import type { Tool } from '$lib/tools/types'; import type { Tool } from '$lib/tools/types';
import type { Device, Protocol } from '$lib/types'; import type { Device, Protocol } from '$lib/types';
import * as Icons from 'lucide-svelte'; import * as Icons from 'lucide-svelte';
@ -47,12 +46,6 @@
let erkanntesSubnetz = $state(''); let erkanntesSubnetz = $state('');
/** Menü hinter den drei Punkten (selten gebrauchte, gefährliche Aktionen) */ /** Menü hinter den drei Punkten (selten gebrauchte, gefährliche Aktionen) */
let ueberlaufOffen = $state(false); let ueberlaufOffen = $state(false);
/** Schnellstart: der nächste Dialog startet ohne Zutun */
let sofortStarten = $state(false);
/** vorbelegte Werte für den nächsten Dialog (zuletzt benutzt) */
let startParams = $state<Record<string, string | number> | undefined>(undefined);
/** frisch entstandene Messung — wird kurz hervorgehoben */
let letzteMessungId = $state<string | null>(null);
/** Kompakte Zusammenfassung für die zugeklappte Kopfzeile */ /** Kompakte Zusammenfassung für die zugeklappte Kopfzeile */
const kopfZusammenfassung = $derived( const kopfZusammenfassung = $derived(
@ -129,9 +122,6 @@
// der Platz für die Werkzeuge frei. // der Platz für die Werkzeuge frei.
kopfdatenOffen = !(p.location || p.subnet || p.note); kopfdatenOffen = !(p.location || p.subnet || p.note);
// Zuletzt benutzte Werkzeug-Parameter laden (fuer den Schnellstart)
await ladeParameterGedaechtnis();
// Erkanntes Subnetz als Eingabevorschlag bereitstellen (best effort) // Erkanntes Subnetz als Eingabevorschlag bereitstellen (best effort)
try { try {
const local = await scanner.getLocalSubnet(); const local = await scanner.getLocalSubnet();
@ -169,13 +159,6 @@
return pushOverlay(() => (activeTool = null)); return pushOverlay(() => (activeTool = null));
}); });
// Dasselbe fürs Überlaufmenü — ohne diese Anmeldung würde der Zurück-Knopf
// das Protokoll verlassen, während das Menü noch offen ist.
$effect(() => {
if (!ueberlaufOffen) return;
return pushOverlay(() => (ueberlaufOffen = false));
});
/** Lucide-Icon dynamisch holen (Tool-Icon-Name ist kebab-case) */ /** Lucide-Icon dynamisch holen (Tool-Icon-Name ist kebab-case) */
function icon(name: string) { function icon(name: string) {
const pascal = name const pascal = name
@ -186,100 +169,9 @@
return (map[pascal] ?? Icons.Wrench) as typeof Icons.Wrench; return (map[pascal] ?? Icons.Wrench) as typeof Icons.Wrench;
} }
/** function openTool(tool: Tool, device?: Device) {
* Die verwendeten Parameter einer Messung als eine Zeile.
*
* Die Beschriftung kommt aus `tool.params`, NICHT aus der Ergebnis-
* Feldtabelle: derselbe Schlüssel bedeutet je nach Werkzeug etwas anderes.
* `count` heißt im IP-Scan-Ergebnis „Gefundene Geräte", als Ping-Parameter
* dagegen „Anzahl Pakete" — über die Ergebnistabelle stünde in der Messkarte
* „Gefundene Geräte: 20", wo „Anzahl Pakete: 20" gemeint ist.
*
* Leere Werte werden weggelassen: ein bewusst leer gelassener Netzbereich
* ist keine Angabe, sondern die Anweisung „nimm das aktuelle Netz".
*
* Ebenfalls weggelassen wird, was das Ergebnis ohnehin schon nennt. Beim
* Traceroute stand sonst „Ziel: 8.8.8.8" direkt über „Ziel: 8.8.8.8 · Ziel
* erreicht: ja" — zwei Zeilen für dieselbe Angabe, und die Messkarte ist auf
* einem Handy der knappste Platz der ganzen App.
*/
function parameterZeile(m: {
tool: string;
params?: Record<string, unknown>;
result?: unknown;
}): string {
const felder = getTool(m.tool)?.params ?? [];
const imErgebnis = new Set(
Object.values((m.result ?? {}) as Record<string, unknown>)
.filter((v) => typeof v === 'string' || typeof v === 'number')
.map((v) => String(v)),
);
return Object.entries(m.params ?? {})
.filter(([, v]) => v !== '' && v !== null && v !== undefined)
.filter(([, v]) => !imErgebnis.has(String(v)))
.map(([k, v]) => {
const label = felder.find((p) => p.key === k)?.label ?? feldLabel(k);
return `${label}: ${v}`;
})
.join(' · ');
}
function openTool(tool: Tool, device?: Device, sofort = false) {
activeTool = tool; activeTool = tool;
activeDevice = device; activeDevice = device;
sofortStarten = sofort;
startParams = startParameter(tool);
}
/* --- Schnellstart: kurz tippen startet, halten öffnet die Optionen ---
*
* Der Standardfall beim Kunden ist „IP-Scan im aktuellen Netz" — dafür waren
* bisher drei Tipper nötig (Karte → Weiter → Scan starten), obwohl das
* einzige Feld ausdrücklich leer bleiben darf. Die Zahlen aus der
* Produktivdatenbank stützen das: 19 Protokolle, aber nur 26 Messungen, fast
* durchweg ein einzelner IP-Scan. Die Werkzeuge waren zu umständlich, um sie
* wirklich zu benutzen.
*
* Der Langdruck muss abbrechen, sobald der Finger wandert: das Werkzeug-
* Raster liegt in einem Scroll-Bereich, sonst öffnet jeder Wisch, der auf
* einer Karte beginnt, nach einer halben Sekunde den Dialog.
*/
const LANGDRUCK_MS = 500;
let druckTimer: ReturnType<typeof setTimeout> | null = null;
let langGedrueckt = false;
let druckStart: { x: number; y: number } | null = null;
function druckAn(e: PointerEvent, tool: Tool) {
langGedrueckt = false;
druckStart = { x: e.clientX, y: e.clientY };
druckTimer = setTimeout(() => {
langGedrueckt = true;
druckTimer = null;
openTool(tool); // Dialog mit Optionen
}, LANGDRUCK_MS);
}
function druckBewegung(e: PointerEvent) {
if (!druckTimer || !druckStart) return;
// 10 px Toleranz: ein ruhiger Finger zittert, ein Wisch nicht
if (Math.hypot(e.clientX - druckStart.x, e.clientY - druckStart.y) > 10) druckAus();
}
function druckAus() {
if (druckTimer) clearTimeout(druckTimer);
druckTimer = null;
druckStart = null;
}
function werkzeugTap(tool: Tool) {
druckAus();
// Der Langdruck hat den Dialog schon geöffnet — der nachfolgende Klick
// darf ihn nicht sofort durch einen zweiten Aufruf ersetzen.
if (langGedrueckt) {
langGedrueckt = false;
return;
}
openTool(tool, undefined, tool.quickRun ?? false);
} }
/** /**
@ -319,7 +211,7 @@
} }
} }
const neueMessung = addMeasurement(protocol, { addMeasurement(protocol, {
deviceClientId: activeDevice?.clientId ?? null, deviceClientId: activeDevice?.clientId ?? null,
tool: tool.id, tool: tool.id,
category: tool.category, category: tool.category,
@ -331,36 +223,6 @@
}); });
await persist(); await persist();
toast.show(`${tool.name}: ${result.label}`, result.measureStatus === 2 ? 'error' : 'success'); toast.show(`${tool.name}: ${result.label}`, result.measureStatus === 2 ? 'error' : 'success');
void merkeParameterFuer(tool.id, params);
await zeigeErgebnis(neueMessung.clientId, activeDevice?.clientId ?? null);
}
/**
* Nach einer Messung zum frischen Ergebnis scrollen und es kurz hervorheben.
*
* Grund: bei einem Kunden mit 46 Geräten liegt die neue Karte dutzende
* Bildschirme entfernt, und der Techniker sucht sie im Keller einhändig.
*
* Drei Feinheiten, die jeweils einen Fehlversuch gekostet hätten:
* - `tick()` ist Pflicht — die Karte existiert vor dem State-Update nicht.
* - `block: 'center'` statt 'start'/'end': die feste Aktionsleiste am
* unteren Rand verdeckt sonst genau das Ergebnis.
* - Bei einer Geräte-Messung wird die GERÄTE-Karte angesteuert, denn dort
* hinein wird das Ergebnis gerendert (DeviceCard), nicht in die
* Messungen-Liste.
*/
async function zeigeErgebnis(messungId: string, geraetId: string | null) {
letzteMessungId = messungId;
await tick();
const ziel = geraetId
? document.getElementById('ger-' + geraetId)
: document.getElementById('mess-' + messungId);
ziel?.scrollIntoView({ behavior: 'smooth', block: 'center' });
// Hervorhebung wieder abräumen: bei mehreren Läufen desselben Werkzeugs
// wäre sonst nicht erkennbar, welche Karte die neue ist.
setTimeout(() => {
if (letzteMessungId === messungId) letzteMessungId = null;
}, 2500);
} }
/** Protokoll abschließen und synchronisieren */ /** Protokoll abschließen und synchronisieren */
@ -447,7 +309,6 @@
title={protocol.label} title={protocol.label}
subtitle={protocol.socName || protocol.orderRef || 'Diagnose'} subtitle={protocol.socName || protocol.orderRef || 'Diagnose'}
back back
aktionen={ueberlaufMenue}
/> />
<div class="flex-1 overflow-y-auto pb-24"> <div class="flex-1 overflow-y-auto pb-24">
@ -535,25 +396,12 @@
{#each protocolTools as tool (tool.id)} {#each protocolTools as tool (tool.id)}
{@const IconC = icon(tool.icon)} {@const IconC = icon(tool.icon)}
<button <button
class="flex select-none touch-manipulation flex-col items-start gap-1 rounded-lg bg-zinc-800 p-3 text-left active:bg-zinc-700" class="flex flex-col items-start gap-1 rounded-lg bg-zinc-800 p-3 text-left active:bg-zinc-700"
onclick={() => werkzeugTap(tool)} onclick={() => openTool(tool)}
onpointerdown={(e) => druckAn(e, tool)}
onpointermove={druckBewegung}
onpointerup={druckAus}
onpointerleave={druckAus}
onpointercancel={druckAus}
oncontextmenu={(e) => e.preventDefault()}
> >
<IconC size={20} class="text-sky-400" /> <IconC size={20} class="text-sky-400" />
<span class="text-sm font-medium">{tool.name}</span> <span class="text-sm font-medium">{tool.name}</span>
<span class="text-[11px] leading-tight text-zinc-500">{tool.description}</span> <span class="text-[11px] leading-tight text-zinc-500">{tool.description}</span>
{#if tool.quickRun}
<!-- Ohne diesen Hinweis ist das Verhalten unsichtbar und der
erste versehentlich ausgeloeste Scan ueberrascht. -->
<span class="text-[10px] leading-tight text-zinc-600">
Tippen startet · Halten für Optionen
</span>
{/if}
</button> </button>
{/each} {/each}
<!-- Dauer-/Stresstest: eigene Seite (Live-Diagramm + Foreground-Service) --> <!-- Dauer-/Stresstest: eigene Seite (Live-Diagramm + Foreground-Service) -->
@ -619,12 +467,7 @@
<section class="px-3 pb-3"> <section class="px-3 pb-3">
<h2 class="mb-2 text-sm font-semibold text-zinc-300">Messungen</h2> <h2 class="mb-2 text-sm font-semibold text-zinc-300">Messungen</h2>
{#each protocolMeasurements() as m (m.clientId)} {#each protocolMeasurements() as m (m.clientId)}
<div <div class="mb-1.5 rounded-lg bg-zinc-900 p-2.5">
id="mess-{m.clientId}"
class="mb-1.5 rounded-lg bg-zinc-900 p-2.5 transition-shadow {letzteMessungId === m.clientId
? 'ring-1 ring-sky-600'
: ''}"
>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<AmpelBadge status={m.measureStatus} /> <AmpelBadge status={m.measureStatus} />
<span class="text-sm font-medium">{getTool(m.tool)?.name ?? werkzeugName(m.tool)}</span> <span class="text-sm font-medium">{getTool(m.tool)?.name ?? werkzeugName(m.tool)}</span>
@ -633,12 +476,6 @@
<span class="ml-auto shrink-0 text-[11px] text-zinc-500">{fmtUhrzeit(m.dateMeasure)}</span> <span class="ml-auto shrink-0 text-[11px] text-zinc-500">{fmtUhrzeit(m.dateMeasure)}</span>
</div> </div>
<p class="mt-1 text-xs {ampel[m.measureStatus]}">{m.label}</p> <p class="mt-1 text-xs {ampel[m.measureStatus]}">{m.label}</p>
<!-- Verwendete Parameter: seit dem Schnellstart laeuft eine Messung
auch ohne Dialog, dann ist das hier der einzige Ort, an dem man
sieht, WOMIT gemessen wurde. -->
{#if parameterZeile(m)}
<p class="mt-0.5 text-[11px] text-zinc-500">{parameterZeile(m)}</p>
{/if}
<div class="mt-0.5"><MeasurementResult result={m.result} /></div> <div class="mt-0.5"><MeasurementResult result={m.result} /></div>
</div> </div>
{/each} {/each}
@ -663,10 +500,6 @@
</p> </p>
{/if} {/if}
{#each sortedDevices as device (device.clientId)} {#each sortedDevices as device (device.clientId)}
<!-- Anker fuer den Sprung nach einer Geraete-Messung: das Ergebnis
wird INNERHALB der Geraetekarte gerendert, nicht in der
Messungen-Liste. -->
<div id="ger-{device.clientId}">
<DeviceCard <DeviceCard
{device} {device}
measurements={measurementsFor(device.clientId)} measurements={measurementsFor(device.clientId)}
@ -675,7 +508,6 @@
onfavorite={() => doToggleFav(device)} onfavorite={() => doToggleFav(device)}
onrename={() => (renameTarget = device)} onrename={() => (renameTarget = device)}
/> />
</div>
{/each} {/each}
</section> </section>
@ -718,6 +550,11 @@
</section> </section>
{/if} {/if}
<div class="px-3">
<button class="text-xs text-red-400 underline" onclick={() => (confirmDelete = true)}>
Protokoll löschen
</button>
</div>
</div> </div>
<!-- Abschluss-Leiste --> <!-- Abschluss-Leiste -->
@ -737,20 +574,13 @@
</div> </div>
{#if activeTool} {#if activeTool}
<!-- {#key} ist noetig: ToolDialog belegt seine Parameter genau einmal beim
Aufbau. Ohne den Schluessel behielte ein bereits gemounteter Dialog die
Werte des vorigen Werkzeugs. -->
{#key activeTool.id}
<ToolDialog <ToolDialog
tool={activeTool} tool={activeTool}
{protocol} {protocol}
device={activeDevice} device={activeDevice}
autostart={sofortStarten}
{startParams}
onclose={() => (activeTool = null)} onclose={() => (activeTool = null)}
onrun={runTool} onrun={runTool}
/> />
{/key}
{/if} {/if}
{#if renameTarget} {#if renameTarget}
@ -764,54 +594,6 @@
/> />
{/if} {/if}
<!--
Ueberlaufmenue. Das Loeschen stand vorher als roter Link unmittelbar ueber
dem gruenen "Abschliessen & synchronisieren" — bei offener Tastatur liegen
beide im selben Daumenbereich, und ein Fehlgriff loescht ein noch nicht
synchronisiertes Protokoll unwiederbringlich. Selten gebrauchte, gefaehrliche
Aktionen gehoeren nicht in den Hauptweg.
-->
{#snippet ueberlaufMenue()}
<button
class="rounded p-1.5 text-zinc-300 active:bg-zinc-800"
onclick={() => (ueberlaufOffen = true)}
aria-label="Weitere Aktionen"
aria-haspopup="menu"
>
<Icons.MoreVertical size={22} />
</button>
{/snippet}
{#if ueberlaufOffen}
<!-- Bewusst ein Blatt von unten statt eines Menues an der Kopfzeile:
einhaendig am Handy ist der obere Bildschirmrand der schlechteste Ort. -->
<div
class="fixed inset-0 z-40 bg-black/60"
role="presentation"
onclick={() => (ueberlaufOffen = false)}
></div>
<div class="fixed inset-x-0 bottom-0 z-50 rounded-t-xl bg-zinc-900 p-3 pb-6" role="menu">
<button
class="flex w-full items-center gap-2 rounded-lg p-3 text-left text-sm text-red-400 active:bg-zinc-800"
role="menuitem"
onclick={() => {
ueberlaufOffen = false;
confirmDelete = true;
}}
>
<Icons.Trash2 size={18} />
Protokoll löschen
</button>
<button
class="mt-1 w-full rounded-lg p-3 text-sm text-zinc-400 active:bg-zinc-800"
role="menuitem"
onclick={() => (ueberlaufOffen = false)}
>
Abbrechen
</button>
</div>
{/if}
{#if confirmDelete} {#if confirmDelete}
<ConfirmDialog <ConfirmDialog
title="Protokoll löschen?" title="Protokoll löschen?"

View file

@ -20,7 +20,7 @@
import { rssiRating } from '$lib/wifi/rating'; import { rssiRating } from '$lib/wifi/rating';
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 type { LinkInfo, MeasureStatus, Protocol } from '$lib/types'; import type { LinkInfo, Protocol } from '$lib/types';
let protocol = $state<Protocol | null>(null); let protocol = $state<Protocol | null>(null);
let links = $state<LinkInfo[]>([]); let links = $state<LinkInfo[]>([]);
@ -104,59 +104,10 @@
saveOpen = true; saveOpen = true;
} }
/**
* Dose bewerten — nicht nur „IP ja/nein".
*
* Das ist der Elektro-Abnahmebeleg schlechthin: 100 Mbit auf einer
* Cat-7-Leitung heißt so gut wie immer, dass zwei Aderpaare nicht aufgelegt
* sind oder die Dose falsch geklemmt ist. Ein Befund, den der Techniker
* sofort beheben kann, solange er noch auf der Leiter steht — vorher stand
* dort eine grüne Ampel, weil ja eine IP da war.
*
* Unbekannte Geschwindigkeit ergibt Status 3 („nicht messbar"), nicht 0:
* bei manchen Android-Versionen ist /sys/class/net/<iface>/speed gesperrt.
* Eine Abnahme darf nicht auf einer Angabe beruhen, die es nicht gibt.
*/
function bewerteDose(l: LinkInfo): { status: MeasureStatus; hinweis?: string } {
if (!l.ipv4) {
return { status: 2, hinweis: 'Keine IP-Adresse — Dose liefert kein Netz.' };
}
const mbit = l.linkSpeedMbps;
if (l.type !== 'ethernet') {
// WLAN: die Aussage über die Dose betrifft nur Kabel
return { status: 0 };
}
if (mbit == null || mbit <= 0) {
return {
status: 3,
hinweis:
'Verbindungsgeschwindigkeit konnte auf diesem Gerät nicht ausgelesen ' +
'werden — über die Leitungsqualität sagt die Messung damit nichts aus.',
};
}
if (mbit < 100) {
return {
status: 2,
hinweis: `Nur ${mbit} Mbit — Leitung, Dose oder Patchfeld prüfen.`,
};
}
if (mbit < 1000) {
return {
status: 1,
hinweis:
`Nur ${mbit} Mbit statt 1000. Typische Ursache: zwei der vier ` +
'Aderpaare sind nicht aufgelegt (Dose oder Patchfeld), seltener eine ' +
'beschädigte Leitung oder ein Gerät, das nur 100 Mbit kann.',
};
}
return { status: 0 };
}
async function doSave(name: string) { async function doSave(name: string) {
saveOpen = false; saveOpen = false;
if (!protocol || !primary) return; if (!protocol || !primary) return;
const raum = name.trim(); const raum = name.trim();
const bewertung = bewerteDose(primary);
addMeasurement(protocol, { addMeasurement(protocol, {
tool: 'iptest', tool: 'iptest',
category: 'netzwerk', category: 'netzwerk',
@ -171,13 +122,12 @@
result: { result: {
raum: raum || undefined, raum: raum || undefined,
...primary, ...primary,
...(bewertung.hinweis ? { hinweis: bewertung.hinweis } : {}),
}, },
measureStatus: bewertung.status, measureStatus: primary.ipv4 ? 0 : 2,
dateMeasure: Date.now(), dateMeasure: Date.now(),
}); });
await persist(); await persist();
toast.show(bewertung.hinweis ?? 'Messung gespeichert', bewertung.status >= 2 ? 'error' : 'success'); toast.show('Messung gespeichert', 'success');
} }
</script> </script>

View file

@ -19,7 +19,6 @@
import { scanner, onMonitorEvent, type MonitorEventData } from '$lib/scanner'; import { scanner, onMonitorEvent, type MonitorEventData } from '$lib/scanner';
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 { finalizeMonitorSession } from '$lib/monitor';
import type { DeviceMonitorSession, MonitorEvent, Protocol } from '$lib/types'; import type { DeviceMonitorSession, MonitorEvent, Protocol } from '$lib/types';
let protocol = $state<Protocol | null>(null); let protocol = $state<Protocol | null>(null);
@ -54,19 +53,12 @@
session = live; session = live;
attachListener(live.runId); attachListener(live.runId);
} else { } else {
// Lauf ist nativ nicht mehr da (Prozess gestorben o.ae.). Als live.status = 'stopped';
// Endzeit den letzten aufgezeichneten Messpunkt nehmen, NICHT jetzt: live.endedAt = Date.now();
// 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(); await persist();
} }
} catch { } catch {
const letztes = live.events.at(-1)?.ts; live.status = 'stopped';
finalizeMonitorSession(p, live, letztes ?? live.startedAt, true);
await persist(); await persist();
} }
} }
@ -139,32 +131,14 @@
try { try {
const res = await scanner.stopMonitor({ runId: session.runId }); const res = await scanner.stopMonitor({ runId: session.runId });
session.events = res.events.map(toStored); session.events = res.events.map(toStored);
session.status = 'stopped';
session.endedAt = Date.now();
offEvent?.(); offEvent?.();
offEvent = undefined; 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(); await persist();
toast.show(messung.label, messung.measureStatus >= 2 ? 'error' : 'success'); toast.show('Überwachung beendet', 'success');
} catch (e) { } catch (e) {
// Auch wenn der native Lauf verschwunden ist (Prozess gestorben, App toast.show(e instanceof Error ? e.message : 'Monitor-Stopp fehlgeschlagen', 'error');
// 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 { } finally {
busy = false; busy = false;
} }

View file

@ -1,89 +0,0 @@
/**
* 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`);