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? /// Same "Port-Konflikt-Prüfung" as the Setup Wizard's LAN step (found.md #1), applied here to /// whichever `.interfacePick` field the current schema has — per explicit request ("die /// Abfrage vom Einrichten-Assistenten auf Expert anwenden ... mit allen Warnungen"). Unlike /// the wizard (one conflict per `LanDhcpConfig.ID` in a list), the edit sheet only ever has /// one interface field open at a time, so a single value (not a dictionary) is enough. @Published private(set) var interfacePortConflict: PortConflict? @Published private(set) var isCheckingInterfacePortConflict = false @Published private(set) var acknowledgedInterfacePortConflict = false /// Bumped on every `checkInterfacePortConflict()` call, same race-safety reasoning as /// `SetupViewModel.portConflictRequestGeneration` (bugs.md #1) — a slower, older in-flight /// check discards its own result if a newer one has since started. private var interfacePortConflictGeneration = 0 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 resetInterfacePortConflictState() } 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 resetInterfacePortConflictState() } /// The key of the current schema's `.interfacePick` field, if it has one — a schema only /// ever has at most one (matches the one physical/logical port the whole menu item applies /// to, e.g. `/ip address`'s "interface"). private var interfaceFieldKey: String? { selectedSchema?.fields.first { if case .interfacePick = $0.kind { return true } else { return false } }?.key } private func resetInterfacePortConflictState() { interfacePortConflict = nil isCheckingInterfacePortConflict = false acknowledgedInterfacePortConflict = false interfacePortConflictGeneration += 1 } /// Mirrors `SetupViewModel.checkPortConflict(for:)` — called whenever the `.interfacePick` /// field's value changes (`ExpertMenuDetailView`'s `.onChange`). Best-effort, same as the /// wizard's version: a failed check just means no warning shown for that attempt, never /// blocks the sheet outright. func checkInterfacePortConflict() { guard let key = interfaceFieldKey else { return } let interfaceName = formValues[key] ?? "" acknowledgedInterfacePortConflict = false interfacePortConflict = nil guard !interfaceName.isEmpty else { return } isCheckingInterfacePortConflict = true interfacePortConflictGeneration += 1 let generation = interfacePortConflictGeneration Task { let result = try? await connectionService.checkPortConflict(interfaceName: interfaceName) guard generation == interfacePortConflictGeneration else { return } interfacePortConflict = result isCheckingInterfacePortConflict = false } } /// The user has been shown what's on this port and, after two explicit confirmations /// (`PortConflictWarningView`), chose to have the app clear it immediately as part of this /// save — unlike the wizard (deferred to "Jetzt anwenden"), the Experte tab has no separate /// review step, so acknowledging here takes effect on the very next "Anlegen"/"Speichern". func acknowledgeInterfacePortConflict() { acknowledgedInterfacePortConflict = true } /// Blocks "Anlegen"/"Speichern" until a found conflict is either acknowledged or the /// interface field is changed to something actually free. var hasUnresolvedInterfacePortConflict: Bool { interfacePortConflict != nil && !acknowledgedInterfacePortConflict } func cancelEditing() { editingItem = nil formValues = [:] extraFields = [] applyError = nil resetInterfacePortConflictState() } /// 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] ?? "") } // A non-`clearable` field (see `RouterOSFieldSchema.clearable`'s doc comment) never sends // an explicit empty value, even if the diff above decided to "clear" it — RouterOS can // reject or even 500 an empty string for a property whose type isn't plain text (confirmed // live: `/system routerboard mode-button`'s "hold-time", a time-interval-range field, // 500'd on `hold-time=""`). for field in schema.fields where !field.clearable { if arguments[field.key]?.isEmpty == true { arguments.removeValue(forKey: field.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 connectionService.beginWrite() defer { connectionService.endWrite() } do { try await ensureSessionBackup() // Port-Konflikt-Auflösung (found.md #1-style, siehe `PortConflictWarningView`) läuft // hier sofort vor dem eigentlichen Befehl — anders als im Wizard gibt es im Experte- // Tab keinen separaten Review-Schritt, an dem das gebündelt würde. if let conflict = interfacePortConflict, acknowledgedInterfacePortConflict { for resolutionCommand in conflict.resolutionCommandsIncludingBridgeDetach() { try await connectionService.apply(resolutionCommand) } } if selectedSchema?.writesRequireSSH == true { try await connectionService.applyViaSSH(command) } else { try await connectionService.apply(command) } cancelEditing() await reloadItems() } catch { if case RouterOSError.untrustedSSHHostKey(let fingerprint) = error { connectionService.noteUntrustedSSHHostKey(fingerprint) } applyError = error.localizedDescription } isApplying = false } func confirmRemoval(_ item: RouterOSMenuItem) async { guard let schema = selectedSchema else { return } isApplying = true applyError = nil connectionService.beginWrite() defer { connectionService.endWrite() } 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 { if case RouterOSError.untrustedSSHHostKey(let fingerprint) = error { connectionService.noteUntrustedSSHHostKey(fingerprint) } applyError = error.localizedDescription } isApplying = false } }