M24: LAN-Scanner Aktionen-Button, Traffic-Monitor+Sparkline, ARP-Fix

- Rechtsklick-Kontextmenü ersetzt durch sichtbaren "Aktionen"-Button
  je Geräte-Zeile (bisher nicht diskoverbar)
- Live-Traffic pro Port (↓/↑, wiederverwendet InterfaceTrafficMonitor)
  plus kleines Sparkline-Liniendiagramm der letzten 10 Sekunden
  (Swift Charts, neuer TrafficSample-Typ)
- Bug 34: ARP-Tabelle kann mehrere Zeilen für dieselbe MAC halten
  (reachable + stale/failed) — Auflösung bevorzugte bisher blind die
  zuletzt gesehene Zeile statt die erreichbare. Zwei Regressionstests.
- Nebenbefund: dedizierte SSH-Dienste (Backup/NetworkTools/Traffic/
  Update/FactoryReset) haben keinen eigenen Bestätigungspfad für einen
  neuen SSH-Host-Key, scheitern still solange REST verbindet — als
  offener Punkt dokumentiert.

Alles live bestätigt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Kay
2026-09-16 11:46:01 +02:00
co-authored by Claude Sonnet 5
parent 567feed93a
commit 4cc30a2016
7 changed files with 310 additions and 6 deletions
+78
View File
@@ -1418,6 +1418,69 @@ Sektion einklappbar machen"). Zwei Schritte:
`.listStyle(.sidebar)` an der `List` ergänzt, für den nativen
macOS-Sidebar-Look. Live bestätigt ("sehr gut, bin sehr zufrieden").
**M24: LAN-Scanner — "Aktionen"-Button, Traffic-Monitor pro Port +
Sparkline, ARP-Auflösungsbug gefixt** (2026-09-16). Drei Nutzerwünsche
in einer Runde:
1. **"Aktionen"-Button statt Rechtsklick** ("der Rechtsklick ist nicht
eindeutig erkennbar oder intuitiv, verlager bitte alle Funktionen
des Rechtsklicks in den Button") — `.contextMenu` komplett entfernt,
derselbe `deviceMenu(for:)`-Inhalt (Feste IP zuweisen/entfernen,
Netzwerk-Tools, Rohdaten anzeigen) läuft jetzt über einen sichtbaren
`Menu`-Button ("•••", Label "Aktionen") hinter jeder Geräte-Zeile.
2. **Traffic-Monitor pro Port + Sparkline** ("ein Traffic-Monitor im
LAN-Scanner", danach "ein kleines Liniendiagramm der letzten 10
Sekunden ... pro Port") — pro-*Gerät* geht nicht ohne RouterOS-
seitige Queue-Trees pro MAC (Nutzer selbst dagegen entschieden:
"Traffic pro PORT statt pro Gerät"), daher denselben bereits
gebauten `InterfaceTrafficMonitor` (Verbinden-Tab) wiederverwendet —
`DevicesViewModel` bekam eigene `startTrafficPolling`/
`stopTrafficPolling` (3s-Takt, gleiches Muster wie
`ConnectViewModel`). Live-↓/↑-Zahl direkt neben jedem Port-Titel,
plus neuer `TrafficSample`-Typ (`Core/Models/InterfaceTraffic.swift`)
für eine nach echter Zeit (nicht fester Sample-Anzahl) auf 10
Sekunden getrimmte Historie, gerendert als `Chart`/`LineMark`
(Swift Charts, `TrafficSparkline` in `DevicesView.swift`) ohne
Achsen — reiner Trend-Blick, die genaue Zahl steht ja daneben.
3. **Bug 34, dabei live gefunden:** ein Laptop an `ether4` (Traffic
sichtbar aktiv) erschien trotzdem unter "Unbekannter Port". Ursache:
RouterOS' `/ip arp`-Tabelle hielt für dieselbe MAC-Adresse zwei
Zeilen gleichzeitig — eine korrekte (`interface=ether4,
status=reachable`) und eine veraltete (`interface=bridge,
status=failed`, Überbleibsel von vor einem Netzwerkwechsel des
Geräts). `DevicesViewModel.buildDevices` baute `arpInterfaceByMAC`
bisher per blindem Überschreiben der letzten gesehenen Zeile auf —
welche Zeile "gewann" hing von der Router-internen Tabellen-
Reihenfolge ab, nicht von Korrektheit. Fix: `status=reachable`
gewinnt jetzt immer gegen jeden anderen Status, bei Gleichstand ein
konkreter Port gegen "bridge". Zwei neue Regressionstests
(`testReachableArpRowWinsOverStaleFailedRowForSameMAC`/
`...RegardlessOfOrder`, letzterer prüft explizit, dass der Fix nicht
nur für die live beobachtete Reihenfolge zufällig funktioniert).
**Nebenbefund beim Debuggen von Punkt 2, kein Code-Bug, aber ein
Betriebshinweis:** der Traffic-Monitor zeigte zunächst dauerhaft nichts
an — `InterfaceTrafficMonitor.fetchTraffic` scheiterte still (`try?`)
mit `untrustedSSHHostKey`. Ursache: der Router hatte seit dem letzten
Werksreset (2026-09-15) einen neuen SSH-Host-Key, aber da die Haupt-
verbindung dieser Session durchgehend über REST lief (M22-Fix), kam der
bereits vorhandene "Unbekannter SSH-Schlüssel"-Bestätigungsdialog nie
zum Zug — der lief bisher nur im SSH-*Fallback*-Pfad von
`ConnectionService.connect`. Dedizierte SSH-Verbindungen (Backup,
Netzwerk-Tools, Traffic-Monitor, Update, Werksreset) haben *keinen*
eigenen UI-Bestätigungspfad für einen neuen Host-Key, scheitern bei
einem Mismatch einfach still. Behoben durch `www-ssl` kurz deaktiviert
(erzwingt den SSH-Fallback-Pfad in der Haupt-Verbindung), Nutzer hat
den neuen Fingerprint einmalig über den bestehenden Dialog bestätigt
(`SSHHostKeyTrustStore` ist ein einziger, über `UserDefaults.standard`
geteilter Speicher — einmal dort vertraut gilt für jede dedizierte
SSH-Verbindung mit), danach `www-ssl` wieder aktiviert. **Bleibt ein
echter, noch offener Schwachpunkt:** falls der SSH-Host-Key sich künftig
nochmal ändert (z.B. nach einem weiteren Werksreset) UND REST zu diesem
Zeitpunkt bereits verbunden ist, würden dieselben dedizierten Dienste
wieder wortlos nichts tun, ohne Hinweis auf die Ursache — siehe
"Nächste Schritte".
## Nächste Schritte
1. ~~M16: restliche `RouterOSSchemaCatalog.swift`-Sektionen übersetzen~~,
@@ -1576,6 +1639,21 @@ Sektion einklappbar machen"). Zwei Schritte:
DHCP-Netzwerk-Optionen zwei Hops entfernt), Klick auf Pool/Adresse
bleibt bei der 1-Hop-Regel — beides wie erwartet bestätigt
("passt, live bestätigt").
13. **Dedizierte SSH-Dienste scheitern still bei SSH-Host-Key-Mismatch,
solange REST verbindet** (siehe M24-Nebenbefund oben) —
`BackupService`/`NetworkToolsService`/`InterfaceTrafficMonitor`/
`UpdateService`/`FactoryResetService` haben keinen eigenen
UI-Bestätigungspfad für einen neuen SSH-Host-Key, nur
`ConnectionService.connect`s SSH-*Fallback* zeigt den Dialog — und
der wird nie erreicht, solange REST erfolgreich verbindet. Ein
künftiger Host-Key-Wechsel (z.B. nach einem weiteren Werksreset)
würde also wieder zu wortlos leeren Ergebnissen führen (leere
Traffic-Anzeige, fehlschlagende Backups, etc.), ohne erkennbaren
Grund in der UI. Mögliche Fixes: diese Dienste bei
`untrustedSSHHostKey` einen eigenen Bestätigungsdialog zeigen
lassen, oder `ConnectionService` beim Verbinden zusätzlich (nicht
nur im Fallback-Fall) einmal den SSH-Host-Key prüfen/bestätigen
lassen, unabhängig davon, ob REST erfolgreich war.
Gitea-Remote `origin` ist eingerichtet und wird laufend gepusht (siehe
oben) — dieser Hinweis war veraltet, korrigiert am 2026-09-15.
+2 -1
View File
@@ -153,6 +153,7 @@ nur die zugehörigen Passwörter liegen weiterhin im macOS-Schlüsselbund.
| M21 | Zweisprachigkeit (DE/EN) auf alle fünf Tabs ausgerollt (Einrichten/Übersicht/LAN-Scanner/Sicherungen) | ✅ live verifiziert |
| M22 | REST-Transport (M7) erstmals live gegen Hardware verifiziert, 4 Bugs gefunden+gefixt | ✅ live verifiziert |
| M23 | Experte-Tab: Sektionsüberschriften prominenter+eingefärbt, einklappbar (Standard: zugeklappt) | ✅ live verifiziert |
| M24 | LAN-Scanner: "Aktionen"-Button statt Rechtsklick, Traffic-Monitor+Sparkline pro Port, ARP-Bug gefixt | ✅ live verifiziert |
| — | LAN-Port-Konflikt-Prüfung + "Fertig"-Button (Einrichten) | 🔶 gebaut, Live-Test offen |
Ausführlicher Stand inkl. aller gefundenen Bugs, offener Punkte und
@@ -176,7 +177,7 @@ RouterOS/
│ │ │ ├── LanDevice.swift — ein Gerät im LAN-Scanner-Tab (Lease+ARP+Bridge-Host verschmolzen)
│ │ │ ├── SavedRouter.swift — ein Eintrag in "Bekannte Router" (Host/Name/Standort)
│ │ │ ├── PortConflict.swift — LAN-Port-Konflikt-Prüfung (Bridge/Adresse/DHCP-Client/PPPoE)
│ │ │ ├── InterfaceTraffic.swift — ein Live-Durchsatz-Sample für die Traffic-Anzeige
│ │ │ ├── InterfaceTraffic.swift — ein Live-Durchsatz-Sample + TrafficSample (10s-Sparkline-Historie, LAN-Scanner)
│ │ │ ├── NetworkToolResult.swift — Rohausgabe von Ping/Traceroute/DNS-Auflösung
│ │ │ ├── PortScanResult.swift — Ergebnis eines Port-Scans, ein Eintrag pro Port
│ │ │ └── RouterOSModels.swift — Credentials, DeviceInfo, RouterBoardInfo, PackageUpdateInfo, Fehler
@@ -835,6 +835,7 @@ enum L10n {
"Keine Geräte": "No Devices",
"Führe Netzwerk-Test aus…": "Running network test…",
"Neu scannen": "Rescan",
"Aktionen": "Actions",
"Feste Zuweisung entfernen": "Remove Static Assignment",
"Feste IP zuweisen": "Assign Static IP",
"Kein DHCP-Lease — feste Zuweisung hier nicht möglich": "No DHCP lease — static assignment not possible here",
@@ -10,3 +10,14 @@ struct InterfaceTraffic: Equatable {
var isActive: Bool { rxBitsPerSecond > 0 || txBitsPerSecond > 0 }
}
/// One timestamped throughput reading, kept in a short rolling per-port history so the
/// LAN-Scanner's port headers can show a small sparkline of the last few seconds, not just the
/// current instantaneous value (Nutzerwunsch: "ein kleines Liniendiagramm der letzten 10 Sekunden
/// ... pro port"). Timestamped (not just appended in order) so trimming to "last 10 seconds" stays
/// correct even if a poll tick is ever delayed or skipped, rather than assuming a fixed cadence.
struct TrafficSample: Identifiable {
let id = UUID()
let timestamp: Date
let totalBitsPerSecond: Int
}
@@ -1,3 +1,4 @@
import Charts
import SwiftUI
/// "LAN-Scanner" tab: one table per physical Ethernet/WLAN port, each listing the
@@ -56,14 +57,40 @@ struct DevicesView: View {
} else {
DeviceColumnHeader(appLanguage: appLanguage)
ForEach(group.devices) { device in
DeviceRow(device: device, appLanguage: appLanguage)
.contextMenu {
HStack(spacing: 4) {
DeviceRow(device: device, appLanguage: appLanguage)
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))
}
}
}
} header: {
Text("\(group.title) (\(group.devices.count))")
HStack(spacing: 8) {
Text("\(group.title) (\(group.devices.count))")
if let traffic = viewModel.portTraffic[group.id] {
Spacer()
TrafficSparkline(history: viewModel.portTrafficHistory[group.id] ?? [])
Label(
Self.formatTraffic(traffic),
systemImage: traffic.isActive ? "arrow.up.arrow.down.circle.fill" : "arrow.up.arrow.down.circle"
)
.font(.caption2)
.foregroundStyle(traffic.isActive ? .green : .secondary)
}
}
}
}
}
@@ -107,6 +134,18 @@ struct DevicesView: View {
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,
@@ -117,6 +156,23 @@ struct DevicesView: View {
}
}
/// Same unit-suffix style RouterOS itself reports live traffic in (`SSHTransport.
/// parseBitsPerSecond`'s doc comment: "50.7kbps", "34.0kbps") kept consistent instead of
/// inventing a different display format for the same underlying number.
private static func formatTraffic(_ traffic: InterfaceTraffic) -> String {
"\(formatBitsPerSecond(traffic.rxBitsPerSecond))\(formatBitsPerSecond(traffic.txBitsPerSecond))"
}
private static func formatBitsPerSecond(_ bitsPerSecond: Int) -> String {
let units: [(suffix: String, divisor: Double)] = [
("Gbps", 1_000_000_000), ("Mbps", 1_000_000), ("kbps", 1_000)
]
for unit in units where Double(bitsPerSecond) >= unit.divisor {
return String(format: "%.1f%@", Double(bitsPerSecond) / unit.divisor, unit.suffix)
}
return "\(bitsPerSecond)bps"
}
@ViewBuilder
private func deviceMenu(for device: LanDevice) -> some View {
if device.hasLease {
@@ -286,6 +342,37 @@ private extension View {
}
}
/// Small sparkline next to each port header last-10-seconds rolling window from
/// `DevicesViewModel.portTrafficHistory` (Nutzerwunsch: "ein kleines Liniendiagramm der letzten
/// 10 Sekunden ... pro Port"). 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]
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: 50, 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
@@ -32,6 +32,21 @@ final class DevicesViewModel: ObservableObject {
/// `PortScanner`, not through the router see its own doc comment for why.
@Published var portScanResult: PortScanResult?
/// Live per-port throughput (Nutzerwunsch: "ein Traffic-Monitor im LAN-Scanner zu den
/// einzelnen Geräten" per-device counters aren't something RouterOS exposes natively
/// without setting up queue trees per MAC, so this reuses the same per-*port* live monitor
/// already built for the Verbinden-Tab instead; keyed by `DevicePortGroup.id` (the physical
/// interface name), not by device). "unbekannt" (devices RouterOS couldn't resolve onto a
/// real port) is never polled it isn't a real interface.
@Published private(set) var portTraffic: [String: InterfaceTraffic] = [:]
/// Rolling last-10-seconds history per port, for the small sparkline next to each port
/// header (Nutzerwunsch: "ein kleines Liniendiagramm der letzten 10 Sekunden ... pro Port").
/// Trimmed by actual elapsed time each tick, not by a fixed sample count, so it stays a true
/// "last 10 seconds" window even if a poll tick is ever late.
@Published private(set) var portTrafficHistory: [String: [TrafficSample]] = [:]
private let trafficMonitor = InterfaceTrafficMonitor()
private var trafficPollingTask: Task<Void, Never>?
private let connectionService: ConnectionService
private let backupService: BackupService
private let networkToolsService: NetworkToolsService
@@ -46,6 +61,44 @@ final class DevicesViewModel: ObservableObject {
self.networkToolsService = networkToolsService
}
/// Same 3s cadence/reasoning as `ConnectViewModel.startTrafficPolling` re-reads
/// `portGroups` every tick so a newly-appearing port (e.g. a VLAN interface added via the
/// Setup wizard while this tab is open) gets picked up without restarting the poll.
func startTrafficPolling(credentials: RouterOSCredentials) {
stopTrafficPolling()
trafficPollingTask = Task {
while !Task.isCancelled {
let names = portGroups.map(\.id).filter { $0 != "unbekannt" }
if !names.isEmpty {
let traffic = await trafficMonitor.fetchTraffic(interfaceNames: names, for: credentials)
if !Task.isCancelled {
portTraffic = traffic
appendTrafficHistory(traffic, at: Date())
}
}
try? await Task.sleep(for: .seconds(3))
}
}
}
private func appendTrafficHistory(_ traffic: [String: InterfaceTraffic], at timestamp: Date) {
let cutoff = timestamp.addingTimeInterval(-10)
for (name, sample) in traffic {
var history = portTrafficHistory[name] ?? []
history.append(TrafficSample(timestamp: timestamp, totalBitsPerSecond: sample.rxBitsPerSecond + sample.txBitsPerSecond))
history.removeAll { $0.timestamp < cutoff }
portTrafficHistory[name] = history
}
}
func stopTrafficPolling() {
trafficPollingTask?.cancel()
trafficPollingTask = nil
portTraffic = [:]
portTrafficHistory = [:]
Task { await trafficMonitor.disconnect() }
}
func runPing(for device: LanDevice) {
runNetworkTool(title: "Ping: \(device.ipAddress)") { [networkToolsService] credentials in
try await networkToolsService.ping(address: device.ipAddress, for: credentials)
@@ -277,11 +330,36 @@ final class DevicesViewModel: ObservableObject {
// Fallback: ARP's "interface" field exact if that interface isn't a bridge, otherwise
// only "somewhere on this bridge" (resolved further by the bridge host table above when
// available).
// available). RouterOS can hold MULTIPLE ARP rows for the same MAC at once (live-
// confirmed: a device on a standalone port like "ether4" still had a second, stale
// "status=failed" row for an old address on "interface=bridge", left over from before it
// moved networks) blindly keeping the last row seen made the resolution depend on
// table order, not correctness, and could silently overwrite a working resolution with
// a dead one (this device fell into "Unbekannter Port" despite a perfectly good
// "reachable" ether4 row existing). Now scored: a "reachable" row always wins over any
// other status, tie-broken by preferring a non-bridge (more specific) interface, so a
// genuinely ambiguous case still prefers whatever's most precise.
var arpInterfaceByMAC: [String: String] = [:]
var arpStatusByMAC: [String: String] = [:]
for item in arpEntries {
guard let mac = item.fields["mac-address"]?.lowercased(), let iface = item.fields["interface"] else { continue }
arpInterfaceByMAC[mac] = iface
let status = item.fields["status"] ?? ""
guard let existingStatus = arpStatusByMAC[mac] else {
arpInterfaceByMAC[mac] = iface
arpStatusByMAC[mac] = status
continue
}
let isNewReachable = status == "reachable"
let isExistingReachable = existingStatus == "reachable"
if isNewReachable && !isExistingReachable {
arpInterfaceByMAC[mac] = iface
arpStatusByMAC[mac] = status
} else if isNewReachable == isExistingReachable,
let existingIface = arpInterfaceByMAC[mac],
bridgeNames.contains(existingIface), !bridgeNames.contains(iface) {
arpInterfaceByMAC[mac] = iface
arpStatusByMAC[mac] = status
}
}
// Last resort: the DHCP server's own configured interface network-level only, since a
@@ -54,6 +54,54 @@ final class DevicesViewModelTests: XCTestCase {
XCTAssertTrue(devices[0].isExactPort)
}
/// Live-confirmed (2026-09-16): RouterOS' `/ip arp` table held two rows for the same MAC at
/// once a "reachable" one on the device's real standalone port, and a stale "failed" one
/// left over from an earlier network on "bridge". The old code built `arpInterfaceByMAC` by
/// blindly overwriting with whatever row came last, so which one "won" depended on array
/// order, not correctness this device fell into "Unbekannter Port" despite the working
/// row existing. Reproduces that exact shape (stale bridge row listed AFTER the reachable
/// direct-port row, matching the live capture) and asserts the reachable, more specific
/// port wins regardless of order.
func testReachableArpRowWinsOverStaleFailedRowForSameMAC() {
let devices = DevicesViewModel.buildDevices(
leases: [item([
"address": "192.168.4.254", "mac-address": "AC:91:A1:04:BB:08",
"host-name": "Laptop", "server": "dhcp_ether4"
])],
arpEntries: [
item(["address": "192.168.4.254", "mac-address": "AC:91:A1:04:BB:08", "interface": "ether4", "status": "reachable"]),
item(["address": "192.168.88.252", "mac-address": "AC:91:A1:04:BB:08", "interface": "bridge", "status": "failed"])
],
bridgeHosts: [],
interfaces: [item(["name": "bridge", "type": "bridge"]), item(["name": "ether4", "type": "ether"])],
dhcpServers: [item(["name": "dhcp_ether4", "interface": "ether4"])]
)
XCTAssertEqual(devices[0].resolvedPort, "ether4")
XCTAssertTrue(devices[0].isExactPort)
XCTAssertNotEqual(devices[0].displayPort, "unbekannt")
}
/// Same shape, rows in the opposite order (reachable row listed second) the fix must not
/// just happen to work for the one order seen live.
func testReachableArpRowWinsOverStaleFailedRowRegardlessOfOrder() {
let devices = DevicesViewModel.buildDevices(
leases: [item([
"address": "192.168.4.254", "mac-address": "AC:91:A1:04:BB:08",
"host-name": "Laptop", "server": "dhcp_ether4"
])],
arpEntries: [
item(["address": "192.168.88.252", "mac-address": "AC:91:A1:04:BB:08", "interface": "bridge", "status": "failed"]),
item(["address": "192.168.4.254", "mac-address": "AC:91:A1:04:BB:08", "interface": "ether4", "status": "reachable"])
],
bridgeHosts: [],
interfaces: [item(["name": "bridge", "type": "bridge"]), item(["name": "ether4", "type": "ether"])],
dhcpServers: [item(["name": "dhcp_ether4", "interface": "ether4"])]
)
XCTAssertEqual(devices[0].resolvedPort, "ether4")
}
func testLeaseNotInStaticMacSetIsDynamic() {
// Confirmed live on a real hEX: "/ip dhcp-server lease print terse" never emits a
// "dynamic" key at all, in either state reading fields can't distinguish static from