IP-Scans als benannte Snapshots speichern

- "Scan speichern" friert den aktuellen Geräte-Stand als benannten
  Snapshot ein (saveScan in protocols.ts, tiefe Kopie)
- Abschnitt "Gespeicherte Scans" auf der Protokollseite: aufklappbare
  Liste mit Name, Subnetz, Datum und Gerätezahl; Snapshot zeigt die
  damals gefundenen Geräte (DeviceCard read-only)
- Scan löschen per ConfirmDialog
- Snapshots bleiben am Auftrag erhalten, auch über spätere Re-Scans hinweg

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Eduard Wisch 2026-05-19 22:56:05 +02:00
parent 484b5f96fa
commit fd75748cb9
2 changed files with 140 additions and 5 deletions

View file

@ -3,7 +3,7 @@
*/
import { saveProtocol } from './db';
import type { Device, Measurement, Protocol } from './types';
import type { Device, Measurement, Protocol, SavedScan } from './types';
/** Eindeutige ID erzeugen */
export function uid(): string {
@ -73,6 +73,30 @@ export function renameDevice(protocol: Protocol, clientId: string, name: string)
if (d) d.customName = name.trim() || undefined;
}
/**
* Den aktuellen Geräte-Stand als benannten Scan-Snapshot einfrieren.
* Der Snapshot ist eine tiefe Kopie spätere Re-Scans verändern ihn nicht.
*/
export function saveScan(protocol: Protocol, name: string): SavedScan {
const scan: SavedScan = {
id: uid(),
name: name.trim() || new Date().toLocaleString('de-DE'),
createdAt: Date.now(),
subnet: protocol.subnet,
// JSON-Roundtrip löst den Svelte-State-Proxy und friert die Daten ein
devices: JSON.parse(JSON.stringify(protocol.devices)) as Device[],
};
(protocol.savedScans ??= []).push(scan);
return scan;
}
/** Gespeicherten Scan-Snapshot löschen */
export function deleteScan(protocol: Protocol, scanId: string): void {
if (protocol.savedScans) {
protocol.savedScans = protocol.savedScans.filter((s) => s.id !== scanId);
}
}
/** Messung zum Protokoll hinzufügen */
export function addMeasurement(
protocol: Protocol,

View file

@ -11,7 +11,14 @@
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 } from '$lib/protocols';
import {
addMeasurement,
upsertDevice,
toggleFavorite,
renameDevice,
saveScan,
deleteScan,
} from '$lib/protocols';
import { sync } from '$lib/sync.svelte';
import { toast } from '$lib/toast.svelte';
import { pushOverlay } from '$lib/overlay.svelte';
@ -26,6 +33,9 @@
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;
@ -162,6 +172,39 @@
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) ?? [];
}
@ -246,9 +289,16 @@
<!-- Geräte -->
<section class="px-3 pb-3">
<h2 class="mb-2 text-sm font-semibold text-zinc-300">
Geräte ({protocol.devices.length})
</h2>
<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.
@ -266,6 +316,45 @@
{/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
@ -315,6 +404,28 @@
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}