forked from kay/RouterOS
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
118 lines
6.3 KiB
Swift
118 lines
6.3 KiB
Swift
import Foundation
|
|
|
|
/// Best-effort parser for RouterOS CLI output over SSH.
|
|
/// Not verified against a live device yet — check against real router output during the M2 dry-run pass.
|
|
enum RouterOSCliParser {
|
|
/// Parses `/system resource print` output (colon-separated "key: value" lines).
|
|
static func parseDeviceInfo(_ raw: String) -> RouterDeviceInfo {
|
|
var fields: [String: String] = [:]
|
|
for line in raw.split(whereSeparator: \.isNewline) {
|
|
guard let colonIndex = line.firstIndex(of: ":") else { continue }
|
|
let key = String(line[line.startIndex..<colonIndex]).trimmingCharacters(in: .whitespaces)
|
|
let value = String(line[line.index(after: colonIndex)...]).trimmingCharacters(in: .whitespaces)
|
|
fields[key] = value
|
|
}
|
|
return RouterDeviceInfo(
|
|
boardName: fields["board-name"] ?? "unbekannt",
|
|
routerOSVersion: fields["version"] ?? "unbekannt",
|
|
architecture: fields["architecture-name"] ?? "unbekannt",
|
|
uptime: fields["uptime"] ?? "-"
|
|
)
|
|
}
|
|
|
|
/// Parses `/interface print terse` output (one line per interface, `key=value` pairs).
|
|
///
|
|
/// Splits on any newline-like character, not just "\n" — a real hEX device's SSH output
|
|
/// turned out not to split on bare "\n", collapsing every interface into a single "line"
|
|
/// 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 { 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: 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`.
|
|
/// A line without `.id` falls back to a synthesized "row-<index>" rather than being dropped,
|
|
/// so a still-listable-but-not-editable item is visible instead of silently missing.
|
|
static func parseGenericItems(_ raw: String) -> [RouterOSMenuItem] {
|
|
raw.split(whereSeparator: \.isNewline).enumerated().compactMap { index, line in
|
|
var fields = keyValues(from: String(line))
|
|
guard !fields.isEmpty else { return nil }
|
|
let id = fields.removeValue(forKey: ".id") ?? "row-\(index)"
|
|
return RouterOSMenuItem(id: id, fields: fields)
|
|
}
|
|
}
|
|
|
|
/// Parses `<singleton menu> print` output (colon-separated "key: value" lines, same shape as
|
|
/// `/system resource print`) into one generic item with the fixed id "singleton" — used for
|
|
/// menus that hold exactly one settable item rather than a list (confirmed live: these
|
|
/// reject "print terse" with "bad parameter terse").
|
|
static func parseSingletonItem(_ raw: String) -> RouterOSMenuItem {
|
|
var fields: [String: String] = [:]
|
|
for line in raw.split(whereSeparator: \.isNewline) {
|
|
guard let colonIndex = line.firstIndex(of: ":") else { continue }
|
|
let key = String(line[line.startIndex..<colonIndex]).trimmingCharacters(in: .whitespaces)
|
|
let value = String(line[line.index(after: colonIndex)...]).trimmingCharacters(in: .whitespaces)
|
|
guard !key.isEmpty else { continue }
|
|
fields[key] = value
|
|
}
|
|
return RouterOSMenuItem(id: "singleton", fields: fields)
|
|
}
|
|
|
|
/// RouterOS CLI output mixes "true"/"false" and "yes"/"no" for booleans depending on field/version.
|
|
private static func isTrue(_ value: String?) -> Bool {
|
|
value == "true" || value == "yes"
|
|
}
|
|
|
|
private static func keyValues(from text: String) -> [String: String] {
|
|
var result: [String: String] = [:]
|
|
let pattern = #"([.a-zA-Z0-9-]+)=("[^"]*"|\S+)"#
|
|
guard let regex = try? NSRegularExpression(pattern: pattern) else { return result }
|
|
let nsText = text as NSString
|
|
let matches = regex.matches(in: text, range: NSRange(location: 0, length: nsText.length))
|
|
for match in matches {
|
|
let key = nsText.substring(with: match.range(at: 1))
|
|
var value = nsText.substring(with: match.range(at: 2))
|
|
if value.hasPrefix("\""), value.hasSuffix("\""), value.count >= 2 {
|
|
value = String(value.dropFirst().dropLast())
|
|
}
|
|
result[key] = value
|
|
}
|
|
return result
|
|
}
|
|
}
|