netdiag-app/src/routes/protokoll/[id]/+page.svelte
Eduard Wisch 69a4c50711 Phase 6: Lesbarkeit und Bedienung — Ampel mit Form, Klartext-Werte, Toasts
Ein Befund davon ist ein Fehler aus Phase 4/5, den erst die Bestandsprüfung
zutage gebracht hat:

- MeasurementResult konnte nur Skalare und Textlisten. Die WLAN-Kanalmessung
  besteht aber aus verschachtelten Objekten (eigenesNetz, netze,
  empfehlung24GHz) — auf dem Handy stand dort "[object Object]", während
  dieselbe Messung in Dolibarr als saubere Tabelle aussah. Jetzt:
  "Eigenes Netz: FRITZ!Box 7590 SM · Kanal 6 · 2.4 GHz · -50 dBm · sehr gut".

Weiter:
- Ampel mit Form UND Zeichen statt nur Farbe (AmpelBadge.svelte): Kreis ✓,
  Dreieck !, Quadrat ✕, Raute ?. Rot-Grün-Sehschwäche betrifft rund 9 % der
  Männer, und im Sonnenlicht sind gesättigte Farben auf dem Handy ohnehin
  kaum zu unterscheiden. Ersetzt drei duplizierte Farbpunkt-Stellen.
- Zeitstempel je Messung — bei mehreren Läufen desselben Werkzeugs war sonst
  nicht erkennbar, welche von wann ist.
- messfelder.ts: deutsche Bezeichnungen mit Einheiten, Gegenstück zu
  netdiagKundenfelder() im Modul. Aus "verlustProzent: 0 · minMs: 4.4" wird
  "Paketverlust: 0 % · Kürzeste Antwortzeit: 4.4 ms". Bewusst KEINE Whitelist
  wie im Kundendokument — die App ist die Technikeransicht, unbekannte
  Schlüssel werden lesbar gemacht statt ausgeblendet.
- Werkzeug-Klarnamen auch in der App: Messungen aus eigenen Routen
  (WLAN-Kanalanalyse, Monitor, IP-Test) haben keine Registry-ID, dort stand
  die rohe ID "wifikanal".
- Fehler-Toasts bleiben 12 s statt 3 s stehen, erscheinen unten im
  Daumenbereich statt oben am Notch und lassen sich zum Schließen antippen.
  Sie weichen einer festen Aktionsleiste aus, statt den "Abschließen"-Knopf
  zu verdecken.
- Kontrast: Messwerte von text-zinc-500 (3,67:1, unter WCAG-Minimum) auf
  text-zinc-400 (6,91:1), 11 -> 12 px, Zahlen mit tabular-nums.
- setKeepAwake (nativ, FLAG_KEEP_SCREEN_ON): Bildschirm bleibt an, solange
  eine Messung läuft. Der WakeLock aus Phase 1 hält nur die CPU wach — das
  Display ging trotzdem aus, und man musste beim Zusehen ständig antippen.

Im Emulator geprüft: WLAN-Kanalmessung wird vollständig lesbar dargestellt
(Kennzahlen, Kanalempfehlung, Hinweise, Netzliste), Ampel als Kreis mit
Haken, Zeitstempel, Klarname.
2026-08-16 11:21:59 +02:00

557 lines
20 KiB
Svelte

<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { App } from '@capacitor/app';
import type { PluginListenerHandle } from '@capacitor/core';
import AppHeader from '$lib/components/AppHeader.svelte';
import ToolDialog from '$lib/components/ToolDialog.svelte';
import MeasurementResult from '$lib/components/MeasurementResult.svelte';
import AmpelBadge from '$lib/components/AmpelBadge.svelte';
import DeviceCard from '$lib/components/DeviceCard.svelte';
import TextPromptDialog from '$lib/components/TextPromptDialog.svelte';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import { getProtocol, saveProtocol, deleteProtocol } from '$lib/db';
import {
addMeasurement,
upsertDevice,
toggleFavorite,
renameDevice,
saveScan,
deleteScan,
} from '$lib/protocols';
import { resumeFinishedStressSessions } from '$lib/stresstest';
import { sync } from '$lib/sync.svelte';
import { toast } from '$lib/toast.svelte';
import { pushOverlay } from '$lib/overlay.svelte';
import { TOOLS, getTool } from '$lib/tools';
import { werkzeugName } from '$lib/messfelder';
import type { Tool } from '$lib/tools/types';
import type { Device, Protocol } from '$lib/types';
import * as Icons from 'lucide-svelte';
let protocol = $state<Protocol | null>(null);
let activeTool = $state<Tool | null>(null);
let activeDevice = $state<Device | undefined>(undefined);
let saving = $state(false);
let renameTarget = $state<Device | null>(null);
let confirmDelete = $state(false);
let saveScanOpen = $state(false);
let expandedScan = $state<string | null>(null);
let deleteScanId = $state<string | null>(null);
let appStateListener: PluginListenerHandle | null = null;
// Stresstest ist seit Phase 2 KEIN Dialog-Werkzeug mehr — eigene Route mit
// Live-Anzeige (siehe stresstest.ts oben). Bleibt in TOOLS nur für
// getTool()-Namenslookups in der Messungen-Liste.
const protocolTools = TOOLS.filter((t) => t.scope === 'protocol' && t.id !== 'stresstest');
const deviceTools = TOOLS.filter((t) => t.scope === 'device');
/** Uhrzeit einer Messung (Datum nur, wenn sie nicht von heute ist) */
function fmtUhrzeit(ts: number): string {
const d = new Date(ts);
const heute = new Date();
const gleicherTag =
d.getDate() === heute.getDate() &&
d.getMonth() === heute.getMonth() &&
d.getFullYear() === heute.getFullYear();
return d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }) +
(gleicherTag ? '' : ' · ' + d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' }));
}
/** IP-Adressen numerisch vergleichen (nicht als Text — "…9" vor "…10") */
function ipCompare(a: string, b: string): number {
const pa = a.split('.').map(Number);
const pb = b.split('.').map(Number);
for (let i = 0; i < 4; i++) {
const diff = (pa[i] ?? 0) - (pb[i] ?? 0);
if (diff !== 0) return diff;
}
return 0;
}
/** Geräte mit Favoriten zuerst, sonst numerisch nach IP */
const sortedDevices = $derived(
[...(protocol?.devices ?? [])].sort((a, b) => {
const fav = Number(b.isFavorite ?? false) - Number(a.isFavorite ?? false);
return fav !== 0 ? fav : ipCompare(a.ip, b.ip);
}),
);
// Index = MeasureStatus (0 ok, 1 warn, 2 fail, 3 nicht messbar)
const ampel = ['ampel-ok', 'ampel-warn', 'ampel-fail', 'ampel-unmess'];
const ampelDot = ['bg-emerald-500', 'bg-amber-400', 'bg-red-500', 'bg-zinc-500'];
onMount(async () => {
const uuid = $page.params.id ?? '';
const p = await getProtocol(uuid);
if (!p) {
toast.show('Protokoll nicht gefunden', 'error');
goto('/auftraege/');
return;
}
protocol = p;
// Sicherheitsnetz: ein Dauertest kann natürlich auslaufen, während niemand
// auf der Stresstest-Seite ist (App im Hintergrund, anderes Werkzeug in
// Arbeit) — dort verpasst dann niemand das stressFinished-Event. Diese
// Seite wird vor dem Abschließen praktisch immer noch einmal geöffnet,
// deshalb hier zusätzlich nachsehen und das Ergebnis nachtragen.
if (await resumeFinishedStressSessions(protocol)) {
await persist();
}
// App wechselt in den Hintergrund (anderer App-Wechsel, Display aus) →
// sofort sichern, bevor Android den Prozess evtl. beendet.
appStateListener = await App.addListener('appStateChange', ({ isActive }) => {
if (!isActive) void persist();
});
});
onDestroy(() => {
// Beim Verlassen der Seite (Back-Tap, Navigation) final sichern — fängt
// auch Eingaben ab, die noch nicht per onblur gespeichert wurden.
void persist();
appStateListener?.remove();
});
/** Protokoll als geändert markieren und lokal speichern */
async function persist() {
if (!protocol) return;
protocol.dirty = true;
await saveProtocol($state.snapshot(protocol) as Protocol);
await sync.refreshPending();
}
// Offenen Werkzeug-Dialog beim Hardware-Backbutton schließen, statt
// gleich die Seite zu verlassen.
$effect(() => {
if (!activeTool) return;
return pushOverlay(() => (activeTool = null));
});
/** Lucide-Icon dynamisch holen (Tool-Icon-Name ist kebab-case) */
function icon(name: string) {
const pascal = name
.split('-')
.map((s) => s.charAt(0).toUpperCase() + s.slice(1))
.join('');
const map = Icons as unknown as Record<string, unknown>;
return (map[pascal] ?? Icons.Wrench) as typeof Icons.Wrench;
}
function openTool(tool: Tool, device?: Device) {
activeTool = tool;
activeDevice = device;
}
/**
* Tool ausführen, Ergebnis ins Protokoll übernehmen.
* `live` ist nur bei `tool.supportsProgress` gesetzt (z.B. IP-Scanner) —
* ToolDialog reicht darüber Fortschritt/Abbruch bis ins Tool durch.
*/
async function runTool(
params: Record<string, string | number>,
live?: {
onProgress?: (p: { done: number; total: number; found: number; foundIps?: string[] }) => void;
isCancelled?: () => boolean;
},
) {
if (!protocol || !activeTool) return;
const tool = activeTool;
const result = await tool.run({
params,
protocol,
device: activeDevice,
onProgress: live?.onProgress,
isCancelled: live?.isCancelled,
});
// Änderungen am Protokoll selbst (z.B. der beim IP-Scan erkannte
// Netzbereich) gezielt hier anwenden — Tools dürfen ctx.protocol nicht
// direkt beschreiben (siehe ToolRunResult.protocolPatch).
if (result.protocolPatch) {
Object.assign(protocol, result.protocolPatch);
}
// Neu gefundene Geräte übernehmen (z.B. IP-Scan) — alle gelieferten
// Felder durchreichen (mac, hostname, vendor, deviceType, mDNS, Ports …)
if (result.devices) {
for (const d of result.devices) {
upsertDevice(protocol, { ...d, lastSeen: Date.now() });
}
}
addMeasurement(protocol, {
deviceClientId: activeDevice?.clientId ?? null,
tool: tool.id,
category: tool.category,
label: result.label,
params,
result: result.result,
measureStatus: result.measureStatus,
dateMeasure: Date.now(),
});
await persist();
toast.show(`${tool.name}: ${result.label}`, result.measureStatus === 2 ? 'error' : 'success');
}
/** Protokoll abschließen und synchronisieren */
async function finish() {
if (!protocol) return;
saving = true;
protocol.status = 1;
await persist();
await sync.syncNow();
saving = false;
toast.show(
sync.status === 'error' ? 'Gespeichert — Sync folgt bei Verbindung' : 'Abgeschlossen & synchronisiert',
sync.status === 'error' ? 'info' : 'success',
);
}
/** Favoriten-Stern eines Geräts umschalten */
function doToggleFav(device: Device) {
if (!protocol) return;
toggleFavorite(protocol, device.clientId);
void persist();
}
/** Gerät umbenennen (aus dem Namens-Dialog) */
function doRename(name: string) {
if (protocol && renameTarget) {
renameDevice(protocol, renameTarget.clientId, name);
void persist();
}
renameTarget = null;
}
async function doDelete() {
confirmDelete = false;
if (!protocol) return;
await deleteProtocol(protocol.clientUuid);
await sync.refreshPending();
goto('/auftraege/');
}
/** Datum + Uhrzeit kurz */
function fmtDateTime(ts: number): string {
return new Date(ts).toLocaleString('de-DE', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
/** Vorschlag für den Scan-Namen */
function defaultScanName(): string {
return 'Scan ' + fmtDateTime(Date.now());
}
/** Aktuellen Geräte-Stand als Snapshot speichern */
function doSaveScan(name: string) {
if (protocol) {
saveScan(protocol, name);
void persist();
toast.show('Scan gespeichert', 'success');
}
saveScanOpen = false;
}
function doDeleteScan() {
if (protocol && deleteScanId) {
deleteScan(protocol, deleteScanId);
void persist();
}
deleteScanId = null;
}
function measurementsFor(deviceClientId: string) {
return protocol?.measurements.filter((m) => m.deviceClientId === deviceClientId) ?? [];
}
function protocolMeasurements() {
return protocol?.measurements.filter((m) => !m.deviceClientId) ?? [];
}
</script>
{#if protocol}
<AppHeader
title={protocol.label}
subtitle={protocol.socName || protocol.orderRef || 'Diagnose'}
back
/>
<div class="flex-1 overflow-y-auto pb-24">
<!-- Stammdaten -->
<section class="flex flex-col gap-2 border-b border-zinc-800 p-3">
<label class="flex flex-col gap-1 text-xs text-zinc-400">
Standort
<input
class="rounded border border-zinc-700 bg-zinc-800 px-2 py-1.5 text-sm text-zinc-100"
bind:value={protocol.location}
onblur={persist}
placeholder="Gebäude / Raum"
/>
</label>
<label class="flex flex-col gap-1 text-xs text-zinc-400">
Netzbereich
<input
class="rounded border border-zinc-700 bg-zinc-800 px-2 py-1.5 text-sm text-zinc-100"
bind:value={protocol.subnet}
onblur={persist}
placeholder="192.168.1.0/24"
/>
</label>
<label class="flex flex-col gap-1 text-xs text-zinc-400">
Notiz
<textarea
class="rounded border border-zinc-700 bg-zinc-800 px-2 py-1.5 text-sm text-zinc-100"
rows="2"
bind:value={protocol.note}
onblur={persist}
></textarea>
</label>
</section>
<!-- Werkzeuge -->
<section class="p-3">
<h2 class="mb-2 text-sm font-semibold text-zinc-300">Werkzeuge</h2>
<div class="grid grid-cols-2 gap-2">
{#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)}
>
<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>
</button>
{/each}
<!-- Dauer-/Stresstest: eigene Seite (Live-Diagramm + Foreground-Service) -->
<a
class="flex flex-col items-start gap-1 rounded-lg bg-zinc-800 p-3 active:bg-zinc-700"
href="/protokoll/{protocol.clientUuid}/stresstest/"
>
<Icons.Gauge size={20} class="text-sky-400" />
<span class="text-sm font-medium">Dauer-/Stresstest</span>
<span class="text-[11px] leading-tight text-zinc-500">
Langzeitmessung: Paketverlust und Latenz über einen Zeitraum, live sichtbar.
</span>
</a>
<!-- Geräte-Monitor: eigene Seite (Mehrfachauswahl + Dauerlauf) -->
<a
class="flex flex-col items-start gap-1 rounded-lg bg-zinc-800 p-3 active:bg-zinc-700"
href="/protokoll/{protocol.clientUuid}/monitor/"
>
<Icons.Activity size={20} class="text-sky-400" />
<span class="text-sm font-medium">Geräte-Monitor</span>
<span class="text-[11px] leading-tight text-zinc-500">
Erreichbarkeit mehrerer Geräte dauerhaft überwachen.
</span>
</a>
<!-- IP-Test: USB-RJ45 in Dose stecken, IP + Link-Speed live anzeigen -->
<a
class="flex flex-col items-start gap-1 rounded-lg bg-zinc-800 p-3 active:bg-zinc-700"
href="/protokoll/{protocol.clientUuid}/iptest/"
>
<Icons.Cable size={20} class="text-sky-400" />
<span class="text-sm font-medium">IP-Test</span>
<span class="text-[11px] leading-tight text-zinc-500">
Dose prüfen: IP, DHCP und 10/100/1000 Mbit ablesen.
</span>
</a>
<!-- WLAN-Empfangstracker: Netz anklicken, durchs Gebäude laufen -->
<a
class="flex flex-col items-start gap-1 rounded-lg bg-zinc-800 p-3 active:bg-zinc-700"
href="/protokoll/{protocol.clientUuid}/wifi/"
>
<Icons.Wifi size={20} class="text-sky-400" />
<span class="text-sm font-medium">WLAN-Empfang</span>
<span class="text-[11px] leading-tight text-zinc-500">
Empfangsstärke beim Durchgehen aufzeichnen.
</span>
</a>
<!-- WLAN-Kanalanalyse: alle Netze auf einen Blick, Kanalgraph -->
<a
class="flex flex-col items-start gap-1 rounded-lg bg-zinc-800 p-3 active:bg-zinc-700"
href="/protokoll/{protocol.clientUuid}/wifikanal/"
>
<Icons.Radio size={20} class="text-sky-400" />
<span class="text-sm font-medium">WLAN-Kanäle</span>
<span class="text-[11px] leading-tight text-zinc-500">
Kanalbelegung, Überlappung und Kanalempfehlung — alle Netze auf einen Blick.
</span>
</a>
</div>
</section>
<!-- Protokoll-Messungen -->
{#if protocolMeasurements().length > 0}
<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 class="flex items-center gap-2">
<AmpelBadge status={m.measureStatus} />
<span class="text-sm font-medium">{getTool(m.tool)?.name ?? werkzeugName(m.tool)}</span>
<!-- Zeitstempel: bei mehreren Läufen desselben Werkzeugs sonst
nicht erkennbar, welche Messung von wann ist -->
<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>
<div class="mt-0.5"><MeasurementResult result={m.result} /></div>
</div>
{/each}
</section>
{/if}
<!-- Geräte -->
<section class="px-3 pb-3">
<div class="mb-2 flex items-center justify-between">
<h2 class="text-sm font-semibold text-zinc-300">
Geräte ({protocol.devices.length})
</h2>
{#if protocol.devices.length > 0}
<button class="text-xs text-sky-300 active:text-sky-200" onclick={() => (saveScanOpen = true)}>
Scan speichern
</button>
{/if}
</div>
{#if protocol.devices.length === 0}
<p class="text-xs text-zinc-500">
Noch keine Geräte — IP-Scanner ausführen, um das Netz zu erfassen.
</p>
{/if}
{#each sortedDevices as device (device.clientId)}
<DeviceCard
{device}
measurements={measurementsFor(device.clientId)}
tools={deviceTools}
onrun={(tool) => openTool(tool, device)}
onfavorite={() => doToggleFav(device)}
onrename={() => (renameTarget = device)}
/>
{/each}
</section>
<!-- Gespeicherte Scans -->
{#if protocol.savedScans && protocol.savedScans.length > 0}
<section class="px-3 pb-3">
<h2 class="mb-2 text-sm font-semibold text-zinc-300">Gespeicherte Scans</h2>
{#each protocol.savedScans as scan (scan.id)}
<div class="mb-1.5 rounded-lg bg-zinc-900">
<button
class="flex w-full items-center justify-between gap-2 p-2.5 text-left"
onclick={() => (expandedScan = expandedScan === scan.id ? null : scan.id)}
>
<div class="min-w-0">
<div class="truncate text-sm font-medium">{scan.name}</div>
<div class="text-[11px] text-zinc-500">
{scan.devices.length} Geräte · {scan.subnet || '—'} · {fmtDateTime(scan.createdAt)}
</div>
</div>
<Icons.ChevronDown
size={16}
class="shrink-0 text-zinc-500 {expandedScan === scan.id ? 'rotate-180' : ''}"
/>
</button>
{#if expandedScan === scan.id}
<div class="border-t border-zinc-800 p-2.5">
{#each scan.devices as d (d.clientId)}
<DeviceCard device={d} />
{/each}
<button
class="mt-1 text-xs text-red-400 underline"
onclick={() => (deleteScanId = scan.id)}
>
Scan löschen
</button>
</div>
{/if}
</div>
{/each}
</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 -->
<!-- Feste Aktionsleiste. Sie setzt --leiste-hoehe, damit die Kurzmeldungen
darüber erscheinen statt den Abschließen-Knopf zu verdecken. -->
<div
class="fixed inset-x-0 bottom-0 border-t border-zinc-800 bg-zinc-900 p-3 safe-bottom"
style="--leiste: 1"
>
<button
class="w-full rounded-lg bg-emerald-600 py-2.5 font-semibold text-white active:bg-emerald-700 disabled:opacity-50"
onclick={finish}
disabled={saving}
>
{protocol.status === 1 ? 'Erneut synchronisieren' : 'Abschließen & synchronisieren'}
</button>
</div>
{#if activeTool}
<ToolDialog
tool={activeTool}
{protocol}
device={activeDevice}
onclose={() => (activeTool = null)}
onrun={runTool}
/>
{/if}
{#if renameTarget}
<TextPromptDialog
title="Gerät benennen"
label="Eigener Name"
value={renameTarget.customName ?? ''}
placeholder={renameTarget.hostname ?? renameTarget.ip}
onsubmit={doRename}
oncancel={() => (renameTarget = null)}
/>
{/if}
{#if confirmDelete}
<ConfirmDialog
title="Protokoll löschen?"
message="Das Protokoll und alle Messungen werden lokal entfernt."
confirmLabel="Löschen"
danger
onconfirm={doDelete}
oncancel={() => (confirmDelete = false)}
/>
{/if}
{#if saveScanOpen}
<TextPromptDialog
title="Scan speichern"
label="Name des Scans"
value={defaultScanName()}
placeholder="z.B. Erdgeschoss"
onsubmit={doSaveScan}
oncancel={() => (saveScanOpen = false)}
/>
{/if}
{#if deleteScanId}
<ConfirmDialog
title="Scan löschen?"
message="Der gespeicherte Scan-Snapshot wird entfernt."
confirmLabel="Löschen"
danger
onconfirm={doDeleteScan}
oncancel={() => (deleteScanId = null)}
/>
{/if}
{:else}
<div class="flex min-h-screen items-center justify-center text-zinc-500">Lädt …</div>
{/if}