M17: "Bekannte Router" im Verbinden-Tab (SavedRouter/SavedRoutersStore), Standort-Freitextfeld, Scroll-Cap ab 4 Einträgen. Bugfix: Umbenennen- TextField steckte in einem sich selbst deaktivierenden Button. M18: Live-Traffic-Punkt an Interfaces (InterfaceTrafficMonitor, eigene SSH-Verbindung, monitor-traffic-Polling). Dabei zwei reale CLI-Parser-Bugs gefunden und gefixt: running/disabled-Flags werden als Buchstaben vor dem ersten Feld codiert, nicht als key=value; monitor-traffic liefert "50.7kbps" statt einer reinen Zahl. M19: Übersicht-Tab — animierte Flussrichtung auf allen Verbindungslinien (TimelineView+dashPhase), frei verschiebbare Knoten mit Live-folgenden Linien, Zurücksetzen-Button. Zusätzlich (noch nicht live getestet, nur Build+Unit-Tests grün): LAN-Port-Konflikt-Prüfung im Einrichten-Assistenten mit doppelter Sicherheitsbestätigung, "Fertig"-Button nach erfolgreichem Anwenden. 82 Tests grün. HANDOFF.md/README.md/Manual.md/CHATLOG.md aktualisiert. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YDmUd93KxsYGr2kLTotWnG
171 lines
8.2 KiB
Swift
171 lines
8.2 KiB
Swift
import SwiftUI
|
|
|
|
struct LanStepView: View {
|
|
@ObservedObject var viewModel: SetupViewModel
|
|
let availableInterfaces: [NetworkInterface]
|
|
|
|
var body: some View {
|
|
Form {
|
|
ForEach($viewModel.lanConfigs) { $config in
|
|
Section("Netzwerk auf \(config.interfaceName)") {
|
|
Picker("Anschluss/Bridge", selection: $config.interfaceName) {
|
|
ForEach(availableInterfaces) { interface in
|
|
Text(interface.name).tag(interface.name)
|
|
}
|
|
}
|
|
.help("Der interne Netzwerk-Anschluss, an dem deine Geräte hängen (dein lokales Netzwerk, LAN).")
|
|
.onChange(of: config.interfaceName) { _, _ in
|
|
viewModel.checkPortConflict(for: config.id)
|
|
}
|
|
.onAppear {
|
|
viewModel.checkPortConflict(for: config.id)
|
|
}
|
|
|
|
if viewModel.isCheckingPortConflict.contains(config.id) {
|
|
HStack {
|
|
ProgressView().controlSize(.small)
|
|
Text("Prüfe, ob der Port frei ist…").font(.caption).foregroundStyle(.secondary)
|
|
}
|
|
} else if let conflict = viewModel.lanPortConflicts[config.id] {
|
|
PortConflictWarningView(
|
|
conflict: conflict,
|
|
isAcknowledged: viewModel.acknowledgedPortConflicts.contains(config.id),
|
|
onConfirm: { viewModel.acknowledgePortConflict(for: config.id) }
|
|
)
|
|
}
|
|
|
|
TextField("Router-Adresse (z.B. 192.168.88.1/24)", text: $config.routerAddress)
|
|
.help("Die Adresse, unter der der Router selbst in diesem Netzwerk erreichbar ist.")
|
|
TextField("Netzwerk (z.B. 192.168.88.0/24)", text: $config.networkAddress)
|
|
.help("Der komplette Adressbereich dieses Netzwerks (z.B. /24 erlaubt bis zu 254 Geräte).")
|
|
TextField("Automatische Adressvergabe von", text: $config.poolRangeStart)
|
|
.help("Ab welcher Adresse der Router automatisch Adressen an Geräte in diesem Netzwerk vergibt.")
|
|
TextField("bis", text: $config.poolRangeEnd)
|
|
.help("Bis zu welcher Adresse der Router automatisch Adressen an Geräte in diesem Netzwerk vergibt.")
|
|
Stepper(
|
|
"Adresse behalten für \(config.leaseTimeHours) Stunden",
|
|
value: $config.leaseTimeHours,
|
|
in: 1...168
|
|
)
|
|
.help("Wie lange ein Gerät seine zugewiesene Adresse behält, bevor sie erneuert werden muss.")
|
|
TextField("DNS-Server", text: $config.dnsServers)
|
|
.help("Welcher Server Geräten in diesem Netzwerk Internetadressen in Namen übersetzt (z.B. www.google.de). Meist der Router selbst.")
|
|
|
|
if viewModel.mode == .expert {
|
|
Toggle("Von anderen Netzwerken isolieren", isOn: $config.isolated)
|
|
.help("Verhindert Datenverkehr zwischen diesem und allen anderen konfigurierten LAN-/VLAN-Netzwerken. Der Internetzugriff bleibt erhalten. Wird im Firewall-Schritt umgesetzt.")
|
|
|
|
if viewModel.lanConfigs.count > 1 {
|
|
Button("Netzwerk entfernen", role: .destructive) {
|
|
viewModel.removeLan(config.id)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if viewModel.mode == .expert {
|
|
Section {
|
|
Button("Weiteres LAN-Netzwerk hinzufügen") {
|
|
viewModel.addLan()
|
|
}
|
|
}
|
|
}
|
|
|
|
Section {
|
|
HStack {
|
|
Button("Zurück") { viewModel.goBack() }
|
|
Spacer()
|
|
Button("Weiter") { viewModel.goNext() }
|
|
.disabled(!isStepValid)
|
|
}
|
|
}
|
|
}
|
|
.formStyle(.grouped)
|
|
.navigationTitle("Heimnetzwerk einrichten")
|
|
}
|
|
|
|
private var isStepValid: Bool {
|
|
viewModel.lanConfigs.allSatisfy { config in
|
|
!config.interfaceName.isEmpty
|
|
&& !config.routerAddress.isEmpty
|
|
&& !config.networkAddress.isEmpty
|
|
&& !config.poolRangeStart.isEmpty
|
|
&& !config.poolRangeEnd.isEmpty
|
|
&& !viewModel.hasUnresolvedPortConflict(for: config.id)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Shown inline under a LAN config's port Picker once `SetupViewModel.checkPortConflict(for:)`
|
|
/// finds the chosen port already carries other configuration. Requires two separate confirmations
|
|
/// before the app is allowed to clear it — per explicit request: this silently overriding a port's
|
|
/// existing role (an active WAN dial-up, a bridge membership, a manually-set address) previously
|
|
/// wasn't visible to the user at all beyond `DhcpServerCommandBuilder`'s own unconditional bridge
|
|
/// detach; this makes the consequences explicit and opt-in instead.
|
|
private struct PortConflictWarningView: View {
|
|
let conflict: PortConflict
|
|
let isAcknowledged: Bool
|
|
let onConfirm: () -> Void
|
|
|
|
@State private var showConsequencesConfirmation = false
|
|
@State private var showFinalConfirmation = false
|
|
|
|
var body: some View {
|
|
if isAcknowledged {
|
|
Label("\(conflict.interfaceName) wird beim Anwenden freigemacht", systemImage: "checkmark.shield")
|
|
.font(.caption)
|
|
.foregroundStyle(.orange)
|
|
} else {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Label("Port \(conflict.interfaceName) ist nicht frei", systemImage: "exclamationmark.triangle.fill")
|
|
.font(.subheadline.bold())
|
|
.foregroundStyle(.red)
|
|
ForEach(Array(conflict.reasons.enumerated()), id: \.offset) { _, reason in
|
|
Text("• \(reason.description)")
|
|
.font(.caption)
|
|
}
|
|
Text("Wähle oben einen anderen, freien Port — oder mache diesen jetzt frei. Die bestehende Konfiguration wird dabei entfernt.")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
Button("Port jetzt freimachen…", role: .destructive) {
|
|
showConsequencesConfirmation = true
|
|
}
|
|
}
|
|
.padding(10)
|
|
.background(RoundedRectangle(cornerRadius: 8).fill(Color.red.opacity(0.08)))
|
|
.confirmationDialog(
|
|
"Port \(conflict.interfaceName) freimachen?",
|
|
isPresented: $showConsequencesConfirmation,
|
|
titleVisibility: .visible
|
|
) {
|
|
Button("Fortfahren", role: .destructive) {
|
|
showFinalConfirmation = true
|
|
}
|
|
Button("Abbrechen", role: .cancel) {}
|
|
} message: {
|
|
Text(consequenceText)
|
|
}
|
|
.confirmationDialog(
|
|
"Wirklich sicher?",
|
|
isPresented: $showFinalConfirmation,
|
|
titleVisibility: .visible
|
|
) {
|
|
Button("Ja, endgültig freimachen", role: .destructive) {
|
|
onConfirm()
|
|
}
|
|
Button("Abbrechen", role: .cancel) {}
|
|
} message: {
|
|
Text("Tatsächlich ausgeführt wird das erst mit \"Jetzt anwenden\" am Ende des Assistenten — bis dahin kannst du das rückgängig machen, indem du hier oben einen anderen Port wählst.")
|
|
}
|
|
}
|
|
}
|
|
|
|
private var consequenceText: String {
|
|
(["Folgendes wird entfernt, bevor \(conflict.interfaceName) als neues Netzwerk eingerichtet wird:"]
|
|
+ conflict.reasons.map { "• \($0.description)" }
|
|
+ ["Bestehender Datenverkehr über diesen Port (z.B. eine laufende Internetverbindung oder Geräte im bisherigen Netz) wird dadurch unterbrochen."])
|
|
.joined(separator: "\n")
|
|
}
|
|
}
|