Files
RouterOS/RouterOSAssistant/Features/Expert/ExpertMenuDetailView.swift
T
KayandClaude Sonnet 5 b503bac82c Experte-Tab: Port-Konflikt-Pruefung wie im Einrichten-Assistenten
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>
2026-09-17 22:21:36 +02:00

439 lines
19 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
struct ExpertMenuDetailView: View {
@ObservedObject var viewModel: ExpertViewModel
let schema: RouterOSMenuSchema
@AppStorage("appLanguage") private var appLanguage: String = "de"
var body: some View {
Form {
Section {
Text(L10n.t(schema.displayName, appLanguage))
.appFont(.title2)
.bold()
Text(L10n.t(schema.category.rawValue, appLanguage))
.appFont(.caption)
.foregroundStyle(.secondary)
Text(L10n.t(schema.explanation, appLanguage))
if let warning = schema.warning {
Label(L10n.t(warning, appLanguage), systemImage: "exclamationmark.triangle")
.appFont(.caption)
.foregroundStyle(.orange)
}
}
Section {
if viewModel.isLoading {
ProgressView()
} else if let error = viewModel.loadError {
Text(error).foregroundStyle(.red)
} else if viewModel.items.isEmpty {
Text(L10n.t("Keine Einträge unter", appLanguage) + " \(schema.menuPath).").foregroundStyle(.secondary)
} else {
// `.id(viewModel.items.count)` forces SwiftUI to treat this as a fresh view
// identity whenever the count changes — without it, deleting an item (via the
// `.confirmationDialog` below, not a `.sheet`) updates `viewModel.items`
// correctly (confirmed live via a temporary debug print: item count goes
// 1 → 0) but the rendered list still showed the removed row until switching
// tabs and back forced a full remount. Add/edit never hit this because their
// `.sheet(item:)` dismissal already forces a remount of this view on its own;
// delete's confirmationDialog doesn't tear this view down at all, so nothing
// was forcing SwiftUI to re-diff the Section in place.
ForEach(Array(viewModel.items.enumerated()), id: \.element.id) { index, item in
HStack {
Button {
viewModel.startEditing(item)
} label: {
Text(rowLabel(for: item))
}
.buttonStyle(.plain)
Spacer()
// Not ".swipeActions" — that's an iOS/iPadOS gesture with no
// equivalent on macOS List rows, so it never showed anything here.
if !schema.isSingleton {
Button(role: .destructive) {
viewModel.pendingRemoval = item
} label: {
Image(systemName: "trash")
}
.buttonStyle(.plain)
.help(L10n.t("Eintrag löschen", appLanguage))
}
}
.contentShape(Rectangle())
.listRowBackground(TableZebra.color(for: index))
}
}
} header: {
HStack {
Text(L10n.t(schema.isSingleton ? "Einstellungen" : "Einträge", appLanguage))
Spacer()
if !schema.isSingleton {
Button(L10n.t("Neu hinzufügen", appLanguage)) { viewModel.startNewItem() }
}
}
}
.id(viewModel.items.count)
}
.formStyle(.grouped)
// See DevicesView's identical fix: `.formStyle(.grouped)` paints an opaque background
// over each Section's rows, hiding `.listRowBackground` (Zebra-Streifen) underneath it.
.scrollContentBackground(.hidden)
.toolbar { ToolbarItem { ManualHelpButton(anchor: ManualAnchor.schema(schema.menuPath)) } }
.navigationTitle(LocalizedStringKey(L10n.t(schema.displayName, appLanguage)))
.task(id: schema.id) { await viewModel.reloadItems() }
.sheet(item: $viewModel.editingItem) { item in
ExpertItemEditView(
viewModel: viewModel,
schema: schema,
isNew: item.id.isEmpty
)
}
.confirmationDialog(
L10n.t("Eintrag wirklich löschen?", appLanguage),
isPresented: Binding(
get: { viewModel.pendingRemoval != nil },
set: { if !$0 { viewModel.pendingRemoval = nil } }
),
presenting: viewModel.pendingRemoval
) { item in
Button(L10n.t("Löschen", appLanguage), role: .destructive) {
Task { await viewModel.confirmRemoval(item) }
}
Button(L10n.t("Abbrechen", appLanguage), role: .cancel) {}
} message: { item in
Text(rowLabel(for: item))
}
}
private func rowLabel(for item: RouterOSMenuItem) -> String {
let columns = schema.listColumns.isEmpty ? Array(item.fields.keys.sorted()) : schema.listColumns
let parts = columns.compactMap { key in item.fields[key].map { "\(key)=\($0)" } }
return parts.isEmpty ? item.id : parts.joined(separator: " · ")
}
}
/// Add/edit sheet: curated fields (with tooltips) from the schema, plus a free-form "weitere
/// Parameter" list for anything the schema doesn't curate — so every field RouterOS actually
/// supports stays reachable even where this app hasn't described it yet. Not private: the
/// Übersicht tab reuses this same sheet (with its own `ExpertViewModel`) so clicking an editable
/// node there writes back through the exact same form/command path as the Experte-Tab.
struct ExpertItemEditView: View {
@ObservedObject var viewModel: ExpertViewModel
let schema: RouterOSMenuSchema
let isNew: Bool
@Environment(\.dismiss) private var dismiss
@State private var showApplyConfirmation = false
@AppStorage("appLanguage") private var appLanguage: String = "de"
var body: some View {
// Header fixed outside the `Form` (not a `Section`, which would scroll away with the
// content) — same layout/behavior as the Übersicht focus popup's header (`OverviewView
// .focusPanel`): title + spacer + `xmark.circle.fill`, `.padding(10)`, `Divider()`
// directly below, per explicit request to keep every popup's close button consistent.
VStack(spacing: 0) {
HStack {
Text(L10n.t(schema.displayName, appLanguage)).appFont(.headline)
Spacer()
Button {
viewModel.cancelEditing()
dismiss()
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.help(L10n.t("Schließen, ohne zu speichern", appLanguage))
}
.padding(10)
Divider()
Form {
ForEach(schema.fields) { field in
fieldEditor(for: field)
}
// Gleiche "Port-Konflikt-Prüfung" wie im Einrichten-Assistenten (LAN-Schritt,
// found.md #1), hier auf das `.interfacePick`-Feld dieses Schemas angewendet — per
// explizitem Nutzerwunsch ("die Abfrage vom Einrichten-Assistenten auf Expert
// anwenden ... mit allen Warnungen").
if let conflict = viewModel.interfacePortConflict {
PortConflictWarningView(
conflict: conflict,
isAcknowledged: viewModel.acknowledgedInterfacePortConflict,
appLanguage: appLanguage,
immediateApply: true,
onConfirm: { viewModel.acknowledgeInterfacePortConflict() }
)
} else if viewModel.isCheckingInterfacePortConflict {
HStack {
ProgressView().controlSize(.small)
Text(L10n.t("Prüfe, ob der Port frei ist…", appLanguage)).appFont(.caption).foregroundStyle(.secondary)
}
}
Section(LocalizedStringKey(L10n.t("Weitere Parameter (frei)", appLanguage))) {
Text(L10n.t("Für alles, was oben nicht als eigenes Feld aufgeführt ist — RouterOS-Parametername genau wie in der Dokumentation.", appLanguage))
.appFont(.caption)
.foregroundStyle(.secondary)
// Zwei Spalten statt einer Zeile pro Parameter — bei Menüs mit vielen ungekuratierten
// RouterOS-Feldern (z.B. "Alle Interfaces (generisch)", wo Ethernet-Ports gut 30+
// Extra-Parameter mitbringen) halbiert das die Scroll-Länge dieses Formulars.
// Eigene Grid-Spalten für Parameter/Wert (statt eines TextFields pro Zeilenhälfte):
// `TextField(titleKey:text:)` zeigt den Titel auf macOS als feste Beschriftung vor
// dem Wert an (kein iOS-Platzhalter, der beim Tippen verschwindet) — bei 30+ Zeilen
// fraß "Parameter"/"Wert" so bei jeder Zeile erneut Breite, während an den äußeren
// Rändern Platz ungenutzt blieb (live per Screenshot bestätigt). Jetzt eine einmalige
// Kopfzeile statt Pro-Zeile-Label, leere TextField-Titel, Wert-Spalten mit
// `maxWidth: .infinity` gemäß Inhalt.
Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 14) {
GridRow {
extraColumnHeader("Parameter")
extraColumnHeader("Wert")
Color.clear.gridCellUnsizedAxes(.horizontal)
extraColumnHeader("Parameter")
extraColumnHeader("Wert")
}
ForEach(Array(stride(from: 0, to: viewModel.extraFields.count, by: 2).enumerated()), id: \.element) { rowIndex, start in
GridRow {
extraKeyField(at: start)
extraValueField(at: start)
if start + 1 < viewModel.extraFields.count {
Rectangle()
.fill(Color.secondary.opacity(0.25))
.frame(width: 1)
.frame(maxHeight: .infinity)
extraKeyField(at: start + 1)
extraValueField(at: start + 1)
} else {
Color.clear
Color.clear
Color.clear
}
}
.background(TableZebra.color(for: rowIndex))
Divider()
.gridCellColumns(5)
}
}
.frame(maxWidth: .infinity)
.padding(.bottom, 12)
Button(L10n.t("Parameter hinzufügen", appLanguage)) {
viewModel.extraFields.append(.init())
}
}
if let command = viewModel.pendingCommand {
Section(LocalizedStringKey(L10n.t("Wird ausgeführt", appLanguage))) {
Text(command.cliLine)
.appFont(.caption, design: .monospaced)
.foregroundStyle(.secondary)
}
}
if let error = viewModel.applyError {
Text(error).foregroundStyle(.red)
}
Section {
HStack {
Spacer()
Button(L10n.t(isNew ? "Anlegen" : "Speichern", appLanguage)) {
showApplyConfirmation = true
}
.disabled(viewModel.isApplying || viewModel.pendingCommand == nil || viewModel.hasUnresolvedInterfacePortConflict)
}
}
}
.formStyle(.grouped)
}
.frame(minWidth: 900, idealWidth: 900, minHeight: 520, idealHeight: 660)
.onAppear {
viewModel.checkInterfacePortConflict()
}
.onChange(of: viewModel.formValues[interfaceFieldKey ?? "", default: ""]) { _, _ in
viewModel.checkInterfacePortConflict()
}
.confirmationDialog(
L10n.t("Jetzt am Router anwenden?", appLanguage),
isPresented: $showApplyConfirmation,
titleVisibility: .visible
) {
Button(L10n.t(isNew ? "Anlegen" : "Speichern", appLanguage)) {
Task {
await viewModel.saveEditingItem()
if viewModel.applyError == nil { dismiss() }
}
}
Button(L10n.t("Abbrechen", appLanguage), role: .cancel) {}
} message: {
if let command = viewModel.pendingCommand {
Text(command.cliLine)
}
}
}
/// The current schema's `.interfacePick` field key, if it has one — mirrors
/// `ExpertViewModel.interfaceFieldKey`, kept here too since `.onChange` needs a concrete
/// key path to observe (a schema only ever has at most one such field).
private var interfaceFieldKey: String? {
schema.fields.first { if case .interfacePick = $0.kind { return true } else { return false } }?.key
}
private func extraColumnHeader(_ key: String) -> some View {
Text(L10n.t(key, appLanguage))
.appFont(.caption)
.foregroundStyle(.secondary)
}
private func extraKeyField(at index: Int) -> some View {
TextField("", text: $viewModel.extraFields[index].key)
.textFieldStyle(.plain)
}
@ViewBuilder
private func extraValueField(at index: Int) -> some View {
HStack {
TextField("", text: $viewModel.extraFields[index].value)
.textFieldStyle(.plain)
.frame(maxWidth: .infinity)
Button(role: .destructive) {
let id = viewModel.extraFields[index].id
viewModel.extraFields.removeAll { $0.id == id }
} label: {
Image(systemName: "minus.circle")
}
.buttonStyle(.plain)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
@ViewBuilder
private func fieldEditor(for field: RouterOSFieldSchema) -> some View {
let binding = Binding<String>(
get: { viewModel.formValues[field.key] ?? "" },
set: { viewModel.formValues[field.key] = $0 }
)
let label = L10n.t(field.label, appLanguage)
Group {
switch field.kind {
case .text, .int:
TextField(label, text: binding)
case .bool:
Toggle(label, isOn: Binding(
get: { binding.wrappedValue == "yes" || binding.wrappedValue == "true" },
set: { binding.wrappedValue = $0 ? "yes" : "no" }
))
case .enumPick(let options):
Picker(label, selection: binding) {
Text("").tag("")
ForEach(options, id: \.self) { option in
Text(option).tag(option)
}
}
case .interfacePick:
Picker(label, selection: binding) {
Text("").tag("")
ForEach(viewModel.liveInterfaceNames, id: \.self) { name in
Text(name).tag(name)
}
}
case .menuItemPick(let menuPath, _, _):
let options = viewModel.crossReferenceOptions[menuPath] ?? []
Picker(label, selection: binding) {
Text("").tag("")
ForEach(options, id: \.self) { option in
Text(option).tag(option)
}
}
case .duration:
HStack {
Text(label)
Spacer()
DurationFieldEditor(value: binding)
}
}
}
.help(L10n.t(field.help.isEmpty ? field.label : field.help, appLanguage))
}
}
/// Day/hour/minute/second steppers for a RouterOS time value (e.g. "1d12h30m") instead of a
/// bare text field where the suffix syntax isn't obvious. Reads any combination of RouterOS'
/// "<number><unit>" tokens (or a bare number, which RouterOS treats as seconds) and always
/// writes back the explicit suffixed form.
private struct DurationFieldEditor: View {
@Binding var value: String
@State private var days = 0
@State private var hours = 0
@State private var minutes = 0
@State private var seconds = 0
@State private var hasInitialized = false
var body: some View {
HStack(spacing: 10) {
component("T", $days, 0...365)
component("Std", $hours, 0...23)
component("Min", $minutes, 0...59)
component("Sek", $seconds, 0...59)
}
.onAppear {
guard !hasInitialized else { return }
hasInitialized = true
parse(value)
}
.onChange(of: days) { _, _ in commit() }
.onChange(of: hours) { _, _ in commit() }
.onChange(of: minutes) { _, _ in commit() }
.onChange(of: seconds) { _, _ in commit() }
}
private func component(_ unit: String, _ binding: Binding<Int>, _ range: ClosedRange<Int>) -> some View {
VStack(spacing: 2) {
Stepper(value: binding, in: range) {
Text("\(binding.wrappedValue)")
.monospacedDigit()
.frame(minWidth: 26)
}
Text(unit)
.appFont(.caption2)
.foregroundStyle(.secondary)
}
}
private func parse(_ raw: String) {
let trimmed = raw.trimmingCharacters(in: .whitespaces)
guard !trimmed.isEmpty else { return }
if let bareSeconds = Int(trimmed) {
seconds = bareSeconds % 60
minutes = (bareSeconds / 60) % 60
hours = (bareSeconds / 3600) % 24
days = bareSeconds / 86400
return
}
guard let regex = try? NSRegularExpression(pattern: #"(\d+)([dhms])"#) else { return }
let nsText = trimmed as NSString
for match in regex.matches(in: trimmed, range: NSRange(location: 0, length: nsText.length)) {
guard let amount = Int(nsText.substring(with: match.range(at: 1))) else { continue }
switch nsText.substring(with: match.range(at: 2)) {
case "d": days = amount
case "h": hours = amount
case "m": minutes = amount
case "s": seconds = amount
default: break
}
}
}
private func commit() {
var parts: [String] = []
if days > 0 { parts.append("\(days)d") }
if hours > 0 { parts.append("\(hours)h") }
if minutes > 0 { parts.append("\(minutes)m") }
if seconds > 0 { parts.append("\(seconds)s") }
value = parts.joined()
}
}