forked from kay/RouterOS
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:
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user