feat(editor): F4.5e a router's connections in the device sheet

SheetRun now clicks through Java's JConnectionsDialog with a router, a
computer and a switch cabled in the GUIContainer: a click marks a card,
the same click unmarks it, another card swaps the cables or moves the
one; "+" adds 192.168.0.10/24 up to eight cards, "-" removes the last
card with its cable down to none, and a router without a card keeps no
gateway.

The document adds, removes and swaps a router's cards (the same cables,
the same devices), Java's click logic is one function for the test and
the page, and the sheet names the address at a cable's far end. The
page "Verbindungen verwalten" shows Java's RJ45 buttons in Java's
colours with each card's far end below. Not adopted: the last card
stays. A card tab or a mark whose card is undone away falls back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 13:12:27 +00:00
co-authored by Claude Opus 5.5
parent 4e062bb347
commit 6e97e3565d
21 changed files with 2780 additions and 41 deletions
+4 -2
View File
@@ -127,6 +127,7 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand
| `src/settings.ts`, `src/sheet.ts` (Router, Heimrouter) | Java `JVermittlungsrechnerKonfiguration`, `JGatewayConfiguration`, `JWeiterleitungsTabelle`, `Weiterleitungstabelle.holeTabelle/validate/addManuellenEintrag` | erweitert (F4.5b): Adresse und Maske je Karte (die erste gespiegelt in den Einstellungen des Geräts, wie das Projekt sie hält), Standard-Gateway, RIP, die manuellen Zeilen der Weiterleitungstabelle (neue Zeile erst mit vier gültigen Zellen, Zelle, Löschen), DHCP am Heimrouter (lässt seinen DHCP-Server laufen, wie Java); das Blatt liest Karten mit dem Gerät am anderen Kabelende und die ganze Tabelle über `routeTableRows` des Kerns (gegen fünf von Java gemessene Tabellen). Abweichend von Java: Eine Kartenadresse, die das Muster, aber nicht `IP.ipCheck` besteht („.0.0.1“), wird nicht `null` (Javas Router-Panel wirft danach); eine abgelehnte Tabellenzelle wird nicht als `null` gespeichert; ein leeres Gateway ist nicht rot (Javas Router-Panel prüft es mit `musterIpAdresse`, übernimmt es aber) | | `src/settings.ts`, `src/sheet.ts` (Router, Heimrouter) | Java `JVermittlungsrechnerKonfiguration`, `JGatewayConfiguration`, `JWeiterleitungsTabelle`, `Weiterleitungstabelle.holeTabelle/validate/addManuellenEintrag` | erweitert (F4.5b): Adresse und Maske je Karte (die erste gespiegelt in den Einstellungen des Geräts, wie das Projekt sie hält), Standard-Gateway, RIP, die manuellen Zeilen der Weiterleitungstabelle (neue Zeile erst mit vier gültigen Zellen, Zelle, Löschen), DHCP am Heimrouter (lässt seinen DHCP-Server laufen, wie Java); das Blatt liest Karten mit dem Gerät am anderen Kabelende und die ganze Tabelle über `routeTableRows` des Kerns (gegen fünf von Java gemessene Tabellen). Abweichend von Java: Eine Kartenadresse, die das Muster, aber nicht `IP.ipCheck` besteht („.0.0.1“), wird nicht `null` (Javas Router-Panel wirft danach); eine abgelehnte Tabellenzelle wird nicht als `null` gespeichert; ein leeres Gateway ist nicht rot (Javas Router-Panel prüft es mit `musterIpAdresse`, übernimmt es aber) |
| `src/settings.ts`, `src/sheet.ts`, `src/fields.ts` (DHCP-Server, Portweiterleitung) | Java `JDHCPKonfiguration`, `DHCPServer.determineGatewayip/determineDnsserverip/addStaticAssignment`, `JPortForwardingDialog`, `GatewayFirmware.validateData`, `EingabenUeberpruefung.musterMacAddress` | erweitert (F4.5c): Grenzen, Gateway und DNS des DHCP-Servers (wie Javas Dialog nie leer), „Manuelle Einstellungen“ und „DHCP aktivieren“, feste Zuordnungen (MAC und Adresse, eine MAC nur einmal, ohne Rücksicht auf Groß-/Kleinschreibung), die Zeilen der Portweiterleitung (TCP oder UDP, Ports nach `musterPort`, LAN-Adresse); das Blatt zeigt, was der Server ausgibt, wie Java es bestimmt (eigene Einstellung, sonst Gateway und DNS des Geräts oder 0.0.0.0; beim Heimrouter die LAN-Adresse) und welche Zeile Javas Firmware weiterleitet (`parsePortForwardingRow` des Kerns, jetzt exportiert). Abweichend von Java: Grenzen in `IP.ipCheck`-Form statt wie getippt („.0.0.1“ nimmt Java), MAC-Adressen groß und nur aus Hexziffern (Javas Klasse `[0-9a-fA-f]` lässt „_“ durch), eine zweite Zuordnung derselben MAC wird mit Meldung abgelehnt statt still verworfen, ungültige Portweiterleitungen werden nicht gespeichert (Java speichert jede, leitet aber nur gültige weiter) | | `src/settings.ts`, `src/sheet.ts`, `src/fields.ts` (DHCP-Server, Portweiterleitung) | Java `JDHCPKonfiguration`, `DHCPServer.determineGatewayip/determineDnsserverip/addStaticAssignment`, `JPortForwardingDialog`, `GatewayFirmware.validateData`, `EingabenUeberpruefung.musterMacAddress` | erweitert (F4.5c): Grenzen, Gateway und DNS des DHCP-Servers (wie Javas Dialog nie leer), „Manuelle Einstellungen“ und „DHCP aktivieren“, feste Zuordnungen (MAC und Adresse, eine MAC nur einmal, ohne Rücksicht auf Groß-/Kleinschreibung), die Zeilen der Portweiterleitung (TCP oder UDP, Ports nach `musterPort`, LAN-Adresse); das Blatt zeigt, was der Server ausgibt, wie Java es bestimmt (eigene Einstellung, sonst Gateway und DNS des Geräts oder 0.0.0.0; beim Heimrouter die LAN-Adresse) und welche Zeile Javas Firmware weiterleitet (`parsePortForwardingRow` des Kerns, jetzt exportiert). Abweichend von Java: Grenzen in `IP.ipCheck`-Form statt wie getippt („.0.0.1“ nimmt Java), MAC-Adressen groß und nur aus Hexziffern (Javas Klasse `[0-9a-fA-f]` lässt „_“ durch), eine zweite Zuordnung derselben MAC wird mit Meldung abgelehnt statt still verworfen, ungültige Portweiterleitungen werden nicht gespeichert (Java speichert jede, leitet aber nur gültige weiter) |
| `src/settings.ts`, `src/sheet.ts`, `src/fields.ts` (Firewall) | Java `JFirewallDialog`, `GatewayFirewallConfigTable.closeEditor`, `Firewall.addRule/moveUp/moveDown/deleteRule`, `FirewallRule.validateExistingAttributes`, `EingabenUeberpruefung.isValidSubnetmask` | erweitert (F4.5d): Firewall an/aus, ICMP filtern, nur SYN verwerfen, Standardaktion, Regeln (neu wie Javas: leer, TCP, alle Ports, verwerfen; Zelle, Protokoll, Aktion, rauf/runter, löschen); Quelle leer, Adresse oder Javas Marke „999.999.999.999“, Ziel leer oder Adresse, Masken leer oder durchgehende Einsen, Port leer (alle) oder `musterPort`. Abweichend von Java: Javas Tabelle schreibt eine Zelle vor der Prüfung in die Regel, sodass die Firewall „abc“, Maske „255.0.255.0“ oder Port 0 hält, während die Tabelle den alten Wert zeigt — hier wird nichts übernommen, was die Prüfung ablehnt; ein Port, der keine Zahl ist, wird nicht still zu „alle Ports“; „.0.0.1“ und „080“ gelten nicht | | `src/settings.ts`, `src/sheet.ts`, `src/fields.ts` (Firewall) | Java `JFirewallDialog`, `GatewayFirewallConfigTable.closeEditor`, `Firewall.addRule/moveUp/moveDown/deleteRule`, `FirewallRule.validateExistingAttributes`, `EingabenUeberpruefung.isValidSubnetmask` | erweitert (F4.5d): Firewall an/aus, ICMP filtern, nur SYN verwerfen, Standardaktion, Regeln (neu wie Javas: leer, TCP, alle Ports, verwerfen; Zelle, Protokoll, Aktion, rauf/runter, löschen); Quelle leer, Adresse oder Javas Marke „999.999.999.999“, Ziel leer oder Adresse, Masken leer oder durchgehende Einsen, Port leer (alle) oder `musterPort`. Abweichend von Java: Javas Tabelle schreibt eine Zelle vor der Prüfung in die Regel, sodass die Firewall „abc“, Maske „255.0.255.0“ oder Port 0 hält, während die Tabelle den alten Wert zeigt — hier wird nichts übernommen, was die Prüfung ablehnt; ein Port, der keine Zahl ist, wird nicht still zu „alle Ports“; „.0.0.1“ und „080“ gelten nicht |
| `src/settings.ts`, `src/sheet.ts`, `src/commands.ts` (Verbindungen) | Java `JConnectionsDialog` (`swapConnection`, `removeConnection`, Knöpfe „+“/„-“), `Vermittlungsrechner.hinzuAnschluss/removeNic`, `NetzwerkInterface()`, `InternetKnotenBetriebssystem.setStandardGateway` | erweitert (F4.5e): eine Karte nach der letzten anfügen (192.168.0.10/24, eigene MAC aus der Port-Kennung, bis acht), die letzte samt Kabel entfernen, die Kabel zweier Karten tauschen oder eines umstecken (dieselben Kabel, dieselben Geräte; zwei freie Karten: nichts), Javas Klicklogik (`connectionsClick`: markieren, abwählen, tauschen); das Blatt nennt die Adresse der Gegenstelle (Rechner, Router; Switch und Modem ohne). Abweichend von Java: Java entfernt auch die letzte Karte — ein Router ohne Karte behält kein Gateway (Java legt es in die Karten, gemessen), und keine Datei könnte es halten; hier bleibt eine Karte (`ROUTER_INTERFACES.fewest`) |
| `src/view.ts` (`nodeLabel`) | Java `Knoten.holeAnzeigeName` | herausgelöst (F4.5b): die Beschriftung eines Geräts, auch für „Verbunden mit …“ | | `src/view.ts` (`nodeLabel`) | Java `Knoten.holeAnzeigeName` | herausgelöst (F4.5b): die Beschriftung eines Geräts, auch für „Verbunden mit …“ |
| `test/sheet.test.ts`, `test/sheet.cases.txt`, `test/sheet.java.json` | — | neu (F4.5a): 77 in Javas Felder getippte Texte, jede Regel dagegen samt der benannten Abweichungen; die Befehle, die Funkverbindungen, Rückgängig je Änderung, alles über eine gespeicherte `.fls` | | `test/sheet.test.ts`, `test/sheet.cases.txt`, `test/sheet.java.json` | — | neu (F4.5a): 77 in Javas Felder getippte Texte, jede Regel dagegen samt der benannten Abweichungen; die Befehle, die Funkverbindungen, Rückgängig je Änderung, alles über eine gespeicherte `.fls` |
| `test/devices.test.ts`, `test/fixtures/new-devices.fls` | — | neu (F4.4a): neue Geräte jeder Art gegen Javas gespeicherte (Zufall maskiert), Kabelregeln, ein selbst gebautes Netz pingt im Kern, Rückgängig nur der eigenen Änderungen über zwei Replikate | | `test/devices.test.ts`, `test/fixtures/new-devices.fls` | — | neu (F4.4a): neue Geräte jeder Art gegen Javas gespeicherte (Zufall maskiert), Kabelregeln, ein selbst gebautes Netz pingt im Kern, Rückgängig nur der eigenen Änderungen über zwei Replikate |
@@ -153,7 +154,7 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand
| Datei | Quelle | Art der Übertragung | | Datei | Quelle | Art der Übertragung |
| --- | --- | --- | | --- | --- | --- |
| `src/assets/hardware/*.png` | Java-Filius `src/main/resources/gfx/hardware/` (GPL) | unverändert kopiert: Javas Standardsymbole (`IconMode.DEFAULT`), damit Arbeitsblätter passen | | `src/assets/hardware/*.png` | Java-Filius `src/main/resources/gfx/hardware/` (GPL) | unverändert kopiert: Javas Standardsymbole (`IconMode.DEFAULT`), damit Arbeitsblätter passen; seit F4.5e auch `rj45.png` für die Knöpfe der Verbindungen |
| `src/canvas/viewport.ts` | Dashboard `frontend/src/apps/kanban/zoom.js` (Rechte bei uns, Plan §7.1) | kopiert und umgebaut (F4.3): Grenzen, Stufen, Einpassen, Fingerrechnung; statt Rollposition ein Ausschnitt aus Maßstab und Versatz | | `src/canvas/viewport.ts` | Dashboard `frontend/src/apps/kanban/zoom.js` (Rechte bei uns, Plan §7.1) | kopiert und umgebaut (F4.3): Grenzen, Stufen, Einpassen, Fingerrechnung; statt Rollposition ein Ausschnitt aus Maßstab und Versatz |
| `src/canvas/layout.ts` | Java `JSidebarButton.getWidth/getHeight`, `JCablePanel.updateBounds/paintComponent`, `IconMode`, `Host.holeAnzeigeName` | neu nach Java (F4.3): Knopfgröße, Symbol oben mittig, Kabel als Javas quadratische Kurve zwischen den Knopfmitten (ganzzahlig wie Java); Beschriftungsschrift nach den Maßen der Dateien ab Java 2.5 | | `src/canvas/layout.ts` | Java `JSidebarButton.getWidth/getHeight`, `JCablePanel.updateBounds/paintComponent`, `IconMode`, `Host.holeAnzeigeName` | neu nach Java (F4.3): Knopfgröße, Symbol oben mittig, Kabel als Javas quadratische Kurve zwischen den Knopfmitten (ganzzahlig wie Java); Beschriftungsschrift nach den Maßen der Dateien ab Java 2.5 |
| `src/canvas/Canvas.tsx`, `src/canvas/ZoomBar.tsx`, `src/canvas/icons.ts`, `src/Editor.tsx`, `src/main.tsx` | Plan §7.1, §7.2 | neu (F4.3): die Fläche (SVG) mit Geräten, Kabeln (Funk gestrichelt), Doku-Elementen; Verschieben (Zeiger erst bei Bewegung gefangen), Strg+Rad und Trackpad-Pinch, zwei Finger, Zoomleiste mit 44-px-Zielen; Entwicklungsseite mit Beispielen | | `src/canvas/Canvas.tsx`, `src/canvas/ZoomBar.tsx`, `src/canvas/icons.ts`, `src/Editor.tsx`, `src/main.tsx` | Plan §7.1, §7.2 | neu (F4.3): die Fläche (SVG) mit Geräten, Kabeln (Funk gestrichelt), Doku-Elementen; Verschieben (Zeiger erst bei Bewegung gefangen), Strg+Rad und Trackpad-Pinch, zwei Finger, Zoomleiste mit 44-px-Zielen; Entwicklungsseite mit Beispielen |
@@ -166,8 +167,9 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand
| `src/Editor.tsx`, `src/canvas/Canvas.tsx` (Werkzeuge) | Java `GUIEvents` (Kabel ziehen, Heimrouter-Hälften, Meldungen `guievents_msg1/12/22–26`) | neu (F4.4b): Auswählen, Gerät ziehen, Langdruck, Kabel ziehen oder zweimal antippen mit Gummiband und WAN/LAN-Hinweis, Javas Meldungen, Entf-Taste, Rückgängig/Wiederholen per Knopf und Strg+Z/Strg+Y; jede Änderung mit dem eigenen Ursprung des Editors | | `src/Editor.tsx`, `src/canvas/Canvas.tsx` (Werkzeuge) | Java `GUIEvents` (Kabel ziehen, Heimrouter-Hälften, Meldungen `guievents_msg1/12/22–26`) | neu (F4.4b): Auswählen, Gerät ziehen, Langdruck, Kabel ziehen oder zweimal antippen mit Gummiband und WAN/LAN-Hinweis, Javas Meldungen, Entf-Taste, Rückgängig/Wiederholen per Knopf und Strg+Z/Strg+Y; jede Änderung mit dem eigenen Ursprung des Editors |
| `browser/design.spec.ts`, `browser/editorPage.ts` | — | neu (F4.4b): die Werkzeuge in Chromium mit Maus und Berührung (CDP) | | `browser/design.spec.ts`, `browser/editorPage.ts` | — | neu (F4.4b): die Werkzeuge in Chromium mit Maus und Berührung (CDP) |
| `src/sheet/DeviceSheet.tsx`, `src/sheet/SheetField.tsx`, `src/sheet/sheet.css` | Java `JKonfiguration` und die Panels je Art (Reihenfolge und Wörter `jhostkonfiguration_msg1`–`16`, `jswitchkonfiguration_msg1`–`5`, `jmodemkonfiguration_msg4`) | neu (F4.5a): das Geräte-Blatt — öffnet mit dem gewählten Gerät wie Javas Panel, neben der Fläche (340 px) oder auf dem Handy darunter; ein Feld wird beim Verlassen oder mit Enter übernommen, abgelehnter Text bleibt rot stehen mit dem, was weiter gilt (Java setzt still den alten Wert zurück), Escape verwirft ihn; Adressfelder bei DHCP gesperrt; jede Änderung ein Schritt im Rückgängig. Vermittlungsrechner und Heimrouter vorerst nur mit Namen | | `src/sheet/DeviceSheet.tsx`, `src/sheet/SheetField.tsx`, `src/sheet/sheet.css` | Java `JKonfiguration` und die Panels je Art (Reihenfolge und Wörter `jhostkonfiguration_msg1`–`16`, `jswitchkonfiguration_msg1`–`5`, `jmodemkonfiguration_msg4`) | neu (F4.5a): das Geräte-Blatt — öffnet mit dem gewählten Gerät wie Javas Panel, neben der Fläche (340 px) oder auf dem Handy darunter; ein Feld wird beim Verlassen oder mit Enter übernommen, abgelehnter Text bleibt rot stehen mit dem, was weiter gilt (Java setzt still den alten Wert zurück), Escape verwirft ihn; Adressfelder bei DHCP gesperrt; jede Änderung ein Schritt im Rückgängig. Vermittlungsrechner und Heimrouter vorerst nur mit Namen |
| `browser/sheet.spec.ts` | — | neu (F4.5a): das Blatt in Chromium — Felder wie Java, Markieren und Behalten, Gerätewechsel, Rückgängig, WLAN, Handy mit Berührung, Englisch; seit F4.5b Router-Reiter, aktives Kabel, Weiterleitungstabelle, RIP, Heimrouter; seit F4.5c DHCP-Server und Portfreigaben; seit F4.5d Firewall | | `browser/sheet.spec.ts` | — | neu (F4.5a): das Blatt in Chromium — Felder wie Java, Markieren und Behalten, Gerätewechsel, Rückgängig, WLAN, Handy mit Berührung, Englisch; seit F4.5b Router-Reiter, aktives Kabel, Weiterleitungstabelle, RIP, Heimrouter; seit F4.5c DHCP-Server und Portfreigaben; seit F4.5d Firewall; seit F4.5e Verbindungen (Tauschen, Umstecken, Karten bis acht und bis eine, Markierung nach Rückgängig) |
| `src/sheet/FirewallPanel.tsx` | Java `JFirewallDialog` (`jfirewalldialog_msg21`–`43`, `jvermittlungsrechnerkonfiguration_msg4`) | neu (F4.5d): die Firewall als Seite des Blatts (bei Vermittlungsrechner und Heimrouter unter „Allgemein“): Reiter Allgemein mit den drei Schaltern und Javas Erklärungen, Reiter Firewall-Regeln mit Javas Einleitung, Standardaktion, Regeltabelle, rauf/runter (an den Enden gesperrt), Neue Regel, Regel löschen (die Nachfolgerin bleibt gewählt, wie bei Java). Javas erster Reiter heißt „Netzwerkschnittstellen“, enthält aber die allgemeinen Schalter — hier „Allgemein“; Javas „Aktion?“ ohne Fragezeichen, „über die Standard-Aktion“ groß | | `src/sheet/FirewallPanel.tsx` | Java `JFirewallDialog` (`jfirewalldialog_msg21`–`43`, `jvermittlungsrechnerkonfiguration_msg4`) | neu (F4.5d): die Firewall als Seite des Blatts (bei Vermittlungsrechner und Heimrouter unter „Allgemein“): Reiter Allgemein mit den drei Schaltern und Javas Erklärungen, Reiter Firewall-Regeln mit Javas Einleitung, Standardaktion, Regeltabelle, rauf/runter (an den Enden gesperrt), Neue Regel, Regel löschen (die Nachfolgerin bleibt gewählt, wie bei Java). Javas erster Reiter heißt „Netzwerkschnittstellen“, enthält aber die allgemeinen Schalter — hier „Allgemein“; Javas „Aktion?“ ohne Fragezeichen, „über die Standard-Aktion“ groß |
| `src/sheet/ConnectionsPanel.tsx`, `src/sheet/SheetField.tsx` (`SheetNote`), `src/theme/tokens.css` (`--connection-*`) | Java `JConnectionsDialog` (`jvermittlungsrechnerkonfiguration_msg19`, `22`–`25`) | neu (F4.5e): „Verbindungen verwalten“ als Seite des Vermittlungsrechners (unter „Allgemein“ nach „Firewall einrichten“, wie bei Java): je Karte Javas RJ45-Knopf links von „NIC n: Adresse“ in Javas Farben (grün belegt, rot frei, gelb markiert), darunter die Gegenstelle wie bei Java („A (10.0.0.2)“, ein Switch nur mit Namen); antippen markiert, dieselbe abwählen, eine andere tauscht; „+“ und „−“ mit Javas „Schnittstelle hinzufügen“ und „Maximal 8 Schnittstellen erlaubt!“; Javas Hinweis darunter wie im Dialog. Java zeichnet die Kabel zwischen zwei Spalten — im schmalen Blatt steht die Gegenstelle unter der Karte; der Reiter einer entfernten Karte fällt auf „Allgemein“ zurück |
| `src/sheet/ServicePanels.tsx`, `src/sheet/RowsEditor.tsx`, `src/sheet/SheetTabs.tsx`, `src/sheet/DeviceSheet.tsx` (Seiten) | Java `JDHCPKonfiguration` (`jdhcpkonfiguration_msg1`–`15`), `JPortForwardingDialog` (`jportforwarding_msg4`–`7`, `jgatewayconfiguration_msg19`) | neu (F4.5c): Javas Dialoge als Seiten des Blatts mit „Zurück“ — DHCP-Server (Grundeinstellungen, statische Adresszuweisung; beim Rechner, gesperrt während DHCP die Karte einrichtet, und auf dem LAN-Reiter des Heimrouters), Portfreigaben (Heimrouter); jedes Feld beim Verlassen übernommen statt mit Javas OK-Knopf. Die Zeilentabellen (Weiterleitung, Zuordnungen, Portfreigaben) teilen sich `RowsEditor`: eine neue Zeile, sobald alle Felder gültig sind, Zeile wählen, bearbeiten, löschen. Javas Tooltip zu „Manuelle Einstellungen“ als Text unter dem Kästchen (ein Finger hat kein Überfahren) | | `src/sheet/ServicePanels.tsx`, `src/sheet/RowsEditor.tsx`, `src/sheet/SheetTabs.tsx`, `src/sheet/DeviceSheet.tsx` (Seiten) | Java `JDHCPKonfiguration` (`jdhcpkonfiguration_msg1`–`15`), `JPortForwardingDialog` (`jportforwarding_msg4`–`7`, `jgatewayconfiguration_msg19`) | neu (F4.5c): Javas Dialoge als Seiten des Blatts mit „Zurück“ — DHCP-Server (Grundeinstellungen, statische Adresszuweisung; beim Rechner, gesperrt während DHCP die Karte einrichtet, und auf dem LAN-Reiter des Heimrouters), Portfreigaben (Heimrouter); jedes Feld beim Verlassen übernommen statt mit Javas OK-Knopf. Die Zeilentabellen (Weiterleitung, Zuordnungen, Portfreigaben) teilen sich `RowsEditor`: eine neue Zeile, sobald alle Felder gültig sind, Zeile wählen, bearbeiten, löschen. Javas Tooltip zu „Manuelle Einstellungen“ als Text unter dem Kästchen (ein Finger hat kein Überfahren) |
| `src/sheet/RouterSheets.tsx`, `src/sheet/NameField.tsx`, `src/canvas/Canvas.tsx` (`highlightedLink`), `src/theme/tokens.css` (`--canvas-cable-active`) | Java `JVermittlungsrechnerKonfiguration`, `JGatewayConfiguration`, `JWeiterleitungsTabelle` (Wörter `jvermittlungsrechnerkonfiguration_msg6`–`26`, `jweiterleitungstabelle_msg3`–`6`), `JCablePanel.farbeBlinken`, `Kabel.setAktiv` | neu (F4.5b): Blatt des Vermittlungsrechners — Reiter Allgemein (Name, Gateway, Automatisches Routing, IP-Weiterleitung nur angezeigt), je Karte (Reiter mit ihrer Adresse, „Verbunden mit …“, Adresse, Maske, MAC) und Weiterleitungstabelle (alle Einträge oder nur die manuellen, Javas Zeilen grau, eine neue Zeile, die mit vier gültigen Feldern übernommen wird, Zelle bearbeiten, Eintrag löschen; bei RIP gesperrt); Blatt des Heimrouters mit Allgemein, LAN, WAN (DHCP sperrt die WAN-Adresse). Das Kabel der offenen Karte leuchtet grün wie bei Java. Javas „Eintrag Löschen“ klein geschrieben | | `src/sheet/RouterSheets.tsx`, `src/sheet/NameField.tsx`, `src/canvas/Canvas.tsx` (`highlightedLink`), `src/theme/tokens.css` (`--canvas-cable-active`) | Java `JVermittlungsrechnerKonfiguration`, `JGatewayConfiguration`, `JWeiterleitungsTabelle` (Wörter `jvermittlungsrechnerkonfiguration_msg6`–`26`, `jweiterleitungstabelle_msg3`–`6`), `JCablePanel.farbeBlinken`, `Kabel.setAktiv` | neu (F4.5b): Blatt des Vermittlungsrechners — Reiter Allgemein (Name, Gateway, Automatisches Routing, IP-Weiterleitung nur angezeigt), je Karte (Reiter mit ihrer Adresse, „Verbunden mit …“, Adresse, Maske, MAC) und Weiterleitungstabelle (alle Einträge oder nur die manuellen, Javas Zeilen grau, eine neue Zeile, die mit vier gültigen Feldern übernommen wird, Zelle bearbeiten, Eintrag löschen; bei RIP gesperrt); Blatt des Heimrouters mit Allgemein, LAN, WAN (DHCP sperrt die WAN-Adresse). Das Kabel der offenen Karte leuchtet grün wie bei Java. Javas „Eintrag Löschen“ klein geschrieben |
+6 -2
View File
@@ -33,8 +33,12 @@ import { emptyProject, inOrder, nodeEntry, ROOTS } from './projectDoc.js';
/** The kinds of the palette, in Java's order (GUIDesignSidebar.addComponentItemsToSidebar; the cable comes first, a tool). */ /** The kinds of the palette, in Java's order (GUIDesignSidebar.addComponentItemsToSidebar; the cable comes first, a tool). */
export const PALETTE: readonly Exclude<NodeKind, 'unsupported'>[] = ['pc', 'notebook', 'networkSwitch', 'gateway', 'router', 'remoteLink']; export const PALETTE: readonly Exclude<NodeKind, 'unsupported'>[] = ['pc', 'notebook', 'networkSwitch', 'gateway', 'router', 'remoteLink'];
/** A router's interfaces, as Java's dialog offers them (2 to 8, 2 when it is closed). */ /**
export const ROUTER_INTERFACES = { minimum: 2, maximum: 8, standard: 2 } as const; * A router's interfaces, as Java's dialog offers them (2 to 8, 2 when it is
* closed); its connections dialog removes them down to `fewest` here (Java:
* none — see settings.ts).
*/
export const ROUTER_INTERFACES = { minimum: 2, maximum: 8, standard: 2, fewest: 1 } as const;
export interface NewDevice { export interface NewDevice {
readonly kind: Exclude<NodeKind, 'unsupported'>; readonly kind: Exclude<NodeKind, 'unsupported'>;
+86 -2
View File
@@ -27,7 +27,10 @@
// taken, and the sheet says why. Nor that Java's firewall table writes a // taken, and the sheet says why. Nor that Java's firewall table writes a
// cell into the rule before it checks it, so that a rule holds "abc" or a // cell into the rule before it checks it, so that a rule holds "abc" or a
// mask of 255.0.255.0 while the table shows the old value, and reads a port // mask of 255.0.255.0 while the table shows the old value, and reads a port
// that is no number as "all ports". // that is no number as "all ports". Nor that Java's connections dialog
// removes a router's last card too: a router without one keeps no gateway
// (setStandardGateway writes it into the cards; SheetRun's "connections"),
// nor could a file hold it; here one card stays.
import * as Y from 'yjs'; import * as Y from 'yjs';
import { import {
@@ -47,7 +50,7 @@ import {
type NodeKind, type NodeKind,
} from '@mdg-filius/core'; } from '@mdg-filius/core';
import { modemPair } from '@mdg-filius/fls'; import { modemPair } from '@mdg-filius/fls';
import { connect, type ConnectError } from './commands.js'; import { connect, ROUTER_INTERFACES, type ConnectError } from './commands.js';
import { checkField, type CheckedField, type FieldRule } from './fields.js'; import { checkField, type CheckedField, type FieldRule } from './fields.js';
import { inOrder, ROOTS } from './projectDoc.js'; import { inOrder, ROOTS } from './projectDoc.js';
import { fromY, toY, type PlainValue } from './values.js'; import { fromY, toY, type PlainValue } from './values.js';
@@ -295,6 +298,87 @@ export function setRip(doc: Y.Doc, id: Id, on: boolean, origin: unknown): void {
}, origin); }, origin);
} }
/** A router's ports, or none for another device. */
function routerPorts(doc: Y.Doc, id: Id): Y.Array<Y.Map<unknown>> | undefined {
const node = nodes(doc).get(id);
return node === undefined || kindOf(node) !== 'router' ? undefined : portsOf(node);
}
/**
* A card added to a router after its last (JConnectionsDialog's "+":
* hinzuAnschluss, a new NetzwerkInterface with 192.168.0.10/24 and its own
* MAC address, here from the port's id), up to eight; says whether it was.
*/
export function addRouterInterface(doc: Y.Doc, id: Id, portId: Id, origin: unknown): boolean {
const ports = routerPorts(doc, id);
if (ports === undefined || ports.length >= ROUTER_INTERFACES.maximum) return false;
doc.transact(() => {
const port = new Y.Map<unknown>();
port.set('id', portId);
port.set('label', `rt${ports.length + 1}`);
port.set('occupied', false);
port.set('config', toY({ ipAddress: '192.168.0.10', subnetMask: '255.255.255.0' }));
ports.push([port]);
}, origin);
return true;
}
type PluggedEnd = { readonly linkId: Id; readonly link: Y.Map<unknown>; readonly end: 'sourcePort' | 'targetPort' };
/** The cables at a device's port, with the end that plugs into it. */
function cablesAt(doc: Y.Doc, id: Id, portId: Id): PluggedEnd[] {
const ends: PluggedEnd[] = [];
for (const [linkId, link] of links(doc).entries()) {
if (link.get('sourceNode') === id && link.get('sourcePort') === portId) ends.push({ linkId, link, end: 'sourcePort' });
else if (link.get('targetNode') === id && link.get('targetPort') === portId) ends.push({ linkId, link, end: 'targetPort' });
}
return ends;
}
/** A router's last card removed with its cable (JConnectionsDialog's "-"), down to one; says whether it was. */
export function removeRouterInterface(doc: Y.Doc, id: Id, origin: unknown): boolean {
const ports = routerPorts(doc, id);
if (ports === undefined || ports.length <= ROUTER_INTERFACES.fewest) return false;
const last = ports.get(ports.length - 1).get('id') as Id;
doc.transact(() => {
for (const { linkId } of cablesAt(doc, id, last)) links(doc).delete(linkId);
ports.delete(ports.length - 1);
}, origin);
return true;
}
/**
* Two of a router's cards trade their cables (JConnectionsDialog.swapConnection):
* both plugged, the cables swap; one plugged, its cable moves to the other
* card; neither, nothing changes. The cables stay the same cables, joining the
* same devices. Says whether a cable moved.
*/
export function swapRouterCables(doc: Y.Doc, id: Id, first: Id, second: Id, origin: unknown): boolean {
const ports = routerPorts(doc, id);
const has = (portId: Id) => ports?.toArray().some((port) => port.get('id') === portId) === true;
if (first === second || !has(first) || !has(second)) return false;
const atFirst = cablesAt(doc, id, first);
const atSecond = cablesAt(doc, id, second);
if (atFirst.length === 0 && atSecond.length === 0) return false;
doc.transact(() => {
for (const { link, end } of atFirst) link.set(end, second);
for (const { link, end } of atSecond) link.set(end, first);
}, origin);
return true;
}
/**
* A click on a card's button in Java's connections dialog, with the card
* marked before (null for none): the first click marks a card, a click on it
* again unmarks it, a click on another card swaps the two cards' cables
* (swapRouterCables) and leaves none marked.
*/
export function connectionsClick(marked: number | null, clicked: number): { readonly marked: number | null; readonly swap?: readonly [number, number] } {
if (marked === null) return { marked: clicked };
if (marked === clicked) return { marked: null };
return { marked: null, swap: [marked, clicked] };
}
/** A column of the forwarding table's manual rows. */ /** A column of the forwarding table's manual rows. */
export type RouteColumn = keyof ManualRoute; export type RouteColumn = keyof ManualRoute;
+26 -5
View File
@@ -88,8 +88,13 @@ export interface InterfaceSheet {
readonly ipAddress: string; readonly ipAddress: string;
readonly subnetMask: string; readonly subnetMask: string;
readonly macAddress: string; readonly macAddress: string;
/** The device at the other end of its cable (its label, as Java names it) and the cable; null without one. */ /**
readonly connected: { readonly device: string; readonly link: string } | null; * The device at the other end of its cable (its label, as Java names it),
* the address of the card the cable plugs into there (a computer's, a
* router's; null for a switch or a modem, which have none) and the cable;
* null without one.
*/
readonly connected: { readonly device: string; readonly address: string | null; readonly link: string } | null;
} }
/** A router's or home router's firewall as Java's dialog shows it (JFirewallDialog). */ /** A router's or home router's firewall as Java's dialog shows it (JFirewallDialog). */
@@ -142,6 +147,22 @@ const setting = (node: Y.Map<unknown>, key: string) => settingsOf(node).get(key)
const text = (map: Y.Map<unknown> | undefined, key: string) => (map?.get(key) as string | undefined) ?? ''; const text = (map: Y.Map<unknown> | undefined, key: string) => (map?.get(key) as string | undefined) ?? '';
/** The address of a device's card at a port, as Java's NetzwerkInterface holds it; null for a device without cards (InternetKnoten). */
function cardAddress(node: Y.Map<unknown>, portId: string): string | null {
switch (node.get('kind') as NodeKind) {
case 'pc':
case 'notebook':
return text(setting(node, 'device'), 'ipAddress');
case 'router':
case 'gateway': {
const port = (node.get('ports') as Y.Array<Y.Map<unknown>>).toArray().find((each) => each.get('id') === portId);
return text(port?.get('config') as Y.Map<unknown> | undefined, 'ipAddress');
}
default:
return null;
}
}
/** A router's or home router's cards, each with what its cable joins. */ /** A router's or home router's cards, each with what its cable joins. */
function interfacesOf(doc: Y.Doc, id: string, node: Y.Map<unknown>): InterfaceSheet[] { function interfacesOf(doc: Y.Doc, id: string, node: Y.Map<unknown>): InterfaceSheet[] {
const all = doc.getMap<Y.Map<unknown>>(ROOTS.nodes); const all = doc.getMap<Y.Map<unknown>>(ROOTS.nodes);
@@ -150,14 +171,14 @@ function interfacesOf(doc: Y.Doc, id: string, node: Y.Map<unknown>): InterfaceSh
const portId = port.get('id') as string; const portId = port.get('id') as string;
const config = port.get('config') as Y.Map<unknown> | undefined; const config = port.get('config') as Y.Map<unknown> | undefined;
const cable = links.find(([, link]) => (link.get('sourceNode') === id && link.get('sourcePort') === portId) || (link.get('targetNode') === id && link.get('targetPort') === portId)); const cable = links.find(([, link]) => (link.get('sourceNode') === id && link.get('sourcePort') === portId) || (link.get('targetNode') === id && link.get('targetPort') === portId));
const other = const near = cable !== undefined && cable[1].get('sourceNode') === id && cable[1].get('sourcePort') === portId;
cable === undefined ? undefined : all.get((cable[1].get('sourceNode') === id && cable[1].get('sourcePort') === portId ? cable[1].get('targetNode') : cable[1].get('sourceNode')) as string); const other = cable === undefined ? undefined : all.get(cable[1].get(near ? 'targetNode' : 'sourceNode') as string);
return { return {
portId, portId,
ipAddress: text(config, 'ipAddress'), ipAddress: text(config, 'ipAddress'),
subnetMask: text(config, 'subnetMask'), subnetMask: text(config, 'subnetMask'),
macAddress: (port.get('mac') as string | undefined) ?? stableGeneratedMacAddress(portId), macAddress: (port.get('mac') as string | undefined) ?? stableGeneratedMacAddress(portId),
connected: cable === undefined || other === undefined ? null : { device: nodeLabel(other), link: cable[0] }, connected: cable === undefined || other === undefined ? null : { device: nodeLabel(other), address: cardAddress(other, cable[1].get(near ? 'targetPort' : 'sourcePort') as string), link: cable[0] },
}; };
}); });
} }
File diff suppressed because it is too large Load Diff
+218 -1
View File
@@ -31,6 +31,10 @@ import {
setSwitchRetention, setSwitchRetention,
setSwitchSsid, setSwitchSsid,
addRoute, addRoute,
addRouterInterface,
connectionsClick,
removeRouterInterface,
swapRouterCables,
addFirewallRule, addFirewallRule,
moveFirewallRule, moveFirewallRule,
removeFirewallRule, removeFirewallRule,
@@ -104,7 +108,27 @@ const java = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)
readonly cells: readonly { readonly column: number; readonly input: string; readonly rule: JavaFirewallRule; readonly valid: boolean }[]; readonly cells: readonly { readonly column: number; readonly input: string; readonly rule: JavaFirewallRule; readonly valid: boolean }[];
readonly moves: readonly (readonly [string, number, boolean | null, readonly string[]])[]; readonly moves: readonly (readonly [string, number, boolean | null, readonly string[]])[];
}; };
readonly connections: {
readonly steps: readonly JavaConnectionsStep[];
readonly hostPortFree: boolean;
readonly switchPortsFree: boolean;
}; };
};
/** A click in Java's connections dialog ("c<i>" a card's button, "+", "-") and what the dialog and the router show then. */
interface JavaConnectionsStep {
readonly click: string;
readonly local: readonly string[];
readonly remote: readonly string[];
readonly buttons: readonly ('off' | 'marked' | 'plugged' | 'free')[];
readonly add: boolean;
readonly remove: boolean;
/** Address, mask and the device its cable joins ("" for none), per card. */
readonly cards: readonly (readonly [string, string, string])[];
readonly cables: number;
readonly panelError?: string | null;
readonly gateway?: string | null;
}
interface JavaFirewallRule { interface JavaFirewallRule {
readonly srcIP: string; readonly srcIP: string;
@@ -654,7 +678,7 @@ describe("a router's sheet", () => {
const pc = add(doc, 'pc', 'PC-A'); const pc = add(doc, 'pc', 'PC-A');
setHostLabels(doc, pc, { ipAddress: true, macAddress: false }, 'me'); setHostLabels(doc, pc, { ipAddress: true, macAddress: false }, 'me');
connect(doc, { node: pc }, { node: id }, 'cable', 'me'); connect(doc, { node: pc }, { node: id }, 'cable', 'me');
expect(router(doc, id).interfaces.map((card) => card.connected)).toEqual([{ device: '192.168.0.10', link: 'cable' }, null]); expect(router(doc, id).interfaces.map((card) => card.connected)).toEqual([{ device: '192.168.0.10', address: '192.168.0.10', link: 'cable' }, null]);
expect(router(doc, id).interfaces[0]!.macAddress).toMatch(/^([0-9A-F]{2}:){5}[0-9A-F]{2}$/); expect(router(doc, id).interfaces[0]!.macAddress).toMatch(/^([0-9A-F]{2}:){5}[0-9A-F]{2}$/);
}); });
}); });
@@ -1089,3 +1113,196 @@ describe("a router's firewall", () => {
} }
}); });
}); });
describe("a router's connections", () => {
/** SheetRun's network: router R with three cards, PC A plugged into the first, switch S into the second. */
function network() {
const doc = new Y.Doc();
const id = add(doc, 'router', 'R', 3);
router(doc, id).interfaces.forEach((card, index) => setInterfaceAddress(doc, id, card.portId, 'ipAddress', `10.${index}.0.1`, 'me'));
const pc = add(doc, 'pc', 'A');
setHostAddress(doc, pc, 'ipAddress', '10.0.0.2', 'me');
const sw = add(doc, 'networkSwitch', 'S');
expect(connect(doc, { node: pc }, { node: id }, 'cable-a', 'me')).toEqual({ link: 'cable-a' });
expect(connect(doc, { node: id, port: router(doc, id).interfaces[1]!.portId }, { node: sw }, 'cable-s', 'me')).toEqual({ link: 'cable-s' });
return { doc, id, pc, sw };
}
/** What Java's dialog would show of the router, with the card marked. */
function shown(doc: Y.Doc, id: string, marked: number | null) {
const cards = router(doc, id).interfaces;
const pad = <T,>(each: T[], blank: T) => [...each, ...Array<T>(8 - each.length).fill(blank)];
return {
local: pad(cards.map((card, index) => `NIC ${index + 1}: ${card.ipAddress}`), ''),
remote: pad(cards.map((card) => (card.connected === null ? '' : card.connected.address === null ? card.connected.device : `${card.connected.device} (${card.connected.address})`)), ''),
buttons: pad(cards.map((card, index) => (index === marked ? 'marked' : card.connected === null ? 'free' : 'plugged')), 'off'),
add: cards.length < 8,
remove: cards.length > 1,
cards: cards.map((card) => [card.ipAddress, card.subnetMask, card.connected?.device ?? '']),
cables: doc.getMap(ROOTS.links).size,
};
}
it('swap, add and remove cards as Java’s dialog does, click by click, down to the last card', () => {
const { doc, id } = network();
let marked: number | null = null;
const steps = java.connections.steps;
const last = steps.findIndex((step) => step.cards.length === 1);
for (const [index, step] of steps.slice(0, last + 1).entries()) {
if (step.click.startsWith('c')) {
const next = connectionsClick(marked, Number(step.click.slice(1)));
if (next.swap !== undefined) {
const ports = router(doc, id).interfaces.map((card) => card.portId);
swapRouterCables(doc, id, ports[next.swap[0]]!, ports[next.swap[1]]!, 'me');
}
marked = next.marked;
} else if (step.click === '+') {
// Java's "+" is greyed out at eight cards; its click does nothing then.
expect(addRouterInterface(doc, id, newId(), 'me'), `step ${index}`).toBe(steps[index - 1]!.add);
marked = null;
} else if (step.click === '-') {
expect(removeRouterInterface(doc, id, 'me'), `step ${index}`).toBe(true);
marked = null;
}
const { local, remote, buttons, add: addable, remove, cards, cables } = step;
// Java's "-" stays open at one card; here the last card stays (below).
expect(shown(doc, id, marked), `step ${index} ${step.click}`).toEqual({ local, remote, buttons, add: addable, remove: index === last ? false : remove, cards, cables });
}
expect(steps[last]!.remove).toBe(true);
expect(steps.at(-1)!.cards).toEqual([]);
// Java's router without a card keeps no gateway; the one with a card does.
expect([steps[last]!.gateway, steps.at(-1)!.gateway]).toEqual(['10.0.0.254', null]);
expect(steps[last]!.panelError).toBeNull();
});
it('keep the last card, with its cable, and its gateway', () => {
const doc = new Y.Doc();
const id = add(doc, 'router', 'R', 2);
const pc = add(doc, 'pc', 'A');
connect(doc, { node: pc }, { node: id }, 'cable', 'me');
setRouterGateway(doc, id, '192.168.0.254', 'me');
expect(removeRouterInterface(doc, id, 'me')).toBe(true);
expect(removeRouterInterface(doc, id, 'me')).toBe(false);
expect(router(doc, id).interfaces).toHaveLength(1);
expect(router(doc, id).interfaces[0]!.connected).toMatchObject({ device: 'A', link: 'cable' });
expect(router(doc, id).gateway).toBe('192.168.0.254');
});
it('remove the last card’s cable with it, and free the other device’s port', () => {
const { doc, id, sw } = network();
const ports = router(doc, id).interfaces.map((card) => card.portId);
expect(swapRouterCables(doc, id, ports[1]!, ports[2]!, 'me')).toBe(true);
expect(removeRouterInterface(doc, id, 'me')).toBe(true);
expect([...doc.getMap<Y.Map<unknown>>(ROOTS.links).keys()]).toEqual(['cable-a']);
expect(java.connections.switchPortsFree).toBe(true);
// The switch's port takes another cable.
const pc = add(doc, 'pc', 'B');
expect(connect(doc, { node: pc }, { node: sw }, 'cable-b', 'me')).toEqual({ link: 'cable-b' });
expect(java.connections.hostPortFree).toBe(true);
});
it('give a new card Java’s address and mask, its own MAC address, the next label and no cable', () => {
const { doc, id } = network();
expect(addRouterInterface(doc, id, 'port-4', 'me')).toBe(true);
const cards = router(doc, id).interfaces;
expect(cards[3]).toMatchObject({ portId: 'port-4', ipAddress: '192.168.0.10', subnetMask: '255.255.255.0', connected: null });
expect(new Set(cards.map((card) => card.macAddress)).size).toBe(4);
const ports = (doc.getMap<Y.Map<unknown>>(ROOTS.nodes).get(id)!.get('ports') as Y.Array<Y.Map<unknown>>).toArray();
expect(ports.map((port) => port.get('label'))).toEqual(['rt1', 'rt2', 'rt3', 'rt4']);
expect(ports[3]!.get('occupied')).toBe(false);
});
it('keep each cable’s identity and devices when cards swap, and move nothing between free cards', () => {
const { doc, id, pc, sw } = network();
const ports = router(doc, id).interfaces.map((card) => card.portId);
const ends = () =>
[...doc.getMap<Y.Map<unknown>>(ROOTS.links).entries()].map(([linkId, link]) => [linkId, link.get('sourceNode'), link.get('sourcePort'), link.get('targetNode'), link.get('targetPort')]);
const before = ends();
expect(swapRouterCables(doc, id, ports[0]!, ports[1]!, 'me')).toBe(true);
// The PC plugged first (the router is the cable's target), the router into the switch (its source).
expect(ends()).toEqual([
['cable-a', pc, before[0]![2], id, ports[1]],
['cable-s', id, ports[0], sw, before[1]![4]],
]);
addRouterInterface(doc, id, 'port-4', 'me');
const now = ends();
expect(swapRouterCables(doc, id, ports[2]!, 'port-4', 'me')).toBe(false);
expect(ends()).toEqual(now);
});
it('refuse a card twice, a card not the router’s, and devices that are no router', () => {
const { doc, id, pc } = network();
const ports = router(doc, id).interfaces.map((card) => card.portId);
const updates: unknown[] = [];
doc.on('update', (update) => updates.push(update));
expect(swapRouterCables(doc, id, ports[0]!, ports[0]!, 'me')).toBe(false);
expect(swapRouterCables(doc, id, ports[0]!, 'no-port', 'me')).toBe(false);
expect(swapRouterCables(doc, id, 'no-port', ports[0]!, 'me')).toBe(false);
const pcPort = (doc.getMap<Y.Map<unknown>>(ROOTS.nodes).get(pc)!.get('ports') as Y.Array<Y.Map<unknown>>).get(0).get('id') as string;
expect(swapRouterCables(doc, id, ports[2]!, pcPort, 'me')).toBe(false);
expect(swapRouterCables(doc, 'nobody', ports[0]!, ports[1]!, 'me')).toBe(false);
const homeId = add(doc, 'gateway');
const [wan, lan] = [home(doc, homeId).wan.portId, home(doc, homeId).lan.portId];
expect(swapRouterCables(doc, homeId, wan, lan, 'me')).toBe(false);
expect(addRouterInterface(doc, homeId, 'port-x', 'me')).toBe(false);
expect(removeRouterInterface(doc, homeId, 'me')).toBe(false);
expect(addRouterInterface(doc, pc, 'port-x', 'me')).toBe(false);
expect(removeRouterInterface(doc, 'nobody', 'me')).toBe(false);
const before = updates.length;
expect(before).toBeGreaterThan(0);
for (let more = 3; more < 8; more++) expect(addRouterInterface(doc, id, `port-${more}`, 'me')).toBe(true);
expect(addRouterInterface(doc, id, 'port-9', 'me')).toBe(false);
expect(router(doc, id).interfaces).toHaveLength(8);
expect(updates).toHaveLength(before + 5);
});
it('click as Java’s dialog does: mark, unmark, swap', () => {
expect(connectionsClick(null, 2)).toEqual({ marked: 2 });
expect(connectionsClick(2, 2)).toEqual({ marked: null });
expect(connectionsClick(2, 0)).toEqual({ marked: null, swap: [2, 0] });
});
it('are each one step of the person’s undo, and go into the file', () => {
const { doc, id } = network();
const undo = createUndoManager(doc, 'me');
const ports = router(doc, id).interfaces.map((card) => card.portId);
undo.stopCapturing();
addRouterInterface(doc, id, 'port-4', 'me');
undo.stopCapturing();
swapRouterCables(doc, id, ports[0]!, 'port-4', 'me');
undo.stopCapturing();
removeRouterInterface(doc, id, 'me');
expect(router(doc, id).interfaces.map((card) => card.connected?.device ?? '')).toEqual(['', 'S', '']);
undo.undo();
expect(router(doc, id).interfaces.map((card) => card.connected?.device ?? '')).toEqual(['', 'S', '', 'A']);
undo.undo();
expect(router(doc, id).interfaces.map((card) => card.connected?.device ?? '')).toEqual(['A', 'S', '', '']);
swapRouterCables(doc, id, ports[0]!, 'port-4', 'me');
let counter = 0;
const reopened = openProjectArchive(saveProjectArchive(readProject(doc)).archive, () => `70000000-0000-0000-0000-${(++counter).toString(16).padStart(12, '0')}`);
const again = reopened.graph.nodes.find((node) => node.displayName === 'R')!;
expect(again.ports.map((port) => port.label)).toEqual(['rt1', 'rt2', 'rt3', 'rt4']);
const plugged = reopened.graph.links.flatMap((link) => [link.sourcePortId, link.targetPortId]);
expect(again.ports.map((port) => plugged.includes(port.id))).toEqual([false, true, false, true]);
});
it('name the address of the card a cable plugs into: a computer’s, a router’s, none for a switch', () => {
const { doc, id } = network();
const other = add(doc, 'router', 'R2');
setInterfaceAddress(doc, other, router(doc, other).interfaces[1]!.portId, 'ipAddress', '10.2.0.2', 'me');
connect(doc, { node: id, port: router(doc, id).interfaces[2]!.portId }, { node: other, port: router(doc, other).interfaces[1]!.portId }, 'cable-r', 'me');
const homeId = add(doc, 'gateway', 'H');
const modem = add(doc, 'remoteLink', 'M');
addRouterInterface(doc, id, 'port-4', 'me');
addRouterInterface(doc, id, 'port-5', 'me');
connect(doc, { node: homeId, port: home(doc, homeId).lan.portId }, { node: id, port: 'port-4' }, 'cable-h', 'me');
connect(doc, { node: modem }, { node: id, port: 'port-5' }, 'cable-m', 'me');
expect(router(doc, id).interfaces.map((card) => card.connected && [card.connected.device, card.connected.address])).toEqual([
['A', '10.0.0.2'],
['S', null],
['R2', '10.2.0.2'],
['H', '192.168.0.10'],
['M', null],
]);
});
});
+96
View File
@@ -400,6 +400,102 @@ test("sets up a router's firewall on its own page: the switches, the default, ru
await expect(sheet(page).getByRole('heading')).toHaveText('Vermittlungsrechner'); await expect(sheet(page).getByRole('heading')).toHaveText('Vermittlungsrechner');
}); });
test("manages a router's connections on its own page: cables swapped with two taps, cards added and removed, the last kept", async ({ page }) => {
await openEditor(page, server.base, 'zwei_rechnernetze_komplett.fls');
const id = (await drawing(page)).nodes.find((node) => node.kind === 'router')!.id;
await choose(page, id);
const joined = async () => ((await sheetOf(page, id)) as RouterSheet).interfaces.map((card) => card.connected?.device ?? '');
const values = (await sheetOf(page, id)) as RouterSheet;
const [one, two] = await joined();
const cables = (await drawing(page)).links.length;
await sheet(page).getByRole('button', { name: 'Verbindungen verwalten' }).click();
await expect(sheet(page).getByRole('heading', { level: 2 })).toHaveText('Verbindungen verwalten');
const items = sheet(page).locator('ol.connections li');
const port = (index: number) => items.nth(index).getByRole('button');
await expect(items.locator('.connection-local')).toHaveText(values.interfaces.map((card, index) => `NIC ${index + 1}: ${card.ipAddress}`));
await expect(items.locator('.connection-remote')).toHaveText([one!, two!]);
// Java's colours: green for a plugged card, yellow for the marked one, red for a free one.
await expect(port(0)).toHaveCSS('background-color', 'rgb(0, 255, 0)');
await port(0).click();
await expect(port(0)).toHaveAttribute('aria-pressed', 'true');
await expect(port(0)).toHaveCSS('background-color', 'rgb(255, 255, 0)');
await port(0).click();
await expect(port(0)).toHaveAttribute('aria-pressed', 'false');
await port(0).click();
await port(1).click();
await expect.poll(joined).toEqual([two, one]);
await expect(items.nth(0)).toHaveAttribute('data-state', 'plugged');
await sheet(page).getByRole('button', { name: 'Schnittstelle hinzufügen' }).click();
await expect(items).toHaveCount(3);
await expect(items.nth(2).locator('.connection-local')).toHaveText('NIC 3: 192.168.0.10');
await expect(port(2)).toHaveCSS('background-color', 'rgb(255, 0, 0)');
await expect(port(2)).toHaveAccessibleName('NIC 3: 192.168.0.10, Keine Verbindung');
// A free card takes the marked card's cable.
await port(0).click();
await port(2).click();
await expect.poll(joined).toEqual(['', one, two]);
await sheet(page).getByRole('button', { name: 'Schnittstelle entfernen' }).click();
await expect.poll(joined).toEqual(['', one]);
expect((await drawing(page)).links).toHaveLength(cables - 1);
for (let count = 2; count < 8; count++) await sheet(page).getByRole('button', { name: 'Schnittstelle hinzufügen' }).click();
await expect(items).toHaveCount(8);
await expect(sheet(page).getByRole('button', { name: 'Schnittstelle hinzufügen' })).toBeDisabled();
await expect(sheet(page).getByText('Maximal 8 Schnittstellen erlaubt!')).toBeVisible();
for (let count = 8; count > 1; count--) await sheet(page).getByRole('button', { name: 'Schnittstelle entfernen' }).click();
await expect(items).toHaveCount(1);
await expect(sheet(page).getByRole('button', { name: 'Schnittstelle entfernen' })).toBeDisabled();
await expect(sheet(page).getByText('Eine Schnittstelle bleibt immer.')).toBeVisible();
await sheet(page).getByRole('button', { name: 'Zurück' }).click();
const tabs = sheet(page).getByRole('tab');
await expect(tabs).toHaveText(['Allgemein', values.interfaces[0]!.ipAddress, 'Weiterleitungstabelle']);
// Undone, the second card comes back with its cable; a card's tab whose card goes again shows the general tab.
await page.keyboard.press('Control+z');
await expect(tabs).toHaveCount(4);
await tabs.nth(2).click();
await expect(sheet(page).locator('.sheet-connected')).toHaveText(`Verbunden mit ${one}`);
await page.keyboard.press('Control+Shift+z');
await expect(tabs).toHaveCount(3);
await expect(tabs.first()).toHaveAttribute('aria-selected', 'true');
await expect(field(page, 'name')).toHaveValue(values.name);
});
test("names a router's computer by its address, and lets go of a marked card when cards change", async ({ page }) => {
await openEditor(page, server.base, 'internetwork_komplett.fls');
const routers = (await drawing(page)).nodes.filter((node) => node.kind === 'router');
let id = '';
for (const each of routers) if (((await sheetOf(page, each.id)) as RouterSheet).interfaces.some((card) => card.connected?.address)) id = each.id;
await choose(page, id);
const values = (await sheetOf(page, id)) as RouterSheet;
await sheet(page).getByRole('button', { name: 'Verbindungen verwalten' }).click();
const items = sheet(page).locator('ol.connections li');
const port = (index: number) => items.nth(index).getByRole('button');
// Java: a computer's or a router's name with the address of its card, a switch's name alone.
await expect(items.locator('.connection-remote')).toHaveText(
values.interfaces.flatMap((card) => (card.connected === null ? [] : [card.connected.address === null ? card.connected.device : `${card.connected.device} (${card.connected.address})`])),
);
expect(values.interfaces.some((card) => card.connected?.address !== null && card.connected?.address !== undefined)).toBe(true);
const pressed = () => sheet(page).locator('.connection-port[aria-pressed="true"]');
await port(0).click();
await sheet(page).getByRole('button', { name: 'Schnittstelle hinzufügen' }).click();
await expect(pressed()).toHaveCount(0);
await port(0).click();
await sheet(page).getByRole('button', { name: 'Schnittstelle entfernen' }).click();
await expect(pressed()).toHaveCount(0);
// A marked card undone away is marked no more: the next tap marks, it swaps nothing.
await sheet(page).getByRole('button', { name: 'Schnittstelle hinzufügen' }).click();
const count = values.interfaces.length + 1;
await port(count - 1).click();
await page.keyboard.press('Control+z');
await expect(items).toHaveCount(count - 1);
const before = ((await sheetOf(page, id)) as RouterSheet).interfaces.map((card) => card.connected?.link ?? null);
await port(0).click();
await expect(port(0)).toHaveAttribute('aria-pressed', 'true');
expect(((await sheetOf(page, id)) as RouterSheet).interfaces.map((card) => card.connected?.link ?? null)).toEqual(before);
});
test("opens a home router's firewall from its general tab", async ({ page }) => { test("opens a home router's firewall from its general tab", async ({ page }) => {
await openEditor(page, server.base, 'new'); await openEditor(page, server.base, 'new');
await page.locator('[data-palette="gateway"]').click(); await page.locator('[data-palette="gateway"]').click();
Binary file not shown.

After

Width:  |  Height:  |  Size: 714 B

+4
View File
@@ -7,7 +7,11 @@ import laptop from '../assets/hardware/laptop.png';
import modem from '../assets/hardware/modem.png'; import modem from '../assets/hardware/modem.png';
import router from '../assets/hardware/router.png'; import router from '../assets/hardware/router.png';
import server from '../assets/hardware/server.png'; import server from '../assets/hardware/server.png';
import rj45 from '../assets/hardware/rj45.png';
import networkSwitch from '../assets/hardware/switch.png'; import networkSwitch from '../assets/hardware/switch.png';
import type { IconName } from './layout.js'; import type { IconName } from './layout.js';
export const ICON_URLS: Readonly<Record<IconName, string>> = { cloud, gateway, laptop, modem, router, server, switch: networkSwitch }; export const ICON_URLS: Readonly<Record<IconName, string>> = { cloud, gateway, laptop, modem, router, server, switch: networkSwitch };
/** A card's socket, on the buttons of a router's connections (JConnectionsDialog). */
export const RJ45_URL = rj45;
+8
View File
@@ -11,6 +11,13 @@
"cable.wanOccupied": "WAN-Anschluss (extern) bereits belegt!", "cable.wanOccupied": "WAN-Anschluss (extern) bereits belegt!",
"cable.wirelessNotPossible": "Drahtlose Verbindung nicht möglich", "cable.wirelessNotPossible": "Drahtlose Verbindung nicht möglich",
"canvas.label": "Netzwerk", "canvas.label": "Netzwerk",
"connections.add": "Schnittstelle hinzufügen",
"connections.fewest": "Eine Schnittstelle bleibt immer.",
"connections.hint": "Hinweis: Zum Umstecken von Verbindungen:\n1. Verbindung auswählen durch Klick auf Schaltfläche links neben der Schnittstellenbezeichnung\n2. Auswählen der neuen Schnittstelle durch Klick auf die entsprechende Schaltfläche\nSollte die Zielschnittstelle bereits belegt sein, so werden die Verbindungen vertauscht.",
"connections.local": "Lokale Schnittstellen",
"connections.most": "Maximal 8 Schnittstellen erlaubt!",
"connections.nic": "NIC {number}: {address}",
"connections.remove": "Schnittstelle entfernen",
"design.actions": "Aktionen", "design.actions": "Aktionen",
"design.palette": "Komponenten", "design.palette": "Komponenten",
"design.redo": "Wiederholen", "design.redo": "Wiederholen",
@@ -88,6 +95,7 @@
"sheet.cloud": "als Wolke darstellen", "sheet.cloud": "als Wolke darstellen",
"sheet.connectedTo": "Verbunden mit {device}", "sheet.connectedTo": "Verbunden mit {device}",
"sheet.connection": "Anschluss", "sheet.connection": "Anschluss",
"sheet.connections": "Verbindungen verwalten",
"sheet.defaultGateway": "Gateway", "sheet.defaultGateway": "Gateway",
"sheet.deleteEntry": "Eintrag löschen", "sheet.deleteEntry": "Eintrag löschen",
"sheet.dhcpClient": "DHCP zur Konfiguration verwenden", "sheet.dhcpClient": "DHCP zur Konfiguration verwenden",
+8
View File
@@ -11,6 +11,13 @@
"cable.wanOccupied": "WAN port (external) already occupied!", "cable.wanOccupied": "WAN port (external) already occupied!",
"cable.wirelessNotPossible": "Wireless connection not possible", "cable.wirelessNotPossible": "Wireless connection not possible",
"canvas.label": "Network", "canvas.label": "Network",
"connections.add": "Add interface",
"connections.fewest": "One interface always stays.",
"connections.hint": "Note: For reorganising cable connections:\n1. Choose cable connection by clicking the button next to the interface name\n2. Choose new interface by clicking according button\nIf the target interface is already occupied by another connection, then those cables will be swapped.",
"connections.local": "Local interfaces",
"connections.most": "At most 8 interfaces supported!",
"connections.nic": "NIC {number}: {address}",
"connections.remove": "Remove interface",
"design.actions": "Actions", "design.actions": "Actions",
"design.palette": "Components", "design.palette": "Components",
"design.redo": "Redo", "design.redo": "Redo",
@@ -88,6 +95,7 @@
"sheet.cloud": "Display as Cloud", "sheet.cloud": "Display as Cloud",
"sheet.connectedTo": "Connected to {device}", "sheet.connectedTo": "Connected to {device}",
"sheet.connection": "Connection", "sheet.connection": "Connection",
"sheet.connections": "Manage connections",
"sheet.defaultGateway": "Gateway", "sheet.defaultGateway": "Gateway",
"sheet.deleteEntry": "Delete entry", "sheet.deleteEntry": "Delete entry",
"sheet.dhcpClient": "Use DHCP for configuration", "sheet.dhcpClient": "Use DHCP for configuration",
@@ -0,0 +1,92 @@
// A router's connections as a page of the device sheet, after Java's dialog
// (JConnectionsDialog): each card with its RJ45 button left of its name and,
// below, what its cable joins; a card's button marks it, another card's
// button then swaps the two cards' cables (or moves the one), as Java's
// buttons do; "+" adds a card after the last, "−" removes the last with its
// cable — down to one card, not to none as in Java. Java's colours on the
// buttons: green plugged, red free, yellow marked. Java draws its cables
// between two columns; a narrow sheet has room for one.
import { useState } from 'react';
import { addRouterInterface, connectionsClick, removeRouterInterface, ROUTER_INTERFACES, swapRouterCables, type RouterSheet } from '@mdg-filius/doc';
import { RJ45_URL } from '../canvas/icons.js';
import { useTranslate } from '../i18n/i18n.js';
import type { Change } from './NameField.js';
import { SheetNote } from './SheetField.js';
export function ConnectionsPanel({ sheet, change }: { readonly sheet: RouterSheet; readonly change: Change }) {
const t = useTranslate();
const [chosen, setChosen] = useState<number | null>(null);
const cards = sheet.interfaces;
// A card another person removed is no longer marked.
const marked = chosen !== null && chosen < cards.length ? chosen : null;
const click = (index: number) => {
const next = connectionsClick(marked, index);
if (next.swap !== undefined) {
const [first, second] = next.swap;
change.run(() => swapRouterCables(change.doc, change.id, cards[first]!.portId, cards[second]!.portId, change.origin));
}
setChosen(next.marked);
};
return (
<>
<h3 className="sheet-subtitle">{t('connections.local')}</h3>
<ol className="connections">
{cards.map((card, index) => {
const name = t('connections.nic', { number: index + 1, address: card.ipAddress });
const joined = card.connected;
return (
<li key={card.portId} data-state={index === marked ? 'marked' : joined === null ? 'free' : 'plugged'}>
<button
type="button"
className="connection-port"
aria-pressed={index === marked}
aria-label={joined === null ? `${name}, ${t('sheet.notConnected')}` : name}
onClick={() => click(index)}
>
<img src={RJ45_URL} alt="" />
</button>
<span className="connection-local">{name}</span>
{joined === null ? null : <span className="connection-remote">{joined.address === null ? joined.device : `${joined.device} (${joined.address})`}</span>}
</li>
);
})}
</ol>
<div className="sheet-buttons">
<button
type="button"
name="addInterface"
aria-label={t('connections.add')}
title={t('connections.add')}
disabled={cards.length >= ROUTER_INTERFACES.maximum}
onClick={() => {
change.run(() => addRouterInterface(change.doc, change.id, crypto.randomUUID(), change.origin));
setChosen(null);
}}
>
+
</button>
<button
type="button"
name="removeInterface"
aria-label={t('connections.remove')}
title={t('connections.remove')}
disabled={cards.length <= ROUTER_INTERFACES.fewest}
onClick={() => {
change.run(() => removeRouterInterface(change.doc, change.id, change.origin));
setChosen(null);
}}
>
−
</button>
</div>
{cards.length >= ROUTER_INTERFACES.maximum ? (
<p className="sheet-note">{t('connections.most')}</p>
) : cards.length <= ROUTER_INTERFACES.fewest ? (
<p className="sheet-note">{t('connections.fewest')}</p>
) : null}
{/* Below the cards, as in Java's dialog. */}
<SheetNote text={t('connections.hint')} />
</>
);
}
+9 -2
View File
@@ -30,6 +30,7 @@ import { ICON_URLS } from '../canvas/icons.js';
import { useTranslate } from '../i18n/i18n.js'; import { useTranslate } from '../i18n/i18n.js';
import { NameField, type Change } from './NameField.js'; import { NameField, type Change } from './NameField.js';
import { HomeRouterFields, RouterFields } from './RouterSheets.js'; import { HomeRouterFields, RouterFields } from './RouterSheets.js';
import { ConnectionsPanel } from './ConnectionsPanel.js';
import { FirewallPanel } from './FirewallPanel.js'; import { FirewallPanel } from './FirewallPanel.js';
import { DhcpServerPanel, PortForwardingPanel } from './ServicePanels.js'; import { DhcpServerPanel, PortForwardingPanel } from './ServicePanels.js';
import { SheetCheck, SheetField } from './SheetField.js'; import { SheetCheck, SheetField } from './SheetField.js';
@@ -62,7 +63,9 @@ export interface DeviceSheetProps {
} }
/** A page of the sheet that Java opens as a dialog. */ /** A page of the sheet that Java opens as a dialog. */
export type SheetPage = 'dhcp' | 'forwarding' | 'firewall'; export type SheetPage = 'dhcp' | 'forwarding' | 'firewall' | 'connections';
const PAGE_TITLES: Readonly<Record<SheetPage, string>> = { dhcp: 'sheet.dhcpServer', forwarding: 'sheet.portForwarding', firewall: 'sheet.firewall', connections: 'sheet.connections' };
export function DeviceSheet({ doc, id, label, origin, onChanged, onMessage, onClose, onHighlight }: DeviceSheetProps) { export function DeviceSheet({ doc, id, label, origin, onChanged, onMessage, onClose, onHighlight }: DeviceSheetProps) {
const t = useTranslate(); const t = useTranslate();
@@ -92,7 +95,7 @@ export function DeviceSheet({ doc, id, label, origin, onChanged, onMessage, onCl
‹ ‹
</button> </button>
)} )}
<h2>{open === 'dhcp' ? t('sheet.dhcpServer') : open === 'forwarding' ? t('sheet.portForwarding') : open === 'firewall' ? t('sheet.firewall') : title}</h2> <h2>{open === null ? title : t(PAGE_TITLES[open])}</h2>
<button type="button" className="sheet-close" onClick={onClose} aria-label={t('sheet.close')} title={t('sheet.close')}> <button type="button" className="sheet-close" onClick={onClose} aria-label={t('sheet.close')} title={t('sheet.close')}>
× ×
</button> </button>
@@ -121,6 +124,10 @@ export function DeviceSheet({ doc, id, label, origin, onChanged, onMessage, onCl
<div key={`${id}-firewall`}> <div key={`${id}-firewall`}>
<FirewallPanel firewall={sheet.firewall} change={change} /> <FirewallPanel firewall={sheet.firewall} change={change} />
</div> </div>
) : open === 'connections' && sheet.kind === 'router' ? (
<div key={`${id}-connections`}>
<ConnectionsPanel sheet={sheet} change={change} />
</div>
) : open === 'forwarding' && sheet.kind === 'gateway' ? ( ) : open === 'forwarding' && sheet.kind === 'gateway' ? (
<div key={`${id}-forwarding`}> <div key={`${id}-forwarding`}>
<PortForwardingPanel sheet={sheet} change={change} /> <PortForwardingPanel sheet={sheet} change={change} />
+5 -18
View File
@@ -22,7 +22,7 @@ import {
import type { FirewallAction, FirewallProtocol, FirewallRule } from '@mdg-filius/core'; import type { FirewallAction, FirewallProtocol, FirewallRule } from '@mdg-filius/core';
import { useTranslate } from '../i18n/i18n.js'; import { useTranslate } from '../i18n/i18n.js';
import type { Change } from './NameField.js'; import type { Change } from './NameField.js';
import { SheetCheck, SheetField } from './SheetField.js'; import { SheetCheck, SheetField, SheetNote } from './SheetField.js';
import { SheetTabs } from './SheetTabs.js'; import { SheetTabs } from './SheetTabs.js';
/** Java's protocol names in the rule table (ruleAsVector): "*" for all. */ /** Java's protocol names in the rule table (ruleAsVector): "*" for all. */
@@ -32,19 +32,6 @@ const OFFERED: readonly FirewallProtocol[] = ['all', 'tcp', 'udp'];
const cellText = (rule: FirewallRule, column: (typeof FIREWALL_RULE_COLUMNS)[number]) => (column === 'port' ? (rule.port < 0 ? '' : String(rule.port)) : rule[column]); const cellText = (rule: FirewallRule, column: (typeof FIREWALL_RULE_COLUMNS)[number]) => (column === 'port' ? (rule.port < 0 ? '' : String(rule.port)) : rule[column]);
/** A text with Java's line breaks as paragraphs. */
function Note({ text }: { readonly text: string }) {
return (
<>
{text.split('\n').map((line, index) => (
<p key={index} className="sheet-note">
{line}
</p>
))}
</>
);
}
export function FirewallPanel({ firewall, change }: { readonly firewall: FirewallSheet; readonly change: Change }) { export function FirewallPanel({ firewall, change }: { readonly firewall: FirewallSheet; readonly change: Change }) {
const t = useTranslate(); const t = useTranslate();
const [tab, setTab] = useState('general'); const [tab, setTab] = useState('general');
@@ -68,15 +55,15 @@ export function FirewallPanel({ firewall, change }: { readonly firewall: Firewal
{tab === 'general' ? ( {tab === 'general' ? (
<> <>
<SheetCheck name="firewallActive" label={t('firewall.active')} checked={firewall.isActive} onChange={flag('isActive')} /> <SheetCheck name="firewallActive" label={t('firewall.active')} checked={firewall.isActive} onChange={flag('isActive')} />
<Note text={t('firewall.activeHint')} /> <SheetNote text={t('firewall.activeHint')} />
<SheetCheck name="dropIcmp" label={t('firewall.dropIcmp')} checked={firewall.dropIcmp} onChange={flag('dropIcmp')} /> <SheetCheck name="dropIcmp" label={t('firewall.dropIcmp')} checked={firewall.dropIcmp} onChange={flag('dropIcmp')} />
<Note text={t('firewall.dropIcmpHint')} /> <SheetNote text={t('firewall.dropIcmpHint')} />
<SheetCheck name="filterSyn" label={t('firewall.filterSyn')} checked={firewall.filterSynSegmentsOnly} onChange={flag('filterSynSegmentsOnly')} /> <SheetCheck name="filterSyn" label={t('firewall.filterSyn')} checked={firewall.filterSynSegmentsOnly} onChange={flag('filterSynSegmentsOnly')} />
<Note text={t('firewall.filterSynHint')} /> <SheetNote text={t('firewall.filterSynHint')} />
</> </>
) : ( ) : (
<> <>
<Note text={t('firewall.intro')} /> <SheetNote text={t('firewall.intro')} />
<label className="sheet-field"> <label className="sheet-field">
<span>{t('firewall.defaultPolicy')}</span> <span>{t('firewall.defaultPolicy')}</span>
<select <select
+10 -5
View File
@@ -3,8 +3,9 @@
// general settings, each card and — on a router — the forwarding table // general settings, each card and — on a router — the forwarding table
// (JWeiterleitungsTabelle), greyed out while automatic routing (RIP) runs. // (JWeiterleitungsTabelle), greyed out while automatic routing (RIP) runs.
// The card whose tab is open has its cable drawn active, as Java draws it. // The card whose tab is open has its cable drawn active, as Java draws it.
// The firewall, the port forwarding, the DHCP server and the change of the // The firewall, the port forwarding, the DHCP server and the router's
// cards follow with their dialogs (F4.5c). // connections open as pages of the sheet (ServicePanels, FirewallPanel,
// ConnectionsPanel).
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { import {
@@ -74,12 +75,13 @@ export function RouterFields({
readonly sheet: RouterSheet; readonly sheet: RouterSheet;
readonly change: Change; readonly change: Change;
readonly onHighlight: (link: string | null) => void; readonly onHighlight: (link: string | null) => void;
readonly onOpen: (page: 'firewall') => void; readonly onOpen: (page: 'firewall' | 'connections') => void;
}) { }) {
const t = useTranslate(); const t = useTranslate();
const [tab, setTab] = useState('general'); const [tab, setTab] = useState('general');
// Java greys the table out while RIP runs; an open table goes back to the general tab. // Java greys the table out while RIP runs; an open table goes back to the general tab, as does the tab of a card removed.
const shown = tab === 'table' && sheet.rip ? 'general' : tab; const gone = tab.startsWith('card-') && Number(tab.slice(5)) >= sheet.interfaces.length;
const shown = (tab === 'table' && sheet.rip) || gone ? 'general' : tab;
const card = shown.startsWith('card-') ? sheet.interfaces[Number(shown.slice(5))] : undefined; const card = shown.startsWith('card-') ? sheet.interfaces[Number(shown.slice(5))] : undefined;
useHighlight(card?.connected?.link ?? null, onHighlight); useHighlight(card?.connected?.link ?? null, onHighlight);
const tabs: Tab[] = [ const tabs: Tab[] = [
@@ -103,6 +105,9 @@ export function RouterFields({
<button type="button" onClick={() => onOpen('firewall')}> <button type="button" onClick={() => onOpen('firewall')}>
{t('sheet.firewall')} {t('sheet.firewall')}
</button> </button>
<button type="button" onClick={() => onOpen('connections')}>
{t('sheet.connections')}
</button>
</div> </div>
</> </>
) : card !== undefined ? ( ) : card !== undefined ? (
+13
View File
@@ -110,3 +110,16 @@ export function SheetCheck({
</label> </label>
); );
} }
/** A text with Java's line breaks as paragraphs. */
export function SheetNote({ text }: { readonly text: string }) {
return (
<>
{text.split('\n').map((line, index) => (
<p key={index} className="sheet-note">
{line}
</p>
))}
</>
);
}
+64
View File
@@ -252,3 +252,67 @@
font-size: 13px; font-size: 13px;
color: var(--danger); color: var(--danger);
} }
.sheet-subtitle {
margin: 12px 0 4px;
font-size: 14px;
font-weight: 600;
}
/* A router's cards: Java's RJ45 button left of the card's name, what its
* cable joins below, joined by a line like Java's cable. */
.connections {
margin: 0;
padding: 0;
list-style: none;
}
.connections li {
display: grid;
grid-template-columns: 52px 1fr;
column-gap: 10px;
align-items: center;
padding: 4px 0;
}
.connection-port {
grid-row: span 2;
width: 52px;
height: 44px;
padding: 0;
border: 1px solid var(--border-strong);
border-radius: 8px;
cursor: pointer;
}
.connections li[data-state='plugged'] .connection-port {
background: var(--connection-plugged);
}
.connections li[data-state='free'] .connection-port {
background: var(--connection-free);
}
.connections li[data-state='marked'] .connection-port {
background: var(--connection-marked);
outline: 2px solid var(--border-focus);
outline-offset: 1px;
}
.connection-port img {
display: block;
margin: auto;
}
.connection-local {
font-size: 14px;
font-variant-numeric: tabular-nums;
}
.connection-remote {
padding-left: 10px;
border-left: 2px solid var(--canvas-cable);
font-size: 13px;
color: var(--text-secondary);
}
+7
View File
@@ -33,6 +33,10 @@
/* Java's active cable (JCablePanel.farbeBlinken): a frame on its way, or the card whose tab is open. */ /* Java's active cable (JCablePanel.farbeBlinken): a frame on its way, or the card whose tab is open. */
--canvas-cable-active: rgb(0 255 64); --canvas-cable-active: rgb(0 255 64);
--canvas-label: #000000; --canvas-label: #000000;
/* Java's buttons of a router's connections (JConnectionsDialog): a card plugged, free, marked. */
--connection-plugged: rgb(0 255 0);
--connection-free: rgb(255 0 0);
--connection-marked: rgb(255 255 0);
} }
:root[data-theme='dark'] { :root[data-theme='dark'] {
@@ -64,6 +68,9 @@
--canvas-cable-wireless: rgb(150 150 150); --canvas-cable-wireless: rgb(150 150 150);
--canvas-cable-active: rgb(0 200 50); --canvas-cable-active: rgb(0 200 50);
--canvas-label: #000000; --canvas-label: #000000;
--connection-plugged: rgb(0 190 0);
--connection-free: rgb(210 40 40);
--connection-marked: rgb(235 215 0);
} }
html, html,
+16 -2
View File
@@ -9,9 +9,9 @@ import { CATALOGS, translate } from '../src/i18n/i18n.js';
const bundles = join(dirname(fileURLToPath(import.meta.url)), '../../../tools/java-oracle/.upstream/src/main/resources/filius/messages'); const bundles = join(dirname(fileURLToPath(import.meta.url)), '../../../tools/java-oracle/.upstream/src/main/resources/filius/messages');
/** A Java .properties file: ISO-8859-1, \uXXXX escapes, `key = value`. */ /** A Java .properties file: ISO-8859-1, \uXXXX escapes, `key = value`, a backslash at a line's end going on in the next without its indent. */
function properties(file: string): Map<string, string> { function properties(file: string): Map<string, string> {
const text = new TextDecoder('latin1').decode(readFileSync(join(bundles, file))); const text = new TextDecoder('latin1').decode(readFileSync(join(bundles, file))).replace(/\\\r?\n[ \t]*/g, '');
const entries = new Map<string, string>(); const entries = new Map<string, string>();
for (const line of text.split(/\r?\n/)) { for (const line of text.split(/\r?\n/)) {
const match = /^([\w.]+)\s*=\s*(.*)$/.exec(line); const match = /^([\w.]+)\s*=\s*(.*)$/.exec(line);
@@ -105,6 +105,10 @@ const FROM_JAVA: Readonly<Record<string, string>> = {
'firewall.dropIcmp': 'jfirewalldialog_msg40', 'firewall.dropIcmp': 'jfirewalldialog_msg40',
'firewall.dropIcmpHint': 'jfirewalldialog_msg41', 'firewall.dropIcmpHint': 'jfirewalldialog_msg41',
'firewall.filterSyn': 'jfirewalldialog_msg42', 'firewall.filterSyn': 'jfirewalldialog_msg42',
'sheet.connections': 'jvermittlungsrechnerkonfiguration_msg23',
'connections.local': 'jvermittlungsrechnerkonfiguration_msg22',
'connections.add': 'jvermittlungsrechnerkonfiguration_msg24',
'connections.most': 'jvermittlungsrechnerkonfiguration_msg25',
}; };
/** Java's words, mended: the prompt says "Vermittlungsrechnerns" (guicontainer_msg1). */ /** Java's words, mended: the prompt says "Vermittlungsrechnerns" (guicontainer_msg1). */
@@ -124,6 +128,16 @@ const FROM_JAVA_MENDED: Readonly<Record<string, readonly [string, (java: string)
// Java's "\n" as line breaks; the last sentence of the German starts with a small letter there. // Java's "\n" as line breaks; the last sentence of the German starts with a small letter there.
'firewall.intro': ['jfirewalldialog_msg37', (java) => java.replaceAll('\\n', '\n').replace('\nüber', '\nÜber')], 'firewall.intro': ['jfirewalldialog_msg37', (java) => java.replaceAll('\\n', '\n').replace('\nüber', '\nÜber')],
'firewall.filterSynHint': ['jfirewalldialog_msg43', (java) => java.replaceAll('\\n', '\n')], 'firewall.filterSynHint': ['jfirewalldialog_msg43', (java) => java.replaceAll('\\n', '\n')],
// Java's lines without their leading space and the empty one; a step starts with a capital letter.
'connections.hint': [
'jvermittlungsrechnerkonfiguration_msg19',
(java) =>
java
.split('\\n')
.map((line) => line.trim().replace(/^(\d\. )(\p{Ll})/u, (_, step: string, letter: string) => step + letter.toUpperCase()))
.filter((line) => line !== '')
.join('\n'),
],
}; };
describe('the catalogs', () => { describe('the catalogs', () => {
+28
View File
@@ -1211,3 +1211,31 @@ Gemessen (03.10.2026):
Ports“; „080“ wird 80. Ports“; „080“ wird 80.
* rauf/runter: Die erste Regel geht nicht höher, die letzte nicht tiefer * rauf/runter: Die erste Regel geht nicht höher, die letzte nicht tiefer
(`moveUp`/`moveDown` antworten `false`). (`moveUp`/`moveDown` antworten `false`).
### Verbindungen verwalten (F4.5e)
`SheetRun` bedient auch `JConnectionsDialog` eines Vermittlungsrechners
(`connections`): drei Karten (10.0.0.1, 10.1.0.1, 10.2.0.1), Rechner A an
der ersten, Switch S an der zweiten, beide Kabel wie `GUIEvents`
(`createConnection`) sie in den `GUIContainer` legt; dann Klicks auf die
Knöpfe der Karten, „+“ und „-“, nach jedem Klick, was der Dialog zeigt
(Beschriftungen, Knopffarben, ob „+“/„-“ frei sind) und woran die Karten
hängen; bei einer und keiner Karte, ob das Router-Panel noch zeichnet und ob
der Router ein Gateway behält.
Gemessen (03.10.2026):
* Ein Klick markiert eine Karte gelb, ein zweiter auf dieselbe hebt das auf,
ein Klick auf eine andere tauscht die Kabel (beide belegt) oder steckt das
eine um (eine frei, auch eine markierte freie Karte); zwei freie: nichts.
Danach ist keine Karte markiert, ebenso nach „+“ und „-“.
* Beschriftungen: lokal „NIC n: Adresse“, Gegenstelle „Name (Adresse)“ bei
einem Rechner oder Router (die Adresse seiner Karte am Kabel), beim Switch
nur der Name.
* „+“: eine Karte mit 192.168.0.10/255.255.255.0 und neuer MAC, auch
mehrfach dieselbe Adresse; bei acht Karten gesperrt.
* „-“: die letzte Karte samt Kabel; der Port am anderen Ende wird frei.
Gesperrt erst bei null Karten. Das Panel zeichnet mit einer und mit keiner
Karte; ohne Karte behält der Router kein Gateway (`setStandardGateway`
schreibt es in die Karten, `getStandardGateway` liest `null`).
+138 -1
View File
@@ -30,18 +30,30 @@
// (JPortForwardingDialog, "forwarding"). Last, a router's firewall dialog // (JPortForwardingDialog, "forwarding"). Last, a router's firewall dialog
// (JFirewallDialog, "firewall"): a new rule, a cell of a rule edited in its // (JFirewallDialog, "firewall"): a new rule, a cell of a rule edited in its
// table as the table takes it (GatewayFirewallConfigTable.closeEditor), the // table as the table takes it (GatewayFirewallConfigTable.closeEditor), the
// rules moved and removed with the dialog's buttons. // rules moved and removed with the dialog's buttons. Then a router's
// connections dialog (JConnectionsDialog, "connections"): its cards and what
// their cables join, cables swapped with two clicks, cards added up to the
// last and removed down to none, and the router's panel and gateway with one
// card and with none.
// //
// Usage: java -cp filius.jar:lib/*:classes SheetRun <cases txt> <output json> // Usage: java -cp filius.jar:lib/*:classes SheetRun <cases txt> <output json>
import filius.gui.GUIContainer; import filius.gui.GUIContainer;
import filius.gui.JMainFrame; import filius.gui.JMainFrame;
import filius.gui.netzwerksicht.GUIDesignSidebar;
import filius.gui.netzwerksicht.GUIKabelItem;
import filius.gui.netzwerksicht.GUIKnotenItem;
import filius.gui.netzwerksicht.JConnectionsDialog;
import filius.gui.netzwerksicht.JDHCPKonfiguration; import filius.gui.netzwerksicht.JDHCPKonfiguration;
import filius.gui.netzwerksicht.JSidebarButton;
import filius.gui.netzwerksicht.JFirewallDialog; import filius.gui.netzwerksicht.JFirewallDialog;
import filius.gui.netzwerksicht.JKonfiguration; import filius.gui.netzwerksicht.JKonfiguration;
import filius.gui.netzwerksicht.JPortForwardingDialog; import filius.gui.netzwerksicht.JPortForwardingDialog;
import filius.gui.netzwerksicht.JWeiterleitungsTabelle; import filius.gui.netzwerksicht.JWeiterleitungsTabelle;
import filius.hardware.Kabel;
import filius.hardware.NetzwerkInterface; import filius.hardware.NetzwerkInterface;
import filius.hardware.Port;
import filius.hardware.knoten.Knoten;
import filius.hardware.knoten.Gateway; import filius.hardware.knoten.Gateway;
import filius.hardware.knoten.Host; import filius.hardware.knoten.Host;
import filius.hardware.knoten.Modem; import filius.hardware.knoten.Modem;
@@ -73,7 +85,11 @@ import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.regex.Pattern; import java.util.regex.Pattern;
import javax.swing.ImageIcon;
import javax.swing.JButton;
import javax.swing.JCheckBox; import javax.swing.JCheckBox;
import javax.swing.JLabel;
import java.awt.Color;
import javax.swing.JTable; import javax.swing.JTable;
import javax.swing.table.DefaultTableModel; import javax.swing.table.DefaultTableModel;
import java.util.LinkedList; import java.util.LinkedList;
@@ -471,6 +487,126 @@ public class SheetRun {
return flows; return flows;
} }
/** A device on the design surface, as GUIContainer keeps it (NewDeviceRun.add). */
static GUIKnotenItem item(Knoten device, String icon) {
JSidebarButton label = new JSidebarButton(new ImageIcon(SheetRun.class.getResource("/" + icon + ".png")), device.holeHardwareTyp());
label.initTextAndUpdateLocation(device.holeAnzeigeName());
GUIKnotenItem item = new GUIKnotenItem();
item.setKnoten(device);
item.setImageLabel(label);
GUIContainer.getGUIContainer().getKnotenItems().add(item);
return item;
}
/** A cable as GUIEvents.createConnection plugs it, from the first device's port to the second's. */
static void cable(GUIKnotenItem one, Port first, GUIKnotenItem two, Port second) {
GUIKabelItem item = new GUIKabelItem();
item.getKabelpanel().setZiel1(one);
item.getKabelpanel().setZiel2(two);
item.setDasKabel(new Kabel(first, second));
GUIContainer.getGUIContainer().getCableItems().add(item);
}
/** What the dialog shows and what the router's cards are joined to. */
static Map<String, Object> connectionsState(JConnectionsDialog dialog, Vermittlungsrechner router) throws Exception {
Map<String, Object> state = new LinkedHashMap<>();
List<Object> local = new ArrayList<>();
List<Object> remote = new ArrayList<>();
List<Object> colours = new ArrayList<>();
for (int i = 0; i < 8; i++) {
local.add(((JLabel) get(dialog, "lblLocal#" + i)).getText());
remote.add(((JLabel) get(dialog, "lblRemote#" + i)).getText());
JButton button = (JButton) get(dialog, "btnLocal#" + i);
Color colour = button.getBackground();
colours.add(!button.isEnabled() ? "off" : colour.equals(Color.YELLOW) ? "marked" : colour.equals(Color.GREEN) ? "plugged" : colour.equals(Color.RED) ? "free" : "other");
}
state.put("local", local);
state.put("remote", remote);
state.put("buttons", colours);
state.put("add", ((JButton) get(dialog, "btnAddInterface")).isEnabled());
state.put("remove", ((JButton) get(dialog, "btnRemoveInterface")).isEnabled());
// Each card: its address, mask, and the device its cable joins ("" for none).
List<Object> cards = new ArrayList<>();
for (NetzwerkInterface nic : router.getNetzwerkInterfaces()) {
String joined = "";
if (nic.getPort().getVerbindung() != null) {
for (Port port : nic.getPort().getVerbindung().getAnschluesse()) {
if (port != nic.getPort()) {
for (GUIKnotenItem each : GUIContainer.getGUIContainer().getKnotenItems()) {
Knoten node = each.getKnoten();
boolean owns = node instanceof Switch ? ((Switch) node).getAnschluesse().contains(port)
: node instanceof filius.hardware.knoten.InternetKnoten && ((filius.hardware.knoten.InternetKnoten) node).getNetzwerkInterfaces().stream().anyMatch(other -> other.getPort() == port);
if (owns) joined = node.holeAnzeigeName();
}
}
}
}
cards.add(Arrays.asList(nic.getIp(), nic.getSubnetzMaske(), joined));
}
state.put("cards", cards);
state.put("cables", GUIContainer.getGUIContainer().getCableItems().size());
return state;
}
static Map<String, Object> connectionsFlows() throws Exception {
GUIContainer.getGUIContainer().getKnotenItems().clear();
GUIContainer.getGUIContainer().getCableItems().clear();
Vermittlungsrechner router = new Vermittlungsrechner();
router.setzeAnzahlAnschluesse(3);
router.setName("R");
String[] addresses = { "10.0.0.1", "10.1.0.1", "10.2.0.1" };
for (int i = 0; i < 3; i++) router.getNetzwerkInterfaces().get(i).setIp(addresses[i]);
Rechner host = new Rechner();
host.setName("A");
host.getNetzwerkInterfaces().get(0).setIp("10.0.0.2");
Switch sw = new Switch();
sw.setName("S");
GUIKnotenItem routerItem = item(router, GUIDesignSidebar.VERMITTLUNGSRECHNER);
GUIKnotenItem hostItem = item(host, GUIDesignSidebar.RECHNER);
GUIKnotenItem switchItem = item(sw, GUIDesignSidebar.SWITCH);
// The host plugs into the router (the host first), the router into the switch.
cable(hostItem, host.getNetzwerkInterfaces().get(0).getPort(), routerItem, router.getNetzwerkInterfaces().get(0).getPort());
cable(routerItem, router.getNetzwerkInterfaces().get(1).getPort(), switchItem, sw.holeFreienPort());
JConnectionsDialog dialog = new JConnectionsDialog(JMainFrame.getJMainFrame(), router);
List<Object> steps = new ArrayList<>();
// A click on a card's button ("c<i>"), "+" and "-".
String[] clicks = { "start", "c0", "c0", "c0", "c2", "c2", "c1", "c1", "c1", "c0", "+", "c2", "c3", "-", "-", "+", "c0", "c2", "c0", "c1", "+", "+", "+", "+", "+", "+", "-", "-", "-", "-", "-", "-", "-", "-" };
for (String click : clicks) {
if (click.startsWith("c")) ((JButton) get(dialog, "btnLocal#" + click.substring(1))).doClick();
else if (click.equals("+")) ((JButton) get(dialog, "btnAddInterface")).doClick();
else if (click.equals("-")) ((JButton) get(dialog, "btnRemoveInterface")).doClick();
Map<String, Object> step = new LinkedHashMap<>();
step.put("click", click);
step.putAll(connectionsState(dialog, router));
// The router's panel with this many cards: does it still draw?
if (router.getNetzwerkInterfaces().size() <= 1) {
String error = null;
try {
JKonfiguration panel = JKonfiguration.getInstance(router);
panel.updateAttribute();
} catch (RuntimeException thrown) {
error = thrown.getClass().getSimpleName();
}
step.put("panelError", error);
// Java keeps the gateway in the cards: does a router without one keep it?
VermittlungsrechnerBetriebssystem os = (VermittlungsrechnerBetriebssystem) router.getSystemSoftware();
os.setStandardGateway("10.0.0.254");
step.put("gateway", os.getStandardGateway());
os.setStandardGateway("");
}
steps.add(step);
}
// New cards: their MAC addresses differ from each other and from the first cards'.
List<String> macs = new ArrayList<>();
for (NetzwerkInterface nic : router.getNetzwerkInterfaces()) macs.add(nic.getMac());
Map<String, Object> flows = new LinkedHashMap<>();
flows.put("steps", steps);
flows.put("hostPortFree", host.getNetzwerkInterfaces().get(0).getPort().getVerbindung() == null);
flows.put("switchPortsFree", sw.getAnschluesse().stream().allMatch(port -> port.getVerbindung() == null));
dialog.dispose();
return flows;
}
public static void main(String[] args) throws Exception { public static void main(String[] args) throws Exception {
// FILIUS' window keeps the JVM alive: end it on any failure too. // FILIUS' window keeps the JVM alive: end it on any failure too.
try { try {
@@ -596,6 +732,7 @@ public class SheetRun {
output.put("dhcp", dhcpFlows()); output.put("dhcp", dhcpFlows());
output.put("forwarding", forwardingFlows()); output.put("forwarding", forwardingFlows());
output.put("firewall", firewallFlows()); output.put("firewall", firewallFlows());
output.put("connections", connectionsFlows());
output.put("edits", edits); output.put("edits", edits);
try (FileOutputStream out = new FileOutputStream(args[1])) { try (FileOutputStream out = new FileOutputStream(args[1])) {
out.write((FlsOracle.Json.write(output, "") + "\n").getBytes(StandardCharsets.UTF_8)); out.write((FlsOracle.Json.write(output, "") + "\n").getBytes(StandardCharsets.UTF_8));