Gleiche Ursache wie Bug 26, aber bei einem kuratierten statt einem
unkuratierten Feld: eine dynamische/verbundene Route hat distance=0,
RouterOS akzeptiert das nur system-intern, nicht als expliziten Eingabe-
wert für "set" — auch wenn der Wert unverändert zurückgesendet wird
("value of distance out of range (1...255)").
Fix generalisiert das "nur bei Änderung senden"-Prinzip aus Bug 26 von
unkuratierten auf ALLE Felder beim Bearbeiten eines bestehenden Items:
pendingCommand diffed jetzt gegen das ursprünglich geladene Item, statt
kuratierte Felder immer komplett neu zu senden. Deckt implizit auch das
Leeren eines Feldes ab (Bug 25), dessen Sonderfall dadurch überflüssig
wurde und entfernt ist.
Nebenbei: ExpertViewModelTests.swift lief bisher gar nicht mit, weil
nach dem Anlegen der Datei kein "xcodegen generate" lief (Xcodegen
erzeugt die Sources-Dateiliste einmalig beim Generieren). Nach erneutem
Generate 58 Unit-Tests grün.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YDmUd93KxsYGr2kLTotWnG
241 lines
11 KiB
Swift
241 lines
11 KiB
Swift
import Foundation
|
|
|
|
@MainActor
|
|
final class ExpertViewModel: ObservableObject {
|
|
/// One raw, uncurated key=value field — the escape hatch that keeps every RouterOS menu
|
|
/// reachable even where `RouterOSMenuSchema.fields` doesn't (yet) curate every parameter.
|
|
struct ExtraField: Identifiable, Equatable {
|
|
let id = UUID()
|
|
var key: String = ""
|
|
var value: String = ""
|
|
}
|
|
|
|
@Published var selectedSchema: RouterOSMenuSchema?
|
|
@Published var customMenuPath: String = ""
|
|
@Published var customRestPath: String = ""
|
|
|
|
@Published private(set) var items: [RouterOSMenuItem] = []
|
|
@Published private(set) var isLoading = false
|
|
@Published private(set) var loadError: String?
|
|
/// Options for `.menuItemPick` fields, keyed by that field's referenced `menuPath` — e.g.
|
|
/// existing pool names for a DHCP server's "address-pool" field. Refreshed alongside
|
|
/// `items` so a pool/profile/script created moments earlier already shows up.
|
|
@Published private(set) var crossReferenceOptions: [String: [String]] = [:]
|
|
/// Live interface names for `.interfacePick` fields, refreshed alongside `items` — unlike
|
|
/// `ConnectionService.interfaces` (fetched once at connect time), this picks up interfaces
|
|
/// created after connecting (e.g. a VLAN added minutes earlier in this same Expert tab).
|
|
/// Confirmed live: a DHCP server's "interface" field didn't offer a just-created VLAN until
|
|
/// this was added.
|
|
@Published private(set) var liveInterfaceNames: [String] = []
|
|
|
|
/// Non-nil while the add/edit sheet is open. An empty `id` means "new item".
|
|
@Published var editingItem: RouterOSMenuItem?
|
|
@Published var formValues: [String: String] = [:]
|
|
@Published var extraFields: [ExtraField] = []
|
|
|
|
@Published private(set) var isApplying = false
|
|
@Published private(set) var applyError: String?
|
|
@Published var pendingRemoval: RouterOSMenuItem?
|
|
|
|
private let connectionService: ConnectionService
|
|
private let backupService: BackupService
|
|
|
|
init(connectionService: ConnectionService, backupService: BackupService = BackupService()) {
|
|
self.connectionService = connectionService
|
|
self.backupService = backupService
|
|
}
|
|
|
|
/// Backs up the current config once per connection, right before the Expert tool's first
|
|
/// write — mirrors the Einrichten-Wizard's "always back up before applying" habit, just
|
|
/// once per session here rather than before every single edit (an Expert-tool session can
|
|
/// easily be a dozen small edits in a row).
|
|
private func ensureSessionBackup() async throws {
|
|
guard !connectionService.hasExpertToolBackedUpThisSession, let credentials = connectionService.credentials else { return }
|
|
_ = try await backupService.createBackup(for: credentials)
|
|
connectionService.markExpertToolBackedUpThisSession()
|
|
}
|
|
|
|
func open(_ schema: RouterOSMenuSchema) {
|
|
selectedSchema = schema
|
|
items = []
|
|
loadError = nil
|
|
Task { await reloadItems() }
|
|
}
|
|
|
|
/// Opens any RouterOS menu path the user types in directly, generic key=value form —
|
|
/// the guarantee that literally any menu is reachable, curated or not.
|
|
func openCustomPath() {
|
|
let menuPath = customMenuPath.trimmingCharacters(in: .whitespaces)
|
|
let restPath = customRestPath.trimmingCharacters(in: .whitespaces)
|
|
guard !menuPath.isEmpty, !restPath.isEmpty else { return }
|
|
open(RouterOSMenuSchema(
|
|
menuPath: menuPath,
|
|
restPath: restPath,
|
|
category: .system,
|
|
displayName: menuPath,
|
|
summary: "Eigener Menüpfad — generischer Zugriff ohne kuratierte Felder.",
|
|
explanation: "Alle Felder, die der Router für diesen Pfad zurückgibt, erscheinen unten als freie Schlüssel/Wert-Paare. Prüfe die RouterOS-Dokumentation für die genaue Bedeutung."
|
|
))
|
|
}
|
|
|
|
func reloadItems() async {
|
|
guard let schema = selectedSchema else { return }
|
|
isLoading = true
|
|
loadError = nil
|
|
do {
|
|
items = try await connectionService.fetchMenuItems(menuPath: schema.menuPath, restPath: schema.restPath)
|
|
} catch {
|
|
loadError = error.localizedDescription
|
|
}
|
|
isLoading = false
|
|
await loadCrossReferenceOptions(for: schema)
|
|
await loadLiveInterfaceNames(for: schema)
|
|
}
|
|
|
|
private func loadLiveInterfaceNames(for schema: RouterOSMenuSchema) async {
|
|
guard schema.fields.contains(where: {
|
|
if case .interfacePick = $0.kind { return true } else { return false }
|
|
}) else { return }
|
|
do {
|
|
let interfaceItems = try await connectionService.fetchMenuItems(menuPath: "/interface", restPath: "interface")
|
|
liveInterfaceNames = interfaceItems.compactMap { $0.fields["name"] }.sorted()
|
|
} catch {
|
|
liveInterfaceNames = []
|
|
}
|
|
}
|
|
|
|
private func loadCrossReferenceOptions(for schema: RouterOSMenuSchema) async {
|
|
for field in schema.fields {
|
|
guard case .menuItemPick(let menuPath, let restPath, let valueField) = field.kind else { continue }
|
|
do {
|
|
let referencedItems = try await connectionService.fetchMenuItems(menuPath: menuPath, restPath: restPath)
|
|
let values = Set(referencedItems.compactMap { $0.fields[valueField] }).sorted()
|
|
crossReferenceOptions[menuPath] = values
|
|
} catch {
|
|
crossReferenceOptions[menuPath] = []
|
|
}
|
|
}
|
|
}
|
|
|
|
func startNewItem() {
|
|
guard let schema = selectedSchema else { return }
|
|
editingItem = RouterOSMenuItem(id: "", fields: [:])
|
|
formValues = Dictionary(uniqueKeysWithValues: schema.fields.compactMap { field in
|
|
field.defaultValue.map { (field.key, $0) }
|
|
})
|
|
extraFields = []
|
|
applyError = nil
|
|
}
|
|
|
|
func startEditing(_ item: RouterOSMenuItem) {
|
|
guard let schema = selectedSchema else { return }
|
|
editingItem = item
|
|
let curatedKeys = Set(schema.fields.map(\.key))
|
|
formValues = item.fields.filter { curatedKeys.contains($0.key) }
|
|
extraFields = item.fields
|
|
.filter { !curatedKeys.contains($0.key) }
|
|
.sorted { $0.key < $1.key }
|
|
.map { ExtraField(key: $0.key, value: $0.value) }
|
|
applyError = nil
|
|
}
|
|
|
|
func cancelEditing() {
|
|
editingItem = nil
|
|
formValues = [:]
|
|
extraFields = []
|
|
applyError = nil
|
|
}
|
|
|
|
/// The command that saving the current edit sheet would run — shown to the user before it
|
|
/// actually executes, consistent with the rest of the app never applying silently.
|
|
var pendingCommand: RouterOSCommand? {
|
|
guard let schema = selectedSchema, let editingItem else { return nil }
|
|
let isNew = editingItem.id.isEmpty
|
|
|
|
// When editing an existing item, only send a field if its value actually differs from
|
|
// what the router originally reported — RouterOS' `set` only touches parameters it's
|
|
// given, so an unchanged field doesn't need resending, and for some fields resending the
|
|
// existing value verbatim is actively rejected: a dynamic/connected route's `distance=0`
|
|
// ("value of distance out of range (1...255)") and a route's read-only `immediate-gw`
|
|
// ("bad parameter immediate-gw") both failed this way when saving an edit to a completely
|
|
// different field like the comment (confirmed live, 2026-09-15). Diffing against the
|
|
// original also naturally covers *clearing* a field (new value "" differs from the old
|
|
// non-empty one, so it's still sent — explicitly, as "" — unlike a field that was already
|
|
// empty and stays empty, which is correctly left out). New items have no "original" to
|
|
// diff against, so every non-empty curated value is sent as before.
|
|
var arguments: [String: String] = isNew
|
|
? formValues.filter { !$0.value.isEmpty }
|
|
: formValues.filter { $0.value != (editingItem.fields[$0.key] ?? "") }
|
|
// RouterOS' CLI parser rejects "true"/"false" for boolean parameters — it only accepts
|
|
// "yes"/"no" (confirmed live: "disabled=false" on "/interface vlan add" produced
|
|
// "syntax error (line 1 column 30)"; "disabled=no" succeeded). Schema defaults are
|
|
// occasionally written as "true"/"false" for readability, so normalize here regardless
|
|
// of where the value came from rather than trust every call site to get it right.
|
|
for field in schema.fields {
|
|
guard case .bool = field.kind, let value = arguments[field.key] else { continue }
|
|
if value == "true" { arguments[field.key] = "yes" }
|
|
else if value == "false" { arguments[field.key] = "no" }
|
|
}
|
|
// Same "only if changed" rule for uncurated ("Weitere Parameter") fields — these are
|
|
// pre-filled from the live item for visibility by `startEditing`, so without this every
|
|
// save would resend all of them, including computed/read-only ones RouterOS refuses.
|
|
for extra in extraFields where !extra.key.isEmpty {
|
|
if extra.value != editingItem.fields[extra.key] {
|
|
arguments[extra.key] = extra.value
|
|
}
|
|
}
|
|
let summary = "\(isNew ? "Neu anlegen" : "Ändern") unter \(schema.menuPath)"
|
|
if isNew {
|
|
return .add(menuPath: schema.menuPath, restPath: schema.restPath, arguments: arguments, summary: summary)
|
|
} else if schema.isSingleton {
|
|
// No "[find ...]" for a singleton menu — see RouterOSCommand.cliLine.
|
|
return .set(
|
|
menuPath: schema.menuPath, restPath: schema.restPath,
|
|
matchField: "", matchValue: "",
|
|
arguments: arguments, summary: summary
|
|
)
|
|
} else {
|
|
return .set(
|
|
menuPath: schema.menuPath, restPath: schema.restPath,
|
|
matchField: ".id", matchValue: editingItem.id,
|
|
arguments: arguments, summary: summary
|
|
)
|
|
}
|
|
}
|
|
|
|
func saveEditingItem() async {
|
|
guard let command = pendingCommand else { return }
|
|
isApplying = true
|
|
applyError = nil
|
|
do {
|
|
try await ensureSessionBackup()
|
|
try await connectionService.apply(command)
|
|
cancelEditing()
|
|
await reloadItems()
|
|
} catch {
|
|
applyError = error.localizedDescription
|
|
}
|
|
isApplying = false
|
|
}
|
|
|
|
func confirmRemoval(_ item: RouterOSMenuItem) async {
|
|
guard let schema = selectedSchema else { return }
|
|
isApplying = true
|
|
applyError = nil
|
|
do {
|
|
let command = RouterOSCommand.remove(
|
|
menuPath: schema.menuPath, restPath: schema.restPath,
|
|
matchField: ".id", matchValue: item.id,
|
|
summary: "Löschen unter \(schema.menuPath)"
|
|
)
|
|
try await ensureSessionBackup()
|
|
try await connectionService.apply(command)
|
|
pendingRemoval = nil
|
|
await reloadItems()
|
|
} catch {
|
|
applyError = error.localizedDescription
|
|
}
|
|
isApplying = false
|
|
}
|
|
}
|