feat(editor): F4.5c the DHCP server and the port forwarding in the device sheet

Measured first: SheetRun now types into FILIUS' DHCP dialog and keeps rows
in its port forwarding dialog. Java's DHCP server keeps its bounds as typed
(".0.0.1" too), never takes an empty gateway or DNS server, hands out the
device's gateway and DNS server unless its own (a home router always its
LAN address, where "manual settings" do nothing), drops a second static
assignment of a MAC address silently and lets "_" through its MAC pattern;
the port forwarding dialog keeps any row, its firmware forwards only the
valid ones. Here a value passes its rule or is not taken, with a word why.

- doc: DHCP server addresses and flags, static assignments (a MAC address
  once, ignoring case), port forwarding rows (TCP or UDP, musterPort, the
  LAN address); the sheet shows what the server hands out and which rows
  Java's firmware forwards (the core's parsePortForwardingRow, exported)
- editor: Java's dialogs as pages of the sheet with a back button, the
  three row tables on one RowsEditor (the forwarding table rebuilt on it);
  a sheet per device, so an open page closes when another device is chosen

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 12:16:04 +00:00
co-authored by Claude Opus 5.5
parent eff9433673
commit c8edb87210
22 changed files with 1525 additions and 143 deletions
+3 -1
View File
@@ -125,6 +125,7 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand
| `src/settings.ts` | Java `JHostKonfiguration.aenderungenAnnehmen/aendereAnzeigeName/updateWifiConnection`, `JSwitchKonfiguration.aenderungenAnnehmen/changeAppearance`, `JModemKonfiguration` | neu (F4.5a): Name, Anzeige als Adresse/MAC, Adresse, Maske, Gateway, DNS, IP-Weiterleitung (nur abweichend von der Art gespeichert), DHCP (hält den eigenen DHCP-Server an), LAN/WLAN mit SSID samt Funkverbindung, SSID (wirft die Funkgeräte ab), Haltezeit und Wolke eines Switches, Port eines Modems (als Paar); je Änderung eine Transaktion mit Ursprung. Abweichend von Java: Der Name bleibt, wenn die MAC-Adresse angezeigt und ein anderes Feld übernommen wird (Java überschreibt ihn mit der MAC-Adresse); ein Rechner tritt dem ersten Switch mit der SSID bei (Javas Suche hängt von unbeteiligten Geräten ab); ein voller Zugangspunkt meldet sich mit Javas Meldung (Java baut ein Kabel ohne Port) | | `src/settings.ts` | Java `JHostKonfiguration.aenderungenAnnehmen/aendereAnzeigeName/updateWifiConnection`, `JSwitchKonfiguration.aenderungenAnnehmen/changeAppearance`, `JModemKonfiguration` | neu (F4.5a): Name, Anzeige als Adresse/MAC, Adresse, Maske, Gateway, DNS, IP-Weiterleitung (nur abweichend von der Art gespeichert), DHCP (hält den eigenen DHCP-Server an), LAN/WLAN mit SSID samt Funkverbindung, SSID (wirft die Funkgeräte ab), Haltezeit und Wolke eines Switches, Port eines Modems (als Paar); je Änderung eine Transaktion mit Ursprung. Abweichend von Java: Der Name bleibt, wenn die MAC-Adresse angezeigt und ein anderes Feld übernommen wird (Java überschreibt ihn mit der MAC-Adresse); ein Rechner tritt dem ersten Switch mit der SSID bei (Javas Suche hängt von unbeteiligten Geräten ab); ein voller Zugangspunkt meldet sich mit Javas Meldung (Java baut ein Kabel ohne Port) |
| `src/sheet.ts` | Java `JHostKonfiguration.updateAttribute/updateSsid`, `JSwitchKonfiguration.updateAttribute`, `JModemKonfiguration.updateAttribute` | neu (F4.5a): was das Blatt zeigt — je Art die Felder des Java-Panels, die Netze der Switches je einmal, WLAN nur bei freiem Port oder schon drahtlos, Haltezeit in ganzen Sekunden | | `src/sheet.ts` | Java `JHostKonfiguration.updateAttribute/updateSsid`, `JSwitchKonfiguration.updateAttribute`, `JModemKonfiguration.updateAttribute` | neu (F4.5a): was das Blatt zeigt — je Art die Felder des Java-Panels, die Netze der Switches je einmal, WLAN nur bei freiem Port oder schon drahtlos, Haltezeit in ganzen Sekunden |
| `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/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 |
@@ -164,7 +165,8 @@ 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 | | `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 |
| `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 |
## tools/swift-oracle ## tools/swift-oracle
+1 -1
View File
@@ -547,7 +547,7 @@ export interface NatEntry {
* TCP or UDP in any case, or 6 or 17; ports 1…65535 without leading zeros; * TCP or UDP in any case, or 6 or 17; ports 1…65535 without leading zeros;
* an IPv4 address (EingabenUeberpruefung). * an IPv4 address (EingabenUeberpruefung).
*/ */
function parsePortForwardingRow( export function parsePortForwardingRow(
row: GatewayPortForwardingRow, row: GatewayPortForwardingRow,
): { protocol: 'tcp' | 'udp'; publicPort: number; lanIPAddress: string; lanPort: number } | undefined { ): { protocol: 'tcp' | 'udp'; publicPort: number; lanIPAddress: string; lanPort: number } | undefined {
const protocolValue = row.protocolValue.toUpperCase(); const protocolValue = row.protocolValue.toUpperCase();
+10 -1
View File
@@ -20,7 +20,7 @@
import { IP_ADDRESS_PATTERN, javaIpCheck } from '@mdg-filius/core'; import { IP_ADDRESS_PATTERN, javaIpCheck } from '@mdg-filius/core';
export type FieldRule = 'name' | 'ipAddress' | 'ipAddressOrEmpty' | 'subnetMask' | 'ssid' | 'seconds' | 'port'; export type FieldRule = 'name' | 'ipAddress' | 'ipAddressOrEmpty' | 'subnetMask' | 'ssid' | 'seconds' | 'port' | 'macAddress';
/** What a field's text comes to: the value the device takes, or that it takes none. */ /** What a field's text comes to: the value the device takes, or that it takes none. */
export type CheckedField = { readonly value: string } | { readonly invalid: true }; export type CheckedField = { readonly value: string } | { readonly invalid: true };
@@ -31,6 +31,12 @@ const SUBNET_MASK_PATTERN = /^(0*([1-9]|1?[0-9]{1,2}|2[0-4]?[0-9]|25[0-5]))\.((0
const SSID_PATTERN = /^[ -~]+$/; const SSID_PATTERN = /^[ -~]+$/;
/** EingabenUeberpruefung.musterPort: 1 to 65535, no leading zero. */ /** EingabenUeberpruefung.musterPort: 1 to 65535, no leading zero. */
const PORT_PATTERN = /^([1-9]|[1-9][0-9]{1,3}|[1-5][0-9]{1,4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])$/; const PORT_PATTERN = /^([1-9]|[1-9][0-9]{1,3}|[1-5][0-9]{1,4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])$/;
/**
* EingabenUeberpruefung.musterMacAddress, whose class [0-9a-fA-f] also lets
* the six characters between Z and a through ("AA:BB:CC:DD:EE:_F"); here
* hexadecimal digits only.
*/
const MAC_ADDRESS_PATTERN = /^[0-9A-Fa-f]{2}(:[0-9A-Fa-f]{2}){5}$/;
/** EingabenUeberpruefung.musterNurZahlen, at least one digit (Java's Long.parseLong refuses none). */ /** EingabenUeberpruefung.musterNurZahlen, at least one digit (Java's Long.parseLong refuses none). */
const DIGITS_PATTERN = /^[0-9]+$/; const DIGITS_PATTERN = /^[0-9]+$/;
@@ -65,5 +71,8 @@ export function checkField(rule: FieldRule, text: string): CheckedField {
} }
case 'port': case 'port':
return PORT_PATTERN.test(text.trim()) ? { value: text.trim() } : INVALID; return PORT_PATTERN.test(text.trim()) ? { value: text.trim() } : INVALID;
case 'macAddress':
// Upper case, as the model keeps MAC addresses (Java keeps them as typed and compares ignoring case).
return MAC_ADDRESS_PATTERN.test(text.trim()) ? { value: text.trim().toUpperCase() } : INVALID;
} }
} }
+164 -2
View File
@@ -20,12 +20,27 @@
// Nor: an interface address that passes the panel's pattern but not // Nor: an interface address that passes the panel's pattern but not
// IP.ipCheck (".0.0.1") becomes null in Java, and a router's panel then // IP.ipCheck (".0.0.1") becomes null in Java, and a router's panel then
// throws; a table cell Java refuses is stored as null. Here neither is taken. // throws; a table cell Java refuses is stored as null. Here neither is taken.
// Nor that a DHCP server keeps its bounds as typed (".0.0.1" too), drops a
// second static assignment of a MAC address without a word, and that a home
// router keeps any port forwarding row, valid or not (its firmware then
// forwards only the valid ones): here a value passes its rule or is not
// taken, and the sheet says why.
import * as Y from 'yjs'; import * as Y from 'yjs';
import { DEFAULT_REMOTE_LINK_LATENCY_MILLISECONDS, defaultSwitchConfiguration, forwardsIPv4ByDefault, type Id, type ManualRoute, type NodeKind } from '@mdg-filius/core'; import {
DEFAULT_REMOTE_LINK_LATENCY_MILLISECONDS,
defaultSwitchConfiguration,
dhcpServerConfiguration,
forwardsIPv4ByDefault,
type DhcpStaticAssignment,
type GatewayPortForwardingRow,
type Id,
type ManualRoute,
type NodeKind,
} 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, type ConnectError } from './commands.js';
import { checkField, type CheckedField } 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 { toY } from './values.js'; import { toY } from './values.js';
@@ -319,3 +334,150 @@ export function removeRoute(doc: Y.Doc, id: Id, index: number, origin: unknown):
if (routes.length === 0) settingsOf(node).delete('routes'); if (routes.length === 0) settingsOf(node).delete('routes');
}, origin); }, origin);
} }
/** Checks a row's cells by their rules: the values, or the columns that do not pass. */
function checkRow<Column extends string>(
columns: readonly Column[],
rule: (column: Column) => FieldRule,
cells: Readonly<Record<Column, string>>,
): { readonly values: Record<Column, string> } | { readonly invalid: readonly Column[] } {
const checked = columns.map((column) => [column, checkField(rule(column), cells[column])] as const);
const invalid = checked.filter(([, each]) => 'invalid' in each).map(([column]) => column);
if (invalid.length > 0) return { invalid };
return { values: Object.fromEntries(checked.map(([column, each]) => [column, (each as { value: string }).value])) as Record<Column, string> };
}
/** A device's DHCP server, made as Java's constructor leaves it when the device has none in the document. */
function dhcpServerOf(node: Y.Map<unknown>): Y.Map<unknown> {
let server = settingsOf(node).get('dhcpServer') as Y.Map<unknown> | undefined;
if (server === undefined) {
server = toY({ ...dhcpServerConfiguration(), staticAssignments: [] }) as Y.Map<unknown>;
settingsOf(node).set('dhcpServer', server);
}
return server;
}
export type DhcpServerField = 'lowerBoundIPAddress' | 'upperBoundIPAddress' | 'gatewayIPAddress' | 'dnsServerIPAddress';
/** An address of a DHCP server (JDHCPKonfiguration: musterIpAdresse, never empty), as Java's IP.ipCheck writes it. */
export function setDhcpServerAddress(doc: Y.Doc, id: Id, field: DhcpServerField, text: string, origin: unknown): CheckedField {
return setChecked(doc, id, checkField('ipAddress', text), (node, value) => dhcpServerOf(node).set(field, value), origin);
}
/** A DHCP server switched on or off, or handing out its own gateway and DNS server instead of the device's. */
export function setDhcpServerFlag(doc: Y.Doc, id: Id, flag: 'isActive' | 'useOwnSettings', on: boolean, origin: unknown): void {
const node = nodes(doc).get(id);
if (node !== undefined) doc.transact(() => dhcpServerOf(node).set(flag, on), origin);
}
export type StaticAssignmentColumn = 'macAddress' | 'ipAddress';
export const STATIC_ASSIGNMENT_COLUMNS: readonly StaticAssignmentColumn[] = ['macAddress', 'ipAddress'];
const staticRule = (column: StaticAssignmentColumn): FieldRule => (column === 'macAddress' ? 'macAddress' : 'ipAddress');
const staticAssignmentsOf = (node: Y.Map<unknown>) => {
const server = dhcpServerOf(node);
let list = server.get('staticAssignments') as Y.Array<Y.Map<unknown>> | undefined;
if (list === undefined) {
list = new Y.Array<Y.Map<unknown>>();
server.set('staticAssignments', list);
}
return list;
};
/** Whether another static assignment has this MAC address (Java compares ignoring case). */
function macTaken(list: Y.Array<Y.Map<unknown>>, mac: string, except: number): boolean {
return list.toArray().some((entry, index) => index !== except && (entry.get('macAddress') as string).toUpperCase() === mac.toUpperCase());
}
/** A static assignment of the DHCP server: once both cells pass and the MAC address has none yet. */
export function addDhcpStaticAssignment(
doc: Y.Doc,
id: Id,
cells: Readonly<Record<StaticAssignmentColumn, string>>,
assignmentId: Id,
origin: unknown,
): { readonly assignment: DhcpStaticAssignment } | { readonly invalid: readonly StaticAssignmentColumn[] } | { readonly error: 'macTaken' } {
const checked = checkRow(STATIC_ASSIGNMENT_COLUMNS, staticRule, cells);
const node = nodes(doc).get(id);
if ('invalid' in checked || node === undefined) return 'invalid' in checked ? checked : { invalid: [] };
const existing = settingsOf(node).get('dhcpServer') as Y.Map<unknown> | undefined;
const list = existing?.get('staticAssignments') as Y.Array<Y.Map<unknown>> | undefined;
if (list !== undefined && macTaken(list, checked.values.macAddress, -1)) return { error: 'macTaken' };
const assignment = { id: assignmentId, ...checked.values };
doc.transact(() => staticAssignmentsOf(node).push([toY(assignment) as Y.Map<unknown>]), origin);
return { assignment };
}
/** A cell of a static assignment; a MAC address another assignment has is not taken. */
export function setDhcpStaticCell(doc: Y.Doc, id: Id, index: number, column: StaticAssignmentColumn, text: string, origin: unknown): CheckedField {
const node = nodes(doc).get(id);
const list = (node?.get('settings') as Y.Map<unknown> | undefined)?.get('dhcpServer') as Y.Map<unknown> | undefined;
const entries = list?.get('staticAssignments') as Y.Array<Y.Map<unknown>> | undefined;
const checked = checkField(staticRule(column), text);
if (entries === undefined || index < 0 || index >= entries.length) return checked;
if ('value' in checked && column === 'macAddress' && macTaken(entries, checked.value, index)) return { invalid: true };
return setChecked(doc, id, checked, () => entries.get(index).set(column, (checked as { value: string }).value), origin);
}
export function removeDhcpStaticAssignment(doc: Y.Doc, id: Id, index: number, origin: unknown): void {
const node = nodes(doc).get(id);
const entries = ((node?.get('settings') as Y.Map<unknown> | undefined)?.get('dhcpServer') as Y.Map<unknown> | undefined)?.get('staticAssignments') as Y.Array<Y.Map<unknown>> | undefined;
if (entries === undefined || index < 0 || index >= entries.length) return;
doc.transact(() => entries.delete(index, 1), origin);
}
export type PortForwardingColumn = keyof GatewayPortForwardingRow;
export const PORT_FORWARDING_COLUMNS: readonly PortForwardingColumn[] = ['protocolValue', 'publicPortValue', 'lanIPAddress', 'lanPortValue'];
/** Java's protocols of a port forwarding row, as its dialog offers them. */
export const PORT_FORWARDING_PROTOCOLS = ['TCP', 'UDP'] as const;
/** Each column's rule (GatewayFirmware.validateData): a protocol, ports by musterPort, the LAN address by musterIpAdresse. */
const forwardingRule = (column: PortForwardingColumn): FieldRule => (column === 'lanIPAddress' ? 'ipAddress' : column === 'protocolValue' ? 'name' : 'port');
function checkForwardingRow(cells: Readonly<Record<PortForwardingColumn, string>>) {
const checked = checkRow(PORT_FORWARDING_COLUMNS, forwardingRule, cells);
const protocolTaken = (PORT_FORWARDING_PROTOCOLS as readonly string[]).includes(cells.protocolValue);
if ('invalid' in checked) return protocolTaken ? checked : { invalid: ['protocolValue' as const, ...checked.invalid] };
return protocolTaken ? checked : { invalid: ['protocolValue' as const] };
}
const forwardingOf = (node: Y.Map<unknown>) => {
let list = settingsOf(node).get('portForwarding') as Y.Array<Y.Map<unknown>> | undefined;
if (list === undefined) {
list = new Y.Array<Y.Map<unknown>>();
settingsOf(node).set('portForwarding', list);
}
return list;
};
/** A home router's port forwarding row, once all four cells pass (Java keeps any row; its firmware forwards only the valid ones). */
export function addPortForwarding(
doc: Y.Doc,
id: Id,
cells: Readonly<Record<PortForwardingColumn, string>>,
origin: unknown,
): { readonly row: GatewayPortForwardingRow } | { readonly invalid: readonly PortForwardingColumn[] } {
const checked = checkForwardingRow(cells);
const node = nodes(doc).get(id);
if ('invalid' in checked || node === undefined) return 'invalid' in checked ? checked : { invalid: [] };
const row = checked.values as GatewayPortForwardingRow;
doc.transact(() => forwardingOf(node).push([toY({ ...row }) as Y.Map<unknown>]), origin);
return { row };
}
export function setPortForwardingCell(doc: Y.Doc, id: Id, index: number, column: PortForwardingColumn, text: string, origin: unknown): CheckedField {
const checked: CheckedField =
column === 'protocolValue' ? ((PORT_FORWARDING_PROTOCOLS as readonly string[]).includes(text) ? { value: text } : { invalid: true }) : checkField(forwardingRule(column), text);
return setChecked(doc, id, checked, (node, value) => (settingsOf(node).get('portForwarding') as Y.Array<Y.Map<unknown>> | undefined)?.get(index)?.set(column, value), origin);
}
/** Removes a port forwarding row; the list goes with its last row, as the project keeps it. */
export function removePortForwarding(doc: Y.Doc, id: Id, index: number, origin: unknown): void {
const node = nodes(doc).get(id);
const list = node === undefined ? undefined : (settingsOf(node).get('portForwarding') as Y.Array<Y.Map<unknown>> | undefined);
if (node === undefined || list === undefined || index < 0 || index >= list.length) return;
doc.transact(() => {
list.delete(index, 1);
if (list.length === 0) settingsOf(node).delete('portForwarding');
}, origin);
}
+53
View File
@@ -7,8 +7,12 @@ import * as Y from 'yjs';
import { import {
defaultSwitchConfiguration, defaultSwitchConfiguration,
DEFAULT_SWITCH_RETENTION_MILLISECONDS, DEFAULT_SWITCH_RETENTION_MILLISECONDS,
dhcpServerConfiguration,
forwardsIPv4ByDefault, forwardsIPv4ByDefault,
parsePortForwardingRow,
routeTableRows, routeTableRows,
type DhcpServerConfiguration,
type GatewayPortForwardingRow,
stableGeneratedMacAddress, stableGeneratedMacAddress,
type HostLabelPolicy, type HostLabelPolicy,
type ManualRoute, type ManualRoute,
@@ -20,6 +24,25 @@ import { inOrder, ROOTS } from './projectDoc.js';
import { fromY } from './values.js'; import { fromY } from './values.js';
import { nodeLabel } from './view.js'; import { nodeLabel } from './view.js';
/** A device's DHCP server as Java's dialog shows it (JDHCPKonfiguration). */
export interface DhcpServerSheet {
readonly isActive: boolean;
readonly lowerBoundIPAddress: string;
readonly upperBoundIPAddress: string;
/** The device's own mask (the LAN card's on a home router), shown, not changed. */
readonly subnetMask: string;
/** What the server hands out (DHCPServer.determineGatewayip): its own setting, the device's gateway (0.0.0.0 without one), a home router's LAN address. */
readonly gateway: string;
readonly gatewayEditable: boolean;
/** determineDnsserverip: its own setting (always on a home router), else the device's DNS server or 0.0.0.0. */
readonly dnsServer: string;
readonly dnsServerEditable: boolean;
readonly useOwnSettings: boolean;
/** A home router's dialog leaves the choice without effect; the sheet offers it on a PC or notebook only. */
readonly ownSettingsOffered: boolean;
readonly staticAssignments: readonly { readonly macAddress: string; readonly ipAddress: string }[];
}
export interface HostSheet { export interface HostSheet {
readonly kind: 'pc' | 'notebook'; readonly kind: 'pc' | 'notebook';
readonly name: string; readonly name: string;
@@ -40,6 +63,7 @@ export interface HostSheet {
readonly wirelessPossible: boolean; readonly wirelessPossible: boolean;
/** The switches' SSIDs, in the order of the devices, each once: the networks to choose from. */ /** The switches' SSIDs, in the order of the devices, each once: the networks to choose from. */
readonly networks: readonly string[]; readonly networks: readonly string[];
readonly dhcpServer: DhcpServerSheet;
} }
export interface SwitchSheet { export interface SwitchSheet {
@@ -89,6 +113,9 @@ export interface HomeRouterSheet {
readonly dhcpClient: boolean; readonly dhcpClient: boolean;
readonly wan: InterfaceSheet; readonly wan: InterfaceSheet;
readonly lan: InterfaceSheet; readonly lan: InterfaceSheet;
readonly dhcpServer: DhcpServerSheet;
/** The port forwarding rows, each with whether Java's firmware forwards it (GatewayFirmware.validateData). */
readonly portForwarding: readonly (GatewayPortForwardingRow & { readonly forwarded: boolean })[];
} }
export interface NameSheet { export interface NameSheet {
@@ -123,6 +150,27 @@ function interfacesOf(doc: Y.Doc, id: string, node: Y.Map<unknown>): InterfaceSh
}); });
} }
/** The DHCP server's sheet, from the device's own address settings (or a home router's LAN card). */
function dhcpServerSheet(node: Y.Map<unknown>, own: { readonly subnetMask: string; readonly gateway: string; readonly dnsServer: string }, home: boolean): DhcpServerSheet {
const stored = (fromY(settingsOf(node).get('dhcpServer')) ?? {}) as Partial<DhcpServerConfiguration>;
const server = dhcpServerConfiguration(stored);
const unset = (value: string) => (value === '' ? '0.0.0.0' : value);
const ownSettings = !home && server.useOwnSettings;
return {
isActive: server.isActive,
lowerBoundIPAddress: server.lowerBoundIPAddress,
upperBoundIPAddress: server.upperBoundIPAddress,
subnetMask: own.subnetMask,
gateway: home ? own.gateway : ownSettings ? server.gatewayIPAddress : unset(own.gateway),
gatewayEditable: ownSettings,
dnsServer: home || ownSettings ? server.dnsServerIPAddress : unset(own.dnsServer),
dnsServerEditable: home || ownSettings,
useOwnSettings: server.useOwnSettings,
ownSettingsOffered: !home,
staticAssignments: server.staticAssignments.map(({ macAddress, ipAddress }) => ({ macAddress, ipAddress })),
};
}
/** The sheet of a device, or undefined when the document has no such device. */ /** The sheet of a device, or undefined when the document has no such device. */
export function deviceSheet(doc: Y.Doc, id: string): DeviceSheet | undefined { export function deviceSheet(doc: Y.Doc, id: string): DeviceSheet | undefined {
const all = doc.getMap<Y.Map<unknown>>(ROOTS.nodes); const all = doc.getMap<Y.Map<unknown>>(ROOTS.nodes);
@@ -160,6 +208,7 @@ export function deviceSheet(doc: Y.Doc, id: string): DeviceSheet | undefined {
ssid: (wireless?.get('ssid') as string | undefined) ?? '', ssid: (wireless?.get('ssid') as string | undefined) ?? '',
wirelessPossible: isWireless || !plugged, wirelessPossible: isWireless || !plugged,
networks: [...networks], networks: [...networks],
dhcpServer: dhcpServerSheet(node, { subnetMask: text(device, 'subnetMask'), gateway: text(device, 'defaultGateway'), dnsServer: text(device, 'dnsServer') }, false),
}; };
} }
case 'networkSwitch': { case 'networkSwitch': {
@@ -192,6 +241,7 @@ export function deviceSheet(doc: Y.Doc, id: string): DeviceSheet | undefined {
} }
case 'gateway': { case 'gateway': {
const [wan, lan] = interfacesOf(doc, id, node); const [wan, lan] = interfacesOf(doc, id, node);
const rows = (fromY(settingsOf(node).get('portForwarding')) ?? []) as unknown as GatewayPortForwardingRow[];
return { return {
kind, kind,
name, name,
@@ -200,6 +250,9 @@ export function deviceSheet(doc: Y.Doc, id: string): DeviceSheet | undefined {
dhcpClient: setting(node, 'dhcpClient')?.get('isEnabled') === true, dhcpClient: setting(node, 'dhcpClient')?.get('isEnabled') === true,
wan: wan!, wan: wan!,
lan: lan!, lan: lan!,
// Java's home router hands out its LAN address as the gateway, with the LAN card's mask.
dhcpServer: dhcpServerSheet(node, { subnetMask: lan!.subnetMask, gateway: lan!.ipAddress, dnsServer: '' }, true),
portForwarding: rows.map((row) => ({ ...row, forwarded: parsePortForwardingRow(row) !== undefined })),
}; };
} }
case 'unsupported': case 'unsupported':
+19
View File
@@ -116,3 +116,22 @@ homeRouter lanIp 192.168.1.256
homeRouter lanIp 192.168.1.1 homeRouter lanIp 192.168.1.1
homeRouter lanMask 255.255.255.128 homeRouter lanMask 255.255.255.128
homeRouter lanMask abc homeRouter lanMask abc
dhcpHost lower 192.168.0.110
dhcpHost lower 010.000.000.001
dhcpHost lower .0.0.1
dhcpHost lower 10..0.0.1
dhcpHost lower 256.0.0.1
dhcpHost lower
dhcpHost lower 192.168.0.110
dhcpHost upper 192.168.0.250
dhcpHost upper abc
dhcpHost gateway 10.0.0.9
dhcpHost gateway
dhcpHost gateway 10.0.0
dhcpHost dns 10.0.0.8
dhcpHost dns
dhcpHost dns 10.0.0.256
dhcpHome dns 42.0.0.53
dhcpHome dns 42.0.0
dhcpHome lower 192.168.0.110
dhcpHome gateway 10.0.0.9
+285 -1
View File
@@ -1035,10 +1035,181 @@
"marked": true, "marked": true,
"stored": "255.255.255.0", "stored": "255.255.255.0",
"shown": "255.255.255.0" "shown": "255.255.255.0"
},
{
"device": "dhcpHost",
"field": "lower",
"input": "192.168.0.110",
"start": "192.168.0.100",
"marked": false,
"stored": "192.168.0.110",
"shown": "192.168.0.110"
},
{
"device": "dhcpHost",
"field": "lower",
"input": "010.000.000.001",
"start": "192.168.0.100",
"marked": false,
"stored": "010.000.000.001",
"shown": "010.000.000.001"
},
{
"device": "dhcpHost",
"field": "lower",
"input": ".0.0.1",
"start": "192.168.0.100",
"marked": false,
"stored": ".0.0.1",
"shown": ".0.0.1"
},
{
"device": "dhcpHost",
"field": "lower",
"input": "10..0.0.1",
"start": "192.168.0.100",
"marked": true,
"stored": "192.168.0.100",
"shown": "192.168.0.100"
},
{
"device": "dhcpHost",
"field": "lower",
"input": "256.0.0.1",
"start": "192.168.0.100",
"marked": true,
"stored": "192.168.0.100",
"shown": "192.168.0.100"
},
{
"device": "dhcpHost",
"field": "lower",
"input": "",
"start": "192.168.0.100",
"marked": true,
"stored": "192.168.0.100",
"shown": "192.168.0.100"
},
{
"device": "dhcpHost",
"field": "lower",
"input": " 192.168.0.110",
"start": "192.168.0.100",
"marked": true,
"stored": "192.168.0.100",
"shown": "192.168.0.100"
},
{
"device": "dhcpHost",
"field": "upper",
"input": "192.168.0.250",
"start": "192.168.0.200",
"marked": false,
"stored": "192.168.0.250",
"shown": "192.168.0.250"
},
{
"device": "dhcpHost",
"field": "upper",
"input": "abc",
"start": "192.168.0.200",
"marked": true,
"stored": "192.168.0.200",
"shown": "192.168.0.200"
},
{
"device": "dhcpHost",
"field": "gateway",
"input": "10.0.0.9",
"start": "10.0.0.1",
"marked": false,
"stored": "10.0.0.9",
"shown": "10.0.0.9"
},
{
"device": "dhcpHost",
"field": "gateway",
"input": "",
"start": "10.0.0.1",
"marked": true,
"stored": "10.0.0.1",
"shown": "10.0.0.1"
},
{
"device": "dhcpHost",
"field": "gateway",
"input": "10.0.0",
"start": "10.0.0.1",
"marked": true,
"stored": "10.0.0.1",
"shown": "10.0.0.1"
},
{
"device": "dhcpHost",
"field": "dns",
"input": "10.0.0.8",
"start": "10.0.0.2",
"marked": false,
"stored": "10.0.0.8",
"shown": "10.0.0.8"
},
{
"device": "dhcpHost",
"field": "dns",
"input": "",
"start": "10.0.0.2",
"marked": true,
"stored": "10.0.0.2",
"shown": "10.0.0.2"
},
{
"device": "dhcpHost",
"field": "dns",
"input": "10.0.0.256",
"start": "10.0.0.2",
"marked": true,
"stored": "10.0.0.2",
"shown": "10.0.0.2"
},
{
"device": "dhcpHome",
"field": "dns",
"input": "42.0.0.53",
"start": "10.0.0.2",
"marked": false,
"stored": "42.0.0.53",
"shown": "42.0.0.53"
},
{
"device": "dhcpHome",
"field": "dns",
"input": "42.0.0",
"start": "10.0.0.2",
"marked": true,
"stored": "10.0.0.2",
"shown": "10.0.0.2"
},
{
"device": "dhcpHome",
"field": "lower",
"input": "192.168.0.110",
"start": "192.168.0.100",
"marked": false,
"stored": "192.168.0.110",
"shown": "192.168.0.110"
},
{
"device": "dhcpHome",
"field": "gateway",
"input": "10.0.0.9",
"start": "10.0.0.1",
"marked": false,
"stored": "10.0.0.1",
"shown": "10.0.0.1"
} }
], ],
"flows": { "flows": {
"useMacAsNameThenOff": "CE:0D:5A:24:8A:C6", "useMacAsNameThenOff": "26:0A:1B:EE:FE:4B",
"useIpAsNameThenOff": "PC-A", "useIpAsNameThenOff": "PC-A",
"dhcpClientStopsServer": true, "dhcpClientStopsServer": true,
"dhcpClient": true, "dhcpClient": true,
@@ -1390,6 +1561,119 @@
} }
] ]
}, },
"dhcp": {
"dhcpHostShown": {
"tfUntergrenze": [
"192.168.0.100",
true
],
"tfObergrenze": [
"192.168.0.200",
true
],
"tfNetzmaske": [
"255.255.255.0",
false
],
"tfGateway": [
"192.168.0.254",
false
],
"tfDNSServer": [
"192.168.0.253",
false
]
},
"dhcpHomeShown": {
"tfUntergrenze": [
"192.168.0.100",
true
],
"tfObergrenze": [
"192.168.0.200",
true
],
"tfNetzmaske": [
"255.255.255.0",
false
],
"tfGateway": [
"192.168.0.10",
false
],
"tfDNSServer": [
"10.0.0.2",
true
]
},
"hostWithoutGatewayShown": [
"0.0.0.0",
"0.0.0.0"
],
"staticAssignments": [
[
"aa:bb:cc:dd:ee:01",
"192.168.0.50"
],
[
"AA:BB:CC:DD:EE:_F",
"192.168.0.52"
],
[
"AA:BB:CC:DD:EE:03",
"010.0.0.1"
]
],
"addable": [
true,
true,
true,
false,
true,
false
]
},
"forwarding": {
"rows": [
[
"TCP",
"80",
"192.168.0.20",
"8080"
],
[
"UDP",
"0",
"192.168.0.21",
"53"
],
[
"TCP",
"abc",
"192.168.0.22",
"80"
],
[
"tcp",
"443",
"192.168.0.300",
"443"
],
[
"17",
"5353",
"192.168.0.23",
"05353"
]
],
"valid": [
true,
false,
false,
false,
false
]
},
"edits": { "edits": {
"invalidCell": { "invalidCell": {
"manual": [ "manual": [
+222 -7
View File
@@ -9,8 +9,10 @@ import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import * as Y from 'yjs'; import * as Y from 'yjs';
import { javaIpCheck } from '@mdg-filius/core';
import { openProjectArchive, saveProjectArchive } from '@mdg-filius/fls'; import { openProjectArchive, saveProjectArchive } from '@mdg-filius/fls';
import { import {
toY,
addDevice, addDevice,
checkField, checkField,
connect, connect,
@@ -29,7 +31,15 @@ import {
setSwitchRetention, setSwitchRetention,
setSwitchSsid, setSwitchSsid,
addRoute, addRoute,
addDhcpStaticAssignment,
addPortForwarding,
removeDhcpStaticAssignment,
removePortForwarding,
removeRoute, removeRoute,
setDhcpServerAddress,
setDhcpServerFlag,
setDhcpStaticCell,
setPortForwardingCell,
setInterfaceAddress, setInterfaceAddress,
setRip, setRip,
setRouteCell, setRouteCell,
@@ -43,7 +53,7 @@ import {
} from '../src/index.js'; } from '../src/index.js';
interface JavaCase { interface JavaCase {
readonly device: 'host' | 'switch' | 'modem' | 'router' | 'homeRouter'; readonly device: 'host' | 'switch' | 'modem' | 'router' | 'homeRouter' | 'dhcpHost' | 'dhcpHome';
readonly field: string; readonly field: string;
readonly input: string; readonly input: string;
readonly start: string; readonly start: string;
@@ -72,6 +82,14 @@ const java = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)
}; };
readonly tables: Readonly<Record<string, readonly JavaRow[]>>; readonly tables: Readonly<Record<string, readonly JavaRow[]>>;
readonly edits: Readonly<Record<string, { readonly manual: readonly (readonly (string | null)[])[]; readonly rowsShown?: number }>>; readonly edits: Readonly<Record<string, { readonly manual: readonly (readonly (string | null)[])[]; readonly rowsShown?: number }>>;
readonly dhcp: {
readonly dhcpHostShown: Readonly<Record<string, readonly [string, boolean]>>;
readonly dhcpHomeShown: Readonly<Record<string, readonly [string, boolean]>>;
readonly hostWithoutGatewayShown: readonly [string, string];
readonly staticAssignments: readonly (readonly [string, string])[];
readonly addable: readonly boolean[];
};
readonly forwarding: { readonly rows: readonly (readonly [string, string, string, string])[]; readonly valid: readonly boolean[] };
}; };
const RULES: Readonly<Record<string, FieldRule>> = { const RULES: Readonly<Record<string, FieldRule>> = {
@@ -95,8 +113,18 @@ const RULES: Readonly<Record<string, FieldRule>> = {
'homeRouter wanMask': 'subnetMask', 'homeRouter wanMask': 'subnetMask',
'homeRouter lanIp': 'ipAddress', 'homeRouter lanIp': 'ipAddress',
'homeRouter lanMask': 'subnetMask', 'homeRouter lanMask': 'subnetMask',
// The DHCP dialog checks every address with musterIpAdresse: none may be empty.
'dhcpHost lower': 'ipAddress',
'dhcpHost upper': 'ipAddress',
'dhcpHost gateway': 'ipAddress',
'dhcpHost dns': 'ipAddress',
'dhcpHome lower': 'ipAddress',
'dhcpHome dns': 'ipAddress',
}; };
/** Fields Java's dialog shows but never takes: a home router hands out its LAN address as the gateway. */
const NOT_OFFERED = new Set(['dhcpHome gateway']);
/** Fields whose text is judged without the spaces around it. */ /** Fields whose text is judged without the spaces around it. */
const TRIMMED = new Set( const TRIMMED = new Set(
Object.entries(RULES) Object.entries(RULES)
@@ -105,10 +133,13 @@ const TRIMMED = new Set(
); );
/** Whether Java's device took the text: its value changed (to an address: null is a card that lost its own), or was the text already. */ /** Whether Java's device took the text: its value changed (to an address: null is a card that lost its own), or was the text already. */
const tookIt = (each: JavaCase) => each.stored !== null && (each.stored !== each.start || each.input === each.start); const tookIt = (each: JavaCase) => each.stored !== null && (each.stored !== each.start || each.input === each.start) && (!keptAsTyped(each) || javaIpCheck(each.stored) !== null);
/** What Java's model holds in the units of the sheet's value: seconds for the retention time. */ /** A DHCP server keeps its addresses as typed ("010.0.0.1"); the model keeps IP.ipCheck's form. */
const javaValue = (each: JavaCase) => (each.field === 'retention' ? String(Number(each.stored) / 1000) : each.stored); const keptAsTyped = (each: JavaCase) => each.device.startsWith('dhcp');
/** What Java's model holds in the units of the sheet's value: seconds for the retention time, an address in IP.ipCheck's form. */
const javaValue = (each: JavaCase) => (each.field === 'retention' ? String(Number(each.stored) / 1000) : keptAsTyped(each) ? javaIpCheck(each.stored!) : each.stored);
/** The case Java judged for a text: the trimmed text's own case where spaces are left out. */ /** The case Java judged for a text: the trimmed text's own case where spaces are left out. */
function judged(each: JavaCase): JavaCase { function judged(each: JavaCase): JavaCase {
@@ -121,13 +152,14 @@ function judged(each: JavaCase): JavaCase {
describe("a field's rule, against FILIUS' settings panels", () => { describe("a field's rule, against FILIUS' settings panels", () => {
it('has a case for every field the sheet checks', () => { it('has a case for every field the sheet checks', () => {
expect(new Set(java.cases.map((each) => `${each.device} ${each.field}`))).toEqual(new Set(Object.keys(RULES))); expect(new Set(java.cases.map((each) => `${each.device} ${each.field}`))).toEqual(new Set([...Object.keys(RULES), ...NOT_OFFERED]));
expect(java.cases.length).toBeGreaterThan(70); expect(java.cases.length).toBeGreaterThan(70);
}); });
it('takes a text exactly when Java neither marks nor refuses it, as the value Java keeps', () => { it('takes a text exactly when Java neither marks nor refuses it, as the value Java keeps', () => {
for (const each of java.cases) { for (const each of java.cases) {
const key = `${each.device} ${each.field}`; const key = `${each.device} ${each.field}`;
if (NOT_OFFERED.has(key)) continue;
const verdict = judged(each); const verdict = judged(each);
// Taken: the value changed, or the text was the value already. // Taken: the value changed, or the text was the value already.
const took = tookIt(verdict); const took = tookIt(verdict);
@@ -143,9 +175,13 @@ describe("a field's rule, against FILIUS' settings panels", () => {
}); });
it("names where Java's two judgements disagree, which here are one", () => { it("names where Java's two judgements disagree, which here are one", () => {
const disagreeing = java.cases.filter((each) => RULES[`${each.device} ${each.field}`] !== 'name' && each.field !== 'port' && each.marked === tookIt(each)); const disagreeing = java.cases.filter(
(each) => !NOT_OFFERED.has(`${each.device} ${each.field}`) && RULES[`${each.device} ${each.field}`] !== 'name' && each.field !== 'port' && each.marked === tookIt(each),
);
const kept = (each: JavaCase) => each.stored !== null && each.stored !== each.start && !tookIt(each);
// Marked red, yet taken; or black, yet refused (the gateway's spaces Java trims to empty) — or lost: a card without its address. // Marked red, yet taken; or black, yet refused (the gateway's spaces Java trims to empty) — or lost: a card without its address.
const verdictOf = (each: JavaCase) => (each.marked ? 'marked, taken' : each.stored === null ? `lost${each.error === undefined ? '' : `, ${each.error}`}` : 'refused'); const verdictOf = (each: JavaCase) =>
each.marked ? 'marked, taken' : each.stored === null ? `lost${each.error === undefined ? '' : `, ${each.error}`}` : kept(each) ? 'kept, no address' : 'refused';
expect(disagreeing.map((each) => `${each.device} ${each.field} ${JSON.stringify(each.input)} ${verdictOf(each)}`)).toEqual([ expect(disagreeing.map((each) => `${each.device} ${each.field} ${JSON.stringify(each.input)} ${verdictOf(each)}`)).toEqual([
'host ip "10..0.0.1" marked, taken', 'host ip "10..0.0.1" marked, taken',
'host ip ".0.0.1" refused', 'host ip ".0.0.1" refused',
@@ -167,6 +203,7 @@ describe("a field's rule, against FILIUS' settings panels", () => {
'router gateway "192.168..0.1" marked, taken', 'router gateway "192.168..0.1" marked, taken',
'homeRouter gateway "" marked, taken', 'homeRouter gateway "" marked, taken',
'homeRouter wanIp ".1.2.3" lost', 'homeRouter wanIp ".1.2.3" lost',
'dhcpHost lower ".0.0.1" kept, no address',
]); ]);
const ports = java.cases.filter((each) => each.field === 'port' && each.stored !== each.start); const ports = java.cases.filter((each) => each.field === 'port' && each.stored !== each.start);
expect(ports.map((each) => each.input)).toEqual(['0', '65535', '65536', '-1', '080', '80']); expect(ports.map((each) => each.input)).toEqual(['0', '65535', '65536', '-1', '080', '80']);
@@ -208,6 +245,19 @@ describe("a PC's sheet", () => {
ssid: '', ssid: '',
wirelessPossible: true, wirelessPossible: true,
networks: [], networks: [],
dhcpServer: {
isActive: false,
lowerBoundIPAddress: '0.0.0.0',
upperBoundIPAddress: '0.0.0.0',
subnetMask: '255.255.255.0',
gateway: '0.0.0.0',
gatewayEditable: false,
dnsServer: '0.0.0.0',
dnsServerEditable: false,
useOwnSettings: false,
ownSettingsOffered: true,
staticAssignments: [],
},
}); });
expect(sheet.macAddress).toMatch(/^([0-9A-F]{2}:){5}[0-9A-F]{2}$/); expect(sheet.macAddress).toMatch(/^([0-9A-F]{2}:){5}[0-9A-F]{2}$/);
expect(deviceSheet(doc, 'nobody')).toBeUndefined(); expect(deviceSheet(doc, 'nobody')).toBeUndefined();
@@ -706,3 +756,168 @@ describe("a home router's sheet", () => {
expect(reopened.deviceConfigurations.get(homeAgain!.id)?.defaultGateway).toBe('42.0.0.1'); expect(reopened.deviceConfigurations.get(homeAgain!.id)?.defaultGateway).toBe('42.0.0.1');
}); });
}); });
describe("a DHCP server's sheet", () => {
/** The devices of SheetRun's DHCP flows: a host 192.168.0.10/24 with gateway and DNS, a home router; the server with known settings. */
function servers() {
const doc = new Y.Doc();
const pc = add(doc, 'pc');
setHostAddress(doc, pc, 'defaultGateway', '192.168.0.254', 'me');
setHostAddress(doc, pc, 'dnsServer', '192.168.0.253', 'me');
const homeId = add(doc, 'gateway');
for (const id of [pc, homeId]) {
setDhcpServerAddress(doc, id, 'lowerBoundIPAddress', '192.168.0.100', 'me');
setDhcpServerAddress(doc, id, 'upperBoundIPAddress', '192.168.0.200', 'me');
setDhcpServerAddress(doc, id, 'gatewayIPAddress', '10.0.0.1', 'me');
setDhcpServerAddress(doc, id, 'dnsServerIPAddress', '10.0.0.2', 'me');
}
return { doc, pc, homeId };
}
it("shows what Java's dialog shows: the device's gateway and DNS server unless its own, a home router's LAN address", () => {
const { doc, pc, homeId } = servers();
const shown = (sheet: ReturnType<typeof host>['dhcpServer']) => ({
tfUntergrenze: [sheet.lowerBoundIPAddress, true],
tfObergrenze: [sheet.upperBoundIPAddress, true],
tfNetzmaske: [sheet.subnetMask, false],
tfGateway: [sheet.gateway, sheet.gatewayEditable],
tfDNSServer: [sheet.dnsServer, sheet.dnsServerEditable],
});
expect(shown(host(doc, pc).dhcpServer)).toEqual(java.dhcp.dhcpHostShown);
expect(shown(home(doc, homeId).dhcpServer)).toEqual(java.dhcp.dhcpHomeShown);
expect(home(doc, homeId).dhcpServer.ownSettingsOffered).toBe(false);
// A home router's choice has no effect (Java's dialog keeps the flag, the gateway stays its LAN address).
setDhcpServerFlag(doc, homeId, 'useOwnSettings', true, 'me');
expect(home(doc, homeId).dhcpServer).toMatchObject({ useOwnSettings: true, gateway: '192.168.0.10', gatewayEditable: false });
const bare = add(doc, 'pc');
expect([host(doc, bare).dhcpServer.gateway, host(doc, bare).dhcpServer.dnsServer]).toEqual(java.dhcp.hostWithoutGatewayShown);
setDhcpServerFlag(doc, pc, 'useOwnSettings', true, 'me');
expect(host(doc, pc).dhcpServer).toMatchObject({ gateway: '10.0.0.1', gatewayEditable: true, dnsServer: '10.0.0.2', dnsServerEditable: true, useOwnSettings: true });
setDhcpServerFlag(doc, pc, 'isActive', true, 'me');
expect(readProject(doc).dhcpServerConfigurations.get(pc)).toMatchObject({ isActive: true, useOwnSettings: true, lowerBoundIPAddress: '192.168.0.100' });
});
it("keeps a static assignment once both cells pass and its MAC address is free, unlike Java's silent drops", () => {
const { doc, pc } = servers();
// Java's add button takes the duplicate and the "_F" too (musterMacAddress); its server drops the duplicate on saving.
expect(java.dhcp.addable).toEqual([true, true, true, false, true, false]);
const rows = [
['aa:bb:cc:dd:ee:01', '192.168.0.50'],
['AA:BB:CC:DD:EE:01', '192.168.0.51'],
['AA:BB:CC:DD:EE:_F', '192.168.0.52'],
['AA:BB:CC:DD:EE:02', '192.168.0.256'],
['AA:BB:CC:DD:EE:03', '010.0.0.1'],
['AABBCCDDEE04', '192.168.0.54'],
] as const;
const outcomes = rows.map(([macAddress, ipAddress], index) => {
const result = addDhcpStaticAssignment(doc, pc, { macAddress, ipAddress }, `assignment-${index}`, 'me');
return 'assignment' in result ? 'taken' : 'error' in result ? result.error : `invalid ${result.invalid.join(' ')}`;
});
expect(outcomes).toEqual(['taken', 'macTaken', 'invalid macAddress', 'invalid ipAddress', 'taken', 'invalid macAddress']);
// Java kept the first, the "_F" its pattern lets through, and "010.0.0.1" as typed; here upper case and IP.ipCheck's form.
expect(java.dhcp.staticAssignments).toEqual([
['aa:bb:cc:dd:ee:01', '192.168.0.50'],
['AA:BB:CC:DD:EE:_F', '192.168.0.52'],
['AA:BB:CC:DD:EE:03', '010.0.0.1'],
]);
expect(host(doc, pc).dhcpServer.staticAssignments).toEqual([
{ macAddress: 'AA:BB:CC:DD:EE:01', ipAddress: '192.168.0.50' },
{ macAddress: 'AA:BB:CC:DD:EE:03', ipAddress: '10.0.0.1' },
]);
expect(setDhcpStaticCell(doc, pc, 1, 'macAddress', 'aa:bb:cc:dd:ee:01', 'me')).toEqual({ invalid: true });
expect(setDhcpStaticCell(doc, pc, 1, 'macAddress', 'aa:bb:cc:dd:ee:09', 'me')).toEqual({ value: 'AA:BB:CC:DD:EE:09' });
expect(setDhcpStaticCell(doc, pc, 0, 'ipAddress', '192.168.0.60', 'me')).toEqual({ value: '192.168.0.60' });
expect(setDhcpStaticCell(doc, pc, 5, 'ipAddress', '192.168.0.61', 'me')).toEqual({ value: '192.168.0.61' });
removeDhcpStaticAssignment(doc, pc, 7, 'me');
removeDhcpStaticAssignment(doc, pc, 0, 'me');
expect(readProject(doc).dhcpServerConfigurations.get(pc)?.staticAssignments).toEqual([{ id: 'assignment-4', macAddress: 'AA:BB:CC:DD:EE:09', ipAddress: '10.0.0.1' }]);
expect(addDhcpStaticAssignment(doc, 'nobody', { macAddress: 'AA:BB:CC:DD:EE:0A', ipAddress: '10.0.0.3' }, 'x', 'me')).toEqual({ invalid: [] });
});
it("compares MAC addresses ignoring case, as Java does, a file's lower case too, and lets a row keep its own", () => {
const doc = new Y.Doc();
const pc = add(doc, 'pc');
addDhcpStaticAssignment(doc, pc, { macAddress: 'AA:BB:CC:DD:EE:01', ipAddress: '10.0.0.5' }, 'a', 'me');
// A file keeps a MAC address as Java's dialog took it, in lower case.
const entry = ((doc.getMap<Y.Map<unknown>>(ROOTS.nodes).get(pc)!.get('settings') as Y.Map<Y.Map<unknown>>).get('dhcpServer')!.get('staticAssignments') as Y.Array<Y.Map<unknown>>).get(0);
entry.set('macAddress', 'aa:bb:cc:dd:ee:01');
expect(addDhcpStaticAssignment(doc, pc, { macAddress: 'AA:BB:CC:DD:EE:01', ipAddress: '10.0.0.6' }, 'b', 'me')).toEqual({ error: 'macTaken' });
expect(setDhcpStaticCell(doc, pc, 0, 'macAddress', 'AA:BB:CC:DD:EE:01', 'me')).toEqual({ value: 'AA:BB:CC:DD:EE:01' });
});
it('takes a static assignment on a device without a server in the document, made as Java makes it', () => {
const doc = new Y.Doc();
const pc = add(doc, 'pc');
expect('assignment' in addDhcpStaticAssignment(doc, pc, { macAddress: 'AA:BB:CC:DD:EE:01', ipAddress: '10.0.0.5' }, 'a', 'me')).toBe(true);
expect(readProject(doc).dhcpServerConfigurations.get(pc)).toMatchObject({
isActive: false,
lowerBoundIPAddress: '0.0.0.0',
staticAssignments: [{ id: 'a', macAddress: 'AA:BB:CC:DD:EE:01', ipAddress: '10.0.0.5' }],
});
});
});
describe("a home router's port forwarding", () => {
const JAVA_ROWS = () => java.forwarding.rows.map(([protocolValue, publicPortValue, lanIPAddress, lanPortValue]) => ({ protocolValue, publicPortValue, lanIPAddress, lanPortValue }));
it('takes a row once all four cells pass, where Java keeps any row and its firmware forwards only the valid ones', () => {
const doc = new Y.Doc();
const id = add(doc, 'gateway');
const outcomes = JAVA_ROWS().map((row) => {
const result = addPortForwarding(doc, id, row, 'me');
return 'row' in result ? 'taken' : result.invalid.join(' ');
});
// Java's firmware forwards the first only ("17" it would take, but not the leading zero of "05353").
expect(java.forwarding.valid).toEqual([true, false, false, false, false]);
expect(outcomes).toEqual(['taken', 'publicPortValue', 'publicPortValue', 'protocolValue lanIPAddress', 'protocolValue lanPortValue']);
expect(home(doc, id).portForwarding).toEqual([{ ...JAVA_ROWS()[0]!, forwarded: true }]);
expect(addPortForwarding(doc, 'nobody', JAVA_ROWS()[0]!, 'me')).toEqual({ invalid: [] });
// Java's dialog offers TCP and UDP; Java's firmware would take "tcp", "6" or "17" from a file.
for (const protocolValue of ['tcp', '6', 'ICMP']) expect(addPortForwarding(doc, id, { ...JAVA_ROWS()[0]!, protocolValue }, 'me')).toEqual({ invalid: ['protocolValue'] });
});
it("marks the rows a file brought that Java's firmware would not forward, and edits and removes rows", () => {
const doc = new Y.Doc();
const id = add(doc, 'gateway');
const settings = doc.getMap<Y.Map<unknown>>(ROOTS.nodes).get(id)!.get('settings') as Y.Map<unknown>;
const list = new Y.Array<Y.Map<unknown>>();
settings.set('portForwarding', list);
list.push(JAVA_ROWS().map((row) => toY({ ...row }) as Y.Map<unknown>));
expect(home(doc, id).portForwarding.map((row) => row.forwarded)).toEqual(java.forwarding.valid);
expect(setPortForwardingCell(doc, id, 1, 'publicPortValue', '0053', 'me')).toEqual({ invalid: true });
expect(setPortForwardingCell(doc, id, 1, 'publicPortValue', ' 53', 'me')).toEqual({ value: '53' });
expect(setPortForwardingCell(doc, id, 1, 'protocolValue', 'ICMP', 'me')).toEqual({ invalid: true });
expect(setPortForwardingCell(doc, id, 0, 'protocolValue', 'UDP', 'me')).toEqual({ value: 'UDP' });
expect(home(doc, id).portForwarding.map((row) => [row.protocolValue, row.publicPortValue, row.forwarded])).toEqual([
['UDP', '80', true],
['UDP', '53', true],
['TCP', 'abc', false],
['tcp', '443', false],
['17', '5353', false],
]);
for (const index of [9, 4, 3, 2, 1]) removePortForwarding(doc, id, index, 'me');
expect(readProject(doc).portForwardingRowsByNodeId.get(id)).toHaveLength(1);
removePortForwarding(doc, id, 0, 'me');
expect(settings.has('portForwarding')).toBe(false);
});
it('keeps the DHCP server and the port forwarding through a saved file', () => {
const doc = new Y.Doc();
const id = add(doc, 'gateway');
setDhcpServerAddress(doc, id, 'lowerBoundIPAddress', '192.168.0.100', 'me');
setDhcpServerAddress(doc, id, 'dnsServerIPAddress', '8.8.8.8', 'me');
setDhcpServerFlag(doc, id, 'isActive', true, 'me');
addDhcpStaticAssignment(doc, id, { macAddress: 'AA:BB:CC:DD:EE:01', ipAddress: '192.168.0.50' }, 'a', 'me');
addPortForwarding(doc, id, JAVA_ROWS()[0]!, 'me');
let counter = 0;
const reopened = openProjectArchive(saveProjectArchive(readProject(doc)).archive, () => `50000000-0000-0000-0000-${(++counter).toString(16).padStart(12, '0')}`);
const again = reopened.graph.nodes[0]!.id;
expect(reopened.dhcpServerConfigurations.get(again)).toMatchObject({
isActive: true,
lowerBoundIPAddress: '192.168.0.100',
dnsServerIPAddress: '8.8.8.8',
staticAssignments: [{ macAddress: 'AA:BB:CC:DD:EE:01', ipAddress: '192.168.0.50' }],
});
expect(reopened.portForwardingRowsByNodeId.get(again)).toEqual([JAVA_ROWS()[0]]);
});
});
+101 -3
View File
@@ -4,7 +4,7 @@
// beside the surface on a wide screen, below it on a phone. // beside the surface on a wide screen, below it on a phone.
import { expect, test, type Page } from '@playwright/test'; import { expect, test, type Page } from '@playwright/test';
import type { HostSheet, RouterSheet } from '@mdg-filius/doc'; import type { HomeRouterSheet, HostSheet, RouterSheet } from '@mdg-filius/doc';
import { drawing, onDevice, openEditor, serveEditor, sheetOf } from './editorPage.js'; import { drawing, onDevice, openEditor, serveEditor, sheetOf } from './editorPage.js';
let server: { readonly base: string; readonly close: () => Promise<void> }; let server: { readonly base: string; readonly close: () => Promise<void> };
@@ -193,7 +193,7 @@ test("edits a router's forwarding table: a new row once its four cells pass, a c
// Java's own rows: two addresses, two networks (the same twice: both cards are new), localhost. // Java's own rows: two addresses, two networks (the same twice: both cards are new), localhost.
await expect(rows).toHaveCount(5); await expect(rows).toHaveCount(5);
await sheet(page).getByRole('button', { name: 'Neuer Eintrag' }).click(); await sheet(page).getByRole('button', { name: 'Neuer Eintrag' }).click();
await expect(sheet(page).getByText('Die Zeile wird übernommen, sobald alle vier Felder gültig sind.')).toBeVisible(); await expect(sheet(page).getByText('Die Zeile wird übernommen, sobald alle Felder gültig sind.')).toBeVisible();
await expect(field(page, 'route-destinationNetwork')).toHaveAttribute('aria-invalid', 'false'); await expect(field(page, 'route-destinationNetwork')).toHaveAttribute('aria-invalid', 'false');
for (const [column, text] of [ for (const [column, text] of [
['destinationNetwork', '10.0.0.0'], ['destinationNetwork', '10.0.0.0'],
@@ -206,7 +206,9 @@ test("edits a router's forwarding table: a new row once its four cells pass, a c
expect(((await sheetOf(page, id)) as RouterSheet).routes).toEqual([]); expect(((await sheetOf(page, id)) as RouterSheet).routes).toEqual([]);
await field(page, 'route-interfaceIPAddress').fill('192.168.0.10'); await field(page, 'route-interfaceIPAddress').fill('192.168.0.10');
await field(page, 'route-interfaceIPAddress').press('Enter'); await field(page, 'route-interfaceIPAddress').press('Enter');
await expect.poll(async () => ((await sheetOf(page, id)) as RouterSheet).routes).toEqual([{ destinationNetwork: '10.0.0.0', subnetMask: '255.0.0.0', gateway: '192.168.0.1', interfaceIPAddress: '192.168.0.10' }]); await expect
.poll(async () => ((await sheetOf(page, id)) as RouterSheet).routes)
.toEqual([{ destinationNetwork: '10.0.0.0', subnetMask: '255.0.0.0', gateway: '192.168.0.1', interfaceIPAddress: '192.168.0.10' }]);
await expect(sheet(page).locator('tr[data-manual="0"]')).toHaveAttribute('aria-selected', 'true'); await expect(sheet(page).locator('tr[data-manual="0"]')).toHaveAttribute('aria-selected', 'true');
await field(page, 'route-gateway').fill('192.168.0.2'); await field(page, 'route-gateway').fill('192.168.0.2');
await field(page, 'route-gateway').press('Enter'); await field(page, 'route-gateway').press('Enter');
@@ -250,6 +252,102 @@ test("shows a home router's LAN and WAN tabs, DHCP closing the WAN card's addres
await expect(tabs.nth(1)).toHaveText('LAN: 192.168.7.1'); await expect(tabs.nth(1)).toHaveText('LAN: 192.168.7.1');
}); });
test("sets up a PC's DHCP server on its own page: base settings as Java determines them, static assignments, back to the fields", async ({ page }) => {
await openEditor(page, server.base, 'new');
await page.locator('[data-palette="pc"]').click();
const id = (await drawing(page)).nodes[0]!.id;
await sheet(page).getByRole('button', { name: 'DHCP-Server einrichten' }).click();
await expect(sheet(page).getByRole('heading')).toHaveText('DHCP-Server einrichten');
await expect(field(page, 'ipAddress')).toBeHidden();
await expect(field(page, 'dhcpGateway')).toHaveValue('0.0.0.0');
await expect(field(page, 'dhcpGateway')).toHaveAttribute('readonly', '');
await expect(field(page, 'dhcpSubnetMask')).toHaveValue('255.255.255.0');
await field(page, 'lowerBound').fill('192.168.0.300');
await field(page, 'lowerBound').press('Enter');
await expect(sheet(page).getByRole('status')).toHaveText('Nicht übernommen – es gilt weiterhin 0.0.0.0.');
await field(page, 'lowerBound').fill('192.168.000.100');
await field(page, 'lowerBound').press('Enter');
await expect(field(page, 'lowerBound')).toHaveValue('192.168.0.100');
await sheet(page).getByLabel('Manuelle Einstellungen').check();
await expect(field(page, 'dhcpGateway')).not.toHaveAttribute('readonly', '');
await field(page, 'dhcpGateway').fill('192.168.0.1');
await field(page, 'dhcpGateway').press('Enter');
await sheet(page).getByLabel('DHCP aktivieren').check();
await expect
.poll(async () => ((await sheetOf(page, id)) as HostSheet).dhcpServer)
.toMatchObject({ isActive: true, useOwnSettings: true, gateway: '192.168.0.1', lowerBoundIPAddress: '192.168.0.100' });
await sheet(page).getByRole('tab', { name: 'Statische Adresszuweisung' }).click();
await sheet(page).getByRole('button', { name: 'Hinzufügen' }).click();
await field(page, 'static-macAddress').fill('aa:bb:cc:dd:ee:01');
await field(page, 'static-macAddress').press('Enter');
await field(page, 'static-ipAddress').fill('192.168.0.50');
await field(page, 'static-ipAddress').press('Enter');
await expect(sheet(page).locator('table[data-rows="static"] tr[data-manual="0"] td').first()).toHaveText('AA:BB:CC:DD:EE:01');
await sheet(page).getByRole('button', { name: 'Hinzufügen' }).click();
await field(page, 'static-macAddress').fill('AA:BB:CC:DD:EE:01');
await field(page, 'static-macAddress').press('Enter');
await field(page, 'static-ipAddress').fill('192.168.0.51');
await field(page, 'static-ipAddress').press('Enter');
await expect(sheet(page).getByRole('alert')).toHaveText('Diese MAC-Adresse hat schon eine feste Adresse.');
expect(((await sheetOf(page, id)) as HostSheet).dhcpServer.staticAssignments).toHaveLength(1);
await sheet(page).locator('table[data-rows="static"] tr[data-manual="0"]').click();
await sheet(page).getByRole('button', { name: 'Entfernen' }).click();
await expect.poll(async () => ((await sheetOf(page, id)) as HostSheet).dhcpServer.staticAssignments).toEqual([]);
// Another device starts at its fields; back at the first, its page is closed too.
await page.locator('[data-palette="pc"]').click();
await expect(sheet(page).getByRole('heading')).toHaveText('Rechner');
await expect(field(page, 'ipAddress')).toBeVisible();
await choose(page, id);
await expect(sheet(page)).toHaveAttribute('data-device', id);
await sheet(page).getByRole('button', { name: 'DHCP-Server einrichten' }).click();
await sheet(page).getByRole('button', { name: 'Zurück' }).click();
await expect(sheet(page).getByRole('heading')).toHaveText('Rechner');
await expect(field(page, 'ipAddress')).toBeVisible();
await sheet(page).getByLabel('DHCP zur Konfiguration verwenden').check();
await expect(sheet(page).getByRole('button', { name: 'DHCP-Server einrichten' })).toBeDisabled();
});
test("keeps a home router's port forwarding on its own page, and shows its DHCP server with the LAN address as gateway", async ({ page }) => {
await openEditor(page, server.base, 'new');
await page.locator('[data-palette="gateway"]').click();
const id = (await drawing(page)).nodes[0]!.id;
await sheet(page).getByRole('button', { name: 'Portfreigaben' }).click();
await expect(sheet(page).getByRole('heading')).toHaveText('Portfreigaben');
await sheet(page).getByRole('button', { name: 'Neuer Eintrag' }).click();
await sheet(page).locator('select[name="forwarding-protocolValue"]').selectOption('UDP');
for (const [column, text] of [
['publicPortValue', '53'],
['lanIPAddress', '192.168.0.20'],
['lanPortValue', '5353'],
] as const) {
await field(page, `forwarding-${column}`).fill(text);
await field(page, `forwarding-${column}`).press('Enter');
}
await expect
.poll(async () => ((await sheetOf(page, id)) as HomeRouterSheet).portForwarding)
.toEqual([{ protocolValue: 'UDP', publicPortValue: '53', lanIPAddress: '192.168.0.20', lanPortValue: '5353', forwarded: true }]);
await field(page, 'forwarding-lanPortValue').fill('05353');
await field(page, 'forwarding-lanPortValue').press('Enter');
await expect(sheet(page).getByRole('status')).toHaveText('Nicht übernommen – es gilt weiterhin 5353.');
await sheet(page).locator('select[name="forwarding-protocolValue"]').selectOption('TCP');
await expect.poll(async () => ((await sheetOf(page, id)) as HomeRouterSheet).portForwarding[0]!.protocolValue).toBe('TCP');
await sheet(page).getByRole('button', { name: 'Eintrag löschen' }).click();
await expect.poll(async () => ((await sheetOf(page, id)) as HomeRouterSheet).portForwarding).toEqual([]);
await sheet(page).getByRole('button', { name: 'Zurück' }).click();
await sheet(page).getByRole('tab', { name: 'LAN: 192.168.0.10' }).click();
await sheet(page).getByRole('button', { name: 'DHCP-Server einrichten' }).click();
await expect(field(page, 'dhcpGateway')).toHaveValue('192.168.0.10');
await expect(field(page, 'dhcpGateway')).toHaveAttribute('readonly', '');
await expect(field(page, 'dhcpDnsServer')).not.toHaveAttribute('readonly', '');
await expect(sheet(page).getByLabel('Manuelle Einstellungen')).toHaveCount(0);
await sheet(page).getByRole('button', { name: 'Zurück' }).click();
// Back on the tab it was opened from, as Java's panel stays behind its dialog.
await expect(sheet(page).getByRole('tab', { name: 'LAN: 192.168.0.10' })).toHaveAttribute('aria-selected', 'true');
});
test.describe('on a phone', () => { test.describe('on a phone', () => {
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true }); test.use({ viewport: { width: 390, height: 844 }, hasTouch: true });
+1
View File
@@ -251,6 +251,7 @@ export function Editor({ doc }: { readonly doc: Y.Doc }) {
{/* Java shows the settings of the device that is chosen; closing them lets go of it, as a click beside it does. */} {/* Java shows the settings of the device that is chosen; closing them lets go of it, as a click beside it does. */}
{selected === undefined ? null : ( {selected === undefined ? null : (
<DeviceSheet <DeviceSheet
key={selected.id}
doc={doc} doc={doc}
id={selected.id} id={selected.id}
label={selected.label} label={selected.label}
+23 -1
View File
@@ -27,6 +27,25 @@
"device.remoteLink": "Modem", "device.remoteLink": "Modem",
"device.router": "Vermittlungsrechner", "device.router": "Vermittlungsrechner",
"device.unsupported": "Unbekanntes Gerät", "device.unsupported": "Unbekanntes Gerät",
"dhcp.active": "DHCP aktivieren",
"dhcp.add": "Hinzufügen",
"dhcp.basic": "Grundeinstellungen",
"dhcp.dnsServer": "DNS-Server",
"dhcp.gateway": "Gateway",
"dhcp.ipAddress": "IP-Adresse",
"dhcp.lowerBound": "Adress-Untergrenze",
"dhcp.macAddress": "MAC-Adresse",
"dhcp.macTaken": "Diese MAC-Adresse hat schon eine feste Adresse.",
"dhcp.ownSettings": "Manuelle Einstellungen",
"dhcp.ownSettingsHint": "Standardmäßig werden die gleichen Einstellungen für den DHCP-Server verwendet, die auf dem Host gelten. Eine abweichende Konfiguration ist jedoch möglich bei Aktivieren dieses Kästchens.",
"dhcp.remove": "Entfernen",
"dhcp.static": "Statische Adresszuweisung",
"dhcp.subnetMask": "Netzmaske",
"dhcp.upperBound": "Adress-Obergrenze",
"forwarding.lanIPAddress": "LAN-Adresse",
"forwarding.lanPortValue": "LAN-Port",
"forwarding.protocolValue": "Protokoll",
"forwarding.publicPortValue": "Portfreigabe",
"menu.delete": "Löschen", "menu.delete": "Löschen",
"menu.removeAllCables": "Alle Verbindungen entfernen", "menu.removeAllCables": "Alle Verbindungen entfernen",
"menu.removeCable": "Verbindung entfernen", "menu.removeCable": "Verbindung entfernen",
@@ -37,6 +56,7 @@
"router.cancel": "Abbrechen", "router.cancel": "Abbrechen",
"router.prompt": "Wählen Sie die Anzahl der Schnittstellen des Vermittlungsrechners aus!", "router.prompt": "Wählen Sie die Anzahl der Schnittstellen des Vermittlungsrechners aus!",
"router.title": "Anzahl Schnittstellen", "router.title": "Anzahl Schnittstellen",
"sheet.back": "Zurück",
"sheet.cable": "Kabelgebunden (LAN)", "sheet.cable": "Kabelgebunden (LAN)",
"sheet.cablePlugged": "Das Kabel ist noch eingesteckt.", "sheet.cablePlugged": "Das Kabel ist noch eingesteckt.",
"sheet.cards": "Netzwerkkarten", "sheet.cards": "Netzwerkkarten",
@@ -48,6 +68,7 @@
"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",
"sheet.dhcpServer": "DHCP-Server einrichten",
"sheet.dnsServer": "Domain Name Server", "sheet.dnsServer": "Domain Name Server",
"sheet.empty": "(leer)", "sheet.empty": "(leer)",
"sheet.forwardingTable": "Weiterleitungstabelle", "sheet.forwardingTable": "Weiterleitungstabelle",
@@ -61,11 +82,12 @@
"sheet.name": "Name", "sheet.name": "Name",
"sheet.network": "Drahtloses Netzwerk (SSID)", "sheet.network": "Drahtloses Netzwerk (SSID)",
"sheet.newEntry": "Neuer Eintrag", "sheet.newEntry": "Neuer Eintrag",
"sheet.newEntryHint": "Die Zeile wird übernommen, sobald alle vier Felder gültig sind.", "sheet.newEntryHint": "Die Zeile wird übernommen, sobald alle Felder gültig sind.",
"sheet.nic": "Netzwerkkarte {number}", "sheet.nic": "Netzwerkkarte {number}",
"sheet.notConnected": "Keine Verbindung", "sheet.notConnected": "Keine Verbindung",
"sheet.notTaken": "Nicht übernommen – es gilt weiterhin {value}.", "sheet.notTaken": "Nicht übernommen – es gilt weiterhin {value}.",
"sheet.port": "Port", "sheet.port": "Port",
"sheet.portForwarding": "Portfreigaben",
"sheet.retention": "Gültigkeitsdauer SAT-Einträge (in Sek.)", "sheet.retention": "Gültigkeitsdauer SAT-Einträge (in Sek.)",
"sheet.rip": "Automatisches Routing", "sheet.rip": "Automatisches Routing",
"sheet.showAllEntries": "Alle Einträge anzeigen", "sheet.showAllEntries": "Alle Einträge anzeigen",
+23 -1
View File
@@ -27,6 +27,25 @@
"device.remoteLink": "Modem", "device.remoteLink": "Modem",
"device.router": "Router", "device.router": "Router",
"device.unsupported": "Unknown device", "device.unsupported": "Unknown device",
"dhcp.active": "Activate DHCP",
"dhcp.add": "Add",
"dhcp.basic": "Base Settings",
"dhcp.dnsServer": "DNS server",
"dhcp.gateway": "Gateway",
"dhcp.ipAddress": "IP Address",
"dhcp.lowerBound": "Lower bound of address",
"dhcp.macAddress": "MAC Address",
"dhcp.macTaken": "This MAC address has a static address already.",
"dhcp.ownSettings": "Manual configuration",
"dhcp.ownSettingsHint": "Usually the same settings as configured for the host system will be used for the DHCP server as well. However, different settings are possible if this checkbox is selected.",
"dhcp.remove": "Remove",
"dhcp.static": "Static Address Assignment",
"dhcp.subnetMask": "Netmask",
"dhcp.upperBound": "Upper bound of address",
"forwarding.lanIPAddress": "LAN Address",
"forwarding.lanPortValue": "LAN Port",
"forwarding.protocolValue": "Protocol",
"forwarding.publicPortValue": "Mapped Port",
"menu.delete": "Delete", "menu.delete": "Delete",
"menu.removeAllCables": "Remove all cables", "menu.removeAllCables": "Remove all cables",
"menu.removeCable": "Remove cable", "menu.removeCable": "Remove cable",
@@ -37,6 +56,7 @@
"router.cancel": "Cancel", "router.cancel": "Cancel",
"router.prompt": "Choose number of NIC of the router!", "router.prompt": "Choose number of NIC of the router!",
"router.title": "Number of NIC", "router.title": "Number of NIC",
"sheet.back": "Back",
"sheet.cable": "Cable (LAN)", "sheet.cable": "Cable (LAN)",
"sheet.cablePlugged": "The cable is still plugged in.", "sheet.cablePlugged": "The cable is still plugged in.",
"sheet.cards": "Network cards", "sheet.cards": "Network cards",
@@ -48,6 +68,7 @@
"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",
"sheet.dhcpServer": "DHCP server setup",
"sheet.dnsServer": "Domain Name Server", "sheet.dnsServer": "Domain Name Server",
"sheet.empty": "(empty)", "sheet.empty": "(empty)",
"sheet.forwardingTable": "Forwarding table", "sheet.forwardingTable": "Forwarding table",
@@ -61,11 +82,12 @@
"sheet.name": "Name", "sheet.name": "Name",
"sheet.network": "Wireless Network (SSID)", "sheet.network": "Wireless Network (SSID)",
"sheet.newEntry": "New entry", "sheet.newEntry": "New entry",
"sheet.newEntryHint": "The row is taken once all four fields are valid.", "sheet.newEntryHint": "The row is taken once all fields are valid.",
"sheet.nic": "NIC {number}", "sheet.nic": "NIC {number}",
"sheet.notConnected": "No Connection", "sheet.notConnected": "No Connection",
"sheet.notTaken": "Not taken – still {value}.", "sheet.notTaken": "Not taken – still {value}.",
"sheet.port": "Port", "sheet.port": "Port",
"sheet.portForwarding": "Port Mapping",
"sheet.retention": "Retention Time SAT Entries (Seconds)", "sheet.retention": "Retention Time SAT Entries (Seconds)",
"sheet.rip": "Automatic Routing", "sheet.rip": "Automatic Routing",
"sheet.showAllEntries": "Show all entries", "sheet.showAllEntries": "Show all entries",
+46 -6
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 { DhcpServerPanel, PortForwardingPanel } from './ServicePanels.js';
import { SheetCheck, SheetField } from './SheetField.js'; import { SheetCheck, SheetField } from './SheetField.js';
import './sheet.css'; import './sheet.css';
@@ -59,10 +60,16 @@ export interface DeviceSheetProps {
readonly onHighlight: (link: string | null) => void; readonly onHighlight: (link: string | null) => void;
} }
/** A page of the sheet that Java opens as a dialog. */
export type SheetPage = 'dhcp' | 'forwarding';
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();
const sheet = useDeviceSheet(doc, id); const sheet = useDeviceSheet(doc, id);
// The page open, if any; the editor makes a sheet per device, so another device starts at its fields.
const [open, setOpen] = useState<SheetPage | null>(null);
if (sheet === undefined) return null; if (sheet === undefined) return null;
const onOpen = (each: SheetPage) => setOpen(each);
const change: Change = { const change: Change = {
doc, doc,
id, id,
@@ -77,16 +84,22 @@ export function DeviceSheet({ doc, id, label, origin, onChanged, onMessage, onCl
return ( return (
<aside className="device-sheet" aria-label={t('sheet.title')} data-device={id}> <aside className="device-sheet" aria-label={t('sheet.title')} data-device={id}>
<header> <header>
{open === null ? (
<img src={ICON_URLS[iconOf(sheet.kind, sheet.kind === 'networkSwitch' && sheet.cloud)]} alt="" /> <img src={ICON_URLS[iconOf(sheet.kind, sheet.kind === 'networkSwitch' && sheet.cloud)]} alt="" />
<h2>{title}</h2> ) : (
<button type="button" className="sheet-close" onClick={() => setOpen(null)} aria-label={t('sheet.back')} title={t('sheet.back')}>
‹
</button>
)}
<h2>{open === 'dhcp' ? t('sheet.dhcpServer') : open === 'forwarding' ? t('sheet.portForwarding') : title}</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>
</header> </header>
{/* A field per device: a text typed for one device is not offered to the next. */} {/* A field per device: a text typed for one device is not offered to the next. Kept while a page is open, so that its tab stays as Java's panel does behind a dialog. */}
<div key={id}> <div key={id} hidden={open !== null}>
{sheet.kind === 'pc' || sheet.kind === 'notebook' ? ( {sheet.kind === 'pc' || sheet.kind === 'notebook' ? (
<HostFields sheet={sheet} label={label} change={change} onMessage={onMessage} /> <HostFields sheet={sheet} label={label} change={change} onMessage={onMessage} onOpen={onOpen} />
) : sheet.kind === 'networkSwitch' ? ( ) : sheet.kind === 'networkSwitch' ? (
<SwitchFields sheet={sheet} change={change} /> <SwitchFields sheet={sheet} change={change} />
) : sheet.kind === 'remoteLink' ? ( ) : sheet.kind === 'remoteLink' ? (
@@ -94,16 +107,37 @@ export function DeviceSheet({ doc, id, label, origin, onChanged, onMessage, onCl
) : sheet.kind === 'router' ? ( ) : sheet.kind === 'router' ? (
<RouterFields sheet={sheet} change={change} onHighlight={onHighlight} /> <RouterFields sheet={sheet} change={change} onHighlight={onHighlight} />
) : sheet.kind === 'gateway' ? ( ) : sheet.kind === 'gateway' ? (
<HomeRouterFields sheet={sheet} change={change} onHighlight={onHighlight} /> <HomeRouterFields sheet={sheet} change={change} onHighlight={onHighlight} onOpen={onOpen} />
) : ( ) : (
<NameField name={sheet.name} change={change} /> <NameField name={sheet.name} change={change} />
)} )}
</div> </div>
{open === 'dhcp' && (sheet.kind === 'pc' || sheet.kind === 'notebook' || sheet.kind === 'gateway') ? (
<div key={`${id}-dhcp`}>
<DhcpServerPanel server={sheet.dhcpServer} change={change} />
</div>
) : open === 'forwarding' && sheet.kind === 'gateway' ? (
<div key={`${id}-forwarding`}>
<PortForwardingPanel sheet={sheet} change={change} />
</div>
) : null}
</aside> </aside>
); );
} }
function HostFields({ sheet, label, change, onMessage }: { readonly sheet: HostSheet; readonly label: string; readonly change: Change; readonly onMessage: (message: string) => void }) { function HostFields({
sheet,
label,
change,
onMessage,
onOpen,
}: {
readonly sheet: HostSheet;
readonly label: string;
readonly change: Change;
readonly onMessage: (message: string) => void;
readonly onOpen: (page: SheetPage) => void;
}) {
const t = useTranslate(); const t = useTranslate();
const address = const address =
(field: 'ipAddress' | 'subnetMask' | 'defaultGateway' | 'dnsServer') => (field: 'ipAddress' | 'subnetMask' | 'defaultGateway' | 'dnsServer') =>
@@ -168,6 +202,12 @@ function HostFields({ sheet, label, change, onMessage }: { readonly sheet: HostS
/> />
<SheetCheck name="ipForwarding" label={t('sheet.ipForwarding')} checked={sheet.ipForwarding} onChange={(on) => change.run(() => setIpForwarding(change.doc, change.id, on, change.origin))} /> <SheetCheck name="ipForwarding" label={t('sheet.ipForwarding')} checked={sheet.ipForwarding} onChange={(on) => change.run(() => setIpForwarding(change.doc, change.id, on, change.origin))} />
<SheetCheck name="dhcpClient" label={t('sheet.dhcpClient')} checked={sheet.dhcpClient} onChange={(on) => change.run(() => setDhcpClient(change.doc, change.id, on, change.origin))} /> <SheetCheck name="dhcpClient" label={t('sheet.dhcpClient')} checked={sheet.dhcpClient} onChange={(on) => change.run(() => setDhcpClient(change.doc, change.id, on, change.origin))} />
{/* Java closes the server's dialog while DHCP configures the card. */}
<div className="sheet-buttons">
<button type="button" disabled={sheet.dhcpClient} onClick={() => onOpen('dhcp')}>
{t('sheet.dhcpServer')}
</button>
</div>
</> </>
); );
} }
+45 -115
View File
@@ -9,7 +9,6 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { import {
addRoute, addRoute,
checkField,
ROUTE_COLUMNS, ROUTE_COLUMNS,
removeRoute, removeRoute,
setDhcpClient, setDhcpClient,
@@ -17,7 +16,6 @@ import {
setRip, setRip,
setRouteCell, setRouteCell,
setRouterGateway, setRouterGateway,
type CheckedField,
type HomeRouterSheet, type HomeRouterSheet,
type InterfaceSheet, type InterfaceSheet,
type RouteColumn, type RouteColumn,
@@ -25,26 +23,10 @@ import {
} from '@mdg-filius/doc'; } from '@mdg-filius/doc';
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 { RowsEditor } from './RowsEditor.js';
import { SheetTabs, type Tab } from './SheetTabs.js';
import { SheetCheck, SheetField } from './SheetField.js'; import { SheetCheck, SheetField } from './SheetField.js';
interface Tab {
readonly key: string;
readonly label: string;
readonly disabled?: boolean;
}
function SheetTabs({ tabs, selected, onSelect }: { readonly tabs: readonly Tab[]; readonly selected: string; readonly onSelect: (key: string) => void }) {
return (
<div role="tablist" className="sheet-tabs">
{tabs.map((tab) => (
<button key={tab.key} type="button" role="tab" data-tab={tab.key} aria-selected={tab.key === selected} disabled={tab.disabled === true} onClick={() => onSelect(tab.key)}>
{tab.label}
</button>
))}
</div>
);
}
/** Reports the cable of the card whose tab is open, and none once the sheet goes. */ /** Reports the cable of the card whose tab is open, and none once the sheet goes. */
function useHighlight(link: string | null, onHighlight: (link: string | null) => void): void { function useHighlight(link: string | null, onHighlight: (link: string | null) => void): void {
useEffect(() => { useEffect(() => {
@@ -118,7 +100,17 @@ export function RouterFields({ sheet, change, onHighlight }: { readonly sheet: R
); );
} }
export function HomeRouterFields({ sheet, change, onHighlight }: { readonly sheet: HomeRouterSheet; readonly change: Change; readonly onHighlight: (link: string | null) => void }) { export function HomeRouterFields({
sheet,
change,
onHighlight,
onOpen,
}: {
readonly sheet: HomeRouterSheet;
readonly change: Change;
readonly onHighlight: (link: string | null) => void;
readonly onOpen: (page: 'dhcp' | 'forwarding') => void;
}) {
const t = useTranslate(); const t = useTranslate();
const [tab, setTab] = useState('general'); const [tab, setTab] = useState('general');
const card = tab === 'lan' ? sheet.lan : tab === 'wan' ? sheet.wan : undefined; const card = tab === 'lan' ? sheet.lan : tab === 'wan' ? sheet.wan : undefined;
@@ -138,9 +130,21 @@ export function HomeRouterFields({ sheet, change, onHighlight }: { readonly shee
<NameField name={sheet.name} change={change} /> <NameField name={sheet.name} change={change} />
<GatewayField value={sheet.gateway} change={change} /> <GatewayField value={sheet.gateway} change={change} />
<SheetCheck name="ipForwarding" label={t('sheet.ipForwarding')} checked={sheet.ipForwarding} disabled onChange={() => undefined} /> <SheetCheck name="ipForwarding" label={t('sheet.ipForwarding')} checked={sheet.ipForwarding} disabled onChange={() => undefined} />
<div className="sheet-buttons">
<button type="button" onClick={() => onOpen('forwarding')}>
{t('sheet.portForwarding')}
</button>
</div>
</> </>
) : tab === 'lan' ? ( ) : tab === 'lan' ? (
<>
<InterfaceFields card={sheet.lan} change={change} /> <InterfaceFields card={sheet.lan} change={change} />
<div className="sheet-buttons">
<button type="button" onClick={() => onOpen('dhcp')}>
{t('sheet.dhcpServer')}
</button>
</div>
</>
) : ( ) : (
<> <>
{/* DHCP configures the WAN card; its address and mask are closed meanwhile, as in Java. */} {/* DHCP configures the WAN card; its address and mask are closed meanwhile, as in Java. */}
@@ -158,109 +162,35 @@ const EMPTY_ROW: Readonly<Record<RouteColumn, string>> = { destinationNetwork: '
/** /**
* The forwarding table: all rows or the manual ones only (Java's "show all * The forwarding table: all rows or the manual ones only (Java's "show all
* entries", on at first), a manual row chosen to edit its cells or remove * entries", on at first); the manual rows are kept with RowsEditor (a new
* it, one new row at a time, taken once all four cells pass (as Java keeps * row once all four cells pass, as Java keeps its new row until
* its new row until Weiterleitungstabelle.validate takes it). * Weiterleitungstabelle.validate takes it).
*/ */
function RouteTable({ sheet, change }: { readonly sheet: RouterSheet; readonly change: Change }) { function RouteTable({ sheet, change }: { readonly sheet: RouterSheet; readonly change: Change }) {
const t = useTranslate(); const t = useTranslate();
const [showAll, setShowAll] = useState(true); const [showAll, setShowAll] = useState(true);
const [chosen, setChosen] = useState<number | 'new' | null>(null);
const [draft, setDraft] = useState<Record<RouteColumn, string>>(EMPTY_ROW);
let manual = -1; let manual = -1;
const rows = sheet.table.map((row) => ({ row, manual: row.isEditable ? ++manual : null })).filter((each) => showAll || each.manual !== null); const rows = sheet.table
const editing = typeof chosen === 'number' ? sheet.routes[chosen] : undefined; .map((row) => ({
cells: { destinationNetwork: row.destinationNetwork, subnetMask: row.subnetMask, gateway: row.nextHop, interfaceIPAddress: row.interfaceIPAddress },
const draftCell = index: row.isEditable ? ++manual : null,
(column: RouteColumn) => }))
(text: string): CheckedField => { .filter((row) => showAll || row.index !== null);
const checked = checkField(routeRule(column), text);
if ('invalid' in checked) return checked;
const next = { ...draft, [column]: checked.value };
setDraft(next);
if (ROUTE_COLUMNS.every((each) => 'value' in checkField(routeRule(each), next[each]))) {
const added = change.run(() => addRoute(change.doc, change.id, next, change.origin));
if ('route' in added) {
setDraft(EMPTY_ROW);
setChosen(sheet.routes.length);
}
}
return checked;
};
return ( return (
<> <>
<SheetCheck name="showAll" label={t('sheet.showAllEntries')} checked={showAll} onChange={setShowAll} /> <SheetCheck name="showAll" label={t('sheet.showAllEntries')} checked={showAll} onChange={setShowAll} />
<div className="route-table-frame"> <RowsEditor
<table className="route-table"> name="route"
<thead> columns={ROUTE_COLUMNS.map((column) => ({ key: column, label: t(`route.${column}`), rule: routeRule(column) }))}
<tr> rows={rows}
{ROUTE_COLUMNS.map((column) => ( blank={EMPTY_ROW}
<th key={column} scope="col"> keptCount={sheet.routes.length}
{t(`route.${column}`)} newLabel={t('sheet.newEntry')}
</th> removeLabel={t('sheet.deleteEntry')}
))} onAdd={(cells) => ({ taken: 'route' in change.run(() => addRoute(change.doc, change.id, cells, change.origin)) })}
</tr> onSetCell={(index, column, text) => change.run(() => setRouteCell(change.doc, change.id, index, column, text, change.origin))}
</thead> onRemove={(index) => change.run(() => removeRoute(change.doc, change.id, index, change.origin))}
<tbody>
{rows.map(({ row, manual: index }, place) => (
<tr key={place} data-manual={index === null ? undefined : String(index)} aria-selected={index !== null && chosen === index} onClick={index === null ? undefined : () => setChosen(index)}>
{[row.destinationNetwork, row.subnetMask, row.nextHop, row.interfaceIPAddress].map((cell, column) => (
// A manual cell the rule refuses (from a file) is marked, not mended.
<td key={column} data-invalid={index !== null && 'invalid' in checkField(routeRule(ROUTE_COLUMNS[column]!), cell) ? 'true' : undefined}>
{cell}
</td>
))}
</tr>
))}
{chosen === 'new' ? (
<tr aria-selected data-new>
{ROUTE_COLUMNS.map((column) => (
<td key={column}>{draft[column]}</td>
))}
</tr>
) : null}
</tbody>
</table>
</div>
<div className="sheet-buttons">
<button type="button" onClick={() => setChosen('new')}>
{t('sheet.newEntry')}
</button>
<button
type="button"
disabled={chosen === null}
onClick={() => {
if (chosen === 'new') setDraft(EMPTY_ROW);
else if (chosen !== null) change.run(() => removeRoute(change.doc, change.id, chosen, change.origin));
setChosen(null);
}}
>
{t('sheet.deleteEntry')}
</button>
</div>
{chosen === 'new' ? (
<fieldset className="sheet-group" key="new">
<p className="sheet-note">{t('sheet.newEntryHint')}</p>
{ROUTE_COLUMNS.map((column) => (
<SheetField key={column} name={`route-${column}`} label={t(`route.${column}`)} value={draft[column]} rule={routeRule(column)} inputMode="decimal" unfilled onCommit={draftCell(column)} />
))}
</fieldset>
) : editing !== undefined ? (
<fieldset className="sheet-group" key={`row-${String(chosen)}`}>
{ROUTE_COLUMNS.map((column) => (
<SheetField
key={column}
name={`route-${column}`}
label={t(`route.${column}`)}
value={editing[column]}
rule={routeRule(column)}
inputMode="decimal"
onCommit={(text) => change.run(() => setRouteCell(change.doc, change.id, chosen as number, column, text, change.origin))}
/> />
))}
</fieldset>
) : null}
</> </>
); );
} }
+164
View File
@@ -0,0 +1,164 @@
// A table of rows a person keeps (a router's manual routes, a DHCP server's
// static assignments, a home router's port forwarding), after Java's tables
// in `JWeiterleitungsTabelle`, `JDHCPKonfiguration` and `JPortForwardingDialog`:
// the rows in a compact table, a row chosen to edit its cells or remove it, one
// new row at a time, taken once all its cells pass. Rows the person does not
// keep (Java's own routes) are shown greyed; a kept cell its rule refuses
// (from a file) is marked, not mended.
import { useState } from 'react';
import { checkField, type CheckedField, type FieldRule } from '@mdg-filius/doc';
import { useTranslate } from '../i18n/i18n.js';
import { SheetField } from './SheetField.js';
export interface RowsColumn<Key extends string> {
readonly key: Key;
readonly label: string;
/** A text cell's rule; a choice instead offers these values. */
readonly rule?: FieldRule;
readonly options?: readonly string[];
}
export interface Row<Key extends string> {
readonly cells: Readonly<Record<Key, string>>;
/** The row's place among the kept rows; null for a row the person does not keep. */
readonly index: number | null;
/** Kept cells to mark beyond their rules (a row Java would not use). */
readonly marked?: boolean;
}
export type AddOutcome = { readonly taken: true } | { readonly taken: false; readonly message?: string };
export interface RowsEditorProps<Key extends string> {
/** A name for the browser tests, the start of each field's name. */
readonly name: string;
readonly columns: readonly RowsColumn<Key>[];
readonly rows: readonly Row<Key>[];
readonly blank: Readonly<Record<Key, string>>;
readonly onAdd: (cells: Readonly<Record<Key, string>>) => AddOutcome;
readonly onSetCell: (index: number, key: Key, text: string) => CheckedField;
readonly onRemove: (index: number) => void;
readonly newLabel: string;
readonly removeLabel: string;
/** The number of kept rows, to choose the row just taken. */
readonly keptCount: number;
}
export function RowsEditor<Key extends string>({ name, columns, rows, blank, onAdd, onSetCell, onRemove, newLabel, removeLabel, keptCount }: RowsEditorProps<Key>) {
const t = useTranslate();
const [chosen, setChosen] = useState<number | 'new' | null>(null);
const [draft, setDraft] = useState<Record<Key, string>>({ ...blank });
const [message, setMessage] = useState<string | null>(null);
const editing = typeof chosen === 'number' ? rows.find((row) => row.index === chosen) : undefined;
const passes = (column: RowsColumn<Key>, text: string) => (column.options !== undefined ? column.options.includes(text) : 'value' in checkField(column.rule!, text));
const take = (next: Record<Key, string>) => {
setDraft(next);
if (!columns.every((column) => passes(column, next[column.key]))) return;
const outcome = onAdd(next);
if (outcome.taken) {
setDraft({ ...blank });
setMessage(null);
setChosen(keptCount);
} else {
setMessage(outcome.message ?? null);
}
};
const draftCell =
(column: RowsColumn<Key>) =>
(text: string): CheckedField => {
const checked = checkField(column.rule!, text);
if ('value' in checked) take({ ...draft, [column.key]: checked.value });
return checked;
};
const field = (column: RowsColumn<Key>, value: string, commit: (text: string) => CheckedField, unfilled: boolean) =>
column.options !== undefined ? (
<label key={column.key} className="sheet-field">
<span>{column.label}</span>
<select name={`${name}-${column.key}`} value={value} onChange={(event) => commit(event.target.value)}>
{column.options.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</label>
) : (
<SheetField key={column.key} name={`${name}-${column.key}`} label={column.label} value={value} rule={column.rule!} inputMode="decimal" unfilled={unfilled} onCommit={commit} />
);
return (
<>
<div className="route-table-frame">
<table className="route-table" data-rows={name}>
<thead>
<tr>
{columns.map((column) => (
<th key={column.key} scope="col">
{column.label}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, place) => (
<tr
key={place}
data-manual={row.index === null ? undefined : String(row.index)}
aria-selected={row.index !== null && chosen === row.index}
onClick={row.index === null ? undefined : () => setChosen(row.index)}
>
{columns.map((column) => (
<td key={column.key} data-invalid={row.index !== null && (row.marked === true || !passes(column, row.cells[column.key])) ? 'true' : undefined}>
{row.cells[column.key]}
</td>
))}
</tr>
))}
{chosen === 'new' ? (
<tr aria-selected data-new>
{columns.map((column) => (
<td key={column.key}>{draft[column.key]}</td>
))}
</tr>
) : null}
</tbody>
</table>
</div>
<div className="sheet-buttons">
<button type="button" onClick={() => setChosen('new')}>
{newLabel}
</button>
<button
type="button"
disabled={chosen === null}
onClick={() => {
if (chosen === 'new') setDraft({ ...blank });
else if (chosen !== null) onRemove(chosen);
setChosen(null);
setMessage(null);
}}
>
{removeLabel}
</button>
</div>
{chosen === 'new' ? (
<fieldset className="sheet-group" key="new">
<p className="sheet-note">{t('sheet.newEntryHint')}</p>
{columns.map((column) => field(column, draft[column.key], column.options !== undefined ? (text) => (take({ ...draft, [column.key]: text }), { value: text }) : draftCell(column), true))}
{message === null ? null : (
<p className="sheet-hint" role="alert">
{message}
</p>
)}
</fieldset>
) : editing !== undefined ? (
<fieldset className="sheet-group" key={`row-${String(chosen)}`}>
{columns.map((column) => field(column, editing.cells[column.key], (text) => onSetCell(chosen as number, column.key, text), false))}
</fieldset>
) : null}
</>
);
}
+137
View File
@@ -0,0 +1,137 @@
// Pages of the device sheet that Java opens as dialogs: a DHCP server's
// settings (JDHCPKonfiguration: base settings, static assignments) and a
// home router's port forwarding (JPortForwardingDialog). Java takes a
// dialog's fields with its OK button; here each field when it is left, as
// on the rest of the sheet.
import { useState } from 'react';
import {
addDhcpStaticAssignment,
addPortForwarding,
PORT_FORWARDING_COLUMNS,
PORT_FORWARDING_PROTOCOLS,
removeDhcpStaticAssignment,
removePortForwarding,
setDhcpServerAddress,
setDhcpServerFlag,
setDhcpStaticCell,
setPortForwardingCell,
STATIC_ASSIGNMENT_COLUMNS,
type DhcpServerField,
type DhcpServerSheet,
type HomeRouterSheet,
type PortForwardingColumn,
type StaticAssignmentColumn,
} from '@mdg-filius/doc';
import { useTranslate } from '../i18n/i18n.js';
import type { Change } from './NameField.js';
import { RowsEditor, type RowsColumn } from './RowsEditor.js';
import { SheetCheck, SheetField } from './SheetField.js';
import { SheetTabs } from './SheetTabs.js';
export function DhcpServerPanel({ server, change }: { readonly server: DhcpServerSheet; readonly change: Change }) {
const t = useTranslate();
const [tab, setTab] = useState('basic');
const address = (field: DhcpServerField) => (text: string) => change.run(() => setDhcpServerAddress(change.doc, change.id, field, text, change.origin));
const columns: RowsColumn<StaticAssignmentColumn>[] = STATIC_ASSIGNMENT_COLUMNS.map((column) => ({ key: column, label: t(`dhcp.${column}`), rule: column }));
return (
<>
<SheetTabs
tabs={[
{ key: 'basic', label: t('dhcp.basic') },
{ key: 'static', label: t('dhcp.static') },
]}
selected={tab}
onSelect={setTab}
/>
<div role="tabpanel" key={tab}>
{tab === 'basic' ? (
<>
<SheetField name="lowerBound" label={t('dhcp.lowerBound')} value={server.lowerBoundIPAddress} rule="ipAddress" inputMode="decimal" onCommit={address('lowerBoundIPAddress')} />
<SheetField name="upperBound" label={t('dhcp.upperBound')} value={server.upperBoundIPAddress} rule="ipAddress" inputMode="decimal" onCommit={address('upperBoundIPAddress')} />
<SheetField name="dhcpSubnetMask" label={t('dhcp.subnetMask')} value={server.subnetMask} rule="name" readOnly onCommit={() => ({ invalid: true })} />
{/* What the server hands out: shown as Java determines it, open only where the server's own setting counts. */}
<SheetField
name="dhcpGateway"
label={t('dhcp.gateway')}
value={server.gateway}
rule="ipAddress"
inputMode="decimal"
readOnly={!server.gatewayEditable}
onCommit={address('gatewayIPAddress')}
/>
<SheetField
name="dhcpDnsServer"
label={t('dhcp.dnsServer')}
value={server.dnsServer}
rule="ipAddress"
inputMode="decimal"
readOnly={!server.dnsServerEditable}
onCommit={address('dnsServerIPAddress')}
/>
{server.ownSettingsOffered ? (
<>
<SheetCheck
name="ownSettings"
label={t('dhcp.ownSettings')}
checked={server.useOwnSettings}
onChange={(on) => change.run(() => setDhcpServerFlag(change.doc, change.id, 'useOwnSettings', on, change.origin))}
/>
{/* Java's tooltip; a finger has no hover. */}
<p className="sheet-note">{t('dhcp.ownSettingsHint')}</p>
</>
) : null}
<SheetCheck
name="dhcpActive"
label={t('dhcp.active')}
checked={server.isActive}
onChange={(on) => change.run(() => setDhcpServerFlag(change.doc, change.id, 'isActive', on, change.origin))}
/>
</>
) : (
<RowsEditor
name="static"
columns={columns}
rows={server.staticAssignments.map((cells, index) => ({ cells, index }))}
blank={{ macAddress: '', ipAddress: '' }}
keptCount={server.staticAssignments.length}
newLabel={t('dhcp.add')}
removeLabel={t('dhcp.remove')}
onAdd={(cells) => {
const result = change.run(() => addDhcpStaticAssignment(change.doc, change.id, cells, crypto.randomUUID(), change.origin));
return 'assignment' in result ? { taken: true } : { taken: false, ...('error' in result ? { message: t('dhcp.macTaken') } : {}) };
}}
onSetCell={(index, column, text) => change.run(() => setDhcpStaticCell(change.doc, change.id, index, column, text, change.origin))}
onRemove={(index) => change.run(() => removeDhcpStaticAssignment(change.doc, change.id, index, change.origin))}
/>
)}
</div>
</>
);
}
const FORWARDING_RULES = { protocolValue: undefined, publicPortValue: 'port', lanIPAddress: 'ipAddress', lanPortValue: 'port' } as const;
/** A home router's port forwarding; a row Java's firmware would not forward (from a file) is marked. */
export function PortForwardingPanel({ sheet, change }: { readonly sheet: HomeRouterSheet; readonly change: Change }) {
const t = useTranslate();
const columns: RowsColumn<PortForwardingColumn>[] = PORT_FORWARDING_COLUMNS.map((column) =>
column === 'protocolValue'
? { key: column, label: t(`forwarding.${column}`), options: PORT_FORWARDING_PROTOCOLS }
: { key: column, label: t(`forwarding.${column}`), rule: FORWARDING_RULES[column] },
);
return (
<RowsEditor
name="forwarding"
columns={columns}
rows={sheet.portForwarding.map(({ forwarded, ...cells }, index) => ({ cells, index, marked: !forwarded }))}
blank={{ protocolValue: 'TCP', publicPortValue: '', lanIPAddress: '', lanPortValue: '' }}
keptCount={sheet.portForwarding.length}
newLabel={t('sheet.newEntry')}
removeLabel={t('sheet.deleteEntry')}
onAdd={(cells) => ({ taken: 'row' in change.run(() => addPortForwarding(change.doc, change.id, cells, change.origin)) })}
onSetCell={(index, column, text) => change.run(() => setPortForwardingCell(change.doc, change.id, index, column, text, change.origin))}
onRemove={(index) => change.run(() => removePortForwarding(change.doc, change.id, index, change.origin))}
/>
);
}
+19
View File
@@ -0,0 +1,19 @@
// Java's tabs (JTabbedPane) in the device sheet: chips that wrap in a narrow sheet.
export interface Tab {
readonly key: string;
readonly label: string;
readonly disabled?: boolean;
}
export function SheetTabs({ tabs, selected, onSelect }: { readonly tabs: readonly Tab[]; readonly selected: string; readonly onSelect: (key: string) => void }) {
return (
<div role="tablist" className="sheet-tabs">
{tabs.map((tab) => (
<button key={tab.key} type="button" role="tab" data-tab={tab.key} aria-selected={tab.key === selected} disabled={tab.disabled === true} onClick={() => onSelect(tab.key)}>
{tab.label}
</button>
))}
</div>
);
}
+6
View File
@@ -246,3 +246,9 @@
padding-top: 13px; padding-top: 13px;
padding-bottom: 13px; padding-bottom: 13px;
} }
.sheet-group .sheet-hint {
margin: 4px 0;
font-size: 13px;
color: var(--danger);
}
+19
View File
@@ -66,6 +66,25 @@ const FROM_JAVA: Readonly<Record<string, string>> = {
'route.subnetMask': 'jweiterleitungstabelle_msg4', 'route.subnetMask': 'jweiterleitungstabelle_msg4',
'route.gateway': 'jweiterleitungstabelle_msg5', 'route.gateway': 'jweiterleitungstabelle_msg5',
'route.interfaceIPAddress': 'jweiterleitungstabelle_msg6', 'route.interfaceIPAddress': 'jweiterleitungstabelle_msg6',
'sheet.dhcpServer': 'jhostkonfiguration_msg8',
'sheet.portForwarding': 'jgatewayconfiguration_msg19',
'dhcp.lowerBound': 'jdhcpkonfiguration_msg1',
'dhcp.upperBound': 'jdhcpkonfiguration_msg2',
'dhcp.subnetMask': 'jdhcpkonfiguration_msg3',
'dhcp.gateway': 'jdhcpkonfiguration_msg4',
'dhcp.dnsServer': 'jdhcpkonfiguration_msg5',
'dhcp.active': 'jdhcpkonfiguration_msg6',
'dhcp.ownSettings': 'jdhcpkonfiguration_msg8',
'dhcp.basic': 'jdhcpkonfiguration_msg10',
'dhcp.static': 'jdhcpkonfiguration_msg11',
'dhcp.macAddress': 'jdhcpkonfiguration_msg12',
'dhcp.ipAddress': 'jdhcpkonfiguration_msg13',
'dhcp.add': 'jdhcpkonfiguration_msg14',
'dhcp.remove': 'jdhcpkonfiguration_msg15',
'forwarding.protocolValue': 'jportforwarding_msg4',
'forwarding.publicPortValue': 'jportforwarding_msg5',
'forwarding.lanIPAddress': 'jportforwarding_msg6',
'forwarding.lanPortValue': 'jportforwarding_msg7',
}; };
/** Java's words, mended: the prompt says "Vermittlungsrechnerns" (guicontainer_msg1). */ /** Java's words, mended: the prompt says "Vermittlungsrechnerns" (guicontainer_msg1). */
+4
View File
@@ -100,3 +100,7 @@ packages/editor/browser/canvas.spec.ts::rechner
# "Eintrag Löschen": Java FILIUS' button text (jvermittlungsrechnerkonfiguration_msg13), quoted where the test mends its capital L. # "Eintrag Löschen": Java FILIUS' button text (jvermittlungsrechnerkonfiguration_msg13), quoted where the test mends its capital L.
packages/editor/test/i18n.test.ts::eintrag packages/editor/test/i18n.test.ts::eintrag
packages/editor/test/i18n.test.ts::loeschen packages/editor/test/i18n.test.ts::loeschen
# tfUntergrenze, tfObergrenze, tfNetzmaske: the fields of Java FILIUS' DHCP dialog (JDHCPKonfiguration), by which SheetRun reports what it shows.
packages/doc/test/sheet.test.ts::untergrenze
packages/doc/test/sheet.test.ts::obergrenze
packages/doc/test/sheet.test.ts::netzmaske
+28
View File
@@ -1157,3 +1157,31 @@ Gemessen (03.10.2026):
* Tabelle bearbeiten: Eine Zelle, die das Muster nicht besteht („abc“, „“, * Tabelle bearbeiten: Eine Zelle, die das Muster nicht besteht („abc“, „“,
„10..0.0“), wird als `null` gespeichert; eine neue Zeile erst, wenn alle „10..0.0“), wird als `null` gespeichert; eine neue Zeile erst, wenn alle
vier Zellen gültig sind; „Neuer Eintrag“ zweimal ergibt eine offene Zeile. vier Zellen gültig sind; „Neuer Eintrag“ zweimal ergibt eine offene Zeile.
### DHCP-Server und Portweiterleitung (F4.5c)
`SheetRun` tippt auch in `JDHCPKonfiguration` (eines Rechners mit Gateway
und DNS oder eines Heimrouters; der Server mit Grenzen 100–200 und eigenem
Gateway 10.0.0.1, DNS 10.0.0.2) und übernimmt mit dem Knopf (`speichern`).
Dazu, was der Dialog ohne eigene Einstellungen zeigt, feste Zuordnungen aus
seiner Tabelle (`dhcp`) und Zeilen des Portweiterleitungs-Dialogs
(`forwarding`).
Gemessen (03.10.2026):
* Grenzen, Gateway, DNS: Muster `musterIpAdresse`, also nie leer. Der Server
speichert den Text wie getippt: „010.000.000.001“ bleibt so, „.0.0.1“
wird unmarkiert gespeichert.
* Ohne eigene Einstellungen zeigt der Dialog Gateway und DNS des Rechners
(gesperrt), ohne solche 0.0.0.0. Beim Heimrouter ist das Gateway immer
seine LAN-Adresse (gesperrt), der DNS-Server immer frei; das Kästchen
„Manuelle Einstellungen“ bewirkt dort nichts. Die Netzmaske ist die der
ersten Karte (beim Heimrouter LAN), gesperrt.
* Feste Zuordnungen: Javas Hinzufügen-Knopf prüft MAC (`musterMacAddress`,
dessen Klasse `[0-9a-fA-f]` auch „_“ durchlässt) und Adresse; beim
Speichern verwirft der Server eine zweite Zuordnung derselben MAC (ohne
Rücksicht auf Groß-/Kleinschreibung) und ungültige Zeilen still. Die MAC
bleibt, wie getippt.
* Portweiterleitung: Der Dialog speichert jede Zeile roh, auch Port „0“,
„abc“, „05353“; die Firmware leitet nur gültige weiter
(`validateData`: `musterProtocol`, `musterPort`, `musterIpAdresse`).
+149 -1
View File
@@ -19,12 +19,23 @@
// shows its address or MAC address, DHCP switched on while the device's own // shows its address or MAC address, DHCP switched on while the device's own
// DHCP server runs; then a router's forwarding table as Java shows it // DHCP server runs; then a router's forwarding table as Java shows it
// (Weiterleitungstabelle.holeTabelle, "tables") and its rows edited in the // (Weiterleitungstabelle.holeTabelle, "tables") and its rows edited in the
// panel's table (JWeiterleitungsTabelle, "edits"). // panel's table (JWeiterleitungsTabelle, "edits"). The DHCP dialog
// (JDHCPKonfiguration) takes its fields with its button (speichern):
//
// <dhcpHost|dhcpHome> <lower|upper|gateway|dns> <text>
//
// a host's (gateway and DNS with its own settings on) or a home router's;
// then what the dialog shows without own settings, the static assignments
// ("dhcp") and a home router's port forwarding as its dialog keeps it
// (JPortForwardingDialog, "forwarding").
// //
// 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.netzwerksicht.JDHCPKonfiguration;
import filius.gui.netzwerksicht.JKonfiguration; import filius.gui.netzwerksicht.JKonfiguration;
import filius.gui.netzwerksicht.JPortForwardingDialog;
import filius.gui.netzwerksicht.JWeiterleitungsTabelle; import filius.gui.netzwerksicht.JWeiterleitungsTabelle;
import filius.hardware.NetzwerkInterface; import filius.hardware.NetzwerkInterface;
import filius.hardware.knoten.Gateway; import filius.hardware.knoten.Gateway;
@@ -40,6 +51,8 @@ import filius.software.system.InternetKnotenBetriebssystem;
import filius.software.system.ModemFirmware; import filius.software.system.ModemFirmware;
import filius.software.system.SwitchFirmware; import filius.software.system.SwitchFirmware;
import filius.software.system.VermittlungsrechnerBetriebssystem; import filius.software.system.VermittlungsrechnerBetriebssystem;
import filius.software.dhcp.DHCPAddressAssignment;
import filius.software.dhcp.DHCPServer;
import filius.software.vermittlungsschicht.Weiterleitungstabelle; import filius.software.vermittlungsschicht.Weiterleitungstabelle;
import java.io.FileOutputStream; import java.io.FileOutputStream;
@@ -55,6 +68,9 @@ 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.JCheckBox; import javax.swing.JCheckBox;
import javax.swing.JTable;
import javax.swing.table.DefaultTableModel;
import java.util.LinkedList;
import javax.swing.JTextField; import javax.swing.JTextField;
public class SheetRun { public class SheetRun {
@@ -242,6 +258,132 @@ public class SheetRun {
return result; return result;
} }
/** A device whose DHCP server the dialog edits: a host 192.168.0.10/24 with gateway and DNS, or a home router; the server with known settings. */
static InternetKnotenBetriebssystem dhcpDevice(String kind) {
InternetKnotenBetriebssystem os;
if (kind.equals("dhcpHost")) {
Rechner host = new Rechner();
os = (InternetKnotenBetriebssystem) host.getSystemSoftware();
os.setStandardGateway("192.168.0.254");
os.setDNSServer("192.168.0.253");
} else {
os = (InternetKnotenBetriebssystem) new Gateway().getSystemSoftware();
}
DHCPServer server = os.getDHCPServer();
server.setUntergrenze("192.168.0.100");
server.setObergrenze("192.168.0.200");
server.setGatewayip("10.0.0.1");
server.setDnsserverip("10.0.0.2");
return os;
}
static JDHCPKonfiguration dhcpDialog(InternetKnotenBetriebssystem os) {
return new JDHCPKonfiguration(JMainFrame.getJMainFrame(), "DHCP", os);
}
static String dhcpValue(DHCPServer server, String field) {
switch (field) {
case "lower": return server.getUntergrenze();
case "upper": return server.getObergrenze();
case "gateway": return server.getGatewayip();
case "dns": return server.getDnsserverip();
}
throw new IllegalArgumentException(field);
}
/** A field of the DHCP dialog typed and taken with its button. */
static Map<String, Object> dhcpCase(String kind, String field, String text) throws Exception {
InternetKnotenBetriebssystem os = dhcpDevice(kind);
DHCPServer server = os.getDHCPServer();
String start = dhcpValue(server, field);
JDHCPKonfiguration dialog = dhcpDialog(os);
if (kind.equals("dhcpHost")) ((JCheckBox) get(dialog, "cbUseInternal")).setSelected(true);
String name = field.equals("lower") ? "tfUntergrenze" : field.equals("upper") ? "tfObergrenze" : field.equals("gateway") ? "tfGateway" : "tfDNSServer";
JTextField input = (JTextField) get(dialog, name);
input.setText(text);
boolean marked = !dialog.ueberpruefen(EingabenUeberpruefung.musterIpAdresse, input);
call(dialog, "speichern");
dialog.dispose();
Map<String, Object> result = new LinkedHashMap<>();
result.put("device", kind);
result.put("field", field);
result.put("input", text);
result.put("start", start);
result.put("marked", marked);
result.put("stored", dhcpValue(server, field));
result.put("shown", dhcpValue(server, field));
return result;
}
/** What the DHCP dialog shows without own settings, and its static assignments taken with its button. */
static Map<String, Object> dhcpFlows() throws Exception {
Map<String, Object> flows = new LinkedHashMap<>();
for (String kind : new String[] { "dhcpHost", "dhcpHome" }) {
InternetKnotenBetriebssystem os = dhcpDevice(kind);
JDHCPKonfiguration dialog = dhcpDialog(os);
Map<String, Object> shown = new LinkedHashMap<>();
for (String name : new String[] { "tfUntergrenze", "tfObergrenze", "tfNetzmaske", "tfGateway", "tfDNSServer" }) {
JTextField input = (JTextField) get(dialog, name);
shown.put(name, Arrays.asList(input.getText(), input.isEditable()));
}
dialog.dispose();
flows.put(kind + "Shown", shown);
}
{
Rechner host = new Rechner();
InternetKnotenBetriebssystem os = (InternetKnotenBetriebssystem) host.getSystemSoftware();
JDHCPKonfiguration dialog = dhcpDialog(os);
flows.put("hostWithoutGatewayShown", Arrays.asList(((JTextField) get(dialog, "tfGateway")).getText(), ((JTextField) get(dialog, "tfDNSServer")).getText()));
dialog.dispose();
}
// Static assignments: rows of the dialog's table, taken with its button.
String[][] rows = { { "aa:bb:cc:dd:ee:01", "192.168.0.50" }, { "AA:BB:CC:DD:EE:01", "192.168.0.51" }, { "AA:BB:CC:DD:EE:_F", "192.168.0.52" }, { "AA:BB:CC:DD:EE:02", "192.168.0.256" },
{ "AA:BB:CC:DD:EE:03", "010.0.0.1" }, { "AABBCCDDEE04", "192.168.0.54" } };
InternetKnotenBetriebssystem os = dhcpDevice("dhcpHost");
JDHCPKonfiguration dialog = dhcpDialog(os);
DefaultTableModel model = (DefaultTableModel) ((JTable) get(dialog, "staticAddressTable")).getModel();
for (String[] row : rows) model.addRow(new Object[] { row[0], row[1] });
call(dialog, "speichern");
dialog.dispose();
List<Object> kept = new ArrayList<>();
for (DHCPAddressAssignment entry : os.getDHCPServer().holeStaticAssignedAddresses()) kept.add(Arrays.asList(entry.getMAC(), entry.getIp()));
flows.put("staticAssignments", kept);
// The add button's own check, per row: MAC and address patterns.
List<Object> addable = new ArrayList<>();
for (String[] row : rows) addable.add(EingabenUeberpruefung.isGueltig(row[0], EingabenUeberpruefung.musterMacAddress) && EingabenUeberpruefung.isGueltig(row[1], EingabenUeberpruefung.musterIpAdresse));
flows.put("addable", addable);
return flows;
}
/** A home router's port forwarding rows as its dialog keeps them, and which of them its firmware would forward (musterProtocol, musterPort, musterIpAdresse). */
static Map<String, Object> forwardingFlows() throws Exception {
Gateway home = new Gateway();
GatewayFirmware firmware = (GatewayFirmware) home.getSystemSoftware();
JPortForwardingDialog dialog = new JPortForwardingDialog(firmware.getStaticNAT(), JMainFrame.getJMainFrame());
dialog.createStaticTable();
String[][] rows = { { "TCP", "80", "192.168.0.20", "8080" }, { "UDP", "0", "192.168.0.21", "53" }, { "TCP", "abc", "192.168.0.22", "80" }, { "tcp", "443", "192.168.0.300", "443" },
{ "17", "5353", "192.168.0.23", "05353" } };
for (String[] row : rows) {
dialog.neuerEintrag();
JTable table = (JTable) get(dialog, "staticTable");
int last = table.getRowCount() - 1;
for (int column = 0; column < 4; column++) table.setValueAt(row[column], last, column);
}
dialog.aenderungenAnnehmen();
dialog.dispose();
Map<String, Object> flows = new LinkedHashMap<>();
List<Object> kept = new ArrayList<>();
List<Object> valid = new ArrayList<>();
for (String[] row : firmware.getStaticNAT()) {
kept.add(Arrays.asList(row));
valid.add(EingabenUeberpruefung.isGueltig(row[0], EingabenUeberpruefung.musterProtocol) && EingabenUeberpruefung.isGueltig(row[1], EingabenUeberpruefung.musterPort)
&& EingabenUeberpruefung.isGueltig(row[2], EingabenUeberpruefung.musterIpAdresse) && EingabenUeberpruefung.isGueltig(row[3], EingabenUeberpruefung.musterPort));
}
flows.put("rows", kept);
flows.put("valid", valid);
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 {
@@ -261,6 +403,10 @@ public class SheetRun {
String[] words = line.split(" ", 3); String[] words = line.split(" ", 3);
String key = words[0] + " " + words[1]; String key = words[0] + " " + words[1];
String text = words.length < 3 ? "" : IpCheckCases.unescape(words[2]); String text = words.length < 3 ? "" : IpCheckCases.unescape(words[2]);
if (words[0].startsWith("dhcp")) {
cases.add(dhcpCase(words[0], words[1], text));
continue;
}
String[] names = FIELDS.get(key); String[] names = FIELDS.get(key);
if (names == null) throw new IllegalArgumentException(line); if (names == null) throw new IllegalArgumentException(line);
Object device = device(words[0]); Object device = device(words[0]);
@@ -360,6 +506,8 @@ public class SheetRun {
output.put("cases", cases); output.put("cases", cases);
output.put("flows", flows); output.put("flows", flows);
output.put("tables", tables); output.put("tables", tables);
output.put("dhcp", dhcpFlows());
output.put("forwarding", forwardingFlows());
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));