Phase 3: IP-Scanner komplett überarbeitet — mehrgleisige Suche, Live-Fortschritt

Bisher fand der IP-Scan Geräte nur per ICMP-Ping; ein Windows-PC mit aktiver
Firewall (Ping blockiert, Ports offen) fiel komplett durch. Kein Fortschritt
während des Laufs, kein Abbrechen, kein Hinweis was gescannt wird.

Nativ (NetDiagScannerPlugin.kt):
- Mehrgleisige Suche: gefunden bei Ping ODER Port-Probe ODER ARP-Tabellen-
  Eintrag. Runde 1 über alle Adressen, ARP-Abgleich, Runde 2 NUR für die
  weiterhin stillen Adressen, ARP erneut abgleichen.
- startIpScan()/cancelIpScan() statt eines einzigen langen ipScan()-Promise:
  läuft als Lauf mit Live-Events (ipScanProgress/ipScanFinished), Abbruch
  liefert das bis dahin gefundene Teilergebnis statt nichts.
- Eigener limitedParallelism(128)-Dispatcher, damit ein großer Sweep keinen
  gleichzeitigen Ping/Monitor/Dauertest ausbremst.
- arpAvailable ehrlich über File.canRead() statt "Tabelle war halt leer".
- mDNS-Budget 4s -> 9s, Discovery/Auflösung entkoppelt (eigene Nachlaufzeit),
  discoveryOk meldet einen echten Suchfehler statt stiller 0 Treffer.

App:
- scanner.ts: neue Events onIpScanProgress/onIpScanFinished.
- ipscan.ts: protocolPatch statt direktem Beschreiben von ctx.protocol;
  fehlgeschlagener Scan erzeugt jetzt auch eine Messung (Status 2 statt
  nichts); "neu"/"nicht mehr erreichbar" als Diagnosefelder.
- ToolDialog: generischer Vorschau-Schritt (tool.preview — Adapter, eigene
  IP, Gateway, Adressenzahl; ab >1024 Adressen Bestätigung "Trotzdem
  scannen") und Live-Fortschritt (tool.supportsProgress — Fortschrittsbalken,
  laufende Trefferliste, Abbrechen). Beide Mechanismen sind generisch für
  alle Tools nutzbar, nicht IP-Scan-spezifisch fest verdrahtet.
- DeviceCard zeigt den Fundweg ("via Ping/Port/ARP/mDNS") als Badge.
- Geräteliste numerisch nach IP sortiert (Favoriten weiter zuerst).

Im Emulator getestet (nicht nur Codereview): ICMP per iptables zu einem
Ziel geblockt, nur TCP/22 offen -> Gerät korrekt "via Port" gefunden.
/24 zweimal komplett gescannt: gleiche 4 Geräte, zweiter Lauf ohne "neu".
/20 (4094 Adressen) löst die Bestätigung korrekt aus. Abbrechen mitten im
Lauf liefert sauber ein Teilergebnis. Sync nach Dolibarr per DB-Query
verifiziert.

Dabei zwei echte Bugs gefunden, die reines Codereview nicht gefunden hätte:
Fortschritt zeigte "504 von 254" (Runde 2 zählte im Zähler von Runde 1
weiter), und das native Feld hieß "gefundenVia" statt "foundVia" wie von
der TS-Seite erwartet — der Fundweg kam nie an.

Details: ROADMAP_UMSETZUNG.md Phase 3
This commit is contained in:
Eduard Wisch 2026-08-15 12:47:28 +02:00
parent 85f1c24618
commit 8b369fc3bc
8 changed files with 953 additions and 206 deletions

View file

@ -26,10 +26,12 @@ import com.getcapacitor.PluginMethod
import com.getcapacitor.annotation.CapacitorPlugin
import com.getcapacitor.annotation.Permission
import com.getcapacitor.annotation.PermissionCallback
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
@ -265,76 +267,216 @@ class NetDiagScannerPlugin : Plugin() {
/* --------------------------------------------------------------------- */
/* IP-Scan: Geräte im Subnetz finden */
/* */
/* Mehrgleisig (ROADMAP_UMSETZUNG.md Phase 3): ein Host gilt als gefunden, */
/* wenn er auf Ping ODER eine kurze Port-Probe ODER einen ARP-Tabellen- */
/* Eintrag antwortet. Die alte Fassung fragte nur ICMP ab und übersah */
/* damit jedes Gerät mit aktiver Firewall (z.B. Windows-PC), das ICMP */
/* blockt, aber TCP-Ports offen hat oder sich zumindest per ARP zeigt. */
/* */
/* Ablauf: Runde 1 (Ping+Port) über ALLE Adressen -> ARP-Tabelle lesen, */
/* stille Adressen damit abgleichen -> Runde 2 NUR für die weiterhin */
/* stillen Adressen (fängt Verluste durch kurze WLAN-Aussetzer ab) -> ARP- */
/* Tabelle erneut lesen und abgleichen -> Anreicherung (Hostname, NetBIOS, */
/* fehlende Portliste bei ARP-only-Treffern). */
/* */
/* Läuft als eigener Run mit `runId`: startIpScan liefert sofort {runId, */
/* total} zurück, Fortschritt kommt über das Event `ipScanProgress`, das */
/* Endergebnis über `ipScanFinished` (genau einmal, ob regulär durchgelaufen*/
/* oder per cancelIpScan abgebrochen — dann mit `cancelled=true` und dem */
/* bis dahin gefundenen Teilergebnis, keine leere Antwort). Eigener, auf */
/* 128 begrenzter Dispatcher statt des geteilten Dispatchers.IO, damit ein */
/* großer Sweep keinen gleichzeitig laufenden Ping/Monitor/Dauertest */
/* ausbremst. */
/* --------------------------------------------------------------------- */
private val ipScanDispatcher = Dispatchers.IO.limitedParallelism(128)
private val ipScanRuns = ConcurrentHashMap<String, Job>()
@PluginMethod
fun ipScan(call: PluginCall) {
fun startIpScan(call: PluginCall) {
val subnet = call.getString("subnet") ?: return call.reject("subnet fehlt")
val hosts = hostsInSubnet(subnet)
if (hosts.isEmpty()) {
return call.reject("Subnetz ungültig oder zu groß (max /16): $subnet")
}
io.launch {
val runId = "ipscan-${System.currentTimeMillis()}"
val job = io.launch {
// Ohne wachgehaltenes Funkmodul faellt bei einem laengeren Sweep die
// Trefferquote, weil das WLAN zwischendurch in den Sparmodus geht.
val locks = acquireRadioLocks("ipscan", (hosts.size * 60L).coerceIn(30_000L, 600_000L))
val startMs = System.currentTimeMillis()
val results = ConcurrentHashMap<Int, EnrichedHost>()
var arpReadable = false
try {
// Parallel-Ping über ALLE Host-Adressen des Subnetzes — CIDR-genau,
// also exakt der Bereich, den die Netzmaske aufspannt (/24, /23, /22 …).
val alive = withContext(Dispatchers.IO) {
hosts.map { ipInt ->
async {
val ip = intToIpv4(ipInt)
if (InetAddress.getByName(ip).isReachable(350)) ip else null
}
}.awaitAll().filterNotNull()
}
val arp = readArpTable()
// Pro lebendem Host parallel anreichern: Reverse-DNS, NetBIOS-Name,
// Quick-Port-Probe (für die Geräteart-Heuristik).
val enriched = withContext(Dispatchers.IO) {
alive.map { ip ->
async {
val hostname = try {
val n = InetAddress.getByName(ip).canonicalHostName
if (n != ip) n else ""
} catch (_: Exception) { "" }
EnrichedHost(ip, hostname, netbiosName(ip), quickPortProbe(ip))
}
}.awaitAll()
}
val devices = JSArray()
for (h in enriched) {
val dev = JSObject().put("ip", h.ip)
val mac = arp[h.ip]
val vendor = mac?.let { ouiVendor(it) } ?: ""
if (mac != null) dev.put("mac", mac)
if (vendor.isNotEmpty()) dev.put("vendor", vendor)
if (h.hostname.isNotEmpty()) dev.put("hostname", h.hostname)
if (!h.netbios.isNullOrEmpty()) dev.put("netbiosName", h.netbios)
if (h.openPorts.isNotEmpty()) {
val pa = JSArray()
h.openPorts.sorted().forEach { pa.put(it) }
dev.put("openPorts", pa)
arpReadable = arpTableReadable()
// done/total bewusst PRO RUNDE gezaehlt (eigener Zaehler je
// probeRound()-Aufruf), nicht ueber beide Runden hinweg
// aufsummiert — sonst zeigt die zweite Runde (nur die stillen
// Adressen aus Runde 1) einen "done" jenseits von "total" an
// (z.B. "504 von 254"), weil Runde 2 auf demselben Zaehler
// weiterlief statt neu bei 0 zu beginnen.
suspend fun probeRound(targets: List<Int>) {
if (targets.isEmpty()) return
val doneCount = java.util.concurrent.atomic.AtomicInteger(0)
val progressStep = (targets.size / 40).coerceAtLeast(1)
withContext(ipScanDispatcher) {
targets.map { ipInt ->
async {
val ip = intToIpv4(ipInt)
var alive = try {
InetAddress.getByName(ip).isReachable(900)
} catch (_: Exception) { false }
var method = if (alive) "ping" else ""
var ports: List<Int> = emptyList()
if (!alive) {
ports = quickPortProbe(ip)
if (ports.isNotEmpty()) { alive = true; method = "port" }
}
if (alive) results[ipInt] = EnrichedHost(ip, "", null, ports, method)
val d = doneCount.incrementAndGet()
if (d % progressStep == 0 || d == targets.size) {
// Live-Trefferliste (nur IPs, ohne Anreicherung — die kommt
// erst im Endergebnis) für "laufende Trefferliste" in der UI.
val foundIps = JSArray()
results.values.forEach { foundIps.put(it.ip) }
notifyListeners(
"ipScanProgress",
JSObject().put("runId", runId).put("done", d)
.put("total", targets.size).put("found", results.size)
.put("foundIps", foundIps),
)
}
}
}.awaitAll()
}
val nameHint = h.hostname.ifEmpty { h.netbios ?: "" }
val type = guessDeviceType(vendor, nameHint, h.openPorts)
if (type.isNotEmpty()) dev.put("deviceType", type)
devices.put(dev)
}
resolve(call, JSObject()
.put("devices", devices)
// Diagnosefelder: ein leeres Ergebnis ist etwas anderes als ein
// gescheiterter Scan — der Aufrufer kann das jetzt unterscheiden.
.put("probed", hosts.size)
.put("answered", alive.size)
.put("arpAvailable", arp.isNotEmpty()))
fun reconcileArp() {
val arp = readArpTable()
for (ipInt in hosts) {
if (results.containsKey(ipInt)) continue
val mac = arp[intToIpv4(ipInt)]
if (mac != null) {
results[ipInt] = EnrichedHost(intToIpv4(ipInt), "", null, emptyList(), "arp")
}
}
}
probeRound(hosts)
reconcileArp()
val silent = hosts.filter { !results.containsKey(it) }
probeRound(silent) // zweite Runde NUR fuer die stillen Adressen
reconcileArp()
finishIpScan(runId, results, hosts.size, arpReadable, startMs, cancelled = false)
} catch (e: CancellationException) {
// NonCancellable: Anreicherung + Abschluss-Event brauchen noch
// Suspend-Aufrufe (withContext/InetAddress), die nach einer
// Job-Stornierung sonst sofort selbst wieder abbrechen wuerden —
// das Teilergebnis soll trotz Abbruch beim Aufrufer ankommen.
withContext(NonCancellable) {
finishIpScan(runId, results, hosts.size, arpReadable, startMs, cancelled = true)
}
} catch (e: Exception) {
call.reject("ipScan: ${e.message}")
android.util.Log.e(TAG, "ipScan ($runId): ${e.message}", e)
notifyListeners(
"ipScanFinished",
JSObject().put("runId", runId).put("devices", JSArray())
.put("probed", hosts.size).put("answered", 0)
.put("arpAvailable", arpReadable)
.put("durationMs", System.currentTimeMillis() - startMs)
.put("cancelled", false)
.put("error", e.message ?: "unbekannter Fehler"),
)
} finally {
releaseRadioLocks(locks)
ipScanRuns.remove(runId)
}
}
ipScanRuns[runId] = job
resolve(call, JSObject().put("runId", runId).put("total", hosts.size))
}
/** Laufenden IP-Scan abbrechen — das bis dahin gefundene Teilergebnis kommt regulär über `ipScanFinished`. */
@PluginMethod
fun cancelIpScan(call: PluginCall) {
val runId = call.getString("runId") ?: return call.reject("runId fehlt")
ipScanRuns[runId]?.cancel()
resolve(call, JSObject().put("ok", true))
}
/**
* Anreicherung (Hostname, NetBIOS, fehlende Portliste bei ARP-only-Treffern)
* und Abschluss-Event eines IP-Scans gemeinsamer Pfad für regulären
* Abschluss und Abbruch, damit in beiden Faellen dieselben Diagnosefelder
* geliefert werden.
*/
private suspend fun finishIpScan(
runId: String,
results: Map<Int, EnrichedHost>,
probed: Int,
arpReadable: Boolean,
startMs: Long,
cancelled: Boolean,
) {
val enrichedFinal = withContext(ipScanDispatcher) {
results.values.map { h ->
async {
val hostname = try {
val n = InetAddress.getByName(h.ip).canonicalHostName
if (n != h.ip) n else ""
} catch (_: Exception) { "" }
val netbios = netbiosName(h.ip)
val ports = if (h.openPorts.isEmpty()) quickPortProbe(h.ip) else h.openPorts
h.copy(hostname = hostname, netbios = netbios, openPorts = ports)
}
}.awaitAll()
}
val arpFinal = readArpTable()
val devices = JSArray()
for (h in enrichedFinal.sortedBy { ipv4ToInt(it.ip) ?: 0 }) {
val dev = JSObject().put("ip", h.ip).put("foundVia", h.method)
val mac = arpFinal[h.ip]
val vendor = mac?.let { ouiVendor(it) } ?: ""
if (mac != null) dev.put("mac", mac)
if (vendor.isNotEmpty()) dev.put("vendor", vendor)
if (h.hostname.isNotEmpty()) dev.put("hostname", h.hostname)
if (!h.netbios.isNullOrEmpty()) dev.put("netbiosName", h.netbios)
if (h.openPorts.isNotEmpty()) {
val pa = JSArray()
h.openPorts.sorted().forEach { pa.put(it) }
dev.put("openPorts", pa)
}
val nameHint = h.hostname.ifEmpty { h.netbios ?: "" }
val type = guessDeviceType(vendor, nameHint, h.openPorts)
if (type.isNotEmpty()) dev.put("deviceType", type)
devices.put(dev)
}
notifyListeners(
"ipScanFinished",
JSObject()
.put("runId", runId)
.put("devices", devices)
// Diagnosefelder: ein leeres Ergebnis ist etwas anderes als ein
// gescheiterter Scan — der Aufrufer kann das jetzt unterscheiden.
.put("probed", probed)
.put("answered", results.size)
.put("arpAvailable", arpReadable)
.put("durationMs", System.currentTimeMillis() - startMs)
.put("cancelled", cancelled),
)
}
/** Ist /proc/net/arp fuer diesen Prozess lesbar? (ab Android 10 haeufig nicht) */
private fun arpTableReadable(): Boolean {
return try {
File("/proc/net/arp").canRead()
} catch (_: Exception) {
false
}
}
/** Zwischenergebnis der parallelen Geräte-Anreicherung im IP-Scan */
@ -343,6 +485,8 @@ class NetDiagScannerPlugin : Plugin() {
val hostname: String,
val netbios: String?,
val openPorts: List<Int>,
/** Fundweg: "ping" | "port" | "arp" */
val method: String = "",
)
/**
@ -452,10 +596,13 @@ class NetDiagScannerPlugin : Plugin() {
*/
@PluginMethod
fun mdnsScan(call: PluginCall) {
val timeoutMs = (call.getInt("timeoutMs") ?: 4000).toLong()
// 8-10 s Budget (ROADMAP_UMSETZUNG.md Phase 3) statt der alten 4 s — die
// meisten mDNS-Antworten trudeln erst nach 2-5 s ein, viele Drucker/
// Kameras antworten erst auf eine zweite Anfrage-Runde.
val timeoutMs = ((call.getInt("timeoutMs") ?: 9000).toLong()).coerceIn(3000L, 15000L)
io.launch {
try {
val found = discoverMdns(timeoutMs)
val (found, discoveryOk) = discoverMdns(timeoutMs)
val arr = JSArray()
for ((ip, info) in found) {
val services = JSArray()
@ -465,7 +612,12 @@ class NetDiagScannerPlugin : Plugin() {
.put("name", info.name)
.put("services", services))
}
resolve(call, JSObject().put("devices", arr))
resolve(call, JSObject()
.put("devices", arr)
// false = die Dienstsuche konnte fuer KEINEN einzigen Diensttyp
// gestartet werden (z.B. NSD nicht verfuegbar) — etwas anderes
// als "gesucht, aber nichts gefunden".
.put("discoveryOk", discoveryOk))
} catch (e: Exception) {
call.reject("mdnsScan: ${e.message}")
}
@ -477,8 +629,18 @@ class NetDiagScannerPlugin : Plugin() {
val services: MutableSet<String> = ConcurrentHashMap.newKeySet()
}
/**
* mDNS/Bonjour-Suche. Discovery und Auflösung sind entkoppelt: die Suche
* läuft das VOLLE Budget, die Auflösung bekommt danach noch eine kurze
* Nachlaufzeit (`resolveGraceMs`) sonst gingen Dienste, die erst gegen
* Ende der Suchzeit gemeldet wurden, unaufgelöst verloren (die alte
* Fassung teilte sich Suche UND Auflösung dieselbe Deadline).
*
* @return gefundene Geräte + ob die Suche für mindestens einen Diensttyp
* überhaupt starten konnte (ehrliches Fehlersignal statt stumm 0).
*/
@Suppress("DEPRECATION")
private fun discoverMdns(timeoutMs: Long): Map<String, MdnsInfo> {
private fun discoverMdns(timeoutMs: Long): Pair<Map<String, MdnsInfo>, Boolean> {
val nsd = context.applicationContext
.getSystemService(Context.NSD_SERVICE) as NsdManager
val wifi = context.applicationContext
@ -491,6 +653,7 @@ class NetDiagScannerPlugin : Plugin() {
val result = ConcurrentHashMap<String, MdnsInfo>()
val pending = ConcurrentLinkedQueue<NsdServiceInfo>()
val listeners = ArrayList<NsdManager.DiscoveryListener>()
var anyStarted = false
val mlock = wifi.createMulticastLock("netdiag-mdns").apply {
setReferenceCounted(true)
try { acquire() } catch (_: Exception) { }
@ -508,14 +671,20 @@ class NetDiagScannerPlugin : Plugin() {
try {
nsd.discoverServices(type, NsdManager.PROTOCOL_DNS_SD, l)
listeners.add(l)
anyStarted = true
} catch (_: Exception) { }
}
// Gefundene Dienste seriell auflösen — NsdManager.resolveService
// verträgt keine parallelen Aufrufe.
val deadline = System.currentTimeMillis() + timeoutMs
while (System.currentTimeMillis() < deadline) {
val discoveryDeadline = System.currentTimeMillis() + timeoutMs
val resolveGraceMs = 1500L
val resolveDeadline = discoveryDeadline + resolveGraceMs
while (System.currentTimeMillis() < resolveDeadline) {
val info = pending.poll()
if (info == null) {
// Suchzeit vorbei UND nichts mehr in der Warteschlange -> fertig,
// nicht bis zum Ende der Nachlaufzeit sinnlos weiterschlafen.
if (System.currentTimeMillis() >= discoveryDeadline && pending.isEmpty()) break
Thread.sleep(100)
continue
}
@ -543,7 +712,7 @@ class NetDiagScannerPlugin : Plugin() {
}
try { if (mlock.isHeld) mlock.release() } catch (_: Exception) { }
}
return result
return result to anyStarted
}
/**

View file

@ -33,6 +33,14 @@
const ampel = ['ampel-ok', 'ampel-warn', 'ampel-fail', 'ampel-unmess'];
const ampelDot = ['bg-emerald-500', 'bg-amber-400', 'bg-red-500', 'bg-zinc-500'];
/** Fundweg des letzten IP-Scans (device.foundVia) als Klartext */
const foundViaLabel: Record<string, string> = {
ping: 'Ping',
port: 'Port',
arp: 'ARP',
mdns: 'mDNS',
};
/** Anzeigename: eigener Name vor mDNS-/Host-/NetBIOS-Name, sonst IP */
const title = $derived(
device.customName ||
@ -84,7 +92,7 @@
</div>
</div>
{#if device.deviceType || device.openPorts?.length || device.mdnsServices?.length}
{#if device.deviceType || device.openPorts?.length || device.mdnsServices?.length || device.foundVia}
<div class="mt-1.5 flex flex-wrap gap-1">
{#if device.deviceType}
<span class="rounded bg-sky-900/60 px-1.5 py-0.5 text-[10px] text-sky-300">
@ -97,6 +105,14 @@
{#each device.mdnsServices ?? [] as svc (svc)}
<span class="rounded bg-zinc-800 px-1.5 py-0.5 text-[10px] text-zinc-500">{svc}</span>
{/each}
{#if device.foundVia}
<span
class="rounded bg-zinc-800 px-1.5 py-0.5 text-[10px] text-zinc-500"
title="Beim letzten IP-Scan gefunden über"
>
via {foundViaLabel[device.foundVia] ?? device.foundVia}
</span>
{/if}
</div>
{/if}

View file

@ -1,9 +1,11 @@
<script lang="ts">
import type { Tool } from '$lib/tools/types';
import type { Tool, ToolPreview } from '$lib/tools/types';
import type { Device, Protocol } from '$lib/types';
import { debugLog } from '$lib/debuglog.svelte';
import { X } from 'lucide-svelte';
type LiveProgress = { done: number; total: number; found: number; foundIps?: string[] };
let {
tool,
protocol,
@ -15,7 +17,10 @@
protocol: Protocol;
device?: Device;
onclose: () => void;
onrun: (params: Record<string, string | number>) => Promise<void>;
onrun: (
params: Record<string, string | number>,
live?: { onProgress?: (p: LiveProgress) => void; isCancelled?: () => boolean },
) => Promise<void>;
} = $props();
// Parameter mit Vorgabewerten füllen
@ -25,22 +30,89 @@
let busy = $state(false);
let error = $state('');
async function execute() {
busy = true;
/**
* 'params' = Eingabeformular (immer der Start)
* 'preview' = Vorschau vor dem Start, nur wenn `tool.preview` gesetzt ist
* (z.B. IP-Scan: Adapter/eigene IP/Maske/Gateway/Anzahl)
* 'running' = Live-Fortschritt, nur wenn `tool.supportsProgress` gesetzt ist
*/
let step = $state<'params' | 'preview' | 'running'>('params');
let previewInfo = $state<ToolPreview | null>(null);
let progress = $state<LiveProgress | null>(null);
let cancelRequested = false;
let cancelling = $state(false);
/** Nur die letzten paar Treffer live rendern — bei einem großen Netz (/20)
* können das mehrere tausend IPs werden, ein voll mitwachsendes DOM waere
* auf einem Mittelklasse-Handy spuerbar traege. */
const visibleFoundIps = $derived((progress?.foundIps ?? []).slice(-150));
const hiddenFoundCount = $derived(Math.max(0, (progress?.foundIps?.length ?? 0) - visibleFoundIps.length));
const progressPct = $derived(
progress && progress.total > 0 ? Math.min(100, Math.round((progress.done / progress.total) * 100)) : 0,
);
/** Formular abgeschickt — bei Tools mit Vorschau erst dort, sonst direkt starten */
async function showPreviewOrRun() {
error = '';
if (!tool.preview) {
await start();
return;
}
busy = true;
try {
await onrun({ ...params });
onclose();
previewInfo = await tool.preview({ params: { ...params }, protocol, device });
step = 'preview';
} catch (e) {
error = e instanceof Error ? e.message : 'Fehler beim Ausführen';
debugLog.add('error', `Tool "${tool.id}":`, e);
error = e instanceof Error ? e.message : 'Vorschau fehlgeschlagen';
debugLog.add('error', `Tool-Vorschau "${tool.id}":`, e);
} finally {
busy = false;
}
}
async function start() {
error = '';
cancelRequested = false;
cancelling = false;
progress = tool.supportsProgress ? { done: 0, total: 0, found: 0 } : null;
if (tool.supportsProgress) step = 'running';
busy = true;
try {
await onrun(
{ ...params },
tool.supportsProgress
? {
onProgress: (p) => {
progress = p;
},
isCancelled: () => cancelRequested,
}
: undefined,
);
onclose();
} catch (e) {
error = e instanceof Error ? e.message : 'Fehler beim Ausführen';
debugLog.add('error', `Tool "${tool.id}":`, e);
// Zurück auf das Formular, damit die Eingabe korrigiert werden kann —
// bei "running" gäbe es sonst kein Formular mehr zum Anzeigen des Fehlers.
step = 'params';
} finally {
busy = false;
}
}
function requestCancel() {
cancelRequested = true;
cancelling = true;
}
</script>
<div class="fixed inset-0 z-40 flex items-end bg-black/60" role="presentation" onclick={onclose}>
<div
class="fixed inset-0 z-40 flex items-end bg-black/60"
role="presentation"
onclick={step === 'running' ? undefined : onclose}
>
<div
class="w-full rounded-t-2xl bg-zinc-900 p-4 safe-bottom"
role="dialog"
@ -50,48 +122,120 @@
>
<div class="mb-3 flex items-center justify-between">
<h2 class="font-semibold">{tool.name}</h2>
<button onclick={onclose} aria-label="Schließen"><X size={20} /></button>
{#if step !== 'running'}
<button onclick={onclose} aria-label="Schließen"><X size={20} /></button>
{/if}
</div>
<p class="mb-3 text-xs text-zinc-400">{tool.description}</p>
{#if device}
<p class="mb-3 text-sm text-sky-400">Gerät: {device.ip}</p>
{/if}
<div class="flex flex-col gap-3">
{#each tool.params as field (field.key)}
<label class="flex flex-col gap-1 text-sm">
<span class="text-zinc-400">{field.label}</span>
{#if field.type === 'select'}
<select
class="rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-2"
bind:value={params[field.key]}
>
{#each field.options ?? [] as opt (opt.value)}
<option value={opt.value}>{opt.label}</option>
{#if step === 'params'}
<p class="mb-3 text-xs text-zinc-400">{tool.description}</p>
{#if device}
<p class="mb-3 text-sm text-sky-400">Gerät: {device.ip}</p>
{/if}
<div class="flex flex-col gap-3">
{#each tool.params as field (field.key)}
<label class="flex flex-col gap-1 text-sm">
<span class="text-zinc-400">{field.label}</span>
{#if field.type === 'select'}
<select
class="rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-2"
bind:value={params[field.key]}
>
{#each field.options ?? [] as opt (opt.value)}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
{:else}
<input
class="rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-2"
type={field.type === 'number' ? 'number' : 'text'}
placeholder={field.placeholder ?? ''}
bind:value={params[field.key]}
/>
{/if}
</label>
{/each}
</div>
{#if error}
<p class="mt-3 text-sm text-red-400">{error}</p>
{/if}
<button
class="mt-4 w-full rounded-lg bg-sky-600 py-2.5 font-semibold text-white active:bg-sky-700 disabled:opacity-50"
onclick={showPreviewOrRun}
disabled={busy}
>
{busy ? 'Bitte warten …' : tool.preview ? 'Weiter' : 'Ausführen'}
</button>
{:else if step === 'preview' && previewInfo}
<div class="flex flex-col gap-2 text-sm">
{#each previewInfo.lines as line (line.label)}
<div class="flex items-center justify-between border-b border-zinc-800 py-1.5">
<span class="text-zinc-400">{line.label}</span>
<span class="font-medium">{line.value}</span>
</div>
{/each}
</div>
{#if previewInfo.warning}
<p class="mt-3 rounded-lg bg-amber-900/40 p-2.5 text-sm text-amber-300">{previewInfo.warning}</p>
{/if}
{#if error}
<p class="mt-3 text-sm text-red-400">{error}</p>
{/if}
<div class="mt-4 flex gap-2">
<button
class="flex-1 rounded-lg border border-zinc-700 py-2.5 font-semibold text-zinc-300 active:bg-zinc-800 disabled:opacity-50"
onclick={() => (step = 'params')}
disabled={busy}
>
Zurück
</button>
<button
class="flex-1 rounded-lg bg-sky-600 py-2.5 font-semibold text-white active:bg-sky-700 disabled:opacity-50"
onclick={start}
disabled={busy}
>
{busy ? 'Bitte warten …' : (previewInfo.confirmLabel ?? 'Starten')}
</button>
</div>
{:else if step === 'running'}
<div class="flex flex-col gap-3">
{#if progress && progress.total > 0}
<div class="h-2.5 w-full overflow-hidden rounded-full bg-zinc-800">
<div class="h-full rounded-full bg-sky-500 transition-all" style="width: {progressPct}%"></div>
</div>
<p class="text-sm text-zinc-400">
{progress.done} von {progress.total} geprüft · {progress.found} gefunden
</p>
{:else}
<p class="text-sm text-zinc-400">Wird gestartet …</p>
{/if}
{#if visibleFoundIps.length > 0}
<div class="max-h-40 overflow-y-auto rounded-lg bg-zinc-800/60 p-2">
<ul class="flex flex-col gap-0.5 font-mono text-xs text-zinc-300">
{#each visibleFoundIps as ip (ip)}
<li>{ip}</li>
{/each}
</select>
{:else}
<input
class="rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-2"
type={field.type === 'number' ? 'number' : 'text'}
placeholder={field.placeholder ?? ''}
bind:value={params[field.key]}
/>
{/if}
</label>
{/each}
</div>
</ul>
{#if hiddenFoundCount > 0}
<p class="mt-1 text-xs text-zinc-500">+{hiddenFoundCount} weitere</p>
{/if}
</div>
{/if}
{#if error}
<p class="mt-3 text-sm text-red-400">{error}</p>
<button
class="mt-1 w-full rounded-lg border border-red-800 py-2.5 font-semibold text-red-400 active:bg-red-950 disabled:opacity-50"
onclick={requestCancel}
disabled={cancelling}
>
{cancelling ? 'Wird abgebrochen …' : 'Abbrechen'}
</button>
</div>
{/if}
<button
class="mt-4 w-full rounded-lg bg-sky-600 py-2.5 font-semibold text-white active:bg-sky-700 disabled:opacity-50"
onclick={execute}
disabled={busy}
>
{busy ? 'Messung läuft …' : 'Ausführen'}
</button>
</div>
</div>

View file

@ -22,6 +22,33 @@ export interface ScannedDevice {
netbiosName?: string;
/** offene Ports aus der Quick-Port-Probe */
openPorts?: number[];
/** wie das Gerät gefunden wurde: 'ping' | 'port' | 'arp' */
foundVia?: string;
}
/** Fortschritt eines laufenden IP-Scans (Live-Event `ipScanProgress`) */
export interface IpScanProgressEvent {
runId: string;
done: number;
total: number;
found: number;
/** bisher gefundene IPs (noch ohne Anreicherung — die kommt erst im Endergebnis) */
foundIps: string[];
}
/** Endergebnis eines IP-Scans (Event `ipScanFinished`, genau einmal pro Lauf) */
export interface IpScanResult {
runId: string;
devices: ScannedDevice[];
/** wie viele Adressen geprueft wurden */
probed: number;
/** wie viele davon geantwortet haben (Ping, Port oder ARP) */
answered: number;
/** war die ARP-Tabelle lesbar? (ab Android 10 meist nicht) */
arpAvailable: boolean;
durationMs: number;
/** true = per cancelIpScan abgebrochen — devices ist dann ein Teilergebnis */
cancelled: boolean;
/** gesetzt bei einem echten Fehler (z.B. Ausnahme im nativen Scan) */
error?: string;
}
/** Ein per mDNS/Bonjour gefundenes Gerät */
export interface MdnsDevice {
@ -179,18 +206,22 @@ export interface NetDiagScannerPlugin {
/** 'adapter' = vom System gemeldet, 'angenommen' = /24 als Rueckfallwert */
prefixQuelle?: string;
}>;
/** IP-Scan: Geräte im Subnetz finden (ARP + Ping-Sweep + Namensauflösung) */
ipScan(opts: { subnet: string }): Promise<{
devices: ScannedDevice[];
/** wie viele Adressen geprueft wurden */
probed?: number;
/** wie viele davon geantwortet haben */
answered?: number;
/** war die ARP-Tabelle lesbar? (ab Android 10 meist nicht) */
arpAvailable?: boolean;
}>;
/**
* IP-Scan starten: Geräte im Subnetz finden (mehrgleisig Ping, Port-Probe,
* ARP-Abgleich). Liefert sofort die Laufkennung + Adressenzahl zurück;
* Fortschritt kommt über das Event `ipScanProgress`, das Endergebnis über
* `ipScanFinished` (genau einmal, ob regulär durchgelaufen oder per
* `cancelIpScan` abgebrochen).
*/
startIpScan(opts: { subnet: string }): Promise<{ runId: string; total: number }>;
/** Laufenden IP-Scan abbrechen — das Teilergebnis kommt regulär über `ipScanFinished` */
cancelIpScan(opts: { runId: string }): Promise<{ ok: boolean }>;
/** mDNS/Bonjour-Dienstsuche: Drucker, Kameras, Chromecast, AirPlay … */
mdnsScan(opts: { timeoutMs?: number }): Promise<{ devices: MdnsDevice[] }>;
mdnsScan(opts: { timeoutMs?: number }): Promise<{
devices: MdnsDevice[];
/** false = die Suche konnte fuer KEINEN Diensttyp gestartet werden (echter Fehler, nicht "nichts gefunden") */
discoveryOk?: boolean;
}>;
/** IP-Konflikt-Prüfung: findet IP-Adressen, die zwei Geräte gleichzeitig benutzen */
arpConflictScan(opts: {
subnet: string;
@ -360,6 +391,79 @@ function finishMockStress(reason: 'completed' | 'stopped'): StressResult {
return result;
}
/* --- IP-Scan: Ereignis-Verteilung + Mock-Simulation --- */
const ipScanProgressListeners = new Set<(e: IpScanProgressEvent) => void>();
const ipScanFinishedListeners = new Set<(e: IpScanResult) => void>();
let mockIpScanTimer: ReturnType<typeof setInterval> | undefined;
let mockIpScanRunId = '';
let mockIpScanTotal = 0;
const MOCK_IP_SCAN_DEVICES: ScannedDevice[] = [
{
ip: '192.168.1.1',
mac: 'AA:BB:CC:00:00:01',
hostname: 'fritzbox',
vendor: 'AVM',
deviceType: 'Router',
openPorts: [53, 80, 443],
foundVia: 'ping',
},
{
ip: '192.168.1.10',
mac: 'AA:BB:CC:00:00:0A',
hostname: 'switch-keller',
vendor: 'TP-Link',
deviceType: 'Switch',
openPorts: [80],
foundVia: 'ping',
},
{
ip: '192.168.1.40',
mac: 'AA:BB:CC:00:00:28',
hostname: 'ipcam-hof',
vendor: 'Hikvision',
deviceType: 'Kamera',
openPorts: [80, 554],
foundVia: 'port',
},
{
ip: '192.168.1.50',
mac: 'AA:BB:CC:00:00:32',
hostname: 'handy',
vendor: 'Samsung',
deviceType: '',
openPorts: [],
foundVia: 'ping',
},
{
ip: '192.168.1.77',
mac: 'AA:BB:CC:00:00:4D',
hostname: 'wallbox',
vendor: '',
netbiosName: 'WALLBOX',
deviceType: 'Wallbox',
openPorts: [80, 502],
foundVia: 'arp',
},
];
/** Mock-IP-Scan beenden: Timer stoppen, Endergebnis bauen, ipScanFinished feuern. */
function finishMockIpScan(runId: string, total: number, cancelled: boolean): IpScanResult {
if (mockIpScanTimer) clearInterval(mockIpScanTimer);
mockIpScanTimer = undefined;
const result: IpScanResult = {
runId,
devices: MOCK_IP_SCAN_DEVICES,
probed: total,
answered: MOCK_IP_SCAN_DEVICES.length,
arpAvailable: true,
durationMs: 3000,
cancelled,
};
ipScanFinishedListeners.forEach((cb) => cb(result));
return result;
}
const mock: NetDiagScannerPlugin = {
async getLocalSubnet() {
return {
@ -370,52 +474,34 @@ const mock: NetDiagScannerPlugin = {
prefixQuelle: 'adapter',
};
},
async ipScan() {
return {
devices: [
{
ip: '192.168.1.1',
mac: 'AA:BB:CC:00:00:01',
hostname: 'fritzbox',
vendor: 'AVM',
deviceType: 'Router',
openPorts: [53, 80, 443],
},
{
ip: '192.168.1.10',
mac: 'AA:BB:CC:00:00:0A',
hostname: 'switch-keller',
vendor: 'TP-Link',
deviceType: 'Switch',
openPorts: [80],
},
{
ip: '192.168.1.40',
mac: 'AA:BB:CC:00:00:28',
hostname: 'ipcam-hof',
vendor: 'Hikvision',
deviceType: 'Kamera',
openPorts: [80, 554],
},
{
ip: '192.168.1.50',
mac: 'AA:BB:CC:00:00:32',
hostname: 'handy',
vendor: 'Samsung',
deviceType: '',
openPorts: [],
},
{
ip: '192.168.1.77',
mac: 'AA:BB:CC:00:00:4D',
hostname: 'wallbox',
vendor: '',
netbiosName: 'WALLBOX',
deviceType: 'Wallbox',
openPorts: [80, 502],
},
],
};
async startIpScan(opts) {
const runId = 'mock-ipscan-' + Date.now();
const total = 254;
mockIpScanRunId = runId;
mockIpScanTotal = total;
let done = 0;
// Simuliert Fortschritt in Schuben, wie der native Sweep — Treffer
// wachsen proportional mit, damit die Live-Trefferliste im Browser-Dev
// etwas zu sehen bekommt.
mockIpScanTimer = setInterval(() => {
done = Math.min(total, done + Math.round(total / 12));
const foundCount = Math.min(
MOCK_IP_SCAN_DEVICES.length,
Math.ceil((done / total) * MOCK_IP_SCAN_DEVICES.length),
);
const foundIps = MOCK_IP_SCAN_DEVICES.slice(0, foundCount).map((d) => d.ip);
ipScanProgressListeners.forEach((cb) => cb({ runId, done, total, found: foundCount, foundIps }));
if (done >= total) {
finishMockIpScan(runId, total, false);
}
}, 250);
return { runId, total };
},
async cancelIpScan() {
if (mockIpScanTimer) {
finishMockIpScan(mockIpScanRunId, mockIpScanTotal, true);
}
return { ok: true };
},
async mdnsScan() {
return {
@ -424,6 +510,7 @@ const mock: NetDiagScannerPlugin = {
{ ip: '192.168.1.30', name: 'Wohnzimmer-TV', services: ['_googlecast._tcp'] },
{ ip: '192.168.1.40', name: 'IP-Kamera Hof', services: ['_rtsp._tcp'] },
],
discoveryOk: true,
};
},
async arpConflictScan() {
@ -744,3 +831,50 @@ export function onStressFinished(cb: (e: StressResult) => void): () => void {
stressFinishedListeners.delete(cb);
};
}
/**
* Auf den Fortschritt eines laufenden IP-Scans hören (x von y geprüft, live
* gefunden). Gibt die Abmeldefunktion zurück.
*/
export function onIpScanProgress(cb: (e: IpScanProgressEvent) => void): () => void {
if (Capacitor.isNativePlatform()) {
const handle = (
native as unknown as {
addListener(
name: string,
cb: (e: IpScanProgressEvent) => void,
): Promise<PluginListenerHandle>;
}
).addListener('ipScanProgress', cb);
return () => {
void handle.then((h) => h.remove());
};
}
ipScanProgressListeners.add(cb);
return () => {
ipScanProgressListeners.delete(cb);
};
}
/**
* Auf das Ende eines IP-Scans hören feuert genau einmal, egal ob der Lauf
* regulär durchlief oder per `cancelIpScan` abgebrochen wurde (dann mit
* `cancelled=true` und dem bis dahin gefundenen Teilergebnis). Gibt die
* Abmeldefunktion zurück.
*/
export function onIpScanFinished(cb: (e: IpScanResult) => void): () => void {
if (Capacitor.isNativePlatform()) {
const handle = (
native as unknown as {
addListener(name: string, cb: (e: IpScanResult) => void): Promise<PluginListenerHandle>;
}
).addListener('ipScanFinished', cb);
return () => {
void handle.then((h) => h.remove());
};
}
ipScanFinishedListeners.add(cb);
return () => {
ipScanFinishedListeners.delete(cb);
};
}

View file

@ -6,12 +6,25 @@
* Protokoll hinterlegte Netzbereich genutzt; ist auch der leer,
* fragt das Tool den aktiven WLAN-/LAN-Adapter ab und scannt
* dessen Subnetz direkt.
*
* Mehrgleisig (ROADMAP_UMSETZUNG.md Phase 3): der native Scan gilt ein Gerät
* als gefunden, wenn es auf Ping ODER eine kurze Port-Probe ODER einen
* ARP-Tabellen-Eintrag antwortet plus mDNS/Bonjour hier oben drauf. Läuft
* als eigener Lauf mit Live-Fortschritt/Abbruch (`ctx.onProgress`/
* `ctx.isCancelled`, siehe ToolDialog) statt eines einzigen langen Promise
* ohne jede Rückmeldung.
*/
import { scanner, type MdnsDevice } from '../../scanner';
import {
scanner,
onIpScanProgress,
onIpScanFinished,
type MdnsDevice,
type IpScanResult,
} from '../../scanner';
import { debugLog } from '../../debuglog.svelte';
import type { Device } from '../../types';
import type { Tool } from '../types';
import type { Tool, ToolContext, ToolPreview } from '../types';
/** Geräteart aus den angebotenen mDNS-Diensten ableiten */
function typeFromMdns(services: string[]): string {
@ -26,17 +39,126 @@ function typeFromMdns(services: string[]): string {
return '';
}
/** IP-Adressen numerisch vergleichen (nicht als Text — "192.168.1.9" vor "192.168.1.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;
}
/** Anzahl der Host-Adressen eines CIDR-Bereichs (fürs Vorschau-Fenster — muss nicht bis auf /31 exakt sein) */
function hostCountForCidr(cidr: string): number | null {
const parts = cidr.trim().split('/');
const prefix = parts.length > 1 ? Number(parts[1].trim()) : 24;
if (!Number.isFinite(prefix) || prefix < 0 || prefix > 32) return null;
if (prefix >= 31) return 2 ** (32 - prefix);
const count = 2 ** (32 - prefix) - 2;
return count > 0 ? count : 0;
}
/**
* Netzbereich nach derselben Regel wie `run()` ermitteln: Dialog-Eingabe
* Protokoll-Feld aktiver Adapter. Wird von `preview()` UND `run()`
* unabhängig aufgerufen (ein doppelter `getLocalSubnet()`-Aufruf bei leerem
* Feld ist unkritisch, < 100 ms) so funktioniert `run()` auch dann korrekt,
* wenn ein Aufrufer die Vorschau übersprungen hat.
*/
async function resolveSubnet(
ctx: ToolContext,
): Promise<{ subnet: string; source: 'eingabe' | 'protokoll' | 'adapter'; ip: string; gateway: string }> {
const dialogValue = String(ctx.params.subnet ?? '').trim();
let subnet = dialogValue || String(ctx.protocol.subnet ?? '').trim();
const source: 'eingabe' | 'protokoll' | 'adapter' = dialogValue
? 'eingabe'
: subnet
? 'protokoll'
: 'adapter';
let ip = '';
let gateway = '';
if (!subnet) {
try {
const local = await scanner.getLocalSubnet();
subnet = String(local.subnet ?? '').trim();
ip = local.ip ?? '';
gateway = local.gateway ?? '';
} catch {
/* kein Adapter ermittelbar — Aufrufer behandelt leeres subnet */
}
} else {
// Auch bei Eingabe/Protokoll-Quelle den Adapter fragen — nur für die
// Vorschau-Anzeige (eigene IP/Gateway), best effort, kein Fehlerfall.
try {
const local = await scanner.getLocalSubnet();
ip = local.ip ?? '';
gateway = local.gateway ?? '';
} catch {
/* egal, Vorschau zeigt dann nur den Netzbereich */
}
}
return { subnet, source, ip, gateway };
}
/**
* IP-Scan starten und auf das Endergebnis (`ipScanFinished`) warten, dabei
* Fortschritt an `ctx.onProgress` durchreichen und `ctx.isCancelled()`
* periodisch abfragen wird die true, `cancelIpScan` auslösen. Liefert auch
* bei Abbruch regulär auf (kein Reject), das Teilergebnis kommt über
* `ipScanFinished`.
*/
function runIpScan(subnet: string, ctx: ToolContext): Promise<IpScanResult> {
return new Promise((resolve, reject) => {
let offProgress = () => {};
let offFinished = () => {};
let cancelTimer: ReturnType<typeof setInterval> | undefined;
let cancelSent = false;
function cleanup() {
offProgress();
offFinished();
if (cancelTimer) clearInterval(cancelTimer);
}
scanner
.startIpScan({ subnet })
.then(({ runId }) => {
offProgress = onIpScanProgress((e) => {
if (e.runId !== runId) return;
ctx.onProgress?.({ done: e.done, total: e.total, found: e.found, foundIps: e.foundIps });
});
offFinished = onIpScanFinished((e) => {
if (e.runId !== runId) return;
cleanup();
resolve(e);
});
if (ctx.isCancelled) {
cancelTimer = setInterval(() => {
if (cancelSent) return;
if (ctx.isCancelled?.()) {
cancelSent = true;
scanner.cancelIpScan({ runId }).catch(() => {});
}
}, 300);
}
})
.catch((e) => {
cleanup();
reject(e instanceof Error ? e : new Error(String(e)));
});
});
}
export const ipScanTool: Tool = {
id: 'ipscan',
category: 'netzwerk',
name: 'IP-Scanner',
icon: 'radar',
// Ehrliche Beschreibung: gefunden wird ein Gerät nur, wenn es auf Ping
// antwortet — oder wenn es sich per mDNS meldet. Die ARP-Tabelle wird
// lediglich nachgeschlagen, um MAC/Hersteller zu ergänzen, und ist ab
// Android 10 meist gar nicht lesbar. Mehrgleisige Suche: Phase 3.
description: 'Sucht Geräte, die auf Ping antworten oder sich per mDNS melden.',
description: 'Sucht Geräte per Ping, Port-Probe, ARP-Abgleich und mDNS/Bonjour.',
scope: 'protocol',
supportsProgress: true,
params: [
{
key: 'subnet',
@ -45,24 +167,36 @@ export const ipScanTool: Tool = {
placeholder: 'leer lassen → automatisch über WLAN/LAN',
},
],
async run(ctx) {
// 1. Dialog-Eingabe 2. im Protokoll hinterlegter Netzbereich
let subnet = String(ctx.params.subnet ?? '').trim() || String(ctx.protocol.subnet ?? '').trim();
let source: 'eingabe' | 'protokoll' | 'adapter' = subnet
? String(ctx.params.subnet ?? '').trim()
? 'eingabe'
: 'protokoll'
: 'adapter';
// 3. Nichts angegeben → aktiven Adapter abfragen und dessen Subnetz scannen
async preview(ctx): Promise<ToolPreview> {
const { subnet, source, ip, gateway } = await resolveSubnet(ctx);
if (!subnet) {
try {
const local = await scanner.getLocalSubnet();
subnet = String(local.subnet ?? '').trim();
} catch {
/* kein Adapter ermittelbar — unten abgefangen */
}
return {
lines: [{ label: 'Netzbereich', value: 'nicht ermittelbar' }],
warning: 'Kein aktives WLAN/LAN gefunden — Netzbereich von Hand eintragen.',
confirmLabel: 'Trotzdem versuchen',
};
}
const count = hostCountForCidr(subnet);
const quelle =
source === 'adapter' ? ' (Adapter erkannt)' : source === 'protokoll' ? ' (aus Protokoll)' : '';
const lines: ToolPreview['lines'] = [{ label: 'Netzbereich', value: `${subnet}${quelle}` }];
if (ip) lines.push({ label: 'Eigene IP', value: ip });
if (gateway) lines.push({ label: 'Gateway', value: gateway });
lines.push({ label: 'Zu prüfende Adressen', value: count != null ? String(count) : 'unbekannt' });
const big = count != null && count > 1024;
return {
lines,
warning: big
? `Große Suche: ${count} Adressen — kann über eine Minute dauern und den Akku/WLAN-Funk stärker beanspruchen.`
: undefined,
confirmLabel: big ? 'Trotzdem scannen' : 'Scan starten',
};
},
async run(ctx) {
const { subnet, source } = await resolveSubnet(ctx);
if (!subnet) {
return {
@ -72,10 +206,10 @@ export const ipScanTool: Tool = {
};
}
// Ermittelten Netzbereich ins Protokoll übernehmen, wenn dort noch leer
if (!String(ctx.protocol.subnet ?? '').trim()) {
ctx.protocol.subnet = subnet;
}
// Ermittelten Netzbereich ins Protokoll übernehmen, wenn dort noch leer
// als Patch, NICHT durch direktes Beschreiben von ctx.protocol (der
// Aufrufer wendet das nach dem Lauf gezielt an, siehe ToolRunResult).
const protocolPatch = !String(ctx.protocol.subnet ?? '').trim() ? { subnet } : undefined;
debugLog.add(
'info',
@ -83,20 +217,56 @@ export const ipScanTool: Tool = {
`Protokoll-Subnetz="${String(ctx.protocol.subnet ?? '')}" → ` +
`gescannt wird "${subnet}" (Quelle: ${source})`,
);
const { devices } = await scanner.ipScan({ subnet });
const previousIps = new Set(ctx.protocol.devices.map((d) => d.ip));
let scanResult: IpScanResult;
try {
scanResult = await runIpScan(subnet, ctx);
} catch (e) {
// Ein gescheiterter Scan ist trotzdem eine Messung — kein stilles Nichts,
// sonst sieht man im Protokoll nicht, dass hier überhaupt etwas versucht wurde.
const msg = e instanceof Error ? e.message : String(e);
debugLog.add('error', `IP-Scan (${subnet}) fehlgeschlagen:`, e);
return {
label: `IP-Scan fehlgeschlagen: ${msg}`,
result: { subnet, fehler: msg },
measureStatus: 2,
protocolPatch,
};
}
if (scanResult.error) {
return {
label: `IP-Scan fehlgeschlagen: ${scanResult.error}`,
result: {
subnet,
fehler: scanResult.error,
probed: scanResult.probed,
arpAvailable: scanResult.arpAvailable,
},
measureStatus: 2,
protocolPatch,
};
}
// mDNS/Bonjour zusätzlich abfragen — liefert sprechende Namen und findet
// Geräte, die nicht auf Ping antworten (manche Kameras/Drucker). Best-Effort.
// Geräte, die weder auf Ping/Port noch per ARP auffallen (manche Kameras/
// Drucker). Best-Effort: discoveryOk=false wird als Warnung vermerkt,
// macht den IP-Scan selbst aber nicht ungültig.
let mdns: MdnsDevice[] = [];
let mdnsOk = true;
try {
mdns = (await scanner.mdnsScan({ timeoutMs: 4000 })).devices;
const r = await scanner.mdnsScan({ timeoutMs: 9000 });
mdns = r.devices;
mdnsOk = r.discoveryOk !== false;
} catch {
/* mDNS fehlgeschlagen — IP-Scan bleibt trotzdem gültig */
mdnsOk = false;
}
const mdnsByIp = new Map(mdns.map((m) => [m.ip, m]));
// Beide Quellen per IP zusammenführen
const merged: (Partial<Device> & { ip: string })[] = devices.map((d) => {
const merged: (Partial<Device> & { ip: string })[] = scanResult.devices.map((d) => {
const m = mdnsByIp.get(d.ip);
if (!m) return d;
return {
@ -115,20 +285,58 @@ export const ipScanTool: Tool = {
mdnsName: m.name,
mdnsServices: m.services,
deviceType: typeFromMdns(m.services),
foundVia: 'mdns',
});
}
merged.sort((a, b) => ipCompare(a.ip, b.ip));
// "Neu" / "Nicht mehr erreichbar" — Vergleich mit dem VOR diesem Lauf im
// Protokoll bekannten Gerätebestand (Diagnosefeld + sprechendes Label).
const neu = merged
.filter((d) => !previousIps.has(d.ip))
.map((d) => d.ip)
.sort(ipCompare);
const gefundeneIps = new Set(merged.map((d) => d.ip));
const nichtMehrErreichbar = ctx.protocol.devices
.filter((d) => !gefundeneIps.has(d.ip))
.map((d) => d.ip)
.sort(ipCompare);
const teile = [`${merged.length} Geräte im Netz ${subnet}`];
if (source === 'adapter') teile.push('Adapter erkannt');
if (scanResult.cancelled) teile.push('abgebrochen — Teilergebnis');
if (neu.length) teile.push(`${neu.length} neu`);
if (nichtMehrErreichbar.length) teile.push(`${nichtMehrErreichbar.length} nicht mehr erreichbar`);
if (!mdnsOk) teile.push('mDNS-Suche fehlgeschlagen');
debugLog.add(
'info',
`IP-Scan Ergebnis: ${merged.length} Geräte in ${subnet} ` +
`(${devices.length} per Ping/ARP, ${mdns.length} per mDNS)`,
`(${scanResult.probed} geprüft, ${scanResult.answered} geantwortet, ` +
`${scanResult.durationMs} ms, arpAvailable=${scanResult.arpAvailable}, ` +
`cancelled=${scanResult.cancelled}, mdns=${mdns.length}/${mdnsOk ? 'ok' : 'fehler'})`,
);
const via = source === 'adapter' ? ' (Adapter erkannt)' : '';
return {
label: `${merged.length} Geräte im Netz ${subnet}${via}`,
result: { subnet, count: merged.length, quelle: source },
measureStatus: merged.length > 0 ? 0 : 1,
label: teile.join(' · '),
result: {
subnet,
quelle: source,
count: merged.length,
probed: scanResult.probed,
answered: scanResult.answered,
arpAvailable: scanResult.arpAvailable,
durationMs: scanResult.durationMs,
abgebrochen: scanResult.cancelled,
neu,
nichtMehrErreichbar,
mdnsOk,
},
// Abgebrochen mit Teilergebnis oder nichts gefunden -> Warnung statt OK;
// ein bewusst abgebrochener Scan ist kein Fehler, aber eben unvollständig.
measureStatus: scanResult.cancelled ? 1 : merged.length > 0 ? 0 : 1,
devices: merged,
protocolPatch,
};
},
};

View file

@ -25,12 +25,39 @@ export interface ToolParamField {
placeholder?: string;
}
/** Eine Zeile der Vorschau vor dem Start eines Tools */
export interface ToolPreviewLine {
label: string;
value: string;
}
/**
* Vorschau, die `ToolDialog` vor dem eigentlichen Lauf anzeigt (z.B. IP-Scan:
* Adapter, eigene IP, Maske, Gateway, Anzahl der zu prüfenden Adressen).
* Mit `warning` gesetzt zeigt der Dialog eine zweite Bestätigung, bevor der
* Lauf wirklich startet (z.B. bei einem sehr großen Adressbereich).
*/
export interface ToolPreview {
lines: ToolPreviewLine[];
warning?: string;
/** Beschriftung des Bestätigen-Buttons, wenn `warning` gesetzt ist (Default: "Trotzdem starten") */
confirmLabel?: string;
}
/** Kontext, mit dem ein Tool ausgeführt wird */
export interface ToolContext {
params: Record<string, string | number>;
protocol: Protocol;
/** gesetzt, wenn das Tool für ein einzelnes Gerät läuft (scope 'device') */
device?: Device;
/**
* Fortschritt melden nur von Tools genutzt, die live Fortschritt liefern
* (z.B. IP-Scanner). `ToolDialog` zeigt bei `tool.supportsProgress` eine
* Fortschrittsanzeige + laufende Trefferliste statt des einfachen Spinners.
*/
onProgress?: (p: { done: number; total: number; found: number; foundIps?: string[] }) => void;
/** true, sobald der Benutzer "Abbrechen" gedrückt hat — das Tool sollte zügig mit einem Teilergebnis zurückkehren */
isCancelled?: () => boolean;
}
/** Rückgabe eines Tool-Laufs */
@ -46,6 +73,14 @@ export interface ToolRunResult {
* übernommen (z.B. beim IP-Scan). `ip` ist Pflicht, alles Weitere optional.
*/
devices?: Array<Partial<Device> & { ip: string }>;
/**
* Optionale Änderungen am Protokoll selbst (z.B. der beim IP-Scan erkannte
* Netzbereich). Tools dürfen `ctx.protocol` NICHT direkt beschreiben das
* geschieht unsichtbar am Aufrufer vorbei und wird beim nächsten `persist()`
* nicht sauber nachvollziehbar. Der Aufrufer wendet das Patch nach dem Lauf
* gezielt an.
*/
protocolPatch?: Partial<Protocol>;
}
/** Ein Diagnose-Werkzeug */
@ -60,6 +95,10 @@ export interface Tool {
/** 'protocol' = global, 'device' = pro gefundenem Gerät */
scope: 'protocol' | 'device';
params: ToolParamField[];
/** true = das Tool meldet Fortschritt über `ctx.onProgress` — ToolDialog zeigt dann Fortschrittsanzeige + Abbrechen statt des einfachen Spinners */
supportsProgress?: 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

@ -68,6 +68,8 @@ export interface Device {
mdnsName?: string;
/** angebotene mDNS-Dienste, z.B. ['_printer._tcp', '_googlecast._tcp'] */
mdnsServices?: string[];
/** wie das Gerät beim letzten IP-Scan gefunden wurde: 'ping' | 'port' | 'arp' | 'mdns' */
foundVia?: string;
}
/** Eingefrorener Snapshot eines IP-Scans (wieder aufrufbar) */

View file

@ -46,11 +46,23 @@
const protocolTools = TOOLS.filter((t) => t.scope === 'protocol' && t.id !== 'stresstest');
const deviceTools = TOOLS.filter((t) => t.scope === 'device');
/** Geräte mit Favoriten zuerst */
/** 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) => Number(b.isFavorite ?? false) - Number(a.isFavorite ?? false),
),
[...(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)
@ -120,11 +132,34 @@
activeDevice = device;
}
/** Tool ausführen, Ergebnis ins Protokoll übernehmen */
async function runTool(params: Record<string, string | number>) {
/**
* 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 });
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 …)