Verbinden-Tab zeigt jetzt die volle Routerboard-Info (Modell/Revision/ Seriennummer/Firmware, aus /system routerboard) sowie einen RouterOS-Software-Update-Check (/system package update: Kanal/ installierte/neueste Version/Status, "Jetzt pruefen"/"Update installieren"), dazu "Firmware aktualisieren" fuer die Routerboard-Bootloader-Firmware und "Jetzt neu starten" danach. Alle vier Aktionen live bestaetigt, inklusive der zuvor unsicheren Frage, ob /system routerboard upgrade's normalerweise interaktive Bestaetigung den nicht-interaktiven SSH-Weg dieser App blockiert (tut es nicht). Bug 20 gefunden und gefixt: fetchMenuItems' Singleton-Fallback (Bug 8) reagierte nur auf eine geworfene Exception fuer "bad parameter terse", aber RouterOS liefert diesen Fehler fuer /system routerboard mit Exit-Code 0 zurueck (dasselbe Bug-10-Muster, diesmal beim Lesen statt Schreiben) - die Routerboard-Sektion blieb dadurch leer, ohne Fehler. Fix: zusaetzlich den Output-Text selbst pruefen, nicht nur die Exception. Design-Durchgang: Verbinden-Detailseite/Sicherungen/Geraete liefen auf nackter List ohne Rahmen - umgestellt auf Form+.formStyle(.grouped), denselben nativen macOS-Karten-Look, den Wizard und Experte-Tab schon hatten, fuer eine einheitliche App. Dark Mode auf Nachfrage gepueft und ohne Codeaenderung bestaetigt funktionierend. HANDOFF.md/CHATLOG.md aktualisiert: M14, Bug 20, Design-Durchgang, Dark-Mode-Bestaetigung. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CTgRxJTzaQwaRkngbaE1GJ
290 lines
12 KiB
Swift
290 lines
12 KiB
Swift
import SwiftUI
|
|
|
|
/// "Geräte" tab: LAN scanner — one table per physical Ethernet/WLAN port, each listing the
|
|
/// devices resolved onto it (name/IP/MAC/status), plus right-click actions to give a device a
|
|
/// permanent static IP (Winbox's "Make Static") or remove that reservation again.
|
|
struct DevicesView: View {
|
|
@ObservedObject var connectionService: ConnectionService
|
|
@StateObject private var viewModel: DevicesViewModel
|
|
@State private var rawFieldsDevice: LanDevice?
|
|
/// Plain, independent Bool — deliberately NOT derived from `pendingStaticAssignment`.
|
|
/// SwiftUI's `.confirmationDialog` calls the `isPresented` binding's setter with `false` on
|
|
/// EVERY dismissal, including tapping "Zuweisen" itself — a computed binding that reacted to
|
|
/// that by nil-ing `pendingStaticAssignment` was found to clear it before
|
|
/// `confirmStaticAssignment()` could read it, so the confirmed action silently ran on `nil`
|
|
/// and did nothing. Matches this app's own established, working pattern for confirmation
|
|
/// dialogs elsewhere (BackupListView's factory-reset dialog): the dialog's own visibility and
|
|
/// its payload are two separate pieces of state.
|
|
@State private var showStaticConfirmation = false
|
|
/// Same reasoning as `showStaticConfirmation` — independent of `pendingStaticRemoval`.
|
|
@State private var showRemovalConfirmation = false
|
|
|
|
init(connectionService: ConnectionService) {
|
|
self.connectionService = connectionService
|
|
_viewModel = StateObject(wrappedValue: DevicesViewModel(connectionService: connectionService))
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
Group {
|
|
if connectionService.credentials == nil {
|
|
ContentUnavailableView(
|
|
"Nicht verbunden",
|
|
systemImage: "network.slash",
|
|
description: Text("Verbinde dich zuerst im Tab \"Verbinden\" mit deinem Router.")
|
|
)
|
|
} else if viewModel.isLoading && viewModel.devices.isEmpty {
|
|
ProgressView("Suche Geräte im Netz…")
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
} else if let error = viewModel.loadError, viewModel.devices.isEmpty {
|
|
ContentUnavailableView(
|
|
"Konnte Geräte nicht laden", systemImage: "exclamationmark.triangle",
|
|
description: Text(error)
|
|
)
|
|
} else if viewModel.portGroups.isEmpty {
|
|
ContentUnavailableView(
|
|
"Keine Ports gefunden", systemImage: "wifi.slash",
|
|
description: Text("Der Router meldet aktuell keine physischen Interfaces.")
|
|
)
|
|
} else {
|
|
Form {
|
|
ForEach(viewModel.portGroups) { group in
|
|
Section {
|
|
if group.devices.isEmpty {
|
|
Text("Keine Geräte").font(.caption).foregroundStyle(.secondary)
|
|
} else {
|
|
DeviceColumnHeader()
|
|
ForEach(group.devices) { device in
|
|
DeviceRow(device: device)
|
|
.contextMenu {
|
|
deviceMenu(for: device)
|
|
}
|
|
}
|
|
}
|
|
} header: {
|
|
Text("\(group.title) (\(group.devices.count))")
|
|
}
|
|
}
|
|
}
|
|
.formStyle(.grouped)
|
|
}
|
|
}
|
|
.navigationTitle("Geräte")
|
|
.toolbar {
|
|
ToolbarItem {
|
|
Button {
|
|
Task { await viewModel.load() }
|
|
} label: {
|
|
if viewModel.isLoading {
|
|
ProgressView().controlSize(.small)
|
|
} else {
|
|
Label("Aktualisieren", systemImage: "arrow.clockwise")
|
|
}
|
|
}
|
|
.disabled(viewModel.isLoading)
|
|
}
|
|
}
|
|
.task {
|
|
if viewModel.devices.isEmpty {
|
|
await viewModel.load()
|
|
}
|
|
}
|
|
.confirmationDialog(
|
|
"Feste IP-Adresse zuweisen?",
|
|
isPresented: $showStaticConfirmation,
|
|
titleVisibility: .visible
|
|
) {
|
|
Button("Zuweisen") {
|
|
Task { await viewModel.confirmStaticAssignment() }
|
|
}
|
|
Button("Abbrechen", role: .cancel) { viewModel.cancelStaticAssignment() }
|
|
} message: {
|
|
if let device = viewModel.pendingStaticAssignment {
|
|
Text(
|
|
"\(device.ipAddress) wird dauerhaft an \(device.macAddress)"
|
|
+ (device.hostName.map { " (\($0))" } ?? "")
|
|
+ " gebunden — die Adresse ändert sich für dieses Gerät nicht mehr automatisch."
|
|
+ "\n\nBefehl: \(viewModel.pendingCommand?.cliLine ?? "")"
|
|
)
|
|
}
|
|
}
|
|
.confirmationDialog(
|
|
"Feste Zuweisung entfernen?",
|
|
isPresented: $showRemovalConfirmation,
|
|
titleVisibility: .visible
|
|
) {
|
|
Button("Entfernen", role: .destructive) {
|
|
Task { await viewModel.confirmStaticRemoval() }
|
|
}
|
|
Button("Abbrechen", role: .cancel) { viewModel.cancelStaticRemoval() }
|
|
} message: {
|
|
if let device = viewModel.pendingStaticRemoval {
|
|
Text(
|
|
"Die feste Zuweisung für \(device.macAddress)"
|
|
+ (device.hostName.map { " (\($0))" } ?? "")
|
|
+ " wird entfernt. RouterOS kennt keine direkte Umkehrung von \"Make Static\" —"
|
|
+ " das Gerät bekommt erst beim nächsten Verbindungsaufbau (Kabel/WLAN neu"
|
|
+ " verbinden, Neustart) automatisch wieder eine Adresse per DHCP, eventuell"
|
|
+ " eine andere IP als bisher."
|
|
+ "\n\nBefehl: \(viewModel.pendingRemovalCommand?.cliLine ?? "")"
|
|
)
|
|
}
|
|
}
|
|
.alert(
|
|
"Fehler",
|
|
isPresented: Binding(
|
|
get: { viewModel.applyError != nil },
|
|
set: { if !$0 { viewModel.dismissApplyError() } }
|
|
),
|
|
presenting: viewModel.applyError
|
|
) { _ in
|
|
Button("OK") {}
|
|
} message: { error in
|
|
Text(error)
|
|
}
|
|
.sheet(item: $rawFieldsDevice) { device in
|
|
RawFieldsSheet(device: device) { rawFieldsDevice = nil }
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func deviceMenu(for device: LanDevice) -> some View {
|
|
if device.hasLease {
|
|
if device.isStatic {
|
|
Button {
|
|
viewModel.pendingStaticRemoval = device
|
|
showRemovalConfirmation = true
|
|
} label: {
|
|
Label("Feste Zuweisung entfernen", systemImage: "pin.slash")
|
|
}
|
|
} else {
|
|
Button {
|
|
viewModel.pendingStaticAssignment = device
|
|
showStaticConfirmation = true
|
|
} label: {
|
|
Label("Feste IP zuweisen", systemImage: "pin.fill")
|
|
}
|
|
}
|
|
} else {
|
|
Text("Kein DHCP-Lease — feste Zuweisung hier nicht möglich")
|
|
}
|
|
Divider()
|
|
Button {
|
|
rawFieldsDevice = device
|
|
} label: {
|
|
Label("Rohdaten anzeigen", systemImage: "list.bullet.rectangle")
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Column widths shared between the header and each row so they line up like a real table.
|
|
private enum DeviceColumn {
|
|
static let icon: CGFloat = 16
|
|
static let name: CGFloat = 130
|
|
static let ip: CGFloat = 110
|
|
static let mac: CGFloat = 140
|
|
static let status: CGFloat = 80
|
|
}
|
|
|
|
private struct DeviceColumnHeader: View {
|
|
var body: some View {
|
|
HStack(spacing: 8) {
|
|
Spacer().frame(width: DeviceColumn.icon)
|
|
Text("Name").frame(width: DeviceColumn.name, alignment: .leading)
|
|
Text("IP-Adresse").frame(width: DeviceColumn.ip, alignment: .leading)
|
|
Text("MAC-Adresse").frame(width: DeviceColumn.mac, alignment: .leading)
|
|
Text("Status").frame(width: DeviceColumn.status, alignment: .leading)
|
|
Spacer()
|
|
}
|
|
.font(.caption2.bold())
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
|
|
private struct DeviceRow: View {
|
|
let device: LanDevice
|
|
|
|
/// Three distinct states, not two — a device with no matching DHCP lease at all is neither
|
|
/// "Fest" (a real static reservation) nor "Dynamisch" (a real active lease); conflating it
|
|
/// with "Fest" previously mislabeled every ARP-only device, DHCP clients included whenever
|
|
/// their lease didn't get matched.
|
|
private var statusIcon: String {
|
|
guard device.hasLease else { return "questionmark.circle" }
|
|
return device.isStatic ? "pin.fill" : "circle.fill"
|
|
}
|
|
|
|
private var statusColor: Color {
|
|
guard device.hasLease else { return .secondary }
|
|
return device.isStatic ? .orange : .green
|
|
}
|
|
|
|
private var statusText: String {
|
|
guard device.hasLease else { return "Kein DHCP" }
|
|
return device.isStatic ? "Fest" : "Dynamisch"
|
|
}
|
|
|
|
var body: some View {
|
|
HStack(spacing: 8) {
|
|
Image(systemName: statusIcon)
|
|
.foregroundStyle(statusColor)
|
|
.font(.system(size: 8))
|
|
.frame(width: DeviceColumn.icon)
|
|
.help(statusText)
|
|
Text(device.hostName?.isEmpty == false ? device.hostName! : "-")
|
|
.font(.system(size: 12))
|
|
.lineLimit(1)
|
|
.frame(width: DeviceColumn.name, alignment: .leading)
|
|
Text(device.ipAddress)
|
|
.font(.system(size: 12))
|
|
.frame(width: DeviceColumn.ip, alignment: .leading)
|
|
Text(device.macAddress)
|
|
.font(.system(size: 11, design: .monospaced))
|
|
.frame(width: DeviceColumn.mac, alignment: .leading)
|
|
Text(statusText)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.frame(width: DeviceColumn.status, alignment: .leading)
|
|
Spacer()
|
|
}
|
|
.padding(.vertical, 1)
|
|
}
|
|
}
|
|
|
|
private struct RawFieldsSheet: View {
|
|
let device: LanDevice
|
|
let onClose: () -> Void
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
Text("Rohdaten: \(device.macAddress)")
|
|
.font(.headline)
|
|
Text("Alle Felder, die RouterOS für diesen Eintrag zurückgegeben hat — hilfreich, falls Status/Port hier falsch aussieht.")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
Divider()
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
ForEach(device.rawFields, id: \.key) { pair in
|
|
HStack(alignment: .top) {
|
|
Text(pair.key).font(.caption.monospaced()).foregroundStyle(.secondary)
|
|
Spacer()
|
|
Text(pair.value).font(.caption.monospaced()).multilineTextAlignment(.trailing)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
HStack {
|
|
Spacer()
|
|
Button("Schließen") { onClose() }
|
|
}
|
|
}
|
|
.padding()
|
|
.frame(minWidth: 380, minHeight: 300)
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
DevicesView(connectionService: ConnectionService())
|
|
}
|