Files
RouterOS/RouterOSAssistant/Features/Devices/DevicesView.swift
T
KayandClaude Sonnet 5 04325e3bbd M20: LAN-Scanner umbenannt + Netzwerk-Tools (Ping/Traceroute/DNS/Port-Scan)
Tab "Geräte" -> "LAN-Scanner", Refresh-Button "Neu scannen" + prominenter
Stil. Neues Netzwerk-Tools-Menü: Ping/Traceroute/DNS-Auflösung über
NetworkToolsService (eigene SSH-Verbindung, wie Backup/InterfaceTraffic-
Monitor), mit Zeichen-Validierung gegen Command-Injection ueber einen
boeswilligen DHCP-Hostnamen. Port-Scan laeuft direkt von diesem Mac ueber
Network.framework (RouterOS hat kein eingebautes Portscan-Tool) - dabei
einen echten NWConnection-Bug gefunden (verweigerte Verbindung meldet sich
ueber .waiting, nicht .failed) und per Unit-Test gegen einen Loopback-Port
aufgedeckt und gefixt.

Zusaetzlich: Warnhinweis bei "Feste IP zuweisen" erklaert jetzt den
Rueckweg. DE/EN-Umschalter zeigt Landesflaggen statt Text.

92 Tests gruen. HANDOFF.md/README.md (inkl. Mermaid-Diagramm)/Manual.md/
CHATLOG.md aktualisiert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YDmUd93KxsYGr2kLTotWnG
2026-09-15 22:41:23 +02:00

489 lines
20 KiB
Swift

import SwiftUI
/// "LAN-Scanner" tab: one table per physical Ethernet/WLAN port, each listing the
/// devices resolved onto it (name/IP/MAC/status), plus right-click actions to give a device a
/// permanent static IP (Winbox's "Make Static") or remove that reservation again.
struct DevicesView: View {
@ObservedObject var connectionService: ConnectionService
@StateObject private var viewModel: DevicesViewModel
@State private var rawFieldsDevice: LanDevice?
/// Plain, independent Bool — deliberately NOT derived from `pendingStaticAssignment`.
/// SwiftUI's `.confirmationDialog` calls the `isPresented` binding's setter with `false` on
/// EVERY dismissal, including tapping "Zuweisen" itself — a computed binding that reacted to
/// that by nil-ing `pendingStaticAssignment` was found to clear it before
/// `confirmStaticAssignment()` could read it, so the confirmed action silently ran on `nil`
/// and did nothing. Matches this app's own established, working pattern for confirmation
/// dialogs elsewhere (BackupListView's factory-reset dialog): the dialog's own visibility and
/// its payload are two separate pieces of state.
@State private var showStaticConfirmation = false
/// Same reasoning as `showStaticConfirmation` — independent of `pendingStaticRemoval`.
@State private var showRemovalConfirmation = false
init(connectionService: ConnectionService) {
self.connectionService = connectionService
_viewModel = StateObject(wrappedValue: DevicesViewModel(connectionService: connectionService))
}
var body: some View {
NavigationStack {
Group {
if connectionService.credentials == nil {
ContentUnavailableView(
"Nicht verbunden",
systemImage: "network.slash",
description: Text("Verbinde dich zuerst im Tab \"Verbinden\" mit deinem Router.")
)
} else if viewModel.isLoading && viewModel.devices.isEmpty {
ProgressView("Suche Geräte im Netz…")
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if let error = viewModel.loadError, viewModel.devices.isEmpty {
ContentUnavailableView(
"Konnte Geräte nicht laden", systemImage: "exclamationmark.triangle",
description: Text(error)
)
} else if viewModel.portGroups.isEmpty {
ContentUnavailableView(
"Keine Ports gefunden", systemImage: "wifi.slash",
description: Text("Der Router meldet aktuell keine physischen Interfaces.")
)
} else {
Form {
ForEach(viewModel.portGroups) { group in
Section {
if group.devices.isEmpty {
Text("Keine Geräte").font(.caption).foregroundStyle(.secondary)
} else {
DeviceColumnHeader()
ForEach(group.devices) { device in
DeviceRow(device: device)
.contextMenu {
deviceMenu(for: device)
}
}
}
} header: {
Text("\(group.title) (\(group.devices.count))")
}
}
}
.formStyle(.grouped)
}
}
.overlay(alignment: .bottom) {
if viewModel.isRunningNetworkTool {
HStack(spacing: 6) {
ProgressView().controlSize(.small)
Text("Führe Netzwerk-Test aus…").font(.caption)
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(.regularMaterial, in: Capsule())
.padding(.bottom, 12)
}
}
.navigationTitle("LAN-Scanner")
.toolbar {
ToolbarItem {
Button {
Task { await viewModel.load() }
} label: {
if viewModel.isLoading {
ProgressView().controlSize(.small)
} else {
Label("Neu scannen", systemImage: "arrow.clockwise")
}
}
// Per explicit request: a plain icon+text toolbar button up in the corner got
// overlooked ("wird übersehen") — `.borderedProminent` gives it a filled,
// colored background so it visually stands out from the window chrome instead
// of blending into it.
.buttonStyle(.borderedProminent)
.disabled(viewModel.isLoading)
}
}
.task {
if viewModel.devices.isEmpty {
await viewModel.load()
}
}
.withStaticAssignmentDialogs(
viewModel: viewModel,
showStaticConfirmation: $showStaticConfirmation,
showRemovalConfirmation: $showRemovalConfirmation
)
.withDeviceDetailSheets(viewModel: viewModel, rawFieldsDevice: $rawFieldsDevice)
}
}
@ViewBuilder
private func deviceMenu(for device: LanDevice) -> some View {
if device.hasLease {
if device.isStatic {
Button {
viewModel.pendingStaticRemoval = device
showRemovalConfirmation = true
} label: {
Label("Feste Zuweisung entfernen", systemImage: "pin.slash")
}
} else {
Button {
viewModel.pendingStaticAssignment = device
showStaticConfirmation = true
} label: {
Label("Feste IP zuweisen", systemImage: "pin.fill")
}
}
} else {
Text("Kein DHCP-Lease — feste Zuweisung hier nicht möglich")
}
Divider()
Menu {
Button {
viewModel.runPing(for: device)
} label: {
Label("Ping", systemImage: "dot.radiowaves.left.and.right")
}
Button {
viewModel.runTraceroute(for: device)
} label: {
Label("Traceroute", systemImage: "point.topleft.down.curvedto.point.bottomright.up")
}
if let hostName = device.hostName, !hostName.isEmpty {
Button {
viewModel.runDnsLookup(for: device)
} label: {
Label("DNS-Auflösung (nslookup)", systemImage: "magnifyingglass")
}
}
Divider()
Button {
viewModel.runPortScan(for: device)
} label: {
Label("Port-Scan", systemImage: "list.bullet.rectangle.portrait")
}
} label: {
Label("Netzwerk-Tools", systemImage: "stethoscope")
}
.disabled(viewModel.isRunningNetworkTool)
Divider()
Button {
rawFieldsDevice = device
} label: {
Label("Rohdaten anzeigen", systemImage: "list.bullet.rectangle")
}
}
}
/// Split out of `body` — same reasoning as `ConnectView`'s dialog-splitting extensions (Bug 19 in
/// HANDOFF.md): too many `.alert`/`.confirmationDialog`/`.sheet` modifiers chained onto one view
/// times out the Swift type-checker with a misleading error location, not an actual logic issue.
private extension View {
func withStaticAssignmentDialogs(
viewModel: DevicesViewModel,
showStaticConfirmation: Binding<Bool>,
showRemovalConfirmation: Binding<Bool>
) -> some View {
self
.confirmationDialog(
"Feste IP-Adresse zuweisen?",
isPresented: showStaticConfirmation,
titleVisibility: .visible
) {
Button("Zuweisen") {
Task { await viewModel.confirmStaticAssignment() }
}
Button("Abbrechen", role: .cancel) { viewModel.cancelStaticAssignment() }
} message: {
if let device = viewModel.pendingStaticAssignment {
Text(
"\(device.ipAddress) wird dauerhaft an \(device.macAddress)"
+ (device.hostName.map { " (\($0))" } ?? "")
+ " gebunden — die Adresse ändert sich für dieses Gerät nicht mehr automatisch."
+ "\n\nRückgängig machen: hier im LAN-Scanner-Tab per Rechtsklick auf das Gerät"
+ " → \"Feste Zuweisung entfernen\". Das Gerät bekommt danach aber nicht sofort"
+ " automatisch eine neue Adresse — dafür muss es die Verbindung kurz trennen und"
+ " neu aufbauen (Netzwerkkabel kurz ziehen/stecken, WLAN aus/an, oder neu starten)."
+ "\n\nBefehl: \(viewModel.pendingCommand?.cliLine ?? "")"
)
}
}
.confirmationDialog(
"Feste Zuweisung entfernen?",
isPresented: showRemovalConfirmation,
titleVisibility: .visible
) {
Button("Entfernen", role: .destructive) {
Task { await viewModel.confirmStaticRemoval() }
}
Button("Abbrechen", role: .cancel) { viewModel.cancelStaticRemoval() }
} message: {
if let device = viewModel.pendingStaticRemoval {
Text(
"Die feste Zuweisung für \(device.macAddress)"
+ (device.hostName.map { " (\($0))" } ?? "")
+ " wird entfernt. RouterOS kennt keine direkte Umkehrung von \"Make Static\" —"
+ " das Gerät bekommt erst beim nächsten Verbindungsaufbau (Kabel/WLAN neu"
+ " verbinden, Neustart) automatisch wieder eine Adresse per DHCP, eventuell"
+ " eine andere IP als bisher."
+ "\n\nBefehl: \(viewModel.pendingRemovalCommand?.cliLine ?? "")"
)
}
}
.alert(
"Fehler",
isPresented: Binding(
get: { viewModel.applyError != nil },
set: { if !$0 { viewModel.dismissApplyError() } }
),
presenting: viewModel.applyError
) { _ in
Button("OK") {}
} message: { error in
Text(error)
}
}
func withDeviceDetailSheets(viewModel: DevicesViewModel, rawFieldsDevice: Binding<LanDevice?>) -> some View {
self
.sheet(item: rawFieldsDevice) { device in
RawFieldsSheet(device: device) { rawFieldsDevice.wrappedValue = nil }
}
.sheet(item: Binding(
get: { viewModel.networkToolResult },
set: { if $0 == nil { viewModel.networkToolResult = nil } }
)) { result in
NetworkToolResultSheet(result: result) { viewModel.networkToolResult = nil }
}
.sheet(item: Binding(
get: { viewModel.portScanResult },
set: { if $0 == nil { viewModel.portScanResult = nil } }
)) { result in
PortScanResultSheet(result: result) { viewModel.portScanResult = nil }
}
.alert(
"Netzwerk-Test fehlgeschlagen",
isPresented: Binding(
get: { viewModel.networkToolError != nil },
set: { if !$0 { viewModel.dismissNetworkToolError() } }
),
presenting: viewModel.networkToolError
) { _ in
Button("OK") {}
} message: { error in
Text(error)
}
}
}
/// Column widths shared between the header and each row so they line up like a real table.
private enum DeviceColumn {
static let icon: CGFloat = 16
static let name: CGFloat = 130
static let ip: CGFloat = 110
static let mac: CGFloat = 140
static let status: CGFloat = 80
}
private struct DeviceColumnHeader: View {
var body: some View {
HStack(spacing: 8) {
Spacer().frame(width: DeviceColumn.icon)
Text("Name").frame(width: DeviceColumn.name, alignment: .leading)
Text("IP-Adresse").frame(width: DeviceColumn.ip, alignment: .leading)
Text("MAC-Adresse").frame(width: DeviceColumn.mac, alignment: .leading)
Text("Status").frame(width: DeviceColumn.status, alignment: .leading)
Spacer()
}
.font(.caption2.bold())
.foregroundStyle(.secondary)
}
}
private struct DeviceRow: View {
let device: LanDevice
/// Three distinct states, not two — a device with no matching DHCP lease at all is neither
/// "Fest" (a real static reservation) nor "Dynamisch" (a real active lease); conflating it
/// with "Fest" previously mislabeled every ARP-only device, DHCP clients included whenever
/// their lease didn't get matched.
private var statusIcon: String {
guard device.hasLease else { return "questionmark.circle" }
return device.isStatic ? "pin.fill" : "circle.fill"
}
private var statusColor: Color {
guard device.hasLease else { return .secondary }
return device.isStatic ? .orange : .green
}
private var statusText: String {
guard device.hasLease else { return "Kein DHCP" }
return device.isStatic ? "Fest" : "Dynamisch"
}
var body: some View {
HStack(spacing: 8) {
Image(systemName: statusIcon)
.foregroundStyle(statusColor)
.font(.system(size: 8))
.frame(width: DeviceColumn.icon)
.help(statusText)
Text(device.hostName?.isEmpty == false ? device.hostName! : "-")
.font(.system(size: 12))
.lineLimit(1)
.frame(width: DeviceColumn.name, alignment: .leading)
Text(device.ipAddress)
.font(.system(size: 12))
.frame(width: DeviceColumn.ip, alignment: .leading)
Text(device.macAddress)
.font(.system(size: 11, design: .monospaced))
.frame(width: DeviceColumn.mac, alignment: .leading)
Text(statusText)
.font(.caption)
.foregroundStyle(.secondary)
.frame(width: DeviceColumn.status, alignment: .leading)
Spacer()
}
.padding(.vertical, 1)
}
}
private struct RawFieldsSheet: View {
let device: LanDevice
let onClose: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Rohdaten: \(device.macAddress)")
.font(.headline)
Text("Alle Felder, die RouterOS für diesen Eintrag zurückgegeben hat — hilfreich, falls Status/Port hier falsch aussieht.")
.font(.caption)
.foregroundStyle(.secondary)
Divider()
ScrollView {
VStack(alignment: .leading, spacing: 4) {
ForEach(device.rawFields, id: \.key) { pair in
HStack(alignment: .top) {
Text(pair.key).font(.caption.monospaced()).foregroundStyle(.secondary)
Spacer()
Text(pair.value).font(.caption.monospaced()).multilineTextAlignment(.trailing)
}
}
}
}
HStack {
Spacer()
Button("Schließen") { onClose() }
}
}
.padding()
.frame(minWidth: 380, minHeight: 300)
}
}
/// Raw text output of a ping/traceroute/DNS-Auflösung run against a device — see
/// `NetworkToolResult`'s doc comment for why this isn't parsed into a nicer structured view.
private struct NetworkToolResultSheet: View {
let result: NetworkToolResult
let onClose: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text(result.title)
.font(.headline)
Text("Ausgeführt vom Router aus (eigene SSH-Verbindung) — testet die Erreichbarkeit vom Router zu diesem Gerät, nicht von diesem Mac.")
.font(.caption)
.foregroundStyle(.secondary)
Divider()
ScrollView {
Text(result.output.isEmpty ? "(keine Ausgabe)" : result.output)
.font(.system(.caption, design: .monospaced))
.frame(maxWidth: .infinity, alignment: .leading)
.textSelection(.enabled)
}
HStack {
Spacer()
Button("Schließen") { onClose() }
}
}
.padding()
.frame(minWidth: 420, minHeight: 320)
}
}
/// Colored per-port result of a `PortScanner` run — red (open), green (closed), grey
/// (unreachable/no response), per explicit request. Run directly from this Mac, not the router
/// (see `PortScanner`'s doc comment), so the caption here says so — different from
/// `NetworkToolResultSheet`'s "run from the router" caption right above it in this same file.
private struct PortScanResultSheet: View {
let result: PortScanResult
let onClose: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Port-Scan: \(result.deviceLabel)")
.font(.headline)
Text("TCP-Verbindungsversuch auf gängige Ports, ausgeführt von diesem Mac aus (nicht vom Router) — rot = offen, grün = geschlossen (Gerät antwortet, aber nichts lauscht dort), grau = keine Antwort (Firewall, Gerät aus, oder Port gefiltert).")
.font(.caption)
.foregroundStyle(.secondary)
Divider()
ScrollView {
VStack(spacing: 0) {
ForEach(Array(result.entries.enumerated()), id: \.element.id) { index, entry in
if index > 0 { Divider() }
PortScanRow(entry: entry)
}
}
}
HStack {
Spacer()
Button("Schließen") { onClose() }
}
}
.padding()
.frame(minWidth: 420, minHeight: 420)
}
}
private struct PortScanRow: View {
let entry: PortScanResult.Entry
private var color: Color {
switch entry.status {
case .open: return .red
case .closed: return .green
case .unreachable: return .secondary
}
}
private var statusText: String {
switch entry.status {
case .open: return "Offen"
case .closed: return "Geschlossen"
case .unreachable: return "Keine Antwort"
}
}
var body: some View {
HStack {
Circle().fill(color).frame(width: 8, height: 8)
Text("\(entry.port)")
.font(.system(.caption, design: .monospaced))
.frame(width: 50, alignment: .leading)
Text(entry.serviceName ?? "-")
.font(.caption)
.foregroundStyle(.secondary)
.frame(width: 140, alignment: .leading)
Spacer()
Text(statusText)
.font(.caption)
.foregroundStyle(color)
}
.padding(.vertical, 3)
}
}
#Preview {
DevicesView(connectionService: ConnectionService())
}