Per explizitem Nutzerwunsch die "Port freimachen?"-Abfrage samt allen Warnungen aus dem LAN-Schritt des Wizards auch auf den Experte-Tab angewendet, ausgeloest durch echten Live-Fall (ether4 wurde manuell ueber Expert als eigenes Netz angelegt, blieb dabei unbemerkt Bridge- Mitglied - zwei DHCP-Server im selben Broadcast-Domain). PortConflictWarningView aus LanStepView.swift in Features/Shared/ extrahiert (jetzt von Wizard UND Experte-Tab genutzt), neuer immediateApply-Parameter fuer die kontextabhaengige Abschluss-Meldung (Wizard: erst bei "Jetzt anwenden"; Experte: sofort bei "Anlegen"/ "Speichern"). Neue PortConflict.resolutionCommandsIncludingBridgeDetach() - der Experte-Tab hat anders als der Wizard keinen separaten, automatischen Bridge-Detach-Schritt, muss die Bridge-Entfernung also selbst mit auflisten. ExpertViewModel bekommt dieselbe Race-sichere Generation-Zaehler-Logik wie SetupViewModel (bugs.md #1), angewendet auf das .interfacePick-Feld des jeweils offenen Schemas. Speichern-Button gesperrt bis Konflikt bestaetigt oder Port gewechselt. 6 neue Regressionstests. Build + alle 111 Unit-Tests gruen. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
338 lines
16 KiB
Swift
338 lines
16 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?
|
|
|
|
/// 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
|
|
}
|
|
}
|