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>
324 lines
13 KiB
Swift
324 lines
13 KiB
Swift
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()
|
||
}
|
||
}
|