Neuer Tab: eine Tabelle pro physischem Ethernet/WLAN-Port mit den dort gefundenen Geräten (Name, IP, MAC, Fest/Dynamisch/Kein-DHCP), gebaut aus DHCP-Leases + ARP + Bridge-Host-Tabelle. Rechtsklick auf ein dynamisches Gerät -> "Feste IP zuweisen" (RouterOS' "Make Static", per /ip dhcp-server lease make-static), mit Bestätigungsdialog und Session-Backup vor dem ersten Schreibvorgang (geteilter Mechanismus mit dem Experte-Tab). Vier reale Bugs live gefunden und gefixt (siehe HANDOFF.md Bug 14-17): - "print terse" gibt das "dynamic"-Feld von /ip dhcp-server lease nie aus, in keinem Zustand -> Status kommt jetzt über RouterOS' find/get gegen die interne Eigenschaft, nicht aus gelesenen Feldern. - fetchMenuItems' .id-Positionsüberlagerung ordnete für dieses Menü die falsche .id der falschen Zeile zu -> Erkennung und make-static-Ziel laufen jetzt über die MAC-Adresse statt .id. - Ein SwiftUI-.confirmationDialog löschte sein eigenes Ziel-Objekt vor der Ausführung der bestätigten Aktion (Setter feuert bei jedem Knopfdruck, nicht nur Abbrechen) -> Dialog-Sichtbarkeit und Nutzlast entkoppelt, wie in BackupListView. - Die eigene Verifikations-Abfrage (get [find ...] feld als ein kombinierter Befehl) war selbst eine nie verifizierte Annahme und lieferte falsche Negative -> ersetzt durch :foreach aus zwei einzeln bestätigten Bausteinen (find, get <id> feld). RouterOSCommand bekommt einen neuen .action-Operationstyp für RouterOS-"Menü-spezifische Befehle" jenseits von add/set/remove (aktuell nur make-static). HANDOFF.md/CHATLOG.md mit allen vier Bugs, neuen Milestones M11/M12 und offenen Punkten aktualisiert. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CTgRxJTzaQwaRkngbaE1GJ
320 lines
17 KiB
Swift
320 lines
17 KiB
Swift
import Foundation
|
|
|
|
/// Builds the "Geräte" tab's LAN device list from a live router — DHCP leases enriched with the
|
|
/// physical port each device was learned on (ARP + bridge host table) — grouped into one table
|
|
/// per physical port, and drives the one write action this tab offers: converting a dynamic
|
|
/// lease to a permanent static one.
|
|
@MainActor
|
|
final class DevicesViewModel: ObservableObject {
|
|
@Published private(set) var devices: [LanDevice] = []
|
|
@Published private(set) var portGroups: [DevicePortGroup] = []
|
|
@Published private(set) var isLoading = false
|
|
@Published private(set) var loadError: String?
|
|
|
|
@Published var pendingStaticAssignment: LanDevice?
|
|
@Published private(set) var isApplying = false
|
|
@Published private(set) var applyError: String?
|
|
|
|
private let connectionService: ConnectionService
|
|
private let backupService: BackupService
|
|
|
|
init(connectionService: ConnectionService, backupService: BackupService = BackupService()) {
|
|
self.connectionService = connectionService
|
|
self.backupService = backupService
|
|
}
|
|
|
|
func load() async {
|
|
isLoading = true
|
|
loadError = nil
|
|
do {
|
|
async let leasesTask = connectionService.fetchMenuItems(menuPath: "/ip dhcp-server lease", restPath: "ip/dhcp-server/lease")
|
|
async let arpTask = connectionService.fetchMenuItems(menuPath: "/ip arp", restPath: "ip/arp")
|
|
async let bridgeHostsTask = connectionService.fetchMenuItems(menuPath: "/interface bridge host", restPath: "interface/bridge/host")
|
|
async let interfacesTask = connectionService.fetchMenuItems(menuPath: "/interface", restPath: "interface")
|
|
async let dhcpServersTask = connectionService.fetchMenuItems(menuPath: "/ip dhcp-server", restPath: "ip/dhcp-server")
|
|
|
|
let leases = try await leasesTask
|
|
let arpEntries = try await arpTask
|
|
let bridgeHosts = try await bridgeHostsTask
|
|
let interfaces = try await interfacesTask
|
|
let dhcpServers = try await dhcpServersTask
|
|
// Best-effort: if this specific lookup fails (e.g. an older RouterOS version that
|
|
// doesn't support filtering "find" on this property), fall back to "nothing known
|
|
// static yet" rather than failing the whole tab — matches the safe default already
|
|
// used when a lease is entirely unmatched. Returns MAC addresses, not `.id`s — see
|
|
// `RouterOSTransport.fetchFieldValues`'s doc comment for why `.id` isn't trustworthy
|
|
// for this menu.
|
|
let staticMacs = Set((try? await connectionService.fetchFieldValues(
|
|
menuPath: "/ip dhcp-server lease", restPath: "ip/dhcp-server/lease",
|
|
whereField: "dynamic", whereValue: "no", returnField: "mac-address"
|
|
))?.map { $0.lowercased() } ?? [])
|
|
|
|
let builtDevices = Self.buildDevices(
|
|
leases: leases, arpEntries: arpEntries, bridgeHosts: bridgeHosts,
|
|
interfaces: interfaces, dhcpServers: dhcpServers, staticMacAddresses: staticMacs
|
|
)
|
|
devices = builtDevices
|
|
portGroups = Self.buildPortGroups(devices: builtDevices, interfaces: interfaces)
|
|
} catch {
|
|
loadError = error.localizedDescription
|
|
}
|
|
isLoading = false
|
|
}
|
|
|
|
/// The command "Feste IP zuweisen" would run — shown to the user before it executes, same
|
|
/// convention as the rest of the app (Wizard review screen, Expert tool's confirmation
|
|
/// dialog).
|
|
var pendingCommand: RouterOSCommand? {
|
|
guard let device = pendingStaticAssignment, device.hasLease else { return nil }
|
|
// Matched by MAC address, not `.id` — deliberately, see `RouterOSTransport.
|
|
// fetchFieldValues`'s doc comment for why the `.id` this app would otherwise have on
|
|
// hand (`device.leaseID`) isn't trustworthy for this menu.
|
|
return .action(
|
|
menuPath: "/ip dhcp-server lease", restPath: "ip/dhcp-server/lease",
|
|
name: "make-static", matchField: "mac-address", matchValue: device.macAddress,
|
|
summary: "Feste IP \(device.ipAddress) für \(device.macAddress)"
|
|
)
|
|
}
|
|
|
|
/// Backs up once per connection before this tab's first write — same shared flag the Expert
|
|
/// tool uses (`ConnectionService.hasExpertToolBackedUpThisSession`), so a session that already
|
|
/// backed up via one "power tool" doesn't back up again via the other.
|
|
private func ensureSessionBackup() async throws {
|
|
guard !connectionService.hasExpertToolBackedUpThisSession, let credentials = connectionService.credentials else { return }
|
|
_ = try await backupService.createBackup(for: credentials)
|
|
connectionService.markExpertToolBackedUpThisSession()
|
|
}
|
|
|
|
func confirmStaticAssignment() async {
|
|
guard let command = pendingCommand, let device = pendingStaticAssignment else { return }
|
|
isApplying = true
|
|
applyError = nil
|
|
do {
|
|
try await ensureSessionBackup()
|
|
try await connectionService.apply(command)
|
|
// Confirmed live: RouterOS' SSH CLI can run "make-static [find ...]" without any
|
|
// error output while genuinely not converting anything (a mismatched selector) — "no
|
|
// error" alone isn't proof it worked (same lesson as Bug 10 in HANDOFF.md, one level
|
|
// deeper: even a real, syntactically valid, silently-successful-looking command can
|
|
// still not have the intended effect). Verify the lease is now actually in the static
|
|
// set before declaring success — by MAC, same reasoning as `pendingCommand`.
|
|
let staticMacs = Set((try await connectionService.fetchFieldValues(
|
|
menuPath: "/ip dhcp-server lease", restPath: "ip/dhcp-server/lease",
|
|
whereField: "dynamic", whereValue: "no", returnField: "mac-address"
|
|
)).map { $0.lowercased() })
|
|
guard staticMacs.contains(device.macAddress.lowercased()) else {
|
|
throw RouterOSError.invalidResponse(
|
|
"Befehl lief ohne Fehlermeldung, aber der Router zeigt den Eintrag weiterhin als dynamisch (geprüft über \"find dynamic=no\"). Bitte manuell mit \"/ip dhcp-server lease print\" kontrollieren."
|
|
)
|
|
}
|
|
pendingStaticAssignment = nil
|
|
await load()
|
|
} catch {
|
|
// Clear this on failure too, not just success — otherwise the confirmationDialog's
|
|
// `isPresented` binding (tied to `pendingStaticAssignment != nil`) stays "open" while
|
|
// SwiftUI also tries to present the error `.alert`, and the two presentations can
|
|
// conflict enough that the alert never actually becomes visible — a silent failure
|
|
// that looked like nothing happened at all.
|
|
pendingStaticAssignment = nil
|
|
applyError = error.localizedDescription
|
|
}
|
|
isApplying = false
|
|
}
|
|
|
|
func cancelStaticAssignment() {
|
|
pendingStaticAssignment = nil
|
|
}
|
|
|
|
func dismissApplyError() {
|
|
applyError = nil
|
|
}
|
|
|
|
// MARK: - Pure device list construction (testable without a live router)
|
|
|
|
nonisolated static func buildDevices(
|
|
leases: [RouterOSMenuItem],
|
|
arpEntries: [RouterOSMenuItem],
|
|
bridgeHosts: [RouterOSMenuItem],
|
|
interfaces: [RouterOSMenuItem],
|
|
dhcpServers: [RouterOSMenuItem],
|
|
staticMacAddresses: Set<String> = []
|
|
) -> [LanDevice] {
|
|
var bridgeNames = Set<String>()
|
|
for item in interfaces where item.fields["type"] == "bridge" {
|
|
if let name = item.fields["name"] { bridgeNames.insert(name) }
|
|
}
|
|
|
|
// Exact: the bridge host table names the actual physical port a MAC was learned on
|
|
// behind a bridge (RouterOS field "on-interface" — confirmed against MikroTik's
|
|
// Bridging and Switching docs, not guessed).
|
|
var exactPortByMAC: [String: String] = [:]
|
|
for item in bridgeHosts {
|
|
guard let mac = item.fields["mac-address"]?.lowercased(), let onInterface = item.fields["on-interface"] else { continue }
|
|
exactPortByMAC[mac] = onInterface
|
|
}
|
|
|
|
// 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).
|
|
var arpInterfaceByMAC: [String: String] = [:]
|
|
for item in arpEntries {
|
|
guard let mac = item.fields["mac-address"]?.lowercased(), let iface = item.fields["interface"] else { continue }
|
|
arpInterfaceByMAC[mac] = iface
|
|
}
|
|
|
|
// Last resort: the DHCP server's own configured interface — network-level only, since a
|
|
// server can sit on a bridge shared by several physical ports.
|
|
var interfaceByDHCPServer: [String: String] = [:]
|
|
for item in dhcpServers {
|
|
guard let name = item.fields["name"], let iface = item.fields["interface"] else { continue }
|
|
interfaceByDHCPServer[name] = iface
|
|
}
|
|
|
|
func resolvePort(mac: String, fallbackServer: String?) -> (port: String?, network: String?) {
|
|
if let exact = exactPortByMAC[mac] {
|
|
return (exact, nil)
|
|
}
|
|
if let iface = arpInterfaceByMAC[mac] {
|
|
return bridgeNames.contains(iface) ? (nil, iface) : (iface, nil)
|
|
}
|
|
if let server = fallbackServer, let iface = interfaceByDHCPServer[server] {
|
|
return bridgeNames.contains(iface) ? (nil, iface) : (iface, nil)
|
|
}
|
|
return (nil, nil)
|
|
}
|
|
|
|
var devices: [LanDevice] = []
|
|
var seenMACs = Set<String>()
|
|
|
|
for (index, item) in leases.enumerated() {
|
|
guard let address = item.fields["address"], let macRaw = item.fields["mac-address"] else { continue }
|
|
let mac = macRaw.lowercased()
|
|
seenMACs.insert(mac)
|
|
// Confirmed live on a real hEX (two independent checks, "/ip dhcp-server lease
|
|
// print"'s flags column before and after converting a lease to static): "dynamic" is
|
|
// never emitted by "print terse" for this menu, in EITHER state — not omitted only
|
|
// for the common case, genuinely absent from the parseable output entirely. Reading
|
|
// the field at all (this app's first two attempts) can't work. Static/dynamic here
|
|
// instead comes from `staticMacAddresses`, fetched via RouterOS' `find dynamic=no` —
|
|
// matched by MAC, not `.id`, because `.id` from `fetchMenuItems`' positional overlay
|
|
// was confirmed live to mis-pair with the wrong lease for this exact menu (a static
|
|
// lease's `.id` got attached to a different, still-dynamic lease's row, making it
|
|
// wrongly show "Fest" too).
|
|
let isStaticLease = staticMacAddresses.contains(mac)
|
|
let server = item.fields["server"]
|
|
let (port, network) = resolvePort(mac: mac, fallbackServer: server)
|
|
devices.append(LanDevice(
|
|
// Unique per row, not just the MAC — if RouterOS ever reports two lease entries
|
|
// for the same MAC (observed: a device that should be static still showing
|
|
// dynamic after conversion, plausibly because RouterOS created a fresh dynamic
|
|
// lease alongside the newly-static one), both must render, not silently collapse
|
|
// to one via a SwiftUI List/ForEach duplicate-id collision.
|
|
id: "lease-\(index)-\(mac)",
|
|
ipAddress: address,
|
|
macAddress: macRaw,
|
|
hostName: item.fields["host-name"],
|
|
isStatic: isStaticLease,
|
|
hasLease: true,
|
|
leaseID: item.id,
|
|
dhcpServerName: server,
|
|
resolvedPort: port,
|
|
networkHint: network,
|
|
comment: item.fields["comment"],
|
|
rawFields: [(key: ".id", value: item.id)] + item.fields.sorted { $0.key < $1.key }.map { (key: $0.key, value: $0.value) }
|
|
))
|
|
}
|
|
|
|
// ARP-only devices: seen on the network but with no matching DHCP lease (statically
|
|
// configured outside DHCP, or a stray/foreign device). Shown read-only — there's no
|
|
// lease item to convert, and guessing which DHCP server's network a static-IP device
|
|
// "belongs to" isn't something this app will do.
|
|
for (index, item) in arpEntries.enumerated() {
|
|
guard let address = item.fields["address"], let macRaw = item.fields["mac-address"] else { continue }
|
|
let mac = macRaw.lowercased()
|
|
guard !seenMACs.contains(mac) else { continue }
|
|
seenMACs.insert(mac)
|
|
let (port, network) = resolvePort(mac: mac, fallbackServer: nil)
|
|
devices.append(LanDevice(
|
|
id: "arp-\(index)-\(mac)",
|
|
ipAddress: address,
|
|
macAddress: macRaw,
|
|
hostName: nil,
|
|
// No lease at all here — "isStatic" (a DHCP *static reservation*) simply doesn't
|
|
// apply, so this must not read as "Fest" (a prior version wrongly hardcoded
|
|
// `true`, which mislabeled every ARP-only device — including ordinary DHCP
|
|
// clients whose lease didn't get matched — as statically assigned). The Geräte
|
|
// view branches on `hasLease` first precisely to keep this case visually distinct
|
|
// from both "Fest" and "Dynamisch".
|
|
isStatic: false,
|
|
hasLease: false,
|
|
leaseID: nil,
|
|
dhcpServerName: nil,
|
|
resolvedPort: port,
|
|
networkHint: network,
|
|
comment: nil,
|
|
rawFields: item.fields.sorted { $0.key < $1.key }.map { (key: $0.key, value: $0.value) }
|
|
))
|
|
}
|
|
|
|
return devices.sorted { isIPLess(ipSortKey($0.ipAddress), ipSortKey($1.ipAddress)) }
|
|
}
|
|
|
|
/// Groups devices by physical port for the per-port tables, including physical ports with
|
|
/// zero devices (so unused ports are visible too) and a catch-all group for devices whose
|
|
/// port isn't known exactly (only a bridge/network — see `LanDevice.networkHint`).
|
|
nonisolated static func buildPortGroups(devices: [LanDevice], interfaces: [RouterOSMenuItem]) -> [DevicePortGroup] {
|
|
let physicalTypes: Set<String> = ["ether", "wlan", "wifi", "wireless", "sfp", "sfp-sfpplus", "sfp28", "combo"]
|
|
var physicalPortNames = interfaces
|
|
.filter { physicalTypes.contains($0.fields["type"] ?? "") }
|
|
.compactMap { $0.fields["name"] }
|
|
physicalPortNames.sort(by: naturalPortOrder)
|
|
|
|
var devicesByPort: [String: [LanDevice]] = [:]
|
|
var otherDevices: [LanDevice] = []
|
|
for device in devices {
|
|
if let port = device.resolvedPort {
|
|
devicesByPort[port, default: []].append(device)
|
|
} else {
|
|
otherDevices.append(device)
|
|
}
|
|
}
|
|
|
|
var groups = physicalPortNames.map { port in
|
|
DevicePortGroup(id: port, title: port, devices: devicesByPort[port] ?? [])
|
|
}
|
|
// A resolved port RouterOS reported that isn't in the physical-interface snapshot
|
|
// (shouldn't normally happen) still gets its own group, so no device silently vanishes.
|
|
let knownPortNames = Set(physicalPortNames)
|
|
for (port, portDevices) in devicesByPort.sorted(by: { $0.key < $1.key }) where !knownPortNames.contains(port) {
|
|
groups.append(DevicePortGroup(id: port, title: port, devices: portDevices))
|
|
}
|
|
if !otherDevices.isEmpty {
|
|
groups.append(DevicePortGroup(id: "unbekannt", title: "Unbekannter Port", devices: otherDevices))
|
|
}
|
|
return groups
|
|
}
|
|
|
|
private nonisolated static func naturalPortOrder(_ lhs: String, _ rhs: String) -> Bool {
|
|
func split(_ value: String) -> (prefix: String, number: Int) {
|
|
let digits = value.reversed().prefix(while: \.isNumber)
|
|
let numberPart = String(digits.reversed())
|
|
let prefix = String(value.dropLast(numberPart.count))
|
|
return (prefix, Int(numberPart) ?? 0)
|
|
}
|
|
let left = split(lhs)
|
|
let right = split(rhs)
|
|
return left.prefix == right.prefix ? left.number < right.number : left.prefix < right.prefix
|
|
}
|
|
|
|
private nonisolated static func ipSortKey(_ address: String) -> [Int] {
|
|
address.split(separator: ".").compactMap { Int($0) }
|
|
}
|
|
|
|
private nonisolated static func isIPLess(_ lhs: [Int], _ rhs: [Int]) -> Bool {
|
|
for (left, right) in zip(lhs, rhs) where left != right {
|
|
return left < right
|
|
}
|
|
return lhs.count < rhs.count
|
|
}
|
|
}
|