M12: Geräte-Tab — LAN-Scanner mit Static-IP-Zuweisung

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
This commit is contained in:
Kay
2026-09-14 13:55:46 +02:00
co-authored by Claude Sonnet 5
parent 10f30a8a7f
commit e2b303963a
14 changed files with 1155 additions and 27 deletions
@@ -13,6 +13,8 @@ struct RouterOSAssistantApp: App {
.tabItem { Label("Einrichten", systemImage: "checklist") }
OverviewView(connectionService: connectionService)
.tabItem { Label("Übersicht", systemImage: "point.3.connected.trianglepath.dotted") }
DevicesView(connectionService: connectionService)
.tabItem { Label("Geräte", systemImage: "laptopcomputer.and.iphone") }
ExpertView(connectionService: connectionService)
.tabItem { Label("Experte", systemImage: "wrench.and.screwdriver") }
BackupListView(connectionService: connectionService)
@@ -0,0 +1,63 @@
import Foundation
/// One device seen on the LAN combines a DHCP lease (name/IP/MAC/static-or-not) with the
/// physical port it was learned on, resolved from ARP + the bridge host table. Built once per
/// "Geräte" tab refresh by `DevicesViewModel.buildDevices`, never mutated in place.
struct LanDevice: Identifiable, Equatable {
/// Unique per row (not the MAC alone) RouterOS can report two lease entries for the same
/// MAC (observed live: a device converted to static that still shows a separate dynamic
/// entry too), and both need to render rather than collide under one SwiftUI List identity.
let id: String
let ipAddress: String
let macAddress: String
let hostName: String?
/// True once RouterOS' own "dynamic" flag on the lease is false/absent a manually added or
/// "make-static"-converted lease. Always true for ARP-only entries (nothing to make static).
/// NOTE: RouterOS' official docs don't formally list "dynamic" as a lease property (only as a
/// print *flag* letter see RouterOSCommand.Operation.action's doc comment) if this turns
/// out wrong on real hardware, check `rawFields` (via "Rohdaten anzeigen") for the actual key.
let isStatic: Bool
/// False for a device seen only via ARP, with no matching DHCP lease RouterOS has no lease
/// item to convert for these, so the Geräte tab shows them read-only.
let hasLease: Bool
/// The lease's RouterOS `.id`, needed for the "make-static" command. Nil when `hasLease` is
/// false.
let leaseID: String?
let dhcpServerName: String?
/// The physical port this device was learned on (e.g. "ether3") set only when resolved via
/// the bridge host table or a non-bridge ARP interface, i.e. an actual port, never a guess.
let resolvedPort: String?
/// Set instead of `resolvedPort` when the device is only known to be somewhere behind this
/// bridge/network, not which physical port kept distinct so the UI never overstates
/// precision it doesn't have.
let networkHint: String?
let comment: String?
/// Every field RouterOS returned for this item, unfiltered shown via "Rohdaten anzeigen"
/// so a wrong assumption above (e.g. about the "dynamic" field) is one right-click away from
/// being checked against the real device instead of guessed again.
let rawFields: [(key: String, value: String)]
var isExactPort: Bool { resolvedPort != nil }
var displayPort: String {
if let resolvedPort { return "Port: \(resolvedPort)" }
if let networkHint { return "Netz: \(networkHint)" }
return "unbekannt"
}
static func == (lhs: LanDevice, rhs: LanDevice) -> Bool {
lhs.id == rhs.id && lhs.ipAddress == rhs.ipAddress && lhs.macAddress == rhs.macAddress
&& lhs.hostName == rhs.hostName && lhs.isStatic == rhs.isStatic && lhs.hasLease == rhs.hasLease
&& lhs.leaseID == rhs.leaseID && lhs.dhcpServerName == rhs.dhcpServerName
&& lhs.resolvedPort == rhs.resolvedPort && lhs.networkHint == rhs.networkHint && lhs.comment == rhs.comment
&& lhs.rawFields.map(\.key) == rhs.rawFields.map(\.key) && lhs.rawFields.map(\.value) == rhs.rawFields.map(\.value)
}
}
/// One physical Ethernet/WLAN port with the devices resolved onto it the "Geräte" tab's
/// per-port table. Includes ports with zero devices, so unused ports are visible too.
struct DevicePortGroup: Identifiable {
let id: String
let title: String
let devices: [LanDevice]
}
@@ -13,6 +13,18 @@ struct RouterOSCommand: Equatable, Identifiable {
/// Deletes an existing item matched by one field's value (SSH: `<menuPath> remove [find
/// field=value]`; REST again needs a GET-for-id first, then `DELETE restPath/<id>`).
case remove(matchField: String, matchValue: String)
/// A RouterOS "menu specific command" beyond add/set/remove, applied to one item matched
/// by a field's value e.g. `/ip dhcp-server lease make-static (id)`, which converts a
/// dynamic lease to a permanent one. Officially documented for the CLI as taking a bare
/// id/index (https://help.mikrotik.com/docs/spaces/ROS/pages/24805500/DHCP); this app
/// renders it via the same `[find field=value]` selector already proven live for
/// `.set`/`.remove` rather than a positional index, since RouterOS "id"-type arguments
/// accept both and this app never has a reliable position, only real `.id` values. REST
/// has no official documentation for this action at all the shape used here
/// (`POST <restPath>/<name>` with `{"numbers": <id>}`) is community-reported only
/// (https://forum.mikrotik.com/t/rest-api-convert-lease-to-static/176515), unverified
/// against real hardware.
case action(name: String, matchField: String, matchValue: String)
}
var id: String {
@@ -23,6 +35,8 @@ struct RouterOSCommand: Equatable, Identifiable {
return "set:\(menuPath):\(field)=\(value):\(summary)"
case .remove(let field, let value):
return "remove:\(menuPath):\(field)=\(value):\(summary)"
case .action(let name, let field, let value):
return "action:\(menuPath):\(name):\(field)=\(value):\(summary)"
}
}
@@ -73,6 +87,23 @@ struct RouterOSCommand: Equatable, Identifiable {
)
}
static func action(
menuPath: String,
restPath: String,
name: String,
matchField: String,
matchValue: String,
summary: String
) -> RouterOSCommand {
RouterOSCommand(
menuPath: menuPath,
restPath: restPath,
operation: .action(name: name, matchField: matchField, matchValue: matchValue),
arguments: [:],
summary: summary
)
}
/// Renders as a RouterOS CLI line, e.g. `/ip address add address=192.168.88.1/24 interface=bridge`
/// or `/interface wireless set [find name=wlan1] ssid=Home`.
var cliLine: String {
@@ -91,6 +122,8 @@ struct RouterOSCommand: Equatable, Identifiable {
return args.isEmpty ? "\(menuPath) set \(finder)" : "\(menuPath) set \(finder) \(args)"
case .remove(let field, let value):
return "\(menuPath) remove [find \(field)=\(Self.quoteIfNeeded(value))]"
case .action(let name, let field, let value):
return "\(menuPath) \(name) [find \(field)=\(Self.quoteIfNeeded(value))]"
}
}
@@ -78,6 +78,19 @@ final class RestTransport: NSObject, RouterOSTransport {
throw RouterOSError.invalidResponse(restPath)
}
/// Unverified against real hardware (this app's REST write/query paths in general are
/// see HANDOFF.md). RouterOS REST's general convention is that a GET accepts query-string
/// property filters (`?field=value`), mirroring the console's `find field=value` used here
/// on the same assumption, not confirmed for this exact property. Unlike SSH, REST's GET
/// already returns full objects, so no separate id-overlay is involved here at all.
func fetchFieldValues(menuPath: String, restPath: String, whereField: String, whereValue: String, returnField: String) async throws -> Set<String> {
let data = try await send(path: "\(restPath)?\(whereField)=\(whereValue)", method: "GET", jsonBody: nil)
guard let array = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else {
return []
}
return Set(array.compactMap { $0[returnField] as? String })
}
private static func menuItem(from item: [String: Any]) -> RouterOSMenuItem {
var fields: [String: String] = [:]
var id = ""
@@ -108,6 +121,12 @@ final class RestTransport: NSObject, RouterOSTransport {
case .remove(let matchField, let matchValue):
let itemID = try await findItemID(path: command.restPath, matchField: matchField, matchValue: matchValue)
_ = try await send(path: "\(command.restPath)/\(itemID)", method: "DELETE", jsonBody: nil)
case .action(let name, let matchField, let matchValue):
// Community-reported shape (not MikroTik-documented, see RouterOSCommand.Operation)
// POST to the action's own sub-path with the matched item's id under "numbers",
// mirroring the console's own `<path> <name> numbers=<id>` argument name.
let itemID = try await findItemID(path: command.restPath, matchField: matchField, matchValue: matchValue)
_ = try await send(path: "\(command.restPath)/\(name)", method: "POST", jsonBody: ["numbers": itemID])
}
}
@@ -143,7 +162,13 @@ final class RestTransport: NSObject, RouterOSTransport {
}
private func send(path: String, method: String, jsonBody: [String: String]?) async throws -> Data {
var request = URLRequest(url: baseURL.appendingPathComponent(path))
// `URL(string:relativeTo:)` (not `appendingPathComponent`, which percent-encodes "?")
// so a path carrying a query string (e.g. "ip/dhcp-server/lease?dynamic=no" from
// `fetchItemIDs`) is actually sent as a query, not a literal "?"-containing path segment.
guard let url = URL(string: path, relativeTo: baseURL) else {
throw RouterOSError.invalidResponse(path)
}
var request = URLRequest(url: url)
request.httpMethod = method
let authString = "\(credentials.username):\(credentials.password)"
@@ -14,6 +14,19 @@ protocol RouterOSTransport: AnyObject {
/// Lists existing items under any RouterOS menu the generic read side of the Expert tool,
/// works for menus without a curated `RouterOSMenuSchema` too.
func fetchMenuItems(menuPath: String, restPath: String) async throws -> [RouterOSMenuItem]
/// Values of one field across every item matching a `find` filter, via RouterOS' own
/// `find`/`get` for properties "print terse" doesn't expose as a key=value field at all.
/// Confirmed live: a DHCP server lease's "dynamic" state never appears in `print terse`
/// output in either state (checked against a real hEX lease both before and after converting
/// it to static via `/ip dhcp-server lease print`'s flags column), even though
/// `find dynamic=no` can still filter on it directly against RouterOS' internal data model.
/// Deliberately does NOT return `.id` (an earlier version did) `fetchMenuItems`' `.id`
/// overlay pairs two separate commands' output by row position, and that pairing was
/// confirmed live to mis-assign `.id` to the wrong row for this exact menu (two leases, the
/// wrong one showed "already static"). `returnField` should be something read directly off
/// the same `print terse` line (e.g. "mac-address"), so the caller never depends on that
/// overlay at all for this lookup.
func fetchFieldValues(menuPath: String, restPath: String, whereField: String, whereValue: String, returnField: String) async throws -> Set<String>
func apply(_ command: RouterOSCommand) async throws
func disconnect() async
}
@@ -95,6 +95,26 @@ final class SSHTransport: RouterOSTransport {
return items
}
/// `:foreach i in=[<menuPath> find whereField=whereValue] do={:put [<menuPath> get $i
/// returnField]}` built from two independently confirmed-live primitives only: a bare
/// `find` with one condition (verified repeatedly this session, e.g.
/// `find dynamic=no` reliably returning exactly the right id), and `get <id> field` on a
/// single, already-known id (standard, unambiguous RouterOS syntax). An earlier version tried
/// `get [find ...] returnField` as one combined call to do this in a single round trip
/// that specific combined form was never actually verified and was confirmed live to be
/// wrong (a real make-static conversion confirmed via Winbox wasn't found by it). `:put`
/// inside `:foreach` prints one value per line, so this splits on newlines, not ";" (the
/// semicolon-joined shape only applies to a single `:put [<menuPath> find ...]` list).
func fetchFieldValues(menuPath: String, restPath: String, whereField: String, whereValue: String, returnField: String) async throws -> Set<String> {
let script = ":foreach i in=[\(menuPath) find \(whereField)=\(whereValue)] do={:put [\(menuPath) get $i \(returnField)]}"
let output = try await run(script)
let values = output
.split(whereSeparator: \.isNewline)
.map { $0.trimmingCharacters(in: .whitespaces) }
.filter { !$0.isEmpty }
return Set(values)
}
/// RouterOS' SSH CLI exits 0 even when a command fails confirmed live: both
/// `/ip dhcp-server add ...` on an interface that already has one ("failure: server or
/// relay with such interface already exists") and an invalid action ("syntax error (line 1
@@ -105,6 +105,12 @@ final class ConnectionService: ObservableObject {
return try await activeTransport.fetchMenuItems(menuPath: menuPath, restPath: restPath)
}
/// Field values matching an internal-property filter see `RouterOSTransport.fetchFieldValues`.
func fetchFieldValues(menuPath: String, restPath: String, whereField: String, whereValue: String, returnField: String) async throws -> Set<String> {
guard let activeTransport else { throw RouterOSError.notConnected }
return try await activeTransport.fetchFieldValues(menuPath: menuPath, restPath: restPath, whereField: whereField, whereValue: whereValue, returnField: returnField)
}
private func finishConnecting(using transport: RouterOSTransport) async {
activeTransport = transport
do {
@@ -0,0 +1,259 @@
import SwiftUI
/// "Geräte" tab: LAN scanner one table per physical Ethernet/WLAN port, each listing the
/// devices resolved onto it (name/IP/MAC/status), plus a right-click action to give a device a
/// permanent static IP the same way Winbox's "Make Static" does.
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
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 {
List {
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))")
}
}
}
}
}
.navigationTitle("Geräte")
.toolbar {
ToolbarItem {
Button {
Task { await viewModel.load() }
} label: {
if viewModel.isLoading {
ProgressView().controlSize(.small)
} else {
Label("Aktualisieren", systemImage: "arrow.clockwise")
}
}
.disabled(viewModel.isLoading)
}
}
.task {
if viewModel.devices.isEmpty {
await viewModel.load()
}
}
.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\nBefehl: \(viewModel.pendingCommand?.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)
}
.sheet(item: $rawFieldsDevice) { device in
RawFieldsSheet(device: device) { rawFieldsDevice = nil }
}
}
}
@ViewBuilder
private func deviceMenu(for device: LanDevice) -> some View {
if device.hasLease {
if device.isStatic {
Text("Bereits fest zugewiesen")
} 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()
Button {
rawFieldsDevice = device
} label: {
Label("Rohdaten anzeigen", systemImage: "list.bullet.rectangle")
}
}
}
/// 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)
}
}
#Preview {
DevicesView(connectionService: ConnectionService())
}
@@ -0,0 +1,319 @@
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
}
}