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").
This commit is contained in:
Eduard Wisch 2026-08-16 19:09:19 +02:00
parent 767861046c
commit 85e68ca528
11 changed files with 346 additions and 20 deletions

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

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

@ -16,6 +16,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();
@ -169,9 +179,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 +312,7 @@
}
}
addMeasurement(protocol, {
const neueMessung = addMeasurement(protocol, {
deviceClientId: activeDevice?.clientId ?? null,
tool: tool.id,
category: tool.category,
@ -223,6 +324,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 */
@ -396,12 +527,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 +611,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 +625,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 +655,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 +667,7 @@
onfavorite={() => doToggleFav(device)}
onrename={() => (renameTarget = device)}
/>
</div>
{/each}
</section>
@ -574,13 +734,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}