import Charts import SwiftUI /// "LAN-Scanner" tab: one table per physical Ethernet/WLAN port, each listing the /// devices resolved onto it (name/IP/MAC/status), plus an "Aktionen" menu button per row 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 @AppStorage("appLanguage") private var appLanguage: String = "de" @AppStorage(AppPreferences.colorThemeKey) private var colorThemeRaw: String = ColorTheme.standard.rawValue @AppStorage(AppPreferences.lanScannerSparklineWidthKey) private var sparklineWidth: Double = 200 private var colorTheme: ColorTheme { ColorTheme(rawValue: colorThemeRaw) ?? .standard } init(connectionService: ConnectionService) { self.connectionService = connectionService _viewModel = StateObject(wrappedValue: DevicesViewModel(connectionService: connectionService)) } var body: some View { NavigationStack { Group { if connectionService.credentials == nil { ContentUnavailableView( LocalizedStringKey(L10n.t("Nicht verbunden", appLanguage)), systemImage: "network.slash", description: Text(L10n.t("Verbinde dich zuerst im Tab \"Verbinden\" mit deinem Router.", appLanguage)) ) } else if viewModel.isLoading && viewModel.devices.isEmpty { ProgressView(L10n.t("Suche Geräte im Netz…", appLanguage)) .frame(maxWidth: .infinity, maxHeight: .infinity) } else if let error = viewModel.loadError, viewModel.devices.isEmpty { ContentUnavailableView( L10n.t("Konnte Geräte nicht laden", appLanguage), systemImage: "exclamationmark.triangle", description: Text(error) ) } else if viewModel.portGroups.isEmpty { ContentUnavailableView( L10n.t("Keine Ports gefunden", appLanguage), systemImage: "wifi.slash", description: Text(L10n.t("Der Router meldet aktuell keine physischen Interfaces.", appLanguage)) ) } else { Form { ForEach(viewModel.portGroups) { group in Section { if group.devices.isEmpty { Text(L10n.t("Keine Geräte", appLanguage)).appFont(.caption).foregroundStyle(.secondary) } else { DeviceColumnHeader(appLanguage: appLanguage) ForEach(Array(group.devices.enumerated()), id: \.element.id) { index, device in HStack(spacing: 4) { DeviceRow(device: device, appLanguage: appLanguage, theme: colorTheme) Spacer() // Per explicit request: right-click alone wasn't // discoverable ("nicht eindeutig erkennbar oder // intuitiv") — every action that used to live only in // `.contextMenu` now lives in this visible button // instead, same `deviceMenu(for:)` content reused. Menu { deviceMenu(for: device) } label: { Label(L10n.t("Aktionen", appLanguage), systemImage: "ellipsis.circle") .labelStyle(.iconOnly) } .menuStyle(.borderlessButton) .fixedSize() .help(L10n.t("Aktionen", appLanguage)) } .listRowBackground(TableZebra.color(for: index)) } } } header: { HStack(spacing: 8) { Text("\(group.title) (\(group.devices.count))") if let traffic = viewModel.portTraffic[group.id] { Spacer() TrafficSparkline(history: viewModel.portTrafficHistory[group.id] ?? [], width: sparklineWidth) Label( Self.formatTraffic(traffic), systemImage: traffic.isActive ? "arrow.up.arrow.down.circle.fill" : "arrow.up.arrow.down.circle" ) .appFont(.caption2) .foregroundStyle(traffic.isActive ? colorTheme.trafficActive : .secondary) } } } } } .formStyle(.grouped) // `.formStyle(.grouped)` paints its own opaque background over each // Section's rows — `.listRowBackground` (Zebra-Streifen below) was applied // correctly but rendered invisible underneath it. Confirmed live ("keine // Änderungen erkennbar"): this is the documented fix, not a bug in the color // computation itself. .scrollContentBackground(.hidden) } } .overlay(alignment: .bottom) { if viewModel.isRunningNetworkTool { HStack(spacing: 6) { ProgressView().controlSize(.small) Text(L10n.t("Führe Netzwerk-Test aus…", appLanguage)).appFont(.caption) } .padding(.horizontal, 12) .padding(.vertical, 6) .background(.regularMaterial, in: Capsule()) .padding(.bottom, 12) } } .navigationTitle(LocalizedStringKey(L10n.t("LAN-Scanner", appLanguage))) .toolbar { ToolbarItem { ManualHelpButton(anchor: ManualAnchor.tabDevices) } } .toolbar { ToolbarItem { Button { Task { await viewModel.load() } } label: { if viewModel.isLoading { ProgressView().controlSize(.small) } else { Label(L10n.t("Neu scannen", appLanguage), 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() } } .onAppear { if let credentials = connectionService.credentials { viewModel.startTrafficPolling(credentials: credentials) } } .onChange(of: connectionService.state) { _, newState in if case .connected = newState, let credentials = connectionService.credentials { viewModel.startTrafficPolling(credentials: credentials) } else { viewModel.stopTrafficPolling() } } .withStaticAssignmentDialogs( viewModel: viewModel, appLanguage: appLanguage, showStaticConfirmation: $showStaticConfirmation, showRemovalConfirmation: $showRemovalConfirmation ) .withDeviceDetailSheets(viewModel: viewModel, appLanguage: appLanguage, theme: colorTheme, rawFieldsDevice: $rawFieldsDevice) } } /// Fixed MB/s (megabytes/second, not auto-scaling kbps/Mbps/Gbps) per explicit request — /// bits-per-second from RouterOS divided by 8 (bytes) then by 1,000,000 (MB). private static func formatTraffic(_ traffic: InterfaceTraffic) -> String { "↓\(formatMegabytesPerSecond(traffic.rxBitsPerSecond)) ↑\(formatMegabytesPerSecond(traffic.txBitsPerSecond))" } private static func formatMegabytesPerSecond(_ bitsPerSecond: Int) -> String { String(format: "%.2fMB/s", Double(bitsPerSecond) / 8 / 1_000_000) } @ViewBuilder private func deviceMenu(for device: LanDevice) -> some View { if device.hasLease { if device.isStatic { Button { viewModel.pendingStaticRemoval = device showRemovalConfirmation = true } label: { Label(L10n.t("Feste Zuweisung entfernen", appLanguage), systemImage: "pin.slash") } } else { Button { viewModel.pendingStaticAssignment = device showStaticConfirmation = true } label: { Label(L10n.t("Feste IP zuweisen", appLanguage), systemImage: "pin.fill") } } } else { Text(L10n.t("Kein DHCP-Lease — feste Zuweisung hier nicht möglich", appLanguage)) } 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(L10n.t("DNS-Auflösung (nslookup)", appLanguage), systemImage: "magnifyingglass") } } Divider() Button { viewModel.runPortScan(for: device) } label: { Label(L10n.t("Port-Scan", appLanguage), systemImage: "list.bullet.rectangle.portrait") } } label: { Label(L10n.t("Netzwerk-Tools", appLanguage), systemImage: "stethoscope") } .disabled(viewModel.isRunningNetworkTool) Divider() Button { rawFieldsDevice = device } label: { Label(L10n.t("Rohdaten anzeigen", appLanguage), systemImage: "list.bullet.rectangle") } } } /// Split out of the dialog `message:` closures below — same Bug 19 reasoning, but this time it's /// a single long string built from many `+`-chained `L10n.t(...)` calls (not a modifier chain) /// that timed out the type-checker. A plain function with an explicit `-> String` return type /// checks each piece independently instead of inferring the whole chained expression at once. private func staticAssignmentMessage(device: LanDevice, appLanguage: String, cliLine: String?) -> String { "\(device.ipAddress) " + L10n.t("wird dauerhaft an", appLanguage) + " \(device.macAddress)" + (device.hostName.map { " (\($0))" } ?? "") + L10n.t(" gebunden — die Adresse ändert sich für dieses Gerät nicht mehr automatisch.", appLanguage) + L10n.t("\n\nRückgängig machen: hier im LAN-Scanner-Tab über den \"Aktionen\"-Button beim Gerät", appLanguage) + L10n.t(" → \"Feste Zuweisung entfernen\". Das Gerät bekommt danach aber nicht sofort", appLanguage) + L10n.t(" automatisch eine neue Adresse — dafür muss es die Verbindung kurz trennen und", appLanguage) + L10n.t(" neu aufbauen (Netzwerkkabel kurz ziehen/stecken, WLAN aus/an, oder neu starten).", appLanguage) + L10n.t("\n\nBefehl:", appLanguage) + " \(cliLine ?? "")" } private func staticRemovalMessage(device: LanDevice, appLanguage: String, cliLine: String?) -> String { L10n.t("Die feste Zuweisung für", appLanguage) + " \(device.macAddress)" + (device.hostName.map { " (\($0))" } ?? "") + L10n.t(" wird entfernt. RouterOS kennt keine direkte Umkehrung von \"Make Static\" —", appLanguage) + L10n.t(" das Gerät bekommt erst beim nächsten Verbindungsaufbau (Kabel/WLAN neu", appLanguage) + L10n.t(" verbinden, Neustart) automatisch wieder eine Adresse per DHCP, eventuell", appLanguage) + L10n.t(" eine andere IP als bisher.", appLanguage) + L10n.t("\n\nBefehl:", appLanguage) + " \(cliLine ?? "")" } /// 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, appLanguage: String, showStaticConfirmation: Binding, showRemovalConfirmation: Binding ) -> some View { self .confirmationDialog( L10n.t("Feste IP-Adresse zuweisen?", appLanguage), isPresented: showStaticConfirmation, titleVisibility: .visible ) { Button(L10n.t("Zuweisen", appLanguage)) { Task { await viewModel.confirmStaticAssignment() } } Button(L10n.t("Abbrechen", appLanguage), role: .cancel) { viewModel.cancelStaticAssignment() } } message: { if let device = viewModel.pendingStaticAssignment { Text(staticAssignmentMessage(device: device, appLanguage: appLanguage, cliLine: viewModel.pendingCommand?.cliLine)) } } .confirmationDialog( L10n.t("Feste Zuweisung entfernen?", appLanguage), isPresented: showRemovalConfirmation, titleVisibility: .visible ) { Button(L10n.t("Entfernen", appLanguage), role: .destructive) { Task { await viewModel.confirmStaticRemoval() } } Button(L10n.t("Abbrechen", appLanguage), role: .cancel) { viewModel.cancelStaticRemoval() } } message: { if let device = viewModel.pendingStaticRemoval { Text(staticRemovalMessage(device: device, appLanguage: appLanguage, cliLine: viewModel.pendingRemovalCommand?.cliLine)) } } .alert( L10n.t("Fehler", appLanguage), isPresented: Binding( get: { viewModel.applyError != nil }, set: { if !$0 { viewModel.dismissApplyError() } } ), presenting: viewModel.applyError ) { _ in Button(L10n.t("OK", appLanguage)) {} } message: { error in Text(error) } } func withDeviceDetailSheets(viewModel: DevicesViewModel, appLanguage: String, theme: ColorTheme, rawFieldsDevice: Binding) -> some View { self .sheet(item: rawFieldsDevice) { device in RawFieldsSheet(device: device, appLanguage: appLanguage) { rawFieldsDevice.wrappedValue = nil } } .sheet(item: Binding( get: { viewModel.networkToolResult }, set: { if $0 == nil { viewModel.networkToolResult = nil } } )) { result in NetworkToolResultSheet(result: result, appLanguage: appLanguage) { viewModel.networkToolResult = nil } } .sheet(item: Binding( get: { viewModel.portScanResult }, set: { if $0 == nil { viewModel.portScanResult = nil } } )) { result in PortScanResultSheet(result: result, appLanguage: appLanguage, theme: theme) { viewModel.portScanResult = nil } } .alert( L10n.t("Netzwerk-Test fehlgeschlagen", appLanguage), isPresented: Binding( get: { viewModel.networkToolError != nil }, set: { if !$0 { viewModel.dismissNetworkToolError() } } ), presenting: viewModel.networkToolError ) { _ in Button(L10n.t("OK", appLanguage)) {} } message: { error in Text(error) } } } /// Small sparkline next to each port header — last-30-seconds rolling window from /// `DevicesViewModel.portTrafficHistory` (window widened from the original 10s per explicit /// follow-up request). No axes/labels by design — a trend glance, not a readable chart; /// the exact current numbers are already shown right next to it via the ↓/↑ text. Stays an empty /// fixed-size placeholder (not collapsing/disappearing) with fewer than two points, so the row /// layout doesn't jump around during the first couple of poll ticks after opening the tab. private struct TrafficSparkline: View { let history: [TrafficSample] let width: Double var body: some View { Group { if history.count >= 2 { Chart(history) { sample in LineMark( x: .value("Zeit", sample.timestamp), y: .value("Traffic", sample.totalBitsPerSecond) ) .interpolationMethod(.linear) .foregroundStyle(Color.accentColor) } .chartXAxis(.hidden) .chartYAxis(.hidden) .chartYScale(domain: 0...max(history.map(\.totalBitsPerSecond).max() ?? 1, 1)) } else { Color.clear } } .frame(width: width, height: 16) } } /// 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 { let appLanguage: String var body: some View { HStack(spacing: 8) { Spacer().frame(width: DeviceColumn.icon) Text(L10n.t("Name", appLanguage)).frame(width: DeviceColumn.name, alignment: .leading) Text(L10n.t("IP-Adresse", appLanguage)).frame(width: DeviceColumn.ip, alignment: .leading) Text(L10n.t("MAC-Adresse", appLanguage)).frame(width: DeviceColumn.mac, alignment: .leading) Text(L10n.t("Status", appLanguage)).frame(width: DeviceColumn.status, alignment: .leading) Spacer() } .font(.caption2.bold()) .foregroundStyle(.secondary) } } private struct DeviceRow: View { let device: LanDevice let appLanguage: String let theme: ColorTheme /// 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 ? theme.staticLease : theme.dynamicLease } private var statusText: String { guard device.hasLease else { return L10n.t("Kein DHCP", appLanguage) } return device.isStatic ? L10n.t("Fest", appLanguage) : L10n.t("Dynamisch", appLanguage) } 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 appLanguage: String let onClose: () -> Void var body: some View { // Same header layout/behavior as every other popup's close button (see `OverviewView // .focusPanel`): fixed title + spacer + `xmark.circle.fill`, `.padding(10)`, `Divider()` // directly below, separate from the scrollable content's own padding. VStack(spacing: 0) { HStack { Text(L10n.t("Rohdaten:", appLanguage) + " \(device.macAddress)") .appFont(.headline) Spacer() Button { onClose() } label: { Image(systemName: "xmark.circle.fill") .foregroundStyle(.secondary) } .buttonStyle(.plain) .help(L10n.t("Schließen", appLanguage)) } .padding(10) Divider() VStack(alignment: .leading, spacing: 12) { Text(L10n.t("Alle Felder, die RouterOS für diesen Eintrag zurückgegeben hat — hilfreich, falls Status/Port hier falsch aussieht.", appLanguage)) .appFont(.caption) .foregroundStyle(.secondary) ScrollView { VStack(alignment: .leading, spacing: 4) { ForEach(Array(device.rawFields.enumerated()), id: \.element.key) { index, pair in HStack(alignment: .top) { Text(pair.key).appFont(.caption, design: .monospaced).foregroundStyle(.secondary) Spacer() Text(pair.value).appFont(.caption, design: .monospaced).multilineTextAlignment(.trailing) } .padding(.vertical, 2) .background(TableZebra.color(for: index)) } } } } .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 appLanguage: String let onClose: () -> Void var body: some View { VStack(spacing: 0) { HStack { Text(result.title) .appFont(.headline) Spacer() Button { onClose() } label: { Image(systemName: "xmark.circle.fill") .foregroundStyle(.secondary) } .buttonStyle(.plain) .help(L10n.t("Schließen", appLanguage)) } .padding(10) Divider() VStack(alignment: .leading, spacing: 12) { Text(L10n.t("Ausgeführt vom Router aus (eigene SSH-Verbindung) — testet die Erreichbarkeit vom Router zu diesem Gerät, nicht von diesem Mac.", appLanguage)) .appFont(.caption) .foregroundStyle(.secondary) ScrollView { Text(result.output.isEmpty ? L10n.t("(keine Ausgabe)", appLanguage) : result.output) .appFont(.caption, design: .monospaced) .frame(maxWidth: .infinity, alignment: .leading) .textSelection(.enabled) } } .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 appLanguage: String let theme: ColorTheme let onClose: () -> Void var body: some View { VStack(spacing: 0) { HStack { Text(L10n.t("Port-Scan:", appLanguage) + " \(result.deviceLabel)") .appFont(.headline) Spacer() Button { onClose() } label: { Image(systemName: "xmark.circle.fill") .foregroundStyle(.secondary) } .buttonStyle(.plain) .help(L10n.t("Schließen", appLanguage)) } .padding(10) Divider() VStack(alignment: .leading, spacing: 12) { Text(L10n.t("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).", appLanguage)) .appFont(.caption) .foregroundStyle(.secondary) ScrollView { VStack(spacing: 0) { ForEach(Array(result.entries.enumerated()), id: \.element.id) { index, entry in if index > 0 { Divider() } PortScanRow(entry: entry, appLanguage: appLanguage, theme: theme) .background(TableZebra.color(for: index)) } } } } .padding() } .frame(minWidth: 420, minHeight: 420) } } private struct PortScanRow: View { let entry: PortScanResult.Entry let appLanguage: String let theme: ColorTheme private var color: Color { switch entry.status { case .open: return theme.portOpen case .closed: return theme.portClosed case .unreachable: return .secondary } } private var statusText: String { switch entry.status { case .open: return L10n.t("Offen", appLanguage) case .closed: return L10n.t("Geschlossen", appLanguage) case .unreachable: return L10n.t("Keine Antwort", appLanguage) } } var body: some View { HStack { Circle().fill(color).frame(width: 8, height: 8) Text("\(entry.port)") .appFont(.caption, design: .monospaced) .frame(width: 50, alignment: .leading) Text(entry.serviceName ?? "-") .appFont(.caption) .foregroundStyle(.secondary) .frame(width: 140, alignment: .leading) Spacer() Text(statusText) .appFont(.caption) .foregroundStyle(color) } .padding(.vertical, 3) } } #Preview { DevicesView(connectionService: ConnectionService()) }