M17-M19: Bekannte Router, Live-Traffic-Anzeige, Übersicht-Animation+Drag

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
This commit is contained in:
Kay
2026-09-15 21:40:46 +02:00
co-authored by Claude Sonnet 5
parent 9b26677dc2
commit 31607b7270
24 changed files with 1400 additions and 61 deletions
@@ -613,6 +613,14 @@ enum L10n {
"Neu starten": "Restart",
"Startet den Router sofort neu. Die Verbindung geht dabei kurz verloren, danach im Tab \"Verbinden\" erneut verbinden.":
"Restarts the router immediately. The connection will briefly drop — reconnect in the \"Connect\" tab afterwards.",
"Neustart fehlgeschlagen": "Restart Failed"
"Neustart fehlgeschlagen": "Restart Failed",
"Bekannte Router": "Known Routers",
"Aktuell ausgefüllt": "Currently filled in",
"Umbenennen": "Rename",
"Bearbeiten": "Edit",
"Standort (optional, z.B. Keller, 1. OG)": "Location (optional, e.g. Basement, 1st Floor)",
"Entfernen": "Remove",
"Fertig": "Done",
"Name": "Name"
]
}
@@ -0,0 +1,12 @@
import Foundation
/// A single live throughput sample for one interface, from RouterOS' `/interface monitor-traffic
/// <name> once` used to distinguish "link up" (`NetworkInterface.running`, already shown) from
/// "actually carrying data right now", which `running` alone can't: a port stays `running` as soon
/// as its link is up/negotiated, even sitting completely idle.
struct InterfaceTraffic: Equatable {
let rxBitsPerSecond: Int
let txBitsPerSecond: Int
var isActive: Bool { rxBitsPerSecond > 0 || txBitsPerSecond > 0 }
}
@@ -0,0 +1,65 @@
import Foundation
/// Pre-existing configuration on a physical port that the Setup wizard's LAN step would silently
/// override if the user picks that port for a new LAN/DHCP network surfaced instead so the user
/// can either choose a different, actually-free port or explicitly (twice-confirmed) opt in to
/// clearing it. `DhcpServerCommandBuilder` already unconditionally detaches a port from any bridge
/// as a safety net (see its own doc comment) this check runs *before* that, to make the same
/// fact visible up front instead of only implicitly, and additionally covers cases that builder
/// doesn't touch at all (an existing IP address, or the port already being used as a WAN dial-up).
struct PortConflict: Equatable {
enum Reason: Equatable {
case bridgeMember(bridgeName: String)
case hasAddresses([String])
case dhcpClient
case pppoeClient
var description: String {
switch self {
case .bridgeMember(let bridgeName):
return "Ist Mitglied der Bridge \"\(bridgeName)\""
case .hasAddresses(let addresses):
return "Trägt bereits die IP-Adresse\(addresses.count == 1 ? "" : "n") \(addresses.joined(separator: ", "))"
case .dhcpClient:
return "Ist als WAN-DHCP-Client konfiguriert (bezieht selbst eine Adresse aus dem Internet)"
case .pppoeClient:
return "Wird von einer PPPoE-Einwahl verwendet"
}
}
}
let interfaceName: String
let reasons: [Reason]
/// Commands that clear each found conflict so the port is actually free before
/// `DhcpServerCommandBuilder`'s own commands run. Bridge membership is deliberately excluded
/// here `DhcpServerCommandBuilder` already removes it unconditionally regardless of whether
/// this check ran or was acknowledged, so repeating it would just be a harmless duplicate
/// `.remove` at best; excluding it keeps this list to exactly what wouldn't otherwise happen.
func resolutionCommands() -> [RouterOSCommand] {
reasons.flatMap { reason -> [RouterOSCommand] in
switch reason {
case .bridgeMember:
return []
case .hasAddresses:
return [RouterOSCommand.remove(
menuPath: "/ip address", restPath: "ip/address",
matchField: "interface", matchValue: interfaceName,
summary: "Bestehende IP-Adresse(n) auf \(interfaceName) entfernen"
)]
case .dhcpClient:
return [RouterOSCommand.remove(
menuPath: "/ip dhcp-client", restPath: "ip/dhcp-client",
matchField: "interface", matchValue: interfaceName,
summary: "WAN-DHCP-Client auf \(interfaceName) entfernen"
)]
case .pppoeClient:
return [RouterOSCommand.remove(
menuPath: "/interface pppoe-client", restPath: "interface/pppoe-client",
matchField: "interface", matchValue: interfaceName,
summary: "PPPoE-Einwahl auf \(interfaceName) entfernen"
)]
}
}
}
}
@@ -0,0 +1,48 @@
import Foundation
/// A router this app has connected to successfully before, remembered in the Verbinden-Tab for
/// quick reconnect the app's own "known devices" list. Login credentials stay exactly where
/// they already did (the macOS Keychain, keyed by "username@host" see `KeychainService`); this
/// only remembers which host/username pairs exist, a user-editable display name, and when it was
/// last used.
struct SavedRouter: Identifiable, Codable, Equatable {
let id: UUID
var host: String
var username: String
/// Editable bookmark name. Defaults to the router's own hardware marketing name (e.g. "hEX")
/// the first time a connection to this host/username succeeds RouterDeviceInfo.boardName,
/// the closest thing to a "werksmäßige Bezeichnung" (factory designation) this app already
/// reads but is never overwritten afterwards, preserving whatever the user renames it to.
var name: String
/// Free-text location/purpose (e.g. "Keller, Serverschrank" or "1. OG, Gästezimmer") added
/// per explicit request to tell multiple saved routers apart at a glance. Always
/// user-supplied, no default; empty means "not set", never shown then.
var location: String
var lastConnectedAt: Date
init(id: UUID = UUID(), host: String, username: String, name: String, location: String = "", lastConnectedAt: Date = Date()) {
self.id = id
self.host = host
self.username = username
self.name = name
self.location = location
self.lastConnectedAt = lastConnectedAt
}
private enum CodingKeys: String, CodingKey {
case id, host, username, name, location, lastConnectedAt
}
/// Custom decoding so an already-saved list from before `location` existed keeps loading
/// (missing key -> "") instead of the whole list silently vanishing `SavedRoutersStore.
/// load()` treats any decode failure as "no saved routers at all".
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(UUID.self, forKey: .id)
host = try container.decode(String.self, forKey: .host)
username = try container.decode(String.self, forKey: .username)
name = try container.decode(String.self, forKey: .name)
location = try container.decodeIfPresent(String.self, forKey: .location) ?? ""
lastConnectedAt = try container.decode(Date.self, forKey: .lastConnectedAt)
}
}
@@ -27,19 +27,42 @@ enum RouterOSCliParser {
/// whose repeated keys (name=, type=, ...) then overwrote each other in the fields
/// dictionary, leaving only the last interface (`lo`) in the result. Found via live testing.
static func parseInterfaces(_ raw: String) -> [NetworkInterface] {
raw.split(whereSeparator: \.isNewline).compactMap { line in
let fields = keyValues(from: String(line))
raw.split(whereSeparator: \.isNewline).compactMap { rawLine in
let line = String(rawLine)
let fields = keyValues(from: line)
guard let name = fields["name"] else { return nil }
// "running"/"disabled" are never present as key=value pairs here RouterOS encodes
// them as single-letter flags in a fixed-width column before the first key=value pair
// instead (e.g. "0 R name=ether1 ..." or "2 S name=ether3 ..."). Confirmed live
// (2026-09-15, hEX/RouterOS 7.x): "X" = disabled, "R" = running, "S" = slave (bridge
// port) every interface's `running` silently read as `false` before this fix, since
// `isTrue(fields["running"])` always found no such key.
let flags = flagsColumn(of: line)
return NetworkInterface(
name: name,
type: fields["type"] ?? "unbekannt",
running: isTrue(fields["running"]),
disabled: isTrue(fields["disabled"]),
running: flags.contains("R"),
disabled: flags.contains("X"),
macAddress: fields["mac-address"]
)
}
}
/// The index+flags prefix of a `print terse` line, up to (excluding) its first `key=value`
/// pair e.g. "0 R " out of "0 R name=ether1 type=ether ...". Robust to the flag column's
/// exact width/character order since it just isolates everything before the first "=", then
/// callers check for individual flag letters within it.
private static func flagsColumn(of line: String) -> String {
guard let equalsIndex = line.firstIndex(of: "=") else { return line }
var keyStart = equalsIndex
while keyStart > line.startIndex {
let previous = line.index(before: keyStart)
if line[previous].isWhitespace { break }
keyStart = previous
}
return String(line[line.startIndex..<keyStart])
}
/// Parses `<any menu> print without-paging terse` output generically used by the Expert
/// tool for menus without a curated parser (i.e. almost all of them). Each line's `.id=*N`
/// field (present in terse output) becomes the item's `id`; everything else becomes `fields`.
@@ -49,6 +49,39 @@ final class SSHTransport: RouterOSTransport {
return RouterOSCliParser.parseInterfaces(output)
}
/// Live throughput for one interface RouterOS' `/interface monitor-traffic <name> once` is
/// a standard, stable CLI command (documented single-interface usage), not a menu item, so
/// it's implemented directly here rather than through the generic `fetchMenuItems` machinery.
/// Not exposed over REST: this is a CLI-only command with no documented REST equivalent, so
/// callers needing it (see `InterfaceTrafficMonitor`) always use a dedicated SSH connection,
/// same reasoning as `BackupService`/`UpdateService`.
func fetchInterfaceTraffic(interfaceName: String) async throws -> InterfaceTraffic {
let output = try await run("/interface monitor-traffic \(interfaceName) once")
let fields = RouterOSCliParser.parseSingletonItem(output).fields
return InterfaceTraffic(
rxBitsPerSecond: Self.parseBitsPerSecond(fields["rx-bits-per-second"]),
txBitsPerSecond: Self.parseBitsPerSecond(fields["tx-bits-per-second"])
)
}
/// RouterOS reports these as human-formatted strings with a unit suffix (e.g. "50.7kbps",
/// "34.0kbps", or plain "0" when idle), never a bare integer confirmed live (2026-09-15,
/// hEX/RouterOS 7.x). Longer/more specific suffixes are checked before shorter ones ("kbps"
/// before "bps") since "kbps" itself ends with "bps" too.
static func parseBitsPerSecond(_ raw: String?) -> Int {
guard let trimmed = raw?.trimmingCharacters(in: .whitespaces), !trimmed.isEmpty else { return 0 }
let unitsBySpecificity: [(suffix: String, multiplier: Double)] = [
("Gbps", 1_000_000_000), ("Mbps", 1_000_000), ("kbps", 1_000), ("bps", 1)
]
for unit in unitsBySpecificity where trimmed.hasSuffix(unit.suffix) {
let numberPart = trimmed.dropLast(unit.suffix.count)
if let value = Double(numberPart) {
return Int(value * unit.multiplier)
}
}
return Int(trimmed) ?? 0
}
func disconnect() async {
try? await client?.close()
client = nil
@@ -112,6 +112,39 @@ final class ConnectionService: ObservableObject {
return try await activeTransport.fetchFieldValues(menuPath: menuPath, restPath: restPath, whereField: whereField, whereValue: whereValue, returnField: returnField)
}
/// Live, read-only check for the Setup wizard's LAN step: whether `interfaceName` already
/// carries configuration that assigning it its own LAN/DHCP role would silently override
/// (bridge membership, an existing IP address, or already being a WAN dial-up). "bridge"
/// itself is never flagged it's the app's own shared-LAN interface, never "someone else's"
/// config. Never modifies anything; the caller decides what, if anything, to remove.
func checkPortConflict(interfaceName: String) async throws -> PortConflict? {
guard interfaceName != "bridge" else { return nil }
async let bridgePorts = fetchMenuItems(menuPath: "/interface bridge port", restPath: "interface/bridge/port")
async let addresses = fetchMenuItems(menuPath: "/ip address", restPath: "ip/address")
async let dhcpClients = fetchMenuItems(menuPath: "/ip dhcp-client", restPath: "ip/dhcp-client")
async let pppoeClients = fetchMenuItems(menuPath: "/interface pppoe-client", restPath: "interface/pppoe-client")
var reasons: [PortConflict.Reason] = []
if let bridgeName = try await bridgePorts.first(where: { $0.fields["interface"] == interfaceName })?.fields["bridge"] {
reasons.append(.bridgeMember(bridgeName: bridgeName))
}
let matchingAddresses = try await addresses
.filter { $0.fields["interface"] == interfaceName }
.compactMap { $0.fields["address"] }
if !matchingAddresses.isEmpty {
reasons.append(.hasAddresses(matchingAddresses))
}
if try await dhcpClients.contains(where: { $0.fields["interface"] == interfaceName }) {
reasons.append(.dhcpClient)
}
if try await pppoeClients.contains(where: { $0.fields["interface"] == interfaceName }) {
reasons.append(.pppoeClient)
}
return reasons.isEmpty ? nil : PortConflict(interfaceName: interfaceName, reasons: reasons)
}
private func finishConnecting(using transport: RouterOSTransport) async {
activeTransport = transport
do {
@@ -0,0 +1,46 @@
import Foundation
/// Polls live per-interface throughput so the Verbinden-Tab's interface list can show which
/// ports are actually carrying traffic right now, not just "link up" (`NetworkInterface.running`
/// stays true as soon as a link is negotiated, even sitting completely idle).
///
/// Always opens its own dedicated SSH connection, independent of whichever transport (REST or
/// SSH) the live session is actually using same reasoning as `BackupService`/`UpdateService`:
/// `/interface monitor-traffic` is a CLI-only RouterOS command with no REST equivalent. The
/// connection is kept open across repeated `fetchTraffic` calls (one new SSH connection per
/// poll tick would be far too slow for a few-times-a-second-feeling refresh) and only reopened
/// if the credentials change or a previous attempt failed.
actor InterfaceTrafficMonitor {
private var transport: SSHTransport?
private var connectedCredentials: RouterOSCredentials?
/// Best-effort per interface one failing/renamed-mid-session interface doesn't take down
/// the whole poll, it's just missing from the result (callers treat "no entry" as "unknown",
/// not "idle").
func fetchTraffic(interfaceNames: [String], for credentials: RouterOSCredentials) async -> [String: InterfaceTraffic] {
if connectedCredentials != credentials {
await disconnect()
}
if transport == nil {
let newTransport = SSHTransport(credentials: credentials)
guard (try? await newTransport.connect()) != nil else { return [:] }
transport = newTransport
connectedCredentials = credentials
}
guard let transport else { return [:] }
var result: [String: InterfaceTraffic] = [:]
for name in interfaceNames {
if let traffic = try? await transport.fetchInterfaceTraffic(interfaceName: name) {
result[name] = traffic
}
}
return result
}
func disconnect() async {
await transport?.disconnect()
transport = nil
connectedCredentials = nil
}
}
@@ -0,0 +1,81 @@
import Foundation
/// Persists the Verbinden-Tab's "known routers" list (host/username/editable name/last-connected)
/// as JSON in UserDefaults small, structured data, no reason for its own file the way backups
/// get a directory. Passwords themselves stay exactly where they already did: the macOS Keychain
/// via `KeychainService`, keyed by "username@host", untouched by this store.
struct SavedRoutersStore {
private static let key = "RouterOSAssistant.SavedRouters"
private let defaults: UserDefaults
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
}
/// Most recently used first.
func load() -> [SavedRouter] {
sortedByRecency(loadRaw())
}
private func loadRaw() -> [SavedRouter] {
guard let data = defaults.data(forKey: Self.key),
let routers = try? JSONDecoder().decode([SavedRouter].self, from: data) else { return [] }
return routers
}
private func sortedByRecency(_ routers: [SavedRouter]) -> [SavedRouter] {
routers.sorted { $0.lastConnectedAt > $1.lastConnectedAt }
}
/// Persists in whatever order given, then returns the recency-sorted view every public
/// method's return value has the same "most recently used first" order as `load()`, so a
/// caller can always assign it straight to a displayed list without a separate re-sort.
@discardableResult
private func save(_ routers: [SavedRouter]) -> [SavedRouter] {
if let data = try? JSONEncoder().encode(routers) {
defaults.set(data, forKey: Self.key)
}
return sortedByRecency(routers)
}
/// Called after a successful connection: adds a new entry (name defaulting to `defaultName`)
/// if this host/username pair isn't known yet, or just bumps `lastConnectedAt` on the existing
/// one. An existing entry's `name` is never touched here that's what preserves a user's own
/// rename across later reconnects.
@discardableResult
func recordSuccessfulConnection(host: String, username: String, defaultName: String) -> [SavedRouter] {
var routers = loadRaw()
if let index = routers.firstIndex(where: { $0.host == host && $0.username == username }) {
routers[index].lastConnectedAt = Date()
} else {
routers.append(SavedRouter(host: host, username: username, name: defaultName))
}
return save(routers)
}
@discardableResult
func rename(_ id: SavedRouter.ID, to newName: String) -> [SavedRouter] {
var routers = loadRaw()
guard let index = routers.firstIndex(where: { $0.id == id }) else { return sortedByRecency(routers) }
routers[index].name = newName
return save(routers)
}
/// Free-text location/purpose (e.g. "Keller" or "1. OG") kept as its own method rather than
/// folded into `rename` since a caller may want to update just one of the two, and it mirrors
/// `rename`'s exact shape.
@discardableResult
func updateLocation(_ id: SavedRouter.ID, to newLocation: String) -> [SavedRouter] {
var routers = loadRaw()
guard let index = routers.firstIndex(where: { $0.id == id }) else { return sortedByRecency(routers) }
routers[index].location = newLocation
return save(routers)
}
@discardableResult
func remove(_ id: SavedRouter.ID) -> [SavedRouter] {
var routers = loadRaw()
routers.removeAll { $0.id == id }
return save(routers)
}
}
@@ -37,6 +37,44 @@ struct OverviewView: View {
return viewModel.graph.highlightedNodeIDs(startingAt: id)
}
/// Manual per-node drag adjustments, on top of `OverviewLayout`'s computed grid position
/// per explicit request: nodes can be dragged around for a clearer layout, connection lines
/// follow automatically, and a toolbar button resets back to the original arrangement. Kept
/// as plain view state (not persisted) "Ausgangszustand" only ever means this session's
/// freshly computed column layout, not something remembered across relaunches.
@State private var nodeOffsets: [String: CGSize] = [:]
/// The node currently mid-drag, if any, plus its live (not-yet-committed) translation kept
/// separate from `nodeOffsets` so `EdgesCanvas`/the dragged card itself update every frame of
/// the drag, not just once it ends.
@GestureState private var activeDrag: ActiveNodeDrag?
private struct ActiveNodeDrag: Equatable {
let nodeID: String
let translation: CGSize
}
private func offset(forNodeID id: String) -> CGSize {
var result = nodeOffsets[id] ?? .zero
if let activeDrag, activeDrag.nodeID == id {
result.width += activeDrag.translation.width
result.height += activeDrag.translation.height
}
return result
}
/// `OverviewLayout`'s computed grid positions, shifted by each node's current (committed +
/// live-dragging) offset what both the node cards and `EdgesCanvas`'s line endpoints
/// actually draw at.
private var effectivePositions: [String: CGPoint] {
var result: [String: CGPoint] = [:]
result.reserveCapacity(layout.positions.count)
for (id, base) in layout.positions {
let off = offset(forNodeID: id)
result[id] = CGPoint(x: base.x + off.width, y: base.y + off.height)
}
return result
}
init(connectionService: ConnectionService) {
self.connectionService = connectionService
_viewModel = StateObject(wrappedValue: OverviewViewModel(connectionService: connectionService))
@@ -103,6 +141,11 @@ struct OverviewView: View {
Button { scale = max(0.4, scale - 0.15) } label: { Image(systemName: "minus.magnifyingglass") }
Button { scale = 1.0 } label: { Text("100%") }
Button { scale = min(2.0, scale + 0.15) } label: { Image(systemName: "plus.magnifyingglass") }
Button("Zurücksetzen") {
nodeOffsets = [:]
}
.disabled(nodeOffsets.isEmpty)
.help("Anordnung zurücksetzen — setzt manuell verschobene Kästchen auf die ursprüngliche Anordnung zurück.")
}
}
.onAppear {
@@ -149,7 +192,7 @@ struct OverviewView: View {
ZStack(alignment: .topLeading) {
EdgesCanvas(
graph: viewModel.graph,
positions: layout.positions,
positions: effectivePositions,
highlightedNodeIDs: highlightedNodeIDs,
hoveredEdge: $hoveredEdge,
hoverPoint: $hoverPoint,
@@ -168,7 +211,7 @@ struct OverviewView: View {
}
ForEach(viewModel.graph.nodes) { node in
if let point = layout.positions[node.id] {
if let point = effectivePositions[node.id] {
NodeCardView(
node: node,
isSelected: node.id == viewModel.selectedNodeID,
@@ -183,6 +226,21 @@ struct OverviewView: View {
.onHover { isHovering in
hoveredNodeID = isHovering ? node.id : (hoveredNodeID == node.id ? nil : hoveredNodeID)
}
// `.simultaneousGesture` (not `.gesture`) so this doesn't steal
// the tap above a small `minimumDistance` lets a plain click
// still register as a tap-to-select instead of a zero-length drag.
.simultaneousGesture(
DragGesture(minimumDistance: 2, coordinateSpace: .local)
.updating($activeDrag) { value, state, _ in
state = ActiveNodeDrag(nodeID: node.id, translation: value.translation)
}
.onEnded { value in
var committed = nodeOffsets[node.id] ?? .zero
committed.width += value.translation.width
committed.height += value.translation.height
nodeOffsets[node.id] = committed
}
)
}
}
@@ -299,40 +357,68 @@ private struct EdgesCanvas: View {
}
}
/// One "on, off" cycle of the flow animation's dash pattern, in points.
private static let flowDash: [CGFloat] = [5, 5]
/// How fast the dash pattern travels along a line, in points/second slow enough to read as
/// "gentle flow", not a distracting marquee.
private static let flowSpeed: CGFloat = 12
/// Per explicit request: every connection line gets an animated flow direction (from "from"
/// to "to", the same direction the diagram's own dependency arrows already document) rather
/// than a static stroke. `TimelineView(.animation)` re-invokes the `Canvas` closure on every
/// display frame with a fresh `timeline.date`, which is what lets a `dashPhase` computed from
/// elapsed time actually animate a plain `@State` `withAnimation(.repeatForever)` value
/// wouldn't: `Canvas` is immediate-mode, so nothing outside of `TimelineView` re-triggers its
/// drawing closure on every frame. The phase decreases over time, which moves the visible
/// dashes in the direction the path is stroked (start end, i.e. "from" "to").
private func dashPhase(at date: Date) -> CGFloat {
let cycleLength = Self.flowDash.reduce(0, +)
let elapsed = CGFloat(date.timeIntervalSinceReferenceDate)
return -(elapsed * Self.flowSpeed).truncatingRemainder(dividingBy: cycleLength)
}
var body: some View {
Canvas { context, _ in
for geometry in geometries {
let edge = geometry.edge
let isConnected = highlightedNodeIDs.contains(edge.from) || highlightedNodeIDs.contains(edge.to)
let isDimmed = !highlightedNodeIDs.isEmpty && !isConnected
let isHovered = hoveredEdge?.id == edge.id || selectedEdge?.id == edge.id
var path = Path()
path.move(to: geometry.start)
path.addCurve(to: geometry.end, control1: geometry.control1, control2: geometry.control2)
let baseColor = OverviewStyle.color(for: edge.kind)
context.stroke(
path,
with: .color(baseColor.opacity(isHovered ? 1.0 : (isDimmed ? 0.1 : (isConnected ? 1.0 : 0.6)))),
lineWidth: isHovered ? 3.2 : (isConnected ? 2.6 : 1.3)
)
}
}
.onContinuousHover { phase in
switch phase {
case .active(let location):
hoverPoint = location
hoveredEdge = stickyNearestEdge(to: location)
case .ended:
hoveredEdge = nil
}
}
.gesture(
SpatialTapGesture()
.onEnded { value in
guard let edge = nearestEdge(to: value.location) else { return }
selectedEdge = (selectedEdge?.id == edge.id) ? nil : edge
TimelineView(.animation) { timeline in
let phase = dashPhase(at: timeline.date)
Canvas { context, _ in
for geometry in geometries {
let edge = geometry.edge
let isConnected = highlightedNodeIDs.contains(edge.from) || highlightedNodeIDs.contains(edge.to)
let isDimmed = !highlightedNodeIDs.isEmpty && !isConnected
let isHovered = hoveredEdge?.id == edge.id || selectedEdge?.id == edge.id
var path = Path()
path.move(to: geometry.start)
path.addCurve(to: geometry.end, control1: geometry.control1, control2: geometry.control2)
let baseColor = OverviewStyle.color(for: edge.kind)
context.stroke(
path,
with: .color(baseColor.opacity(isHovered ? 1.0 : (isDimmed ? 0.1 : (isConnected ? 1.0 : 0.6)))),
style: StrokeStyle(
lineWidth: isHovered ? 3.2 : (isConnected ? 2.6 : 1.3),
lineCap: .round,
dash: Self.flowDash,
dashPhase: phase
)
)
}
)
}
.onContinuousHover { phase in
switch phase {
case .active(let location):
hoverPoint = location
hoveredEdge = stickyNearestEdge(to: location)
case .ended:
hoveredEdge = nil
}
}
.gesture(
SpatialTapGesture()
.onEnded { value in
guard let edge = nearestEdge(to: value.location) else { return }
selectedEdge = (selectedEdge?.id == edge.id) ? nil : edge
}
)
}
}
/// Edges eligible for hover right now every edge normally, but narrowed down to just the
@@ -9,6 +9,14 @@ struct ConnectView: View {
@State private var showRebootConfirmation = false
@AppStorage("appLanguage") private var appLanguage: String = "de"
/// How many "Bekannte Router" rows are visible before the list scrolls in place per
/// explicit request, that list shouldn't be allowed to grow indefinitely. `savedRouterRowHeight`
/// is an estimate (name + optional location line + "user@host" caption, plus vertical padding)
/// good enough for a scroll-height cap; it doesn't need to match exactly since `ScrollView`
/// only ever caps at `maxHeight`, never forces it when content is shorter.
private static let savedRoutersMaxVisibleRows: CGFloat = 4
private static let savedRouterRowHeight: CGFloat = 60
init(connectionService: ConnectionService) {
self.connectionService = connectionService
_viewModel = StateObject(wrappedValue: ConnectViewModel(connectionService: connectionService))
@@ -26,6 +34,38 @@ struct ConnectView: View {
private var mainContent: some View {
NavigationSplitView {
Form {
if !viewModel.savedRouters.isEmpty {
Section(L10n.t("Bekannte Router", appLanguage)) {
// Capped height + its own ScrollView instead of letting the ForEach grow
// the outer Form indefinitely per explicit request, the saved-router
// list shouldn't be allowed to get long; past ~4 entries it scrolls in
// place instead. `maxHeight` only ever caps, so with 4 or fewer entries
// the ScrollView still just sizes to fit its content, no dead space.
ScrollView {
VStack(spacing: 0) {
ForEach(Array(viewModel.savedRouters.enumerated()), id: \.element.id) { index, router in
if index > 0 {
Divider()
}
SavedRouterRow(
router: router,
isCurrent: router.host == viewModel.host && router.username == viewModel.username,
appLanguage: appLanguage,
onSelect: { viewModel.selectSavedRouter(router) },
onSave: { newName, newLocation in
viewModel.renameSavedRouter(router.id, to: newName)
viewModel.updateSavedRouterLocation(router.id, to: newLocation)
},
onDelete: { viewModel.removeSavedRouter(router.id) }
)
.padding(.vertical, 6)
}
}
}
.frame(maxHeight: Self.savedRoutersMaxVisibleRows * Self.savedRouterRowHeight)
}
}
Section(L10n.t("Verbindung", appLanguage)) {
TextField(L10n.t("IP-Adresse oder Hostname", appLanguage), text: $viewModel.host)
.help(L10n.t("Die Adresse deines Routers im Netzwerk. Werkseinstellung bei Mikrotik ist meist 192.168.88.1.", appLanguage))
@@ -54,6 +94,16 @@ struct ConnectView: View {
.onAppear {
viewModel.onAppear()
backupViewModel.load()
if case .connected = connectionService.state, let credentials = connectionService.credentials {
viewModel.startTrafficPolling(credentials: credentials)
}
}
.onChange(of: connectionService.state) { _, newState in
if case .connected = newState, let credentials = connectionService.credentials {
viewModel.startTrafficPolling(credentials: credentials)
} else {
viewModel.stopTrafficPolling()
}
}
.alert(
L10n.t("Unbekanntes Zertifikat", appLanguage),
@@ -282,9 +332,10 @@ struct ConnectView: View {
Section("Interfaces") {
ForEach(connectionService.interfaces) { interface in
HStack {
Image(systemName: interface.running ? "circle.fill" : "circle")
.foregroundStyle(interface.running ? .green : .secondary)
.font(.caption)
InterfaceActivityDot(
running: interface.running,
isActive: viewModel.interfaceTraffic[interface.name]?.isActive ?? false
)
VStack(alignment: .leading) {
Text(interface.name).bold()
Text(interface.type).font(.caption).foregroundStyle(.secondary)
@@ -435,6 +486,123 @@ private extension View {
}
}
/// Status dot in front of an interface's name in the Verbinden-Tab's "Interfaces" list. Grey =
/// link down, solid green = link up but idle, pulsing green = actually carrying traffic right
/// now (per `InterfaceTrafficMonitor`'s live `/interface monitor-traffic` polling) `running`
/// alone can't tell "idle" from "busy", it only reflects link negotiation.
private struct InterfaceActivityDot: View {
let running: Bool
let isActive: Bool
@State private var pulseDown = false
var body: some View {
Circle()
.fill(running ? Color.green : Color.secondary)
.frame(width: 8, height: 8)
.opacity(isActive && pulseDown ? 0.35 : 1.0)
.onChange(of: isActive, initial: true) { _, active in
if active {
withAnimation(.easeInOut(duration: 0.6).repeatForever(autoreverses: true)) {
pulseDown = true
}
} else {
withAnimation(.easeInOut(duration: 0.2)) {
pulseDown = false
}
}
}
}
}
/// One row in the Verbinden-Tab's "Bekannte Router" list click fills in the host/username/
/// remembered password without connecting yet (the normal "Verbinden" button still does that),
/// so a wrong pick is a no-op. Editing (name + free-text location, e.g. "Keller" or "1. OG") is
/// inline (tap "Bearbeiten", edit both fields, commit) rather than a separate sheet.
private struct SavedRouterRow: View {
let router: SavedRouter
let isCurrent: Bool
let appLanguage: String
let onSelect: () -> Void
let onSave: (_ name: String, _ location: String) -> Void
let onDelete: () -> Void
@State private var isEditing = false
@State private var draftName = ""
@State private var draftLocation = ""
@FocusState private var isNameFieldFocused: Bool
var body: some View {
HStack {
// Not wrapped in a Button a `Button(action: onSelect)` around this whole VStack
// previously nested the edit TextField *inside* a Button, and `.disabled(isEditing)`
// on that Button also disabled every descendant, including the TextField itself:
// nothing could be typed. Confirmed live (2026-09-15): "das umbenennen funktioniert
// nicht". `.onTapGesture` (guarded to skip while editing) gets the same "click row to
// select" behavior without disabling anything.
VStack(alignment: .leading, spacing: 2) {
if isEditing {
TextField(L10n.t("Name", appLanguage), text: $draftName)
.textFieldStyle(.roundedBorder)
.focused($isNameFieldFocused)
.onSubmit(commitEdits)
TextField(L10n.t("Standort (optional, z.B. Keller, 1. OG)", appLanguage), text: $draftLocation)
.textFieldStyle(.roundedBorder)
.onSubmit(commitEdits)
} else {
Text(router.name).bold()
if !router.location.isEmpty {
Label(router.location, systemImage: "mappin.and.ellipse")
.font(.caption)
.foregroundStyle(.secondary)
}
}
Text("\(router.username)@\(router.host)")
.font(.caption)
.foregroundStyle(.secondary)
}
.contentShape(Rectangle())
.onTapGesture {
guard !isEditing else { return }
onSelect()
}
Spacer()
if isCurrent {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
.help(L10n.t("Aktuell ausgefüllt", appLanguage))
}
Menu {
if isEditing {
Button(L10n.t("Fertig", appLanguage), action: commitEdits)
} else {
Button(L10n.t("Bearbeiten", appLanguage)) {
draftName = router.name
draftLocation = router.location
isEditing = true
isNameFieldFocused = true
}
}
Button(L10n.t("Entfernen", appLanguage), role: .destructive, action: onDelete)
} label: {
Image(systemName: "ellipsis.circle")
}
.menuStyle(.borderlessButton)
.fixedSize()
}
}
private func commitEdits() {
let trimmedName = draftName.trimmingCharacters(in: .whitespaces)
let trimmedLocation = draftLocation.trimmingCharacters(in: .whitespaces)
onSave(trimmedName.isEmpty ? router.name : trimmedName, trimmedLocation)
isEditing = false
}
}
#Preview {
ConnectView(connectionService: ConnectionService())
}
@@ -6,6 +6,7 @@ final class ConnectViewModel: ObservableObject {
@Published var username: String = "admin"
@Published var password: String = ""
@Published var rememberPassword: Bool = true
@Published private(set) var savedRouters: [SavedRouter] = []
@Published private(set) var packageUpdateInfo: PackageUpdateInfo?
@Published private(set) var isCheckingForUpdates = false
@@ -23,20 +24,46 @@ final class ConnectViewModel: ObservableObject {
@Published var rebootError: String?
@Published private(set) var didSendReboot = false
@Published private(set) var interfaceTraffic: [String: InterfaceTraffic] = [:]
let connectionService: ConnectionService
private let keychain = KeychainService()
private let updateService = UpdateService()
private let savedRoutersStore = SavedRoutersStore()
private let trafficMonitor = InterfaceTrafficMonitor()
private var trafficPollingTask: Task<Void, Never>?
init(connectionService: ConnectionService) {
self.connectionService = connectionService
}
func onAppear() {
savedRouters = savedRoutersStore.load()
if let saved = keychain.loadPassword(forHost: host, username: username) {
password = saved
}
}
/// Fills in a known router's host/username (and its remembered password, if any) without
/// connecting yet the user still reviews/confirms via the normal "Verbinden" button.
func selectSavedRouter(_ router: SavedRouter) {
host = router.host
username = router.username
password = keychain.loadPassword(forHost: router.host, username: router.username) ?? ""
}
func renameSavedRouter(_ id: SavedRouter.ID, to newName: String) {
savedRouters = savedRoutersStore.rename(id, to: newName)
}
func updateSavedRouterLocation(_ id: SavedRouter.ID, to newLocation: String) {
savedRouters = savedRoutersStore.updateLocation(id, to: newLocation)
}
func removeSavedRouter(_ id: SavedRouter.ID) {
savedRouters = savedRoutersStore.remove(id)
}
func connect() {
let credentials = RouterOSCredentials(host: host, username: username, password: password)
if rememberPassword {
@@ -44,9 +71,43 @@ final class ConnectViewModel: ObservableObject {
}
Task {
await connectionService.connect(with: credentials)
if case .connected = connectionService.state {
let defaultName = connectionService.deviceInfo?.boardName ?? host
savedRouters = savedRoutersStore.recordSuccessfulConnection(
host: host, username: username, defaultName: defaultName
)
}
}
}
/// Starts (or restarts, if already running) periodic traffic polling for whatever interfaces
/// `connectionService.interfaces` currently lists re-read every tick so newly-appearing
/// interfaces (e.g. a VLAN added via the Setup wizard) get picked up without restarting the
/// poll. 3s cadence: fast enough to feel "live" for a status dot, not so fast it noticeably
/// loads a home router's CPU with a constant stream of SSH round-trips.
func startTrafficPolling(credentials: RouterOSCredentials) {
stopTrafficPolling()
trafficPollingTask = Task {
while !Task.isCancelled {
let names = connectionService.interfaces.map(\.name)
if !names.isEmpty {
let traffic = await trafficMonitor.fetchTraffic(interfaceNames: names, for: credentials)
if !Task.isCancelled {
interfaceTraffic = traffic
}
}
try? await Task.sleep(for: .seconds(3))
}
}
}
func stopTrafficPolling() {
trafficPollingTask?.cancel()
trafficPollingTask = nil
interfaceTraffic = [:]
Task { await trafficMonitor.disconnect() }
}
func trustAndRetry(fingerprint: String) {
Task {
await connectionService.trustCurrentCertificateAndRetry(fingerprint: fingerprint)
@@ -14,6 +14,26 @@ struct LanStepView: View {
}
}
.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)
@@ -72,6 +92,79 @@ struct LanStepView: View {
&& !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")
}
}
@@ -48,18 +48,23 @@ struct ReviewApplyView: View {
Button("Zurück") { viewModel.goBack() }
.disabled(viewModel.isApplying)
Spacer()
Button {
if let credentials {
viewModel.apply(credentials: credentials)
}
} label: {
if viewModel.isApplying {
ProgressView()
} else {
Text("Jetzt anwenden")
if viewModel.didApplySuccessfully {
Button("Fertig") { viewModel.finish() }
.keyboardShortcut(.defaultAction)
} else {
Button {
if let credentials {
viewModel.apply(credentials: credentials)
}
} label: {
if viewModel.isApplying {
ProgressView()
} else {
Text("Jetzt anwenden")
}
}
.disabled(credentials == nil || viewModel.isApplying)
}
.disabled(credentials == nil || viewModel.isApplying || viewModel.didApplySuccessfully)
}
}
}
@@ -33,6 +33,15 @@ final class SetupViewModel: ObservableObject {
@Published private(set) var isLoadingFirewallRuleCounts = false
@Published private(set) var firewallRuleCountsError: String?
/// Live port-conflict result per LAN config, once checked nil means either "not checked
/// yet" or "checked, port is free". See `checkPortConflict(for:)`.
@Published private(set) var lanPortConflicts: [LanDhcpConfig.ID: PortConflict] = [:]
@Published private(set) var isCheckingPortConflict: Set<LanDhcpConfig.ID> = []
/// A conflict the user has explicitly (twice-confirmed) opted to clear only these get their
/// `resolutionCommands()` added to `plannedCommands`. Cleared whenever the port selection
/// changes, so switching to a different, already-acknowledged port re-asks.
@Published var acknowledgedPortConflicts: Set<LanDhcpConfig.ID> = []
@Published private(set) var isApplying = false
@Published private(set) var applyLog: [String] = []
@Published private(set) var applyError: String?
@@ -49,7 +58,12 @@ final class SetupViewModel: ObservableObject {
var plannedCommands: [RouterOSCommand] {
wanConfig.buildCommands()
+ lanConfigs.flatMap { $0.buildCommands() }
+ lanConfigs.flatMap { config -> [RouterOSCommand] in
let resolution = acknowledgedPortConflicts.contains(config.id)
? (lanPortConflicts[config.id]?.resolutionCommands() ?? [])
: []
return resolution + config.buildCommands()
}
+ vlans.flatMap { $0.buildCommands() }
+ wifiNetworks.flatMap { $0.buildCommands() }
+ (firewallSectionEnabled
@@ -117,6 +131,39 @@ final class SetupViewModel: ObservableObject {
func removeLan(_ id: LanDhcpConfig.ID) {
guard lanConfigs.count > 1 else { return }
lanConfigs.removeAll { $0.id == id }
lanPortConflicts[id] = nil
acknowledgedPortConflicts.remove(id)
isCheckingPortConflict.remove(id)
}
/// Live-checks whether the port currently picked for this LAN config already carries other
/// configuration (bridge membership, an existing address, WAN dial-up) called when the LAN
/// step appears and whenever its interface Picker selection changes. Any prior acknowledgement
/// is dropped: switching ports means re-asking, since a previously-cleared conflict on the old
/// port says nothing about the newly picked one. Best-effort a failed check (e.g. transient
/// connection hiccup) must not block the wizard; the user simply doesn't get the extra warning
/// for that attempt, same as before this feature existed.
func checkPortConflict(for configID: LanDhcpConfig.ID) {
guard let config = lanConfigs.first(where: { $0.id == configID }) else { return }
acknowledgedPortConflicts.remove(configID)
lanPortConflicts[configID] = nil
isCheckingPortConflict.insert(configID)
Task {
defer { isCheckingPortConflict.remove(configID) }
lanPortConflicts[configID] = try? await connectionService.checkPortConflict(interfaceName: config.interfaceName)
}
}
/// The user has been shown what's on this port and, after two explicit confirmations, chose
/// to have the app clear it see `PortConflictWarningView` in `LanStepView.swift`.
func acknowledgePortConflict(for configID: LanDhcpConfig.ID) {
acknowledgedPortConflicts.insert(configID)
}
/// Blocks "Weiter" on the LAN step until every conflicting port has either been acknowledged
/// (app will clear it) or the user picked a different, actually-free port instead.
func hasUnresolvedPortConflict(for configID: LanDhcpConfig.ID) -> Bool {
lanPortConflicts[configID] != nil && !acknowledgedPortConflicts.contains(configID)
}
/// Replaces the placeholder WAN/LAN interface names ("ether1"/"bridge") with real ones
@@ -200,6 +247,34 @@ final class SetupViewModel: ObservableObject {
setFirewallSectionEnabled(true)
}
/// Ends the wizard after a successful apply without this, the completed review screen just
/// sits there with a disabled "Jetzt anwenden" and no way forward except "Zurück" (which would
/// re-walk now-stale steps against the router state this apply just changed). Resets to a
/// fresh run and re-reads live interface defaults, so a follow-up run (e.g. adding one more
/// LAN afterwards) starts from the router's actual current state rather than this session's
/// now-outdated in-memory one.
func finish() {
step = .mode
mode = .simple
wanConfig = WanConfig(interfaceName: "ether1")
lanConfigs = [LanDhcpConfig()]
vlanSectionEnabled = false
vlans = []
wifiNetworks = []
unsupportedWifiInterfaces = []
firewallSectionEnabled = false
existingFirewallRuleCounts = nil
isLoadingFirewallRuleCounts = false
firewallRuleCountsError = nil
lanPortConflicts = [:]
isCheckingPortConflict = []
acknowledgedPortConflicts = []
applyLog = []
applyError = nil
didApplySuccessfully = false
prepareDefaults(from: connectionService.interfaces)
}
func apply(credentials: RouterOSCredentials) {
isApplying = true
applyError = nil