Compare commits
4 commits
767861046c
...
a5ef9ff9a1
| Author | SHA1 | Date | |
|---|---|---|---|
| a5ef9ff9a1 | |||
| 0fe938a764 | |||
| 6483589592 | |||
| 85e68ca528 |
19 changed files with 1034 additions and 55 deletions
|
|
@ -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 -> ""
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
249
src/lib/monitor.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
|
|
@ -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
88
src/lib/toolparams.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
<ToolDialog
|
||||
tool={activeTool}
|
||||
{protocol}
|
||||
device={activeDevice}
|
||||
onclose={() => (activeTool = null)}
|
||||
onrun={runTool}
|
||||
/>
|
||||
<!-- {#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?"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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
89
tools/pruefe-monitor.mjs
Normal 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`);
|
||||
Loading…
Reference in a new issue