Neues Schema für /system routerboard mode-button (enabled/on-event/ hold-time), live gegen echte Hardware verifiziert. Dabei drei echte Bugs gefunden und gefixt: - SSH-Trust-Dead-End beim ersten Experte-Tab-Schreiben (Backup-vor- Schreiben-Pfad hatte keinen Weg, den Trust-Dialog auszulösen) — ConnectionService.noteUntrustedSSHHostKey - RouterOSFieldSchema.clearable: optionale Felder senden nie mehr einen expliziten Leer-Wert, wenn das RouterOS ablehnt - RouterOSMenuSchema.writesRequireSSH + ConnectionService.applyViaSSH: für Menüs ohne REST-Anbindung (bewiesen per direktem SSH-Test) wird zwingend eine dedizierte SSH-Verbindung genutzt statt REST Live Ende-zu-Ende bestätigt: Skript anlegen, Mode-Taste zuweisen, Tastendruck löst Skript korrekt aus. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
533 lines
28 KiB
Swift
533 lines
28 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 two write actions this tab offers: converting a dynamic
|
|
/// lease to a permanent static one, and removing a static lease again (RouterOS has no reverse
|
|
/// of "make-static", so "back to dynamic" is just removal — see `pendingStaticRemoval`).
|
|
@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?
|
|
/// The device whose static reservation the user is about to remove — RouterOS has no
|
|
/// "make-dynamic" (confirmed against the official DHCP docs: only check-status, make-static,
|
|
/// send-reconfigure exist for this menu), so "back to dynamic" is genuinely just removing the
|
|
/// static lease; the device gets a fresh dynamic lease on its own next DHCP request, not
|
|
/// immediately or necessarily on the same IP.
|
|
@Published var pendingStaticRemoval: LanDevice?
|
|
@Published private(set) var isApplying = false
|
|
@Published private(set) var applyError: String?
|
|
|
|
/// Set once a ping/traceroute/DNS lookup finishes — `.sheet(item:)` in `DevicesView` shows its
|
|
/// raw output. `nil` in between runs and while one is in progress.
|
|
@Published var networkToolResult: NetworkToolResult?
|
|
@Published private(set) var isRunningNetworkTool = false
|
|
@Published var networkToolError: String?
|
|
/// Set once a port scan finishes — separate from `networkToolResult` since it needs a
|
|
/// structured (colored per-port) display, not raw text. Runs directly from this Mac via
|
|
/// `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-30-seconds history per port, for the small sparkline next to each port
|
|
/// header (window widened from 10s to 30s per explicit follow-up request). Trimmed by
|
|
/// actual elapsed time each tick, not by a fixed sample count, so it stays a true
|
|
/// "last 30 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
|
|
|
|
init(
|
|
connectionService: ConnectionService,
|
|
backupService: BackupService = BackupService(),
|
|
networkToolsService: NetworkToolsService = NetworkToolsService()
|
|
) {
|
|
self.connectionService = connectionService
|
|
self.backupService = backupService
|
|
self.networkToolsService = networkToolsService
|
|
}
|
|
|
|
/// Cadence and sparkline window are both live Settings values (`AppPreferences`, defaults
|
|
/// 0.1s/30s) — read fresh from `UserDefaults` each tick/append rather than captured once, so
|
|
/// a change in the Settings window while this tab is already polling takes effect on the next
|
|
/// tick instead of needing the tab reopened. Deliberately faster by default than
|
|
/// `ConnectViewModel.startTrafficPolling`'s fixed 3s (a separate, unrelated poll — this tab's
|
|
/// sparkline needs a finer-grained trend, not just the link dot). 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())
|
|
}
|
|
}
|
|
let intervalSeconds = UserDefaults.standard.object(forKey: AppPreferences.lanScannerPollIntervalSecondsKey) as? Double ?? 0.1
|
|
try? await Task.sleep(for: .milliseconds(max(1, Int(intervalSeconds * 1000))))
|
|
}
|
|
}
|
|
}
|
|
|
|
private func appendTrafficHistory(_ traffic: [String: InterfaceTraffic], at timestamp: Date) {
|
|
let windowSeconds = UserDefaults.standard.object(forKey: AppPreferences.lanScannerSparklineWindowSecondsKey) as? Double ?? 30
|
|
let cutoff = timestamp.addingTimeInterval(-windowSeconds)
|
|
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)
|
|
}
|
|
}
|
|
|
|
func runTraceroute(for device: LanDevice) {
|
|
runNetworkTool(title: "Traceroute: \(device.ipAddress)") { [networkToolsService] credentials in
|
|
try await networkToolsService.traceroute(address: device.ipAddress, for: credentials)
|
|
}
|
|
}
|
|
|
|
func runDnsLookup(for device: LanDevice) {
|
|
guard let hostName = device.hostName, !hostName.isEmpty else { return }
|
|
runNetworkTool(title: "DNS-Auflösung: \(hostName)") { [networkToolsService] credentials in
|
|
try await networkToolsService.resolve(hostname: hostName, for: credentials)
|
|
}
|
|
}
|
|
|
|
func dismissNetworkToolError() {
|
|
networkToolError = nil
|
|
}
|
|
|
|
/// Runs directly from this Mac (no router credentials involved), so it doesn't go through
|
|
/// `runNetworkTool(title:action:)` — but still respects the same `isRunningNetworkTool` busy
|
|
/// flag so a scan and a ping/traceroute/DNS lookup can't overlap and race on the same overlay.
|
|
func runPortScan(for device: LanDevice) {
|
|
guard !isRunningNetworkTool else { return }
|
|
isRunningNetworkTool = true
|
|
networkToolError = nil
|
|
let label = (device.hostName?.isEmpty == false ? "\(device.hostName!) (\(device.ipAddress))" : device.ipAddress)
|
|
Task {
|
|
let results = await PortScanner.scan(host: device.ipAddress)
|
|
portScanResult = PortScanResult(
|
|
deviceLabel: label,
|
|
entries: results.map { PortScanResult.Entry(port: $0.port.port, serviceName: $0.port.serviceName, status: $0.status) }
|
|
)
|
|
isRunningNetworkTool = false
|
|
}
|
|
}
|
|
|
|
private func runNetworkTool(title: String, action: @escaping (RouterOSCredentials) async throws -> String) {
|
|
guard let credentials = connectionService.credentials, !isRunningNetworkTool else { return }
|
|
isRunningNetworkTool = true
|
|
networkToolError = nil
|
|
Task {
|
|
do {
|
|
let output = try await action(credentials)
|
|
networkToolResult = NetworkToolResult(title: title, output: output)
|
|
} catch {
|
|
networkToolError = error.localizedDescription
|
|
}
|
|
isRunningNetworkTool = false
|
|
}
|
|
}
|
|
|
|
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)"
|
|
)
|
|
}
|
|
|
|
/// The command "Feste Zuweisung entfernen" would run — matched by MAC, same reasoning as
|
|
/// `pendingCommand`.
|
|
var pendingRemovalCommand: RouterOSCommand? {
|
|
guard let device = pendingStaticRemoval, device.hasLease else { return nil }
|
|
return .remove(
|
|
menuPath: "/ip dhcp-server lease", restPath: "ip/dhcp-server/lease",
|
|
matchField: "mac-address", matchValue: device.macAddress,
|
|
summary: "Feste Zuweisung entfernen 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
|
|
if case RouterOSError.untrustedSSHHostKey(let fingerprint) = error {
|
|
connectionService.noteUntrustedSSHHostKey(fingerprint)
|
|
}
|
|
applyError = error.localizedDescription
|
|
}
|
|
isApplying = false
|
|
}
|
|
|
|
func cancelStaticAssignment() {
|
|
pendingStaticAssignment = nil
|
|
}
|
|
|
|
func confirmStaticRemoval() async {
|
|
guard let command = pendingRemovalCommand, let device = pendingStaticRemoval else { return }
|
|
isApplying = true
|
|
applyError = nil
|
|
do {
|
|
try await ensureSessionBackup()
|
|
try await connectionService.apply(command)
|
|
// Same lesson as `confirmStaticAssignment` (Bug 10/17 in HANDOFF.md): empty output
|
|
// isn't proof it worked. Verify the lease for this MAC is actually gone — a lingering
|
|
// "no error" success is worth less than an honest check.
|
|
let remainingLeases = try await connectionService.fetchMenuItems(
|
|
menuPath: "/ip dhcp-server lease", restPath: "ip/dhcp-server/lease"
|
|
)
|
|
let stillPresent = remainingLeases.contains { $0.fields["mac-address"]?.lowercased() == device.macAddress.lowercased() }
|
|
guard !stillPresent else {
|
|
throw RouterOSError.invalidResponse(
|
|
"Befehl lief ohne Fehlermeldung, aber der Lease-Eintrag ist weiterhin vorhanden. Bitte manuell mit \"/ip dhcp-server lease print\" kontrollieren."
|
|
)
|
|
}
|
|
pendingStaticRemoval = nil
|
|
await load()
|
|
} catch {
|
|
pendingStaticRemoval = nil
|
|
if case RouterOSError.untrustedSSHHostKey(let fingerprint) = error {
|
|
connectionService.noteUntrustedSSHHostKey(fingerprint)
|
|
}
|
|
applyError = error.localizedDescription
|
|
}
|
|
isApplying = false
|
|
}
|
|
|
|
func cancelStaticRemoval() {
|
|
pendingStaticRemoval = 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). 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 }
|
|
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
|
|
// 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
|
|
}
|
|
}
|