Compare commits

...

4 commits

Author SHA1 Message Date
a5ef9ff9a1 Trigger: APK-Rebuild [apk]
All checks were successful
Build APK / build-apk (push) Successful in 2m6s
2026-08-16 19:29:13 +02:00
0fe938a764 Modbus/LPD in der Schnellprobe, Dosen-Bewertung, Loeschen ins Ueberlaufmenue
Port-Schnellprobe um 502 (Modbus) und 515 (LPD) erweitert. 502 ist in diesem
Betrieb der wichtigste Hinweis ueberhaupt - Wechselrichter, Batteriespeicher
und Wallboxen sprechen Modbus/TCP. Die Geraeteart-Heuristik wertet 502 laengst
aus ("IoT/SPS"), der Zweig war nur nachweislich unerreichbar, weil die Sonde
den Port gar nicht geprueft hat. Dasselbe bei 515 fuer Drucker.
Laufzeit vorher/nachher im Emulator gemessen (voller /24): 44,8 s -> 52,0 s,
also +16 % bei 20 % mehr Ports. Proportional und vertretbar.
88 und 8443 bewusst NICHT in die Schnellprobe: sie kommen im Handwerksnetz
praktisch nicht vor, und dieser Pfad laeuft je stiller Adresse in beiden
Runden plus in der Anreicherung. Im Port-Scan auf ein einzelnes Geraet sind
sie dagegen jetzt dabei, ebenso 554 (RTSP) und 9100.
Servicenamen ergaenzt, damit im Protokoll nicht die nackte Portnummer steht.

IP-Test bewertet jetzt die Dose statt nur "IP ja/nein". Das ist der
Elektro-Abnahmebeleg schlechthin: 100 Mbit auf einer Cat-7-Leitung heisst 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 gruene Ampel, weil ja eine
IP da war. Unbekannte Geschwindigkeit ergibt Status 3 statt 0: bei manchen
Android-Versionen ist /sys/class/net/<iface>/speed gesperrt, und eine Abnahme
darf nicht auf einer Angabe beruhen, die es nicht gibt.

"Protokoll loeschen" ist ins Ueberlaufmenue der Kopfzeile gewandert. Es stand
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.
AppHeader hat dafuer ein optionales Snippet bekommen (die Komponente wird auf
zehn Seiten benutzt, das Zahnrad darf durch nichts verdraengt werden). Das
Menue meldet sich als Overlay an, sonst haette der Hardware-Zurueck das
Protokoll verlassen statt das Menue zu schliessen.

Im Emulator geprueft.
2026-08-16 19:27:33 +02:00
6483589592 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.
2026-08-16 19:20:08 +02:00
85e68ca528 Schnellstart, Sprung zum Ergebnis und Parametergedaechtnis
Werkzeuge waren zu umstaendlich, um sie beim Kunden wirklich zu benutzen: die
Produktivdaten zeigen 19 Protokolle, aber nur 26 Messungen, fast durchweg ein
einzelner IP-Scan. Der Standardfall "IP-Scan im aktuellen Netz" kostete drei
Tipper, obwohl das einzige Feld ausdruecklich leer bleiben darf.

- Kurzer Tap auf eine Werkzeugkarte startet sofort, langes Halten oeffnet die
  Optionen. Auf den betroffenen Karten steht "Tippen startet · Halten fuer
  Optionen" - sonst ist das Verhalten unsichtbar und der erste versehentliche
  Scan ueberrascht.
- quickRun wird je Werkzeug AUSDRUECKLICH gesetzt, nicht hergeleitet:
  ToolParamField kennt kein 'required', die Pflicht steht allein im run()-Rumpf.
  portscan.ports hat keinen Vorgabewert und ist optional, iperf.host hat
  ebenfalls keinen und ist Pflicht - eine Heuristik ueber "hat Default" wuerde
  genau falsch herum raten. iperf bleibt deshalb bewusst ohne Schnellstart.
- Der Lauf bleibt zwingend im ToolDialog: dort sitzen Bildschirmsperre,
  Fortschritt mit Abbrechen und die EINZIGE Fehlerbehandlung eines
  Werkzeuglaufs (runTool selbst hat kein try/catch). Ein Schnellstart daran
  vorbei wuerde Fehler verschlucken.
- Die Vorschau wird nicht uebersprungen, sondern nur ohne Warnung automatisch
  durchgewunken. Der IP-Scan warnt ab ~1000 Adressen - genau der Fall, in dem
  ein versehentlicher Tap sonst minutenlang das falsche Netz absucht.
- Langdruck bricht ab, sobald der Finger mehr als 10 px wandert: das
  Werkzeug-Raster liegt in einem Scroll-Bereich, sonst oeffnet jeder Wisch nach
  einer halben Sekunde den Dialog.

- Nach einer Messung wird zum Ergebnis gescrollt und es 2,5 s hervorgehoben.
  Bei 46 Geraeten liegt die neue Karte sonst dutzende Bildschirme entfernt.
  block:'center', weil die feste Aktionsleiste den unteren Rand verdeckt; bei
  einer Geraete-Messung wird die Geraetekarte angesteuert, denn dort hinein
  wird gerendert.
- Verwendete Parameter stehen in der Messkarte. Seit dem Schnellstart laeuft
  eine Messung auch ohne Dialog - dann ist das der einzige Ort, an dem man
  sieht, WOMIT gemessen wurde. Beschriftung aus tool.params, nicht aus der
  Ergebnistabelle: 'count' heisst dort "Gefundene Geraete", als Ping-Parameter
  aber "Anzahl Pakete".
- Zuletzt benutzte Parameter werden gemerkt (neues Modul toolparams.ts).
  Ortsgebundene Schluessel wie 'subnet' ausdruecklich NICHT: ein aus dem
  vorigen Kundennetz uebernommener Netzbereich waere schlimmer als gar keiner,
  der Schnellstart wuerde stillschweigend das falsche Netz scannen.

Im Emulator geprueft, und dabei zwei Fehler gefunden, die im Code nicht
auffielen: die Parameterzeile wiederholte "Ziel: 8.8.8.8" direkt ueber dem
Ergebnis (jetzt werden Werte ausgelassen, die das Ergebnis ohnehin nennt), und
Traceroute schrieb "1 Hops" ins Kundendokument (jetzt "1 Station").
2026-08-16 19:09:19 +02:00
19 changed files with 1034 additions and 55 deletions

View file

@ -499,7 +499,21 @@ class NetDiagScannerPlugin : Plugin() {
* Geräteart-Heuristik (z.B. 554 Kamera, 9100 Drucker). 500 ms Timeout.
*/
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) {
probe.map { port ->
async {
@ -1645,6 +1659,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 +1701,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 +1790,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 +1804,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 +1824,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 {
@ -2390,8 +2475,10 @@ class NetDiagScannerPlugin : Plugin() {
private fun serviceName(port: Int): String = when (port) {
21 -> "ftp"; 22 -> "ssh"; 23 -> "telnet"; 53 -> "dns"; 80 -> "http"
139 -> "netbios"; 443 -> "https"; 445 -> "smb"; 502 -> "modbus"
1883 -> "mqtt"; 3389 -> "rdp"; 8080 -> "http-alt"; 8443 -> "https-alt"
88 -> "kerberos"; 139 -> "netbios"; 443 -> "https"; 445 -> "smb"
502 -> "modbus"; 515 -> "lpd"; 554 -> "rtsp"; 1883 -> "mqtt"
3389 -> "rdp"; 8000 -> "http-alt"; 8080 -> "http-alt"
8443 -> "https-alt"; 9100 -> "jetdirect"
else -> ""
}

View file

@ -6,7 +6,18 @@
title,
back = false,
subtitle = '',
}: { title: string; back?: boolean; subtitle?: string } = $props();
aktionen = undefined,
}: {
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
const onSettings = $derived($page.url.pathname.startsWith('/einstellungen'));
@ -24,6 +35,7 @@
<p class="truncate text-xs text-zinc-400">{subtitle}</p>
{/if}
</div>
{@render aktionen?.()}
{#if !onSettings}
<a
class="rounded p-1.5 text-zinc-300 active:bg-zinc-800"

View file

@ -11,12 +11,24 @@
tool,
protocol,
device = undefined,
autostart = false,
startParams = undefined,
onclose,
onrun,
}: {
tool: Tool;
protocol: Protocol;
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;
onrun: (
params: Record<string, string | number>,
@ -24,10 +36,11 @@
) => Promise<void>;
} = $props();
// Parameter mit Vorgabewerten füllen
let params = $state<Record<string, string | number>>(
Object.fromEntries(tool.params.map((p) => [p.key, p.default ?? ''])),
);
// Parameter mit Vorgabewerten füllen, dann die zuletzt benutzten drüber
let params = $state<Record<string, string | number>>({
...Object.fromEntries(tool.params.map((p) => [p.key, p.default ?? ''])),
...(startParams ?? {}),
});
let busy = $state(false);
let error = $state('');
@ -111,6 +124,31 @@
cancelRequested = 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>
<div

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

88
src/lib/toolparams.ts Normal file
View file

@ -0,0 +1,88 @@
/**
* 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,6 +19,9 @@ export const iperfTool: Tool = {
icon: 'gauge-circle',
description: 'Misst Down-/Upload-Bandbreite gegen eine Gegenstelle.',
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: [
{ key: 'host', label: 'Gegenstelle (IP)', type: 'text', placeholder: '192.168.1.20' },
{ key: 'port', label: 'Port', type: 'number', default: 5201 },

View file

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

View file

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

View file

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

View file

@ -6,8 +6,17 @@
import { scanner } from '../../scanner';
import type { Tool } from '../types';
/** Häufige Ports im Standard-Scan */
const DEFAULT_PORTS = [21, 22, 23, 53, 80, 139, 443, 445, 502, 1883, 3389, 8080, 8443];
/**
* Häufige Ports im Standard-Scan.
*
* 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 = {
id: 'portscan',
@ -16,6 +25,8 @@ export const portScanTool: Tool = {
icon: 'scan-line',
description: 'Prüft offene TCP-Ports eines einzelnen Geräts.',
scope: 'device',
// Leere Portliste bedeutet ausdruecklich DEFAULT_PORTS
quickRun: true,
params: [
{
key: 'ports',

View file

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

View file

@ -12,6 +12,8 @@ export const tracerouteTool: Tool = {
icon: 'route',
description: 'Zeigt die Netzwerk-Hops bis zum Ziel.',
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' }],
async run(ctx) {
const host = String(ctx.params.host || '8.8.8.8');
@ -25,12 +27,16 @@ export const tracerouteTool: Tool = {
if (!reachedTarget) status = 2;
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 {
label: reachedTarget
? `${hops.length} Hops bis ${host}`
? `${stationen(hops.length)} bis ${host}`
: antwortend === 0
? `${host}: keine Antwort auf dem Weg`
: `${host} nicht erreicht (${antwortend} Hops geantwortet)`,
: `${host} nicht erreicht (${stationen(antwortend)} geantwortet)`,
result: {
ziel: host,
zielErreicht: reachedTarget,

View file

@ -97,6 +97,20 @@ export interface Tool {
params: ToolParamField[];
/** true = das Tool meldet Fortschritt über `ctx.onProgress` — ToolDialog zeigt dann Fortschrittsanzeige + Abbrechen statt des einfachen Spinners */
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) */
preview?(ctx: ToolContext): Promise<ToolPreview>;
run(ctx: ToolContext): Promise<ToolRunResult>;

View file

@ -1,5 +1,5 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { onMount, onDestroy, tick } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { App } from '@capacitor/app';
@ -26,7 +26,8 @@
import { toast } from '$lib/toast.svelte';
import { pushOverlay } from '$lib/overlay.svelte';
import { TOOLS, getTool } from '$lib/tools';
import { werkzeugName } from '$lib/messfelder';
import { werkzeugName, feldLabel } from '$lib/messfelder';
import { ladeParameterGedaechtnis, startParameter, merkeParameterFuer } from '$lib/toolparams';
import type { Tool } from '$lib/tools/types';
import type { Device, Protocol } from '$lib/types';
import * as Icons from 'lucide-svelte';
@ -46,6 +47,12 @@
let erkanntesSubnetz = $state('');
/** Menü hinter den drei Punkten (selten gebrauchte, gefährliche Aktionen) */
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 */
const kopfZusammenfassung = $derived(
@ -122,6 +129,9 @@
// der Platz für die Werkzeuge frei.
kopfdatenOffen = !(p.location || p.subnet || p.note);
// Zuletzt benutzte Werkzeug-Parameter laden (fuer den Schnellstart)
await ladeParameterGedaechtnis();
// Erkanntes Subnetz als Eingabevorschlag bereitstellen (best effort)
try {
const local = await scanner.getLocalSubnet();
@ -159,6 +169,13 @@
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) */
function icon(name: string) {
const pascal = name
@ -169,9 +186,100 @@
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;
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);
}
/**
@ -211,7 +319,7 @@
}
}
addMeasurement(protocol, {
const neueMessung = addMeasurement(protocol, {
deviceClientId: activeDevice?.clientId ?? null,
tool: tool.id,
category: tool.category,
@ -223,6 +331,36 @@
});
await persist();
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 */
@ -309,6 +447,7 @@
title={protocol.label}
subtitle={protocol.socName || protocol.orderRef || 'Diagnose'}
back
aktionen={ueberlaufMenue}
/>
<div class="flex-1 overflow-y-auto pb-24">
@ -396,12 +535,25 @@
{#each protocolTools as tool (tool.id)}
{@const IconC = icon(tool.icon)}
<button
class="flex flex-col items-start gap-1 rounded-lg bg-zinc-800 p-3 text-left active:bg-zinc-700"
onclick={() => openTool(tool)}
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"
onclick={() => werkzeugTap(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" />
<span class="text-sm font-medium">{tool.name}</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>
{/each}
<!-- Dauer-/Stresstest: eigene Seite (Live-Diagramm + Foreground-Service) -->
@ -467,7 +619,12 @@
<section class="px-3 pb-3">
<h2 class="mb-2 text-sm font-semibold text-zinc-300">Messungen</h2>
{#each protocolMeasurements() as m (m.clientId)}
<div class="mb-1.5 rounded-lg bg-zinc-900 p-2.5">
<div
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">
<AmpelBadge status={m.measureStatus} />
<span class="text-sm font-medium">{getTool(m.tool)?.name ?? werkzeugName(m.tool)}</span>
@ -476,6 +633,12 @@
<span class="ml-auto shrink-0 text-[11px] text-zinc-500">{fmtUhrzeit(m.dateMeasure)}</span>
</div>
<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>
{/each}
@ -500,6 +663,10 @@
</p>
{/if}
{#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
{device}
measurements={measurementsFor(device.clientId)}
@ -508,6 +675,7 @@
onfavorite={() => doToggleFav(device)}
onrename={() => (renameTarget = device)}
/>
</div>
{/each}
</section>
@ -550,11 +718,6 @@
</section>
{/if}
<div class="px-3">
<button class="text-xs text-red-400 underline" onclick={() => (confirmDelete = true)}>
Protokoll löschen
</button>
</div>
</div>
<!-- Abschluss-Leiste -->
@ -574,13 +737,20 @@
</div>
{#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
tool={activeTool}
{protocol}
device={activeDevice}
autostart={sofortStarten}
{startParams}
onclose={() => (activeTool = null)}
onrun={runTool}
/>
{/key}
{/if}
{#if renameTarget}
@ -594,6 +764,54 @@
/>
{/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}
<ConfirmDialog
title="Protokoll löschen?"

View file

@ -20,7 +20,7 @@
import { rssiRating } from '$lib/wifi/rating';
import { sync } from '$lib/sync.svelte';
import { toast } from '$lib/toast.svelte';
import type { LinkInfo, Protocol } from '$lib/types';
import type { LinkInfo, MeasureStatus, Protocol } from '$lib/types';
let protocol = $state<Protocol | null>(null);
let links = $state<LinkInfo[]>([]);
@ -104,10 +104,59 @@
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) {
saveOpen = false;
if (!protocol || !primary) return;
const raum = name.trim();
const bewertung = bewerteDose(primary);
addMeasurement(protocol, {
tool: 'iptest',
category: 'netzwerk',
@ -122,12 +171,13 @@
result: {
raum: raum || undefined,
...primary,
...(bewertung.hinweis ? { hinweis: bewertung.hinweis } : {}),
},
measureStatus: primary.ipv4 ? 0 : 2,
measureStatus: bewertung.status,
dateMeasure: Date.now(),
});
await persist();
toast.show('Messung gespeichert', 'success');
toast.show(bewertung.hinweis ?? 'Messung gespeichert', bewertung.status >= 2 ? 'error' : 'success');
}
</script>

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