forked from kay/RouterOS
M9: Einrichten-Wizard bekommt einen Einfach/Experte-Modusschalter (ModeStepView). Einfach überspringt VLAN, erlaubt nur ein LAN-Netzwerk ohne Isolation, Firewall-Grundschutz fest an. M10: neuer "Experte"-Tab mit generischem Motor (RouterOSMenuItem, RouterOSCommand.remove, ConnectionService.fetchMenuItems, freies "eigener Menüpfad"-Feld) plus kuratierten Formularen mit Tooltips (RouterOSSchemaCatalog) für Firewall/NAT/Mangle/Raw/Adress-Listen, Interfaces, IP, VPN, WLAN, Queues, System, Werkzeuge. Live gegen einen hEX-Testrouter verifiziert (erst per SSH, dann vom Nutzer selbst in der App), dabei 7 reale Bugs gefunden und gefixt — der wichtigste: RouterOS' SSH-CLI gibt bei fehlgeschlagenen Befehlen Exit-Code 0 zurück, wodurch apply() app-weit Fehler verschluckte statt sie zu melden. Danach ergänzt: Bestätigungsdialog vor Anlegen/Ändern + Auto-Backup vor dem ersten Experte-Tab-Schreibvorgang je Sitzung (Angleichung an den Wizard), sowie ein Dauer-Editor (Tage/Std/Min/Sek) für Lease-/Ablaufzeit-Felder statt Freitext. Details zu allen Bugs/Fixes: HANDOFF.md, CHATLOG.md. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EW3r6rW1xCf6UT5jNvt6rn
303 lines
11 KiB
Swift
303 lines
11 KiB
Swift
import SwiftUI
|
||
|
||
struct ExpertMenuDetailView: View {
|
||
@ObservedObject var viewModel: ExpertViewModel
|
||
let schema: RouterOSMenuSchema
|
||
|
||
var body: some View {
|
||
Form {
|
||
Section {
|
||
Text(schema.explanation)
|
||
if let warning = schema.warning {
|
||
Label(warning, 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("Keine Einträge unter \(schema.menuPath).").foregroundStyle(.secondary)
|
||
} else {
|
||
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("Eintrag löschen")
|
||
}
|
||
}
|
||
.contentShape(Rectangle())
|
||
}
|
||
}
|
||
} header: {
|
||
HStack {
|
||
Text(schema.isSingleton ? "Einstellungen" : "Einträge")
|
||
Spacer()
|
||
if !schema.isSingleton {
|
||
Button("Neu hinzufügen") { viewModel.startNewItem() }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.formStyle(.grouped)
|
||
.navigationTitle(schema.displayName)
|
||
.task(id: schema.id) { await viewModel.reloadItems() }
|
||
.sheet(item: $viewModel.editingItem) { item in
|
||
ExpertItemEditView(
|
||
viewModel: viewModel,
|
||
schema: schema,
|
||
isNew: item.id.isEmpty
|
||
)
|
||
}
|
||
.confirmationDialog(
|
||
"Eintrag wirklich löschen?",
|
||
isPresented: Binding(
|
||
get: { viewModel.pendingRemoval != nil },
|
||
set: { if !$0 { viewModel.pendingRemoval = nil } }
|
||
),
|
||
presenting: viewModel.pendingRemoval
|
||
) { item in
|
||
Button("Löschen", role: .destructive) {
|
||
Task { await viewModel.confirmRemoval(item) }
|
||
}
|
||
Button("Abbrechen", 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.
|
||
private struct ExpertItemEditView: View {
|
||
@ObservedObject var viewModel: ExpertViewModel
|
||
let schema: RouterOSMenuSchema
|
||
let isNew: Bool
|
||
@Environment(\.dismiss) private var dismiss
|
||
@State private var showApplyConfirmation = false
|
||
|
||
var body: some View {
|
||
Form {
|
||
ForEach(schema.fields) { field in
|
||
fieldEditor(for: field)
|
||
}
|
||
|
||
Section("Weitere Parameter (frei)") {
|
||
Text("Für alles, was oben nicht als eigenes Feld aufgeführt ist — RouterOS-Parametername genau wie in der Dokumentation.")
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
ForEach($viewModel.extraFields) { $extra in
|
||
HStack {
|
||
TextField("Parameter", text: $extra.key)
|
||
TextField("Wert", text: $extra.value)
|
||
Button(role: .destructive) {
|
||
viewModel.extraFields.removeAll { $0.id == extra.id }
|
||
} label: {
|
||
Image(systemName: "minus.circle")
|
||
}
|
||
}
|
||
}
|
||
Button("Parameter hinzufügen") {
|
||
viewModel.extraFields.append(.init())
|
||
}
|
||
}
|
||
|
||
if let command = viewModel.pendingCommand {
|
||
Section("Wird ausgeführt") {
|
||
Text(command.cliLine)
|
||
.font(.system(.caption, design: .monospaced))
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
|
||
if let error = viewModel.applyError {
|
||
Text(error).foregroundStyle(.red)
|
||
}
|
||
|
||
Section {
|
||
HStack {
|
||
Button("Abbrechen") {
|
||
viewModel.cancelEditing()
|
||
dismiss()
|
||
}
|
||
Spacer()
|
||
Button(isNew ? "Anlegen" : "Speichern") {
|
||
showApplyConfirmation = true
|
||
}
|
||
.disabled(viewModel.isApplying || viewModel.pendingCommand == nil)
|
||
}
|
||
}
|
||
}
|
||
.formStyle(.grouped)
|
||
.frame(minWidth: 420, minHeight: 480)
|
||
.confirmationDialog(
|
||
"Jetzt am Router anwenden?",
|
||
isPresented: $showApplyConfirmation,
|
||
titleVisibility: .visible
|
||
) {
|
||
Button(isNew ? "Anlegen" : "Speichern") {
|
||
Task {
|
||
await viewModel.saveEditingItem()
|
||
if viewModel.applyError == nil { dismiss() }
|
||
}
|
||
}
|
||
Button("Abbrechen", 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 }
|
||
)
|
||
Group {
|
||
switch field.kind {
|
||
case .text, .int:
|
||
TextField(field.label, text: binding)
|
||
case .bool:
|
||
Toggle(field.label, isOn: Binding(
|
||
get: { binding.wrappedValue == "yes" || binding.wrappedValue == "true" },
|
||
set: { binding.wrappedValue = $0 ? "yes" : "no" }
|
||
))
|
||
case .enumPick(let options):
|
||
Picker(field.label, selection: binding) {
|
||
Text("–").tag("")
|
||
ForEach(options, id: \.self) { option in
|
||
Text(option).tag(option)
|
||
}
|
||
}
|
||
case .interfacePick:
|
||
Picker(field.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(field.label, selection: binding) {
|
||
Text("–").tag("")
|
||
ForEach(options, id: \.self) { option in
|
||
Text(option).tag(option)
|
||
}
|
||
}
|
||
case .duration:
|
||
HStack {
|
||
Text(field.label)
|
||
Spacer()
|
||
DurationFieldEditor(value: binding)
|
||
}
|
||
}
|
||
}
|
||
.help(field.help.isEmpty ? field.label : field.help)
|
||
}
|
||
}
|
||
|
||
/// 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()
|
||
}
|
||
}
|