Werkzeuge waren zu umstaendlich, um sie beim Kunden wirklich zu benutzen: die Produktivdaten zeigen 19 Protokolle, aber nur 26 Messungen, fast durchweg ein einzelner IP-Scan. Der Standardfall "IP-Scan im aktuellen Netz" kostete drei Tipper, obwohl das einzige Feld ausdruecklich leer bleiben darf. - Kurzer Tap auf eine Werkzeugkarte startet sofort, langes Halten oeffnet die Optionen. Auf den betroffenen Karten steht "Tippen startet · Halten fuer Optionen" - sonst ist das Verhalten unsichtbar und der erste versehentliche Scan ueberrascht. - quickRun wird je Werkzeug AUSDRUECKLICH gesetzt, nicht hergeleitet: ToolParamField kennt kein 'required', die Pflicht steht allein im run()-Rumpf. portscan.ports hat keinen Vorgabewert und ist optional, iperf.host hat ebenfalls keinen und ist Pflicht - eine Heuristik ueber "hat Default" wuerde genau falsch herum raten. iperf bleibt deshalb bewusst ohne Schnellstart. - Der Lauf bleibt zwingend im ToolDialog: dort sitzen Bildschirmsperre, Fortschritt mit Abbrechen und die EINZIGE Fehlerbehandlung eines Werkzeuglaufs (runTool selbst hat kein try/catch). Ein Schnellstart daran vorbei wuerde Fehler verschlucken. - Die Vorschau wird nicht uebersprungen, sondern nur ohne Warnung automatisch durchgewunken. Der IP-Scan warnt ab ~1000 Adressen - genau der Fall, in dem ein versehentlicher Tap sonst minutenlang das falsche Netz absucht. - Langdruck bricht ab, sobald der Finger mehr als 10 px wandert: das Werkzeug-Raster liegt in einem Scroll-Bereich, sonst oeffnet jeder Wisch nach einer halben Sekunde den Dialog. - Nach einer Messung wird zum Ergebnis gescrollt und es 2,5 s hervorgehoben. Bei 46 Geraeten liegt die neue Karte sonst dutzende Bildschirme entfernt. block:'center', weil die feste Aktionsleiste den unteren Rand verdeckt; bei einer Geraete-Messung wird die Geraetekarte angesteuert, denn dort hinein wird gerendert. - Verwendete Parameter stehen in der Messkarte. Seit dem Schnellstart laeuft eine Messung auch ohne Dialog - dann ist das der einzige Ort, an dem man sieht, WOMIT gemessen wurde. Beschriftung aus tool.params, nicht aus der Ergebnistabelle: 'count' heisst dort "Gefundene Geraete", als Ping-Parameter aber "Anzahl Pakete". - Zuletzt benutzte Parameter werden gemerkt (neues Modul toolparams.ts). Ortsgebundene Schluessel wie 'subnet' ausdruecklich NICHT: ein aus dem vorigen Kundennetz uebernommener Netzbereich waere schlimmer als gar keiner, der Schnellstart wuerde stillschweigend das falsche Netz scannen. Im Emulator geprueft, und dabei zwei Fehler gefunden, die im Code nicht auffielen: die Parameterzeile wiederholte "Ziel: 8.8.8.8" direkt ueber dem Ergebnis (jetzt werden Werte ausgelassen, die das Ergebnis ohnehin nennt), und Traceroute schrieb "1 Hops" ins Kundendokument (jetzt "1 Station").
344 lines
12 KiB
TypeScript
344 lines
12 KiB
TypeScript
/**
|
|
* Tool: IP-Scanner — findet Geräte im Subnetz.
|
|
* Die gefundenen Geräte werden ins Protokoll übernommen.
|
|
*
|
|
* Netzbereich-Logik: ist im Dialog nichts angegeben, wird der im
|
|
* 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,
|
|
onIpScanProgress,
|
|
onIpScanFinished,
|
|
type MdnsDevice,
|
|
type IpScanResult,
|
|
} from '../../scanner';
|
|
import { debugLog } from '../../debuglog.svelte';
|
|
import type { Device } from '../../types';
|
|
import type { Tool, ToolContext, ToolPreview } from '../types';
|
|
|
|
/** Geräteart aus den angebotenen mDNS-Diensten ableiten */
|
|
function typeFromMdns(services: string[]): string {
|
|
const s = services.join(' ');
|
|
if (s.includes('_printer') || s.includes('_ipp') || s.includes('_pdl-datastream'))
|
|
return 'Drucker';
|
|
if (s.includes('_googlecast')) return 'Chromecast/TV';
|
|
if (s.includes('_airplay') || s.includes('_raop')) return 'AirPlay-Gerät';
|
|
if (s.includes('_rtsp') || s.includes('_axis-video')) return 'Kamera';
|
|
if (s.includes('_hap')) return 'HomeKit-Gerät';
|
|
if (s.includes('_smb')) return 'NAS';
|
|
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',
|
|
description: 'Sucht Geräte per Ping, Port-Probe, ARP-Abgleich und mDNS/Bonjour.',
|
|
scope: 'protocol',
|
|
supportsProgress: true,
|
|
// Netzbereich darf leer bleiben (wird ueber den Adapter aufgeloest)
|
|
quickRun: true,
|
|
params: [
|
|
{
|
|
key: 'subnet',
|
|
label: 'Netzbereich (CIDR) — leer = aktiver Adapter',
|
|
type: 'text',
|
|
placeholder: 'leer lassen → automatisch über WLAN/LAN',
|
|
},
|
|
],
|
|
|
|
async preview(ctx): Promise<ToolPreview> {
|
|
const { subnet, source, ip, gateway } = await resolveSubnet(ctx);
|
|
if (!subnet) {
|
|
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 {
|
|
label: 'Kein Netzbereich — WLAN/LAN nicht aktiv?',
|
|
result: { error: 'Netzbereich konnte nicht ermittelt werden' },
|
|
measureStatus: 2,
|
|
};
|
|
}
|
|
|
|
// 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',
|
|
`IP-Scan: Dialog-Eingabe="${String(ctx.params.subnet ?? '')}", ` +
|
|
`Protokoll-Subnetz="${String(ctx.protocol.subnet ?? '')}" → ` +
|
|
`gescannt wird "${subnet}" (Quelle: ${source})`,
|
|
);
|
|
|
|
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 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 {
|
|
const r = await scanner.mdnsScan({ timeoutMs: 9000 });
|
|
mdns = r.devices;
|
|
mdnsOk = r.discoveryOk !== false;
|
|
} catch {
|
|
mdnsOk = false;
|
|
}
|
|
const mdnsByIp = new Map(mdns.map((m) => [m.ip, m]));
|
|
|
|
// Beide Quellen per IP zusammenführen
|
|
const merged: (Partial<Device> & { ip: string })[] = scanResult.devices.map((d) => {
|
|
const m = mdnsByIp.get(d.ip);
|
|
if (!m) return d;
|
|
return {
|
|
...d,
|
|
mdnsName: m.name,
|
|
mdnsServices: m.services,
|
|
deviceType: d.deviceType || typeFromMdns(m.services),
|
|
};
|
|
});
|
|
// Geräte, die nur per mDNS auftauchten, ergänzen
|
|
for (const m of mdns) {
|
|
if (merged.some((d) => d.ip === m.ip)) continue;
|
|
merged.push({
|
|
ip: m.ip,
|
|
hostname: m.name,
|
|
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} ` +
|
|
`(${scanResult.probed} geprüft, ${scanResult.answered} geantwortet, ` +
|
|
`${scanResult.durationMs} ms, arpAvailable=${scanResult.arpAvailable}, ` +
|
|
`cancelled=${scanResult.cancelled}, mdns=${mdns.length}/${mdnsOk ? 'ok' : 'fehler'})`,
|
|
);
|
|
|
|
return {
|
|
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,
|
|
};
|
|
},
|
|
};
|