Files
RouterOS/RouterOSAssistant/Features/Expert/ExpertViewModel.swift
T
KayandClaude Sonnet 5 6f29bbcb2b M30: Experte-Tab Mode-Taste-Menü + SSH-Trust-/REST-Fixes
Neues Schema für /system routerboard mode-button (enabled/on-event/
hold-time), live gegen echte Hardware verifiziert.

Dabei drei echte Bugs gefunden und gefixt:
- SSH-Trust-Dead-End beim ersten Experte-Tab-Schreiben (Backup-vor-
  Schreiben-Pfad hatte keinen Weg, den Trust-Dialog auszulösen) —
  ConnectionService.noteUntrustedSSHHostKey
- RouterOSFieldSchema.clearable: optionale Felder senden nie mehr
  einen expliziten Leer-Wert, wenn das RouterOS ablehnt
- RouterOSMenuSchema.writesRequireSSH + ConnectionService.applyViaSSH:
  für Menüs ohne REST-Anbindung (bewiesen per direktem SSH-Test) wird
  zwingend eine dedizierte SSH-Verbindung genutzt statt REST

Live Ende-zu-Ende bestätigt: Skript anlegen, Mode-Taste zuweisen,
Tastendruck löst Skript korrekt aus.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 15:14:31 +02:00

261 lines
12 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] ?? "") }
// 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
do {
try await ensureSessionBackup()
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
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
}
}