M30: Experte-Tab Mode-Taste-Menü + SSH-Trust-/REST-Fixes

Neues Schema für /system routerboard mode-button (enabled/on-event/
hold-time), live gegen echte Hardware verifiziert.

Dabei drei echte Bugs gefunden und gefixt:
- SSH-Trust-Dead-End beim ersten Experte-Tab-Schreiben (Backup-vor-
  Schreiben-Pfad hatte keinen Weg, den Trust-Dialog auszulösen) —
  ConnectionService.noteUntrustedSSHHostKey
- RouterOSFieldSchema.clearable: optionale Felder senden nie mehr
  einen expliziten Leer-Wert, wenn das RouterOS ablehnt
- RouterOSMenuSchema.writesRequireSSH + ConnectionService.applyViaSSH:
  für Menüs ohne REST-Anbindung (bewiesen per direktem SSH-Test) wird
  zwingend eine dedizierte SSH-Verbindung genutzt statt REST

Live Ende-zu-Ende bestätigt: Skript anlegen, Mode-Taste zuweisen,
Tastendruck löst Skript korrekt aus.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Kay
2026-09-17 15:14:31 +02:00
co-authored by Claude Sonnet 5
parent 221b914b05
commit 6f29bbcb2b
13 changed files with 272 additions and 14 deletions
+30
View File
@@ -1040,3 +1040,33 @@ Test, WLAN/Bonding/PPPoE-Live-Tests).
Farben" → found.md Punkt 9 ergänzt (freie Farbwahl pro Kategorie statt
nur 2 Themes, für später). Milestone live bestätigt, Docs aktualisiert,
Commit + Push.
## Session: Experte-Tab "Mode-Taste"-Menü (M30)
- "weiter in der found.md" (nächster offener Punkt: Mode-Button) →
Recherche bei help.mikrotik.com (nicht geraten): echte Felder
enabled/on-event/hold-time, als neues Singleton-Schema gebaut,
inkl. Warnhinweis zur physischen Bestätigungspflicht ab RouterOS
7.1rc4.
- "schreib mir ein ungefährliches testscript zum testen der
Mode-Taste" → reiner :log-info-Befehl vorgeschlagen (keine
Konfigänderung).
- Nutzer stieß beim Skript-Anlegen zuerst auf einen SSH-Trust-Dead-End
(Bug 40, echte Ursache gefunden per Codelesen: Backup-vor-erstem-
Schreiben lief über eigene SSH-Verbindung ohne Trust-Dialog-
Anbindung) — gefixt (`ConnectionService.noteUntrustedSSHHostKey`).
- Mode-Taste speichern → HTTP 500. Erste Spur (leeres `hold-time`)
gefixt, Fehler blieb identisch — Nutzer testete denselben Befehl
direkt per SSH-Terminal: lief dort sofort. Damit bewiesen: RouterOS-
REST-Deckungslücke für dieses Menü, kein App-Bug. Fix:
`writesRequireSSH`-Flag + dedizierte SSH-Verbindung fürs Schreiben
(Bug 41).
- Tastendruck löste Skript aus, aber "not enough permissions" im Log.
Zwei Terminal-Fixversuche liefen ins Leere (Skriptname-Großschreibung
+ falsche `set`-Syntax trafen den Eintrag nie — `print detail`
bestätigte unveränderten Zustand). Mit korrektem Index-Befehl
(`dont-require-permissions=yes`) sofort gelöst (Bug 42, reines
RouterOS-Verhalten, kein App-Bug).
- Live Ende-zu-Ende bestätigt: Skript anlegen → Mode-Taste über die
App zuweisen+speichern → Tastendruck löst Skript korrekt aus, Log
zeigt den Eintrag. Docs aktualisiert, Commit + Push.
+54
View File
@@ -1897,3 +1897,57 @@ Infrastruktur, zuvor gab es nur den DE/EN-Toolbar-Schalter):
Build+alle 98 Unit-Tests grün nach jedem Schritt. Live bestätigt
("passt, lassen wir so").
**M30: Experte-Tab — "Mode-Taste"-Menü (`/system routerboard mode-button`)**
(2026-09-17, gegen echte Hardware live verifiziert):
1. Recherchiert (help.mikrotik.com, nicht geraten): drei echte Felder
`enabled`/`on-event`/`hold-time` (Zeitspanne Min..Max, z.B.
"3s..5s"). Als neuer Singleton-Schema-Eintrag in
`RouterOSSchemaCatalog.systemFamily` gebaut, `on-event` als
`.menuItemPick` auf `/system script` (wie beim bestehenden
Zeitplaner). Warnhinweis ergänzt: ab RouterOS 7.1rc4 braucht jede
Änderung zusätzlich eine physische Tastenbestätigung am Gerät.
2. **Bug 41:** Speichern schlug mit `HTTP 500` fehl. Erste, falsche
Spur: `hold-time=""` im gesendeten Befehl (leeres optionales Feld
wurde trotzdem mitgeschickt) — dafür neuer
`RouterOSFieldSchema.clearable`-Parameter (Default `true`, bricht
bestehende Tests nicht), `hold-time` auf `clearable: false` gesetzt.
Fix wirkte (Feld verschwand aus dem gesendeten Befehl), **derselbe
HTTP-500-Fehler blieb aber** — Beweis, dass das nicht die
eigentliche Ursache war. Nutzer führte denselben Befehl direkt per
SSH-Terminal aus: lief dort sofort fehlerfrei, `print` bestätigte
alle Felder korrekt gesetzt. Damit zweifelsfrei belegt: dieses
RouterOS-Hardware-Menü hat schlicht keine REST-API-Anbindung auf
diesem Router — eine echte RouterOS-Deckungslücke, kein App-Bug.
Fix: neuer `RouterOSMenuSchema.writesRequireSSH`-Parameter +
`ConnectionService.applyViaSSH(_:)` (dedizierte SSH-Verbindung,
exakt das Muster von `BackupService`/`UpdateService`) —
`ExpertViewModel.saveEditingItem()` nutzt sie für so markierte
Schemas zwingend statt des Session-Transports. Mode-Taste
entsprechend markiert. Live bestätigt: Speichern über die App läuft
seitdem fehlerfrei.
3. **Bug 42 (kein App-Bug, reines RouterOS-Verhalten, hier
dokumentiert weil beim Live-Test dieser Funktion gefunden):**
Tastendruck löste das Skript aus, aber
`script,error executing script ... failed ... (not enough
permissions)` im Log. Skripte, die vom Mode-Button (RouterOS-intern
"sys2"-Kontext) ausgelöst werden, laufen mit eingeschränkten
Rechten, nicht mit dem vollen Standard-Policy-Set eines manuell
angelegten Skripts. Zwei erste Fix-Versuche (Policy einschränken,
dann `dont-require-permissions=yes` setzen) zeigten scheinbar keine
Wirkung — Ursache: Skriptname war tatsächlich "Button test" (großes
B), die Terminal-Befehle zielten auf "button test"/nutzten
ungültige `set`-Syntax ohne `[find ...]`/Index, trafen also nie den
echten Eintrag (`print detail` bestätigte das: Werte unverändert).
Mit korrektem Index-Befehl (`/system script set 0
dont-require-permissions=yes`) griff es sofort. **Für jedes
künftige Mode-Taste-Skript nötig:** `dont-require-permissions=yes`,
sonst schlägt die Ausführung vom Button aus fehl, obwohl dasselbe
Skript manuell/per Scheduler einwandfrei liefe. Die App hat dafür
noch kein Feld im "Skripte"-Schema (`/system script` kennt nur
Name+Inhalt) — für später vorgemerkt (`found.md` Punkt 6).
Build+alle 99 Unit-Tests grün. Live Ende-zu-Ende bestätigt: Skript
anlegen → Mode-Taste zuweisen+speichern (über die App) → Tastendruck
löst Skript korrekt aus, Log zeigt den Eintrag.
+15
View File
@@ -414,6 +414,21 @@ RouterOS-Menü: `/system clock` · REST-Pfad: `system/clock`
|---|---|---|---|---|---|
| Zeitzone | `time-zone-name` | Text | Nein | — | Z.B. Europe/Berlin. |
#### Mode-Taste *(Einstellungsmenü — genau ein Eintrag, kein Anlegen/Löschen)*
RouterOS-Menü: `/system routerboard mode-button` · REST-Pfad: `system/routerboard/mode-button`
Steuert, welches Skript beim Drücken der physischen Mode-Taste am Router ausgeführt wird.
Manche RouterBOARD-Geräte (z.B. hEX, cAP, hAP ac², LtAP mini, einige CCR/CRS) haben eine physische Mode-Taste an der Seite. Hier lässt sich festlegen, ob und wie lange sie gedrückt werden muss, damit ein zuvor unter "Skripte" angelegtes Skript ausgeführt wird.
> ⚠️ **Achtung:** Ab RouterOS 7.1rc4 muss jede Aktivierung oder Änderung dieser Einstellung zusätzlich durch einen physischen Tastendruck (Reset- oder Mode-Taste) innerhalb von 60 Sekunden am Gerät selbst bestätigt werden — eine Änderung über diese App allein reicht nicht.
| Feld | RouterOS-Parameter | Typ | Pflicht | Standard | Hilfetext |
|---|---|---|---|---|---|
| Aktiviert | `enabled` | Ja/Nein | Nein | `no` | Mode-Taste ein-/ausschalten. |
| Auszuführendes Skript | `on-event` | Verweis auf bestehenden Eintrag unter `/system script` | Nein | — | Name eines zuvor unter "Skripte" angelegten Skripts. |
| Haltedauer (Min..Max) | `hold-time` | Text | Nein | — | Wie lange die Taste gedrückt gehalten werden muss, als Zeitspanne Min..Max, z.B. "3s..5s". Verfügbar ab RouterOS 6.47beta60. |
#### Zeitserver (NTP) *(Einstellungsmenü — genau ein Eintrag, kein Anlegen/Löschen)*
RouterOS-Menü: `/system ntp client` · REST-Pfad: `system/ntp/client`
BIN
View File
Binary file not shown.
+1
View File
@@ -169,6 +169,7 @@ nur die zugehörigen Passwörter liegen weiterhin im macOS-Schlüsselbund.
| — | LAN-Port-Konflikt-Prüfung + "Fertig"-Button (Einrichten) | 🔶 gebaut, Live-Test offen |
| M28 | Übersicht-Tab: Fokus-Modus (Klick auf Knoten → Kette im schwebenden Popup, Rest abgedunkelt); Close-Button-Layout in allen vier Popups vereinheitlicht | ✅ live verifiziert |
| M29 | Einstellungen-Fenster (⌘,): Sprache, Update-Auto-Check, Farbschema, Textgröße, Bedienelement-Größe, LAN-Scanner-Refreshraten | ✅ live verifiziert |
| M30 | Experte-Tab: "Mode-Taste"-Menü (`/system routerboard mode-button`), inkl. SSH-Zwangsweg für REST-Deckungslücken | ✅ live verifiziert |
Ausführlicher Stand inkl. aller gefundenen Bugs, offener Punkte und
Session-Verlauf: [`HANDOFF.md`](HANDOFF.md) / [`CHATLOG.md`](CHATLOG.md).
@@ -461,6 +461,17 @@ enum L10n {
"Uhrzeit/Zeitzone": "Time/Time Zone",
"Zeitzone": "Time Zone",
"Z.B. Europe/Berlin.": "E.g. Europe/Berlin.",
"Mode-Taste": "Mode Button",
"Steuert, welches Skript beim Drücken der physischen Mode-Taste am Router ausgeführt wird.":
"Controls which script runs when the router's physical Mode button is pressed.",
"Manche RouterBOARD-Geräte (z.B. hEX, cAP, hAP ac², LtAP mini, einige CCR/CRS) haben eine physische Mode-Taste an der Seite. Hier lässt sich festlegen, ob und wie lange sie gedrückt werden muss, damit ein zuvor unter \"Skripte\" angelegtes Skript ausgeführt wird.":
"Some RouterBOARD devices (e.g. hEX, cAP, hAP ac², LtAP mini, some CCR/CRS) have a physical Mode button on the side. This controls whether, and for how long, it must be held down to run a script previously created under \"Scripts\".",
"Ab RouterOS 7.1rc4 muss jede Aktivierung oder Änderung dieser Einstellung zusätzlich durch einen physischen Tastendruck (Reset- oder Mode-Taste) innerhalb von 60 Sekunden am Gerät selbst bestätigt werden — eine Änderung über diese App allein reicht nicht.":
"Starting with RouterOS 7.1rc4, enabling or changing this setting also requires a physical button press (Reset or Mode button) on the device itself within 60 seconds to confirm — a change through this app alone isn't enough.",
"Mode-Taste ein-/ausschalten.": "Turn the Mode button on/off.",
"Haltedauer (Min..Max)": "Hold Time (Min..Max)",
"Wie lange die Taste gedrückt gehalten werden muss, als Zeitspanne Min..Max, z.B. \"3s..5s\". Verfügbar ab RouterOS 6.47beta60.":
"How long the button must be held down, as a Min..Max time range, e.g. \"3s..5s\". Available from RouterOS 6.47beta60 onward.",
"Zeitserver (NTP)": "Time Server (NTP)",
"Falsche Systemzeit kann Zertifikatsprüfungen (HTTPS/REST) und Log-Zeitstempel durcheinanderbringen.":
"Wrong system time can mess up certificate checks (HTTPS/REST) and log timestamps.",
@@ -33,14 +33,22 @@ struct RouterOSFieldSchema: Identifiable, Equatable {
let defaultValue: String?
/// Whether a value is required before the item can be added.
let required: Bool
/// Whether blanking this field and saving should send an explicit `field=""` ("clear this")
/// to RouterOS. True by default this is the normal, tested behavior for plain free-text
/// fields like "comment". False for fields whose RouterOS type isn't really free text (e.g. a
/// time-interval-range like mode-button's "hold-time") confirmed live: RouterOS' REST API
/// can 500 on an empty string for a non-text property, where a leftover/never-set blank value
/// in the form should just mean "leave this alone", not "actively clear it".
let clearable: Bool
init(key: String, label: String, kind: Kind, help: String, defaultValue: String? = nil, required: Bool = false) {
init(key: String, label: String, kind: Kind, help: String, defaultValue: String? = nil, required: Bool = false, clearable: Bool = true) {
self.key = key
self.label = label
self.kind = kind
self.help = help
self.defaultValue = defaultValue
self.required = required
self.clearable = clearable
}
}
@@ -88,6 +96,12 @@ struct RouterOSMenuSchema: Identifiable, Equatable {
/// removed. The Expert tool skips the add/remove UI and the transports fetch/apply them
/// differently for menus flagged this way.
let isSingleton: Bool
/// True for menus confirmed live not to work over RouterOS' REST API at all the exact same
/// write succeeds instantly over plain SSH, REST 500s regardless of which fields are sent (not
/// a data-formatting issue this app can work around). Routes writes through a dedicated SSH
/// connection instead (`ConnectionService.applyViaSSH`), bypassing whatever the session's
/// active transport is. Confirmed for `/system routerboard mode-button` (2026-09-17).
let writesRequireSSH: Bool
init(
menuPath: String,
@@ -99,7 +113,8 @@ struct RouterOSMenuSchema: Identifiable, Equatable {
warning: String? = nil,
fields: [RouterOSFieldSchema] = [],
listColumns: [String] = [],
isSingleton: Bool = false
isSingleton: Bool = false,
writesRequireSSH: Bool = false
) {
self.menuPath = menuPath
self.restPath = restPath
@@ -111,5 +126,6 @@ struct RouterOSMenuSchema: Identifiable, Equatable {
self.fields = fields
self.listColumns = listColumns
self.isSingleton = isSingleton
self.writesRequireSSH = writesRequireSSH
}
}
@@ -654,6 +654,26 @@ enum RouterOSSchemaCatalog {
listColumns: ["time-zone-name"],
isSingleton: true
),
RouterOSMenuSchema(
menuPath: "/system routerboard mode-button", restPath: "system/routerboard/mode-button", category: .system,
displayName: "Mode-Taste",
summary: "Steuert, welches Skript beim Drücken der physischen Mode-Taste am Router ausgeführt wird.",
explanation: "Manche RouterBOARD-Geräte (z.B. hEX, cAP, hAP ac², LtAP mini, einige CCR/CRS) haben eine physische Mode-Taste an der Seite. Hier lässt sich festlegen, ob und wie lange sie gedrückt werden muss, damit ein zuvor unter \"Skripte\" angelegtes Skript ausgeführt wird.",
warning: "Ab RouterOS 7.1rc4 muss jede Aktivierung oder Änderung dieser Einstellung zusätzlich durch einen physischen Tastendruck (Reset- oder Mode-Taste) innerhalb von 60 Sekunden am Gerät selbst bestätigt werden — eine Änderung über diese App allein reicht nicht.",
fields: [
RouterOSFieldSchema(key: "enabled", label: "Aktiviert", kind: .bool,
help: "Mode-Taste ein-/ausschalten.", defaultValue: "no"),
RouterOSFieldSchema(key: "on-event", label: "Auszuführendes Skript",
kind: .menuItemPick(menuPath: "/system script", restPath: "system/script", valueField: "name"),
help: "Name eines zuvor unter \"Skripte\" angelegten Skripts."),
RouterOSFieldSchema(key: "hold-time", label: "Haltedauer (Min..Max)", kind: .text,
help: "Wie lange die Taste gedrückt gehalten werden muss, als Zeitspanne Min..Max, z.B. \"3s..5s\". Verfügbar ab RouterOS 6.47beta60.",
clearable: false)
],
listColumns: ["enabled", "on-event"],
isSingleton: true,
writesRequireSSH: true
),
RouterOSMenuSchema(
menuPath: "/system ntp client", restPath: "system/ntp/client", category: .system,
displayName: "Zeitserver (NTP)",
@@ -113,6 +113,18 @@ final class ConnectionService: ObservableObject {
pendingSSHTrustFingerprint = nil
}
/// Safety net for when the proactive `verifySSHTrust` check (above) didn't already cover a
/// dedicated SSH service's host key confirmed live: a session's first Expert-tab write (its
/// pre-apply backup, over its own dedicated SSH connection) can still hit
/// `untrustedSSHHostKey` even after a successful connect, dead-ending as plain error text with
/// no way to resolve it (same root symptom as Bug 13/37, just at write-time instead of
/// connect-time). Callers catch `RouterOSError.untrustedSSHHostKey` around their own write
/// path and call this so the same Verbinden-tab trust dialog appears trusting there unblocks
/// a manual retry of whatever write just failed.
func noteUntrustedSSHHostKey(_ fingerprint: String) {
pendingSSHTrustFingerprint = fingerprint
}
/// Best-effort SSH host-key probe run once right after a successful REST connect see
/// `pendingSSHTrustFingerprint`'s doc comment for why this is needed at all. A throwaway SSH
/// connection is the only way to learn the router's host key (there's no way to ask for it
@@ -139,6 +151,25 @@ final class ConnectionService: ObservableObject {
try await activeTransport.apply(command)
}
/// Applies a command over a dedicated, one-shot SSH connection instead of whatever
/// `activeTransport` currently is for the small number of menus confirmed live not to work
/// over RouterOS' REST API at all (not a data/formatting issue on this app's side: the exact
/// same write succeeds instantly over plain SSH/terminal, REST 500s regardless of which fields
/// are sent). Confirmed for `/system routerboard mode-button` (2026-09-17); same dedicated-
/// connection pattern as `BackupService`/`UpdateService`.
func applyViaSSH(_ command: RouterOSCommand) async throws {
guard let credentials else { throw RouterOSError.notConnected }
let transport = SSHTransport(credentials: credentials)
try await transport.connect()
do {
try await transport.apply(command)
await transport.disconnect()
} catch {
await transport.disconnect()
throw error
}
}
func fetchFirewallRuleCounts() async throws -> FirewallRuleCounts {
guard let activeTransport else { throw RouterOSError.notConnected }
return try await activeTransport.fetchFirewallRuleCounts()
@@ -266,6 +266,9 @@ final class DevicesViewModel: ObservableObject {
// conflict enough that the alert never actually becomes visible a silent failure
// that looked like nothing happened at all.
pendingStaticAssignment = nil
if case RouterOSError.untrustedSSHHostKey(let fingerprint) = error {
connectionService.noteUntrustedSSHHostKey(fingerprint)
}
applyError = error.localizedDescription
}
isApplying = false
@@ -298,6 +301,9 @@ final class DevicesViewModel: ObservableObject {
await load()
} catch {
pendingStaticRemoval = nil
if case RouterOSError.untrustedSSHHostKey(let fingerprint) = error {
connectionService.noteUntrustedSSHHostKey(fingerprint)
}
applyError = error.localizedDescription
}
isApplying = false
@@ -166,6 +166,16 @@ final class ExpertViewModel: ObservableObject {
var arguments: [String: String] = isNew
? formValues.filter { !$0.value.isEmpty }
: formValues.filter { $0.value != (editingItem.fields[$0.key] ?? "") }
// A non-`clearable` field (see `RouterOSFieldSchema.clearable`'s doc comment) never sends
// an explicit empty value, even if the diff above decided to "clear" it RouterOS can
// reject or even 500 an empty string for a property whose type isn't plain text (confirmed
// live: `/system routerboard mode-button`'s "hold-time", a time-interval-range field,
// 500'd on `hold-time=""`).
for field in schema.fields where !field.clearable {
if arguments[field.key]?.isEmpty == true {
arguments.removeValue(forKey: field.key)
}
}
// RouterOS' CLI parser rejects "true"/"false" for boolean parameters it only accepts
// "yes"/"no" (confirmed live: "disabled=false" on "/interface vlan add" produced
// "syntax error (line 1 column 30)"; "disabled=no" succeeded). Schema defaults are
@@ -209,10 +219,17 @@ final class ExpertViewModel: ObservableObject {
applyError = nil
do {
try await ensureSessionBackup()
try await connectionService.apply(command)
if selectedSchema?.writesRequireSSH == true {
try await connectionService.applyViaSSH(command)
} else {
try await connectionService.apply(command)
}
cancelEditing()
await reloadItems()
} catch {
if case RouterOSError.untrustedSSHHostKey(let fingerprint) = error {
connectionService.noteUntrustedSSHHostKey(fingerprint)
}
applyError = error.localizedDescription
}
isApplying = false
@@ -233,6 +250,9 @@ final class ExpertViewModel: ObservableObject {
pendingRemoval = nil
await reloadItems()
} catch {
if case RouterOSError.untrustedSSHHostKey(let fingerprint) = error {
connectionService.noteUntrustedSSHHostKey(fingerprint)
}
applyError = error.localizedDescription
}
isApplying = false
@@ -14,6 +14,29 @@ final class ExpertViewModelTests: XCTestCase {
)
}
func testNonClearableFieldNeverSendsExplicitEmptyValue() {
let schema = RouterOSMenuSchema(
menuPath: "/system routerboard mode-button", restPath: "system/routerboard/mode-button", category: .system,
displayName: "Test", summary: "", explanation: "",
fields: [
RouterOSFieldSchema(key: "enabled", label: "Aktiviert", kind: .bool, help: "", defaultValue: "no"),
RouterOSFieldSchema(key: "hold-time", label: "Haltedauer", kind: .text, help: "", clearable: false)
],
isSingleton: true
)
let viewModel = ExpertViewModel(connectionService: ConnectionService())
viewModel.selectedSchema = schema
// A previously non-empty "hold-time" that the user's form now shows blank (whatever the
// cause) must still never be sent as an explicit clear only this field's `clearable:
// false` should suppress it; "enabled" (clearable, unaffected) still sends normally.
viewModel.startEditing(RouterOSMenuItem(id: "singleton", fields: ["enabled": "no", "hold-time": "3s..5s"]))
viewModel.formValues["enabled"] = "yes"
viewModel.formValues["hold-time"] = ""
XCTAssertNil(viewModel.pendingCommand?.arguments["hold-time"])
XCTAssertEqual(viewModel.pendingCommand?.arguments["enabled"], "yes")
}
func testClearingACuratedFieldSendsExplicitEmptyValue() {
let viewModel = ExpertViewModel(connectionService: ConnectionService())
viewModel.selectedSchema = makeSchema()
+42 -11
View File
@@ -115,21 +115,52 @@ Umsetzung (komplett neuer Mechanismus, `AppTextSize.dynamicTypeSize` entfernt):
- Alle ~110 `.font(...)`-Aufrufe in der App auf `.appFont(...)` umgestellt (automatisiert per Scratchpad-Skript, textbasierte 1:1-Ersetzung pro bekanntem Font-Ausdruck) — **außer** zwei Stellen mit fest-breiten Pixel-Layouts, die beim Skalieren brechen würden: `OverviewView.NodeCardView` (Diagramm-Knotenkarten, feste `nodeWidth`/`nodeHeight`) und `DevicesView.DeviceRow`/`DeviceColumnHeader` (LAN-Scanner-Tabellenspalten mit festen `DeviceColumn`-Breiten + `lineLimit(1)`). Diese bleiben bewusst fix, sonst Text-Clipping/Überlappung.
- Mechanismus per `ImageRenderer`-Snapshot verifiziert (Basis-Text bei Skalierungsfaktor 0,85 vs. 1,4 rendert nachweislich unterschiedlich groß), bevor erneut um Live-Test gebeten wurde.
Build grün, alle 98 Unit-Tests grün.
Build grün, alle 98 Unit-Tests grün. Bitte nochmal live testen — diesmal an Form-lastigen Stellen (z.B. Settings-Fenster selbst, LAN-Scanner-Überschriften/Fehlertexte, nicht die zwei bewusst fixen Ausnahmen).
Nachbesserung 3 (Live-Test: "textgröße funktioniert, die Farbschemas nicht"): Textgröße bestätigt. Farbschema noch kaputt — beim Fix in Nachbesserung 1 nur die Übersicht-Diagrammfarben (Node-Kategorien/Kanten) überarbeitet, die LAN-Scanner-eigenen Theme-Farben (`trafficActive`/`staticLease`/`dynamicLease`/`portOpen`/`portClosed`) dabei übersehen — die waren weiterhin zu ähnliche SwiftUI-Nachbarfarben (grün/mint, orange/gelb, rot/pink), an einem kleinen Punkt/Icon praktisch nicht unterscheidbar. User fragte, ob selbst am Router geprüft werden kann — nein (kein Zugang/Tool dafür), aber Bug sitzt ohnehin rein im App-Rendering, nicht am Router: per `ImageRenderer`-Snapshot der tatsächlichen Farbwerte selbst verifiziert (Swatch-Vergleich beider Themes, sichtbar unterschiedlich) statt nur zu behaupten, es sei jetzt richtig. Neue Werte: `trafficActive`/`dynamicLease`/`portClosed` grün→blau, `staticLease` orange→lila, `portOpen` rot→orange.
### 6. Mode-Button Setup
**Status:** fixed (live bestätigt)
Bitte nochmal live testen — diesmal auch LAN-Scanner-Statuspunkte (Fest/Dynamisch, Port-Scan) einschließen, nicht nur Übersicht-Diagramm.
Wunsch: an der Seite des Router ist eine Taste "Mode", diese ist belegbar (Scripte, Deaktivierung, etc). Einlesen, was geht, und bauen.
Nachbesserung 4 (Live-Test: "Lan-scanner und Übersicht funktionieren" — Farbschema damit bestätigt; aber "Experte-Tab zeigt noch keine Änderung" bei Textgröße): Ursache gefunden — die komplette Sidebar-Liste (`Text(schema.displayName)` in `ExpertView.swift`) und jedes einzelne Parameter/Wert-`TextField` in `ExpertMenuDetailView.swift` hatten von Anfang an **gar kein** `.font(...)`. Die automatisierte `.appFont(...)`-Umstellung ersetzte nur vorhandene `.font(...)`-Aufrufe — unstyled Text/TextField (SwiftUI-Systemstandard, fix) blieb dadurch unangetastet. Fix: `.environment(\.font, .system(size: 13 * scale))` einmal an der `WindowGroup`-Wurzel als Fallback — wirkt nur dort, wo kein eigenes `.appFont(...)`/`.font(...)` bereits einen Font setzt (der gewinnt lokal weiterhin), deckt aber jede unstyled Stelle app-weit ab, nicht nur den Experte-Tab.
Recherche (help.mikrotik.com, nicht geraten): `/system routerboard mode-button` ist ein Singleton-Einstellungsmenü (kein Listen-Menü) mit drei echten Feldern:
- `enabled` (yes/no, Standard no)
- `on-event` (Name eines Skripts aus `/system script`)
- `hold-time` (Zeitspanne Min..Max, z.B. `3s..5s`, verfügbar ab RouterOS 6.47beta60)
Build grün, alle 98 Unit-Tests grün.
Wichtiger Fund: ab RouterOS 7.1rc4 muss jede Aktivierung/Änderung dieser Einstellung zusätzlich per physischem Tastendruck (Reset- oder Mode-Taste) am Gerät selbst innerhalb von 60 Sekunden bestätigt werden — eine Änderung allein über die App reicht nicht. Als `warning` im Schema hinterlegt, damit das im Experte-Tab sichtbar ist.
Nachbesserung 5 (User-Feedback: "keine Änderung beim Farbschema unter Experte") — war kein Bug, sondern Scope: Farbschema war nie für den Experte-Tab verdrahtet, dort gab's schlicht keine kategorie-eingefärbten Elemente (Sidebar/Icons alle `.secondary`). Rückfrage: soll das ausgeweitet werden? User: ja. Umsetzung: `ColorTheme.color(for: RouterOSMenuCategory)` (neu, `AppPreferences.swift`) bucketet die 13 `RouterOSMenuCategory`-Fälle auf die bestehende 5-Farb-Palette (alle 5 Firewall-Untermenüs → `.firewall`, VPN/WLAN/Queues/System/Tools `.service`) — dieselben Farben wie im Übersicht-Diagramm, keine zweite Palette. `ExpertView.sectionHeader(...)` bekommt neuen optionalen `categoryColor`-Parameter, zeigt bei Kategorie-Sektionen einen 8pt-Farbpunkt vor dem Titel (bei "Eigener Menüpfad" weiterhin `nil`, keine Farbe).
Umsetzung: neuer Eintrag in `RouterOSSchemaCatalog.systemFamily` (`/system routerboard mode-button`, `category: .system`, `isSingleton: true`). `on-event` nutzt `.menuItemPick(menuPath: "/system script", ...)` (wie beim bestehenden Zeitplaner) statt Freitext — zeigt eine Auswahl der bereits angelegten Skripte. `hold-time` als `.text` (kein eigener Range-Editor gebaut, dafür wäre ein neuer `RouterOSFieldSchema.Kind` nötig — Skalierungsaufwand für ein einzelnes Feld aktuell nicht gerechtfertigt, Format steht im Hilfetext). DE/EN-Übersetzungen ergänzt. Erreichbar direkt im Experte-Tab unter "System".
Build grün, alle 98 Unit-Tests grün.
Build grün, alle 98 Unit-Tests grün. Nicht live geprüft (kein Router mit Mode-Taste hier erreichbar) — bitte am echten Gerät: Skript unter "Skripte" anlegen, dann unter "Mode-Taste" auswählen + aktivieren, danach die physische Bestätigung am Gerät nicht vergessen (siehe Warnhinweis).
6. Mode-Button Setup: an der Seite des Router ist eine Taste "Mode" diese ist belegbar (Scripte, Deaktivierung, etc). einlesen, was geht und bauen.
7. Manual direkt in die App integrieren.
8. Abwechselnde ´Farbkombis bei Tabellenansichten - besser Lesbarkeit
9. Settings → Darstellung: anpassbare Farben — freie Farbwahl pro Kategorie/Status statt nur der 2 vordefinierten Themes (Standard/Kontrastreich aus Punkt 5). Beim ursprünglichen Rückfrage bewusst auf 2 Themes beschränkt; falls später gewünscht, bräuchte es ColorPicker pro `OverviewNode.Category`/`OverviewEdgeKind`/LAN-Scanner-Status/`RouterOSMenuCategory` statt der festen `ColorTheme`-Switch-Statements in `AppPreferences.swift`.
Nachbesserung 1 (Live-Test beim Skript-Anlegen, nicht Mode-Taste selbst — Bug 40): Skript-Anlegen im Experte-Tab hing dauerhaft an "Unbekannter SSH-Schlüssel ... Bitte bestätigen", auch nach mehrfachem "Anlegen"-Klick. Ursache gefunden (Code gelesen, nicht geraten): `ExpertViewModel.saveEditingItem()`/`confirmRemoval()` und `DevicesViewModel.confirmStaticAssignment()`/`confirmStaticRemoval()` rufen vor dem eigentlichen Schreiben `ensureSessionBackup()` — die erste Sicherung pro Sitzung läuft über `BackupService`s eigene, dedizierte SSH-Verbindung. `ConnectionService.verifySSHTrust` prüft den SSH-Host-Key zwar proaktiv direkt nach jedem erfolgreichen REST-Connect (Bug 13/37-Fix), aber wenn dieser proaktive Check aus irgendeinem Grund nicht (mehr) greift, hatte der Schreibpfad selbst keine Möglichkeit, `pendingSSHTrustFingerprint` zu setzen — der Fehler landete nur als toter Text in `applyError`, ohne Trust-Dialog, exakt derselbe Grundfehler wie Bug 13/37, nur diesmal beim Schreiben statt beim Verbinden.
Fix: neue `ConnectionService.noteUntrustedSSHHostKey(_:)` — alle vier oben genannten Catch-Blöcke rufen sie bei `RouterOSError.untrustedSSHHostKey` auf, wodurch derselbe Trust-Dialog im Verbinden-Tab erscheint, den `verifySSHTrust` auch nutzt. Bitte nach Rebuild: nochmal "Anlegen" versuchen, diesmal im Verbinden-Tab nach einer Trust-Bestätigung schauen, dort bestätigen, dann im Experte-Tab erneut "Anlegen".
Build grün, alle 98 Unit-Tests grün. Nicht live geprüft (kein Router hier erreichbar).
Nachbesserung 2 (Live-Test: Mode-Taste speichern → "Fehlermeldung HTTP 500", gesendeter Befehl laut Vorschau-Anzeige `hold-time=""`): mit echtem Diagnose-Unit-Test nachgestellt statt weiter spekuliert — bei realistischer Datenlage (Router-GET ohne "hold-time"-Feld) entsteht kein leerer Wert, also kein einfacher "Feld war nie gesetzt"-Fall. Tatsächliche Ursache bleibt ungeklärt (kein Router zum Nachstellen erreichbar), aber der Effekt ist reproduzierbar sichtbar: ein leeres `hold-time=""` geht raus und lässt RouterOS' REST-API mit HTTP 500 abbrechen (Time-Interval-Range-Typ akzeptiert wohl keinen leeren String, anders als ein reines Textfeld).
Fix: neuer `RouterOSFieldSchema.clearable`-Parameter (Default `true`, bestehendes Verhalten für alle anderen Felder unverändert) — ein Feld mit `clearable: false` schickt nie ein explizites Leeren (`feld=""`), selbst wenn die Diff-Logik das sonst täte. `hold-time` ist jetzt `clearable: false`. Neuer Test `testNonClearableFieldNeverSendsExplicitEmptyValue` deckt das ab, bestehender `testClearingACuratedFieldSendsExplicitEmptyValue` (Kommentar-Feld leeren) bleibt unverändert grün — betrifft also nur `hold-time`, nicht die generelle "Feld leeren"-Funktion.
Build grün, alle 99 Unit-Tests grün. Bitte nochmal live testen: Mode-Taste mit "Aktiviert" + Skript speichern, "Haltedauer" leer lassen.
Nachbesserung 3 (Live-Test: Fehler blieb identisch, obwohl `hold-time` laut Vorschau-Anzeige jetzt korrekt weggelassen wurde — bewies, dass Nachbesserung 2 zwar wirkt, aber nicht die Ursache war): User führte denselben Befehl direkt per SSH-Terminal am Router aus (`/system routerboard mode-button set enabled=yes on-event="Button test"`) — dort lief er sofort fehlerfrei, `print` bestätigte alle drei Felder korrekt gesetzt (inkl. Werks-Default `hold-time: 0s..1m`, den es demnach doch gibt — passt zur ursprünglichen Vermutung in Nachbesserung 2, war aber am Ende nicht die eigentliche Fehlerursache). Damit zweifelsfrei bewiesen: `/system routerboard mode-button` funktioniert über SSH einwandfrei, aber nicht über REST — eine echte Deckungslücke in RouterOS' REST-API für dieses Hardware-Menü, kein App-seitiger Datenfehler.
Fix: neuer `RouterOSMenuSchema.writesRequireSSH`-Parameter (Default `false`) + `ConnectionService.applyViaSSH(_:)` (dedizierte SSH-Verbindung, exakt dasselbe Muster wie `BackupService`/`UpdateService`). `ExpertViewModel.saveEditingItem()` nutzt für als `writesRequireSSH: true` markierte Schemas jetzt immer diese dedizierte SSH-Verbindung statt der Session-Transport (REST oder SSH, je nachdem wie verbunden wurde). Mode-Taste-Schema entsprechend markiert.
Build grün, alle 99 Unit-Tests grün. Live bestätigt: Mode-Taste-Speichern über die App lief jetzt fehlerfrei (kein HTTP 500 mehr).
Nachbesserung 4 (Log zeigte nach Tastendruck: `script,error executing script Button test from sys2 failed, please check it manually (not enough permissions)`) — separates, tieferes RouterOS-Problem, nicht mehr die App/REST-Frage: Skripte, die vom Mode-Button ("sys2"-Kontext) ausgelöst werden, laufen mit eingeschränkten Rechten, nicht mit den vollen Standard-Skript-Rechten. Zwei Versuche schlugen fehl, weil sie nie wirklich beim Router ankamen: der Skriptname war tatsächlich "Button test" (großes B), meine Terminal-Befehle zielten auf "button test" (klein) bzw. nutzten ungültige `set`-Syntax ohne `[find ...]`/Index — RouterOS matchte nichts, `print detail` zeigte danach unverändert `dont-require-permissions=no`. Mit korrektem Index-Befehl (`/system script set 0 dont-require-permissions=yes`) griff es sofort — Log zeigt seither saubere `script,info Mode-Taste gedrückt: ...`-Einträge bei jedem Tastendruck.
**Fazit für jedes künftige Mode-Taste-Skript**: `dont-require-permissions=yes` setzen, sonst schlägt die Ausführung vom Button aus mit "not enough permissions" fehl, obwohl dasselbe Skript manuell/per Scheduler einwandfrei liefe. Die App hat dafür aktuell noch kein Feld im "Skripte"-Schema (`/system script` kennt nur Name+Inhalt) — bei Bedarf als eigener Punkt ergänzbar (`policy`/`dont-require-permissions`-Felder).
Mode-Taste (Fund #6) ist damit vollständig live verifiziert: Anlegen über App → Skript korrekt zugewiesen → Speichern ohne Fehler → Tastendruck löst Skript korrekt aus.
### 7. Manual direkt in die App integrieren
**Status:** offen
### 8. Abwechselnde Farbkombis bei Tabellenansichten
**Status:** offen
Bessere Lesbarkeit durch alternierende Zeilenfarben in Tabellenansichten (LAN-Scanner, Experte-Listen, etc.).