Files
RouterOS/RouterOSAssistant/Features/Overview/OverviewViewModel.swift
T
KayandClaude Sonnet 5 18fefc76a8 Bug 28: dynamische Routen (distance=0) nicht mehr über die Übersicht editierbar
Eine dynamische/verbundene Route (automatisch angelegt durch eine
IP-Adresse auf einem Interface) scheiterte beim Bearbeiten mit "no such
item (4)" — bestätigt per /ip route print detail: D-Flag, distance=0.
RouterOS' "dynamic"-Flag steht nicht zuverlässig in print terse
(dasselbe Problem schon bei DHCP-Leases dokumentiert), aber distance=0
ist ein verlässliches Signal, da keine echte statische Route das je
haben kann. Solche Routen bekommen jetzt kein editTarget mehr — ihre
.id ist ohnehin nicht stabil, RouterOS kann sie jederzeit neu anlegen.

59 Unit-Tests grün (neuer Test OverviewGraphTests.
testDynamicRouteHasNoEditTarget).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YDmUd93KxsYGr2kLTotWnG
2026-09-15 13:54:11 +02:00

295 lines
16 KiB
Swift

import Foundation
/// Builds the read-only "Übersicht" topology graph from a live router: fetches the same generic
/// menus the Expert tool already knows how to read (`ConnectionService.fetchMenuItems`) and wires
/// them together into nodes/edges using RouterOS' own reference fields (an interface name a DHCP
/// server points at, a pool name it uses, ...) — never guessed, only what the router itself
/// returns. Purely additive/read-only: never calls `apply()`, nothing here can change the router.
@MainActor
final class OverviewViewModel: ObservableObject {
@Published private(set) var graph = OverviewGraph()
@Published private(set) var isLoading = false
@Published private(set) var loadError: String?
@Published var selectedNodeID: String?
private let connectionService: ConnectionService
init(connectionService: ConnectionService) {
self.connectionService = connectionService
}
func load() async {
isLoading = true
loadError = nil
do {
async let interfaces = connectionService.fetchMenuItems(menuPath: "/interface", restPath: "interface")
async let bridgePorts = connectionService.fetchMenuItems(menuPath: "/interface bridge port", restPath: "interface/bridge/port")
async let vlans = connectionService.fetchMenuItems(menuPath: "/interface vlan", restPath: "interface/vlan")
async let wireguardPeers = connectionService.fetchMenuItems(menuPath: "/interface wireguard peers", restPath: "interface/wireguard/peers")
async let addresses = connectionService.fetchMenuItems(menuPath: "/ip address", restPath: "ip/address")
async let pools = connectionService.fetchMenuItems(menuPath: "/ip pool", restPath: "ip/pool")
async let dhcpServers = connectionService.fetchMenuItems(menuPath: "/ip dhcp-server", restPath: "ip/dhcp-server")
async let dhcpNetworks = connectionService.fetchMenuItems(menuPath: "/ip dhcp-server network", restPath: "ip/dhcp-server/network")
async let dhcpClients = connectionService.fetchMenuItems(menuPath: "/ip dhcp-client", restPath: "ip/dhcp-client")
async let routes = connectionService.fetchMenuItems(menuPath: "/ip route", restPath: "ip/route")
async let filterRules = connectionService.fetchMenuItems(menuPath: "/ip firewall filter", restPath: "ip/firewall/filter")
async let natRules = connectionService.fetchMenuItems(menuPath: "/ip firewall nat", restPath: "ip/firewall/nat")
async let addressLists = connectionService.fetchMenuItems(menuPath: "/ip firewall address-list", restPath: "ip/firewall/address-list")
graph = try await Self.buildGraph(
interfaces: interfaces,
bridgePorts: bridgePorts,
vlans: vlans,
wireguardPeers: wireguardPeers,
addresses: addresses,
pools: pools,
dhcpServers: dhcpServers,
dhcpNetworks: dhcpNetworks,
dhcpClients: dhcpClients,
routes: routes,
filterRules: filterRules,
natRules: natRules,
addressLists: addressLists
)
} catch {
loadError = error.localizedDescription
}
isLoading = false
}
// MARK: - Pure graph construction (testable without a live router)
nonisolated static func buildGraph(
interfaces: [RouterOSMenuItem],
bridgePorts: [RouterOSMenuItem],
vlans: [RouterOSMenuItem],
wireguardPeers: [RouterOSMenuItem],
addresses: [RouterOSMenuItem],
pools: [RouterOSMenuItem],
dhcpServers: [RouterOSMenuItem],
dhcpNetworks: [RouterOSMenuItem],
dhcpClients: [RouterOSMenuItem],
routes: [RouterOSMenuItem],
filterRules: [RouterOSMenuItem],
natRules: [RouterOSMenuItem],
addressLists: [RouterOSMenuItem]
) -> OverviewGraph {
var nodes: [OverviewNode] = []
var edges: [OverviewEdge] = []
var knownInterfaceNames = Set<String>()
// Interfaces: every physical/virtual interface RouterOS reports (ethernet, vlan, bridge,
// wireless, wireguard, ...). "vlan-id"/parent info is filled in below from "/interface
// vlan", since the generic "/interface" listing alone doesn't carry it.
var vlanInfoByName: [String: (vlanID: String, parent: String)] = [:]
for item in vlans {
guard let name = item.fields["name"] else { continue }
vlanInfoByName[name] = (item.fields["vlan-id"] ?? "?", item.fields["interface"] ?? "")
}
for item in interfaces {
guard let name = item.fields["name"] else { continue }
let type = item.fields["type"] ?? "unbekannt"
knownInterfaceNames.insert(name)
var subtitle = type
if let vlanInfo = vlanInfoByName[name] {
subtitle = "VLAN \(vlanInfo.vlanID)"
}
if item.fields["disabled"] == "true" || item.fields["disabled"] == "yes" {
subtitle += " · deaktiviert"
}
nodes.append(OverviewNode(
id: nodeID(.interface, name), category: .interface, kind: type,
title: name, subtitle: subtitle,
detail: sortedDetail(item.fields)
))
}
// VLAN -> parent interface (e.g. a VLAN on ether5, or on a bridge).
for (vlanName, info) in vlanInfoByName where !info.parent.isEmpty {
edges.append(OverviewEdge(from: nodeID(.interface, info.parent), to: nodeID(.interface, vlanName), label: "trägt VLAN \(info.vlanID)", kind: .vlan))
}
// Bridge ports: physical/virtual interface -> the bridge it's a member of.
for item in bridgePorts {
guard let bridge = item.fields["bridge"], let iface = item.fields["interface"] else { continue }
edges.append(OverviewEdge(from: nodeID(.interface, iface), to: nodeID(.interface, bridge), label: "Bridge-Port", kind: .bridgePort))
}
// WireGuard peers: shown as small nodes hanging off their WireGuard interface.
for (index, item) in wireguardPeers.enumerated() {
guard let iface = item.fields["interface"] else { continue }
let id = "wgpeer:\(index)"
let allowed = item.fields["allowed-address"] ?? "?"
nodes.append(OverviewNode(
id: id, category: .interface, kind: "wireguard-peer",
title: "Peer", subtitle: allowed,
detail: sortedDetail(item.fields),
editTarget: .init(menuPath: "/interface wireguard peers", restPath: "interface/wireguard/peers", itemID: item.id)
))
edges.append(OverviewEdge(from: nodeID(.interface, iface), to: id, label: nil, kind: .wireguardPeer))
}
// IP addresses: hang off the interface they're assigned to.
var networkToAddressID: [String: String] = [:] // "192.168.88.0" -> "ip:192.168.88.1/24"
for item in addresses {
guard let address = item.fields["address"], let iface = item.fields["interface"] else { continue }
let id = nodeID(.ipAddress, address)
nodes.append(OverviewNode(
id: id, category: .ipAddress, kind: "address",
title: address, subtitle: iface,
detail: sortedDetail(item.fields),
editTarget: .init(menuPath: "/ip address", restPath: "ip/address", itemID: item.id)
))
edges.append(OverviewEdge(from: nodeID(.interface, iface), to: id, label: nil, kind: .ipAddress))
if let network = item.fields["network"] {
networkToAddressID[network] = id
}
}
// Address pools.
for item in pools {
guard let name = item.fields["name"] else { continue }
nodes.append(OverviewNode(
id: nodeID(.service, "pool:" + name), category: .service, kind: "pool",
title: name, subtitle: item.fields["ranges"],
detail: sortedDetail(item.fields),
editTarget: .init(menuPath: "/ip pool", restPath: "ip/pool", itemID: item.id)
))
}
// DHCP servers: interface -> server -> pool it hands out addresses from.
for item in dhcpServers {
guard let name = item.fields["name"] else { continue }
let id = nodeID(.service, "dhcp:" + name)
nodes.append(OverviewNode(
id: id, category: .service, kind: "dhcp-server",
title: name, subtitle: "DHCP-Server",
detail: sortedDetail(item.fields),
editTarget: .init(menuPath: "/ip dhcp-server", restPath: "ip/dhcp-server", itemID: item.id)
))
if let iface = item.fields["interface"] {
edges.append(OverviewEdge(from: nodeID(.interface, iface), to: id, label: nil, kind: .dhcp))
}
if let pool = item.fields["address-pool"], pool != "static-only" {
edges.append(OverviewEdge(from: id, to: nodeID(.service, "pool:" + pool), label: "nutzt Pool", kind: .dhcp))
}
}
// DHCP networks (gateway/DNS options): linked to the IP address whose subnet matches,
// using RouterOS' own "network" field on /ip address — not recomputed CIDR math.
for item in dhcpNetworks {
guard let address = item.fields["address"] else { continue }
let id = nodeID(.service, "dhcpnet:" + address)
nodes.append(OverviewNode(
id: id, category: .service, kind: "dhcp-network",
title: address, subtitle: item.fields["gateway"].map { "Gateway \($0)" },
detail: sortedDetail(item.fields),
editTarget: .init(menuPath: "/ip dhcp-server network", restPath: "ip/dhcp-server/network", itemID: item.id)
))
let networkPart = address.split(separator: "/").first.map(String.init) ?? address
if let addressID = networkToAddressID[networkPart] {
edges.append(OverviewEdge(from: addressID, to: id, label: "Optionen", kind: .dhcp))
}
}
// DHCP client (WAN side): interface -> "bezieht Adresse per DHCP".
for item in dhcpClients {
guard let iface = item.fields["interface"] else { continue }
let id = nodeID(.service, "dhcpclient:" + iface)
nodes.append(OverviewNode(
id: id, category: .service, kind: "dhcp-client",
title: "DHCP-Client", subtitle: iface,
detail: sortedDetail(item.fields),
editTarget: .init(menuPath: "/ip dhcp-client", restPath: "ip/dhcp-client", itemID: item.id)
))
edges.append(OverviewEdge(from: nodeID(.interface, iface), to: id, label: nil, kind: .dhcp))
}
// Static routes: linked to their gateway interface when the gateway *is* an interface
// name (RouterOS allows either an IP or an interface name here); otherwise shown
// unconnected rather than guessed.
for (index, item) in routes.enumerated() {
guard let dst = item.fields["dst-address"] else { continue }
let gateway = item.fields["gateway"] ?? "?"
let id = "route:\(index)"
// Dynamic/connected routes (auto-created by an IP address on an interface) always
// report distance=0 — no static route can ever have that (RouterOS enforces 1-255 on
// `set`). RouterOS' own "dynamic" flag isn't reliably present in `print terse` output
// (confirmed live for DHCP leases too, see `LanDevice.swift`), so distance=0 is the
// one dependable signal available here. Editing such a route failed live two ways:
// resending its own distance=0 was rejected outright, and its `.id` isn't stable
// (RouterOS can recreate/renumber a dynamic route at any time), so even a
// comment-only edit could hit "no such item" if the id had since changed underneath.
let isDynamic = item.fields["distance"] == "0"
nodes.append(OverviewNode(
id: id, category: .route, kind: "route",
title: dst, subtitle: "über \(gateway)",
detail: sortedDetail(item.fields),
editTarget: isDynamic ? nil : .init(menuPath: "/ip route", restPath: "ip/route", itemID: item.id)
))
if knownInterfaceNames.contains(gateway) {
edges.append(OverviewEdge(from: nodeID(.interface, gateway), to: id, label: nil, kind: .route))
}
}
// Address lists: one node per distinct list name, entries folded into its detail.
var addressListEntries: [String: [String]] = [:]
for item in addressLists {
guard let list = item.fields["list"] else { continue }
addressListEntries[list, default: []].append(item.fields["address"] ?? "?")
}
for (list, addressesInList) in addressListEntries.sorted(by: { $0.key < $1.key }) {
nodes.append(OverviewNode(
id: nodeID(.firewall, "addrlist:" + list), category: .firewall, kind: "address-list",
title: list, subtitle: "\(addressesInList.count) Einträge",
detail: addressesInList.enumerated().map { (key: "Adresse \($0.offset + 1)", value: $0.element) }
))
}
// Firewall filter + NAT rules: connected to their in/out interface and any address-list
// they reference — the two things that make a rule's *placement* in the topology visible.
appendFirewallRules(filterRules, kind: "filter", label: "Filter", menuPath: "/ip firewall filter", restPath: "ip/firewall/filter", into: &nodes, edges: &edges)
appendFirewallRules(natRules, kind: "nat", label: "NAT", menuPath: "/ip firewall nat", restPath: "ip/firewall/nat", into: &nodes, edges: &edges)
return OverviewGraph(nodes: nodes, edges: edges)
}
private nonisolated static func appendFirewallRules(
_ rules: [RouterOSMenuItem], kind: String, label: String, menuPath: String, restPath: String,
into nodes: inout [OverviewNode], edges: inout [OverviewEdge]
) {
for (index, item) in rules.enumerated() {
let chain = item.fields["chain"] ?? "?"
let action = item.fields["action"] ?? "?"
let id = "\(kind):\(index)"
nodes.append(OverviewNode(
id: id, category: .firewall, kind: kind,
title: "\(label): \(action)", subtitle: item.fields["comment"] ?? chain,
detail: sortedDetail(item.fields),
editTarget: .init(menuPath: menuPath, restPath: restPath, itemID: item.id)
))
if let inIface = item.fields["in-interface"], !inIface.isEmpty {
edges.append(OverviewEdge(from: nodeID(.interface, inIface), to: id, label: nil, kind: .firewallInterface))
}
if let outIface = item.fields["out-interface"], !outIface.isEmpty {
edges.append(OverviewEdge(from: id, to: nodeID(.interface, outIface), label: nil, kind: .firewallInterface))
}
if let srcList = item.fields["src-address-list"], !srcList.isEmpty {
edges.append(OverviewEdge(from: nodeID(.firewall, "addrlist:" + srcList), to: id, label: nil, kind: .addressList))
}
if let dstList = item.fields["dst-address-list"], !dstList.isEmpty {
edges.append(OverviewEdge(from: nodeID(.firewall, "addrlist:" + dstList), to: id, label: nil, kind: .addressList))
}
}
}
private nonisolated static func nodeID(_ category: OverviewNode.Category, _ key: String) -> String {
"\(category.rawValue):\(key)"
}
private nonisolated static func sortedDetail(_ fields: [String: String]) -> [(key: String, value: String)] {
fields.sorted { $0.key < $1.key }.map { (key: $0.key, value: $0.value) }
}
}