Files
RouterOS/RouterOSAssistant/Features/Expert/ExpertMenuDetailView.swift
T
KayandClaude Sonnet 5 a0e7e53f22 M7: REST-Transport erstmals live verifiziert, vier Bugs gefunden+gefixt
Erstmaliger REST-Schreibtest gegen echte Hardware (www-ssl vorher auf
jedem Testgerät aus, Pfad lief nur gegen Mocks). Vier Bugs gefunden:

- baseURL fehlte trailing slash, relative URL-Auflösung warf "/rest"
  aus jedem Pfad (404, fiel still auf SSH zurück statt Zertifikat-
  Dialog zu zeigen)
- .add nutzte POST statt PUT (RouterOS' REST-API erwartet PUT für neue
  Einträge)
- RouterOS sendet Cache-Control: max-age=31536000 auf jede REST-
  Antwort, URLSession cachte dadurch die erste (leere) GET-Antwort für
  den Rest der App-Laufzeit
- Löschen aktualisierte die Experte-Tab-Liste nicht in-place (SwiftUI-
  Render-Problem, kein Datenfehler) — Anlegen/Bearbeiten laufen über
  ein Sheet, dessen Schließen automatisch neu rendert, Löschen über
  ein confirmationDialog ohne Remount

Anlegen/Bearbeiten (inkl. Feld-Leeren)/Löschen live bestätigt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 10:51:55 +02:00

324 lines
13 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))
.font(.title2)
.bold()
Text(L10n.t(schema.category.rawValue, appLanguage))
.font(.caption)
.foregroundStyle(.secondary)
Text(L10n.t(schema.explanation, appLanguage))
if let warning = schema.warning {
Label(L10n.t(warning, appLanguage), systemImage: "exclamationmark.triangle")
.font(.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(viewModel.items) { 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())
}
}
} 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)
.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 {
Form {
ForEach(schema.fields) { field in
fieldEditor(for: field)
}
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))
.font(.caption)
.foregroundStyle(.secondary)
ForEach($viewModel.extraFields) { $extra in
HStack {
TextField(L10n.t("Parameter", appLanguage), text: $extra.key)
TextField(L10n.t("Wert", appLanguage), text: $extra.value)
Button(role: .destructive) {
viewModel.extraFields.removeAll { $0.id == extra.id }
} label: {
Image(systemName: "minus.circle")
}
}
}
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)
.font(.system(.caption, design: .monospaced))
.foregroundStyle(.secondary)
}
}
if let error = viewModel.applyError {
Text(error).foregroundStyle(.red)
}
Section {
HStack {
Button(L10n.t("Abbrechen", appLanguage)) {
viewModel.cancelEditing()
dismiss()
}
Spacer()
Button(L10n.t(isNew ? "Anlegen" : "Speichern", appLanguage)) {
showApplyConfirmation = true
}
.disabled(viewModel.isApplying || viewModel.pendingCommand == nil)
}
}
}
.formStyle(.grouped)
.frame(minWidth: 420, minHeight: 480)
.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)
}
}
}
@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)
.font(.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()
}
}