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
141 lines
6.0 KiB
Swift
141 lines
6.0 KiB
Swift
import Foundation
|
|
|
|
/// A single RouterOS configuration change, expressed once and executed over either transport
|
|
/// (rendered as a CLI line for SSH, as a JSON body — plus a lookup for `.set` — for REST).
|
|
struct RouterOSCommand: Equatable, Identifiable {
|
|
enum Operation: Equatable {
|
|
/// Creates a new item under `restPath` (SSH: `<menuPath> add ...`, REST: `POST`).
|
|
case add
|
|
/// Modifies an existing item matched by one field's value (SSH: `<menuPath> set [find
|
|
/// field=value] ...`; REST has no such inline lookup, so it needs a GET first to find
|
|
/// the item's `.id`, then `PATCH restPath/<id>`).
|
|
case set(matchField: String, matchValue: String)
|
|
/// 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 {
|
|
switch operation {
|
|
case .add:
|
|
return "add:\(menuPath):\(summary)"
|
|
case .set(let field, let value):
|
|
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)"
|
|
}
|
|
}
|
|
|
|
/// RouterOS menu path without the action word, e.g. "/ip address" or "/interface wireless".
|
|
let menuPath: String
|
|
/// REST resource path under `/rest/`, e.g. "ip/address" or "interface/wireless".
|
|
let restPath: String
|
|
let operation: Operation
|
|
/// RouterOS "words" (key=value fields) to add or change.
|
|
let arguments: [String: String]
|
|
/// Human-readable description shown on the review screen before applying.
|
|
let summary: String
|
|
|
|
static func add(menuPath: String, restPath: String, arguments: [String: String], summary: String) -> RouterOSCommand {
|
|
RouterOSCommand(menuPath: menuPath, restPath: restPath, operation: .add, arguments: arguments, summary: summary)
|
|
}
|
|
|
|
static func set(
|
|
menuPath: String,
|
|
restPath: String,
|
|
matchField: String,
|
|
matchValue: String,
|
|
arguments: [String: String],
|
|
summary: String
|
|
) -> RouterOSCommand {
|
|
RouterOSCommand(
|
|
menuPath: menuPath,
|
|
restPath: restPath,
|
|
operation: .set(matchField: matchField, matchValue: matchValue),
|
|
arguments: arguments,
|
|
summary: summary
|
|
)
|
|
}
|
|
|
|
static func remove(
|
|
menuPath: String,
|
|
restPath: String,
|
|
matchField: String,
|
|
matchValue: String,
|
|
summary: String
|
|
) -> RouterOSCommand {
|
|
RouterOSCommand(
|
|
menuPath: menuPath,
|
|
restPath: restPath,
|
|
operation: .remove(matchField: matchField, matchValue: matchValue),
|
|
arguments: [:],
|
|
summary: summary
|
|
)
|
|
}
|
|
|
|
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 {
|
|
let args = renderedArguments
|
|
switch operation {
|
|
case .add:
|
|
return args.isEmpty ? "\(menuPath) add" : "\(menuPath) add \(args)"
|
|
case .set(let field, let value):
|
|
// Empty matchField = singleton menu (e.g. "/ip dns", "/system identity"): these hold
|
|
// exactly one item and are "set" directly, with no "[find ...]" — there's nothing to
|
|
// find. Confirmed live against RouterOS 7.24.2.
|
|
guard !field.isEmpty else {
|
|
return args.isEmpty ? "\(menuPath) set" : "\(menuPath) set \(args)"
|
|
}
|
|
let finder = "[find \(field)=\(Self.quoteIfNeeded(value))]"
|
|
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))]"
|
|
}
|
|
}
|
|
|
|
private var renderedArguments: String {
|
|
arguments
|
|
.sorted { $0.key < $1.key }
|
|
.map { "\($0.key)=\(Self.quoteIfNeeded($0.value))" }
|
|
.joined(separator: " ")
|
|
}
|
|
|
|
private static func quoteIfNeeded(_ value: String) -> String {
|
|
value.contains(" ") ? "\"\(value)\"" : value
|
|
}
|
|
}
|