Compare commits
2 commits
85f1c24618
...
7fdf693f36
| Author | SHA1 | Date | |
|---|---|---|---|
| 7fdf693f36 | |||
| 8b369fc3bc |
8 changed files with 953 additions and 206 deletions
|
|
@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) */
|
||||
|
|
|
|||
|
|
@ -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 …)
|
||||
|
|
|
|||
Loading…
Reference in a new issue