feat(editor): F4.5b the device sheet for routers and home routers

Measured first: SheetRun now types into FILIUS' router and home router
panels, records five forwarding tables (holeTabelle) and steps in the
panel's table. Java turns a card address that passes the pattern but not
IP.ipCheck (".0.0.1") into null and the router panel throws; an empty
gateway is marked red yet taken; a refused table cell is stored as null;
a home router's DHCP client leaves its DHCP server running. None of the
nulls is adopted. The core's routeTableRows matches all five tables.

- doc: card addresses (the first card mirrored in the device's settings),
  router gateway, RIP, manual routes (a new row once all four cells pass,
  a cell, removal); the sheet reads cards with what their cable joins and
  the whole table
- editor: Java's tabs (general, each card titled by its address, the
  forwarding table greyed out under RIP; general, LAN, WAN on a home
  router); the open card's cable drawn active in Java's green

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 11:51:03 +00:00
co-authored by Claude Opus 5.5
parent 9b7ec2fc72
commit eff9433673
22 changed files with 2201 additions and 119 deletions
+4 -1
View File
@@ -124,6 +124,8 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand
| `src/fields.ts` | Java `EingabenUeberpruefung` (`musterIpAdresse`, `musterSubNetz`, `musterServiceSetIdentifier`, `musterNurZahlen`, `musterPort`), `IP.ipCheck`, wie `JHostKonfiguration`, `JSwitchKonfiguration`, `JModemKonfiguration` sie nutzen | neu (F4.5a): die Regeln der Felder des Geräte-Blatts, gegen Javas Felder gemessen (`SheetRun`). Abweichend von Java: Markieren und Übernehmen sind eine Regel — Java übernimmt rot markierten Text („10..0.0.1“, „+10.0.0.1“, „-0.0.0.1“, arabisch-indische Ziffern, Haltezeit „-5“), verwirft schwarzen („.0.0.1“, eine Haltezeit jenseits von `long`) und nimmt als Modem-Port jede Zahl (0, 65536, -1); hier gilt Javas `musterPort`. Leerzeichen um Adressen und Zahlen fallen weg (Java verwirft „ 10.0.0.1“) | | `src/fields.ts` | Java `EingabenUeberpruefung` (`musterIpAdresse`, `musterSubNetz`, `musterServiceSetIdentifier`, `musterNurZahlen`, `musterPort`), `IP.ipCheck`, wie `JHostKonfiguration`, `JSwitchKonfiguration`, `JModemKonfiguration` sie nutzen | neu (F4.5a): die Regeln der Felder des Geräte-Blatts, gegen Javas Felder gemessen (`SheetRun`). Abweichend von Java: Markieren und Übernehmen sind eine Regel — Java übernimmt rot markierten Text („10..0.0.1“, „+10.0.0.1“, „-0.0.0.1“, arabisch-indische Ziffern, Haltezeit „-5“), verwirft schwarzen („.0.0.1“, eine Haltezeit jenseits von `long`) und nimmt als Modem-Port jede Zahl (0, 65536, -1); hier gilt Javas `musterPort`. Leerzeichen um Adressen und Zahlen fallen weg (Java verwirft „ 10.0.0.1“) |
| `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/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 |
| `test/projectDoc.test.ts` | — | neu (F4.1): alle 23 Korpusdateien → Dokument → Yjs-Update → Projekt: dasselbe Projekt, derselbe Schnappschuss, dieselbe `.fls`, dieselbe Reihenfolge je Ordner; Kennungen absichtlich gegen die Dateireihenfolge | | `test/projectDoc.test.ts` | — | neu (F4.1): alle 23 Korpusdateien → Dokument → Yjs-Update → Projekt: dasselbe Projekt, derselbe Schnappschuss, dieselbe `.fls`, dieselbe Reihenfolge je Ordner; Kennungen absichtlich gegen die Dateireihenfolge |
@@ -162,7 +164,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 | | `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 |
| `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
+128 -5
View File
@@ -1,7 +1,9 @@
// What the device sheet does to the document: a device's settings changed as // What the device sheet does to the document: a device's settings changed as
// FILIUS' settings panels change them (JHostKonfiguration, // FILIUS' settings panels change them (JHostKonfiguration,
// JSwitchKonfiguration, JModemKonfiguration; measured with SheetRun), each a // JSwitchKonfiguration, JModemKonfiguration, JVermittlungsrechnerKonfiguration
// transaction with the person's origin, like the design tools' commands. // with its JWeiterleitungsTabelle, JGatewayConfiguration; measured with
// SheetRun), each a transaction with the person's origin, like the design
// tools' commands.
// //
// A text field's setter takes the text only when the field's rule does // A text field's setter takes the text only when the field's rule does
// (fields.ts) and says so; the device keeps its value otherwise. A host's // (fields.ts) and says so; the device keeps its value otherwise. A host's
@@ -15,9 +17,12 @@
// search for the access point (updateWifiConnection) keeps the last switch // search for the access point (updateWifiConnection) keeps the last switch
// with the SSID it passes until a device that is neither comes after the // with the SSID it passes until a device that is neither comes after the
// host — which switch depends on unrelated devices; here it is the first. // host — which switch depends on unrelated devices; here it is the first.
// 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
// throws; a table cell Java refuses is stored as null. Here neither is taken.
import * as Y from 'yjs'; import * as Y from 'yjs';
import { DEFAULT_REMOTE_LINK_LATENCY_MILLISECONDS, defaultSwitchConfiguration, forwardsIPv4ByDefault, type Id, type NodeKind } from '@mdg-filius/core'; import { DEFAULT_REMOTE_LINK_LATENCY_MILLISECONDS, defaultSwitchConfiguration, forwardsIPv4ByDefault, 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 } from './fields.js';
@@ -86,7 +91,11 @@ export function setIpForwarding(doc: Y.Doc, id: Id, on: boolean, origin: unknown
}, origin); }, origin);
} }
/** DHCP for the configuration; switched on, it stops the device's own DHCP server (JHostKonfiguration.aenderungenAnnehmen). */ /**
* DHCP for the configuration. On a PC or notebook it stops the device's own
* DHCP server (JHostKonfiguration.aenderungenAnnehmen); a home router's
* panel (its WAN card) leaves its server running, as Java does.
*/
export function setDhcpClient(doc: Y.Doc, id: Id, on: boolean, origin: unknown): void { export function setDhcpClient(doc: Y.Doc, id: Id, on: boolean, origin: unknown): void {
const node = nodes(doc).get(id); const node = nodes(doc).get(id);
if (node === undefined) return; if (node === undefined) return;
@@ -96,7 +105,7 @@ export function setDhcpClient(doc: Y.Doc, id: Id, on: boolean, origin: unknown):
return; return;
} }
settingsOf(node).set('dhcpClient', toY({ isEnabled: true })); settingsOf(node).set('dhcpClient', toY({ isEnabled: true }));
(settingsOf(node).get('dhcpServer') as Y.Map<unknown> | undefined)?.set('isActive', false); if (kindOf(node) === 'pc' || kindOf(node) === 'notebook') (settingsOf(node).get('dhcpServer') as Y.Map<unknown> | undefined)?.set('isActive', false);
}, origin); }, origin);
} }
@@ -196,3 +205,117 @@ export function setModemPort(doc: Y.Doc, id: Id, text: string, origin: unknown):
origin, origin,
); );
} }
const portsOf = (node: Y.Map<unknown>) => node.get('ports') as Y.Array<Y.Map<unknown>>;
/**
* A router's or home router's interface: its address or mask
* (NetzwerkInterface.setIp, setSubnetzMaske). The device's own settings
* mirror its first interface, as the project keeps them (the home router's
* WAN card).
*/
export function setInterfaceAddress(doc: Y.Doc, id: Id, portId: Id, field: 'ipAddress' | 'subnetMask', text: string, origin: unknown): CheckedField {
const node = nodes(doc).get(id);
const ports = node === undefined ? [] : portsOf(node).toArray();
const index = ports.findIndex((port) => port.get('id') === portId);
if (index < 0) return checkField(field, text);
return setChecked(
doc,
id,
checkField(field, text),
(device, value) => {
const port = ports[index]!;
let config = port.get('config') as Y.Map<unknown> | undefined;
if (config === undefined) {
config = toY({ ipAddress: '192.168.0.10', subnetMask: '255.255.255.0' }) as Y.Map<unknown>;
port.set('config', config);
}
config.set(field, value);
if (index === 0) (settingsOf(device).get('device') as Y.Map<unknown> | undefined)?.set(field, value);
},
origin,
);
}
/**
* A router's or home router's standard gateway (setStandardGateway: the
* gateway of every card), empty for none. A router keeps its own settings
* only while it has a gateway, as the project keeps them.
*/
export function setRouterGateway(doc: Y.Doc, id: Id, text: string, origin: unknown): CheckedField {
return setChecked(
doc,
id,
checkField('ipAddressOrEmpty', text),
(node, value) => {
const first = portsOf(node).get(0)?.get('config') as Y.Map<unknown> | undefined;
const device = settingMap(node, 'device', {
ipAddress: (first?.get('ipAddress') as string | undefined) ?? '',
subnetMask: (first?.get('subnetMask') as string | undefined) ?? '',
defaultGateway: '',
dnsServer: '',
});
device.set('defaultGateway', value);
if (kindOf(node) === 'router' && value === '' && device.get('dnsServer') === '') settingsOf(node).delete('device');
},
origin,
);
}
/** RIP on a router, kept only when on (as the project keeps it). */
export function setRip(doc: Y.Doc, id: Id, on: boolean, origin: unknown): void {
const node = nodes(doc).get(id);
if (node === undefined) return;
doc.transact(() => {
if (on) settingsOf(node).set('rip', true);
else settingsOf(node).delete('rip');
}, origin);
}
/** A column of the forwarding table's manual rows. */
export type RouteColumn = keyof ManualRoute;
export const ROUTE_COLUMNS: readonly RouteColumn[] = ['destinationNetwork', 'subnetMask', 'gateway', 'interfaceIPAddress'];
/** Each column's rule (JWeiterleitungsTabelle: musterIpAdresse, musterSubNetz for the mask). */
const routeRule = (column: RouteColumn) => (column === 'subnetMask' ? 'subnetMask' : 'ipAddress');
function routesOf(node: Y.Map<unknown>): Y.Array<Y.Map<unknown>> {
let routes = settingsOf(node).get('routes') as Y.Array<Y.Map<unknown>> | undefined;
if (routes === undefined) {
routes = new Y.Array<Y.Map<unknown>>();
settingsOf(node).set('routes', routes);
}
return routes;
}
/**
* A new manual row, taken once all four cells pass (Java keeps a new row in
* the table until Weiterleitungstabelle.validate takes it); the columns
* that do not pass otherwise.
*/
export function addRoute(doc: Y.Doc, id: Id, cells: Readonly<Record<RouteColumn, string>>, origin: unknown): { readonly route: ManualRoute } | { readonly invalid: readonly RouteColumn[] } {
const checked = ROUTE_COLUMNS.map((column) => [column, checkField(routeRule(column), cells[column])] as const);
const invalid = checked.filter(([, each]) => 'invalid' in each).map(([column]) => column);
const node = nodes(doc).get(id);
if (invalid.length > 0 || node === undefined) return { invalid };
const route = Object.fromEntries(checked.map(([column, each]) => [column, (each as { value: string }).value])) as unknown as ManualRoute;
doc.transact(() => routesOf(node).push([toY({ ...route }) as Y.Map<unknown>]), origin);
return { route };
}
/** A cell of a manual row (by its place among the manual rows). */
export function setRouteCell(doc: Y.Doc, id: Id, index: number, column: RouteColumn, text: string, origin: unknown): CheckedField {
return setChecked(doc, id, checkField(routeRule(column), text), (node, value) => (settingsOf(node).get('routes') as Y.Array<Y.Map<unknown>> | undefined)?.get(index)?.set(column, value), origin);
}
/** Removes a manual row (by its place among the manual rows); the list goes with its last row, as the project keeps it. */
export function removeRoute(doc: Y.Doc, id: Id, index: number, origin: unknown): void {
const node = nodes(doc).get(id);
const routes = node === undefined ? undefined : (settingsOf(node).get('routes') as Y.Array<Y.Map<unknown>> | undefined);
if (node === undefined || routes === undefined || index < 0 || index >= routes.length) return;
doc.transact(() => {
routes.delete(index, 1);
if (routes.length === 0) settingsOf(node).delete('routes');
}, origin);
}
+102 -6
View File
@@ -1,12 +1,24 @@
// What the device sheet shows of a device, read straight from the document: // What the device sheet shows of a device, read straight from the document:
// the fields of FILIUS' settings panel of its kind (JHostKonfiguration, // the fields of FILIUS' settings panel of its kind (JHostKonfiguration,
// JSwitchKonfiguration, JModemKonfiguration). A router's and a home router's // JSwitchKonfiguration, JModemKonfiguration, JVermittlungsrechnerKonfiguration
// sheet show their name only, until their own panels follow. // with its forwarding table, JGatewayConfiguration).
import * as Y from 'yjs'; import * as Y from 'yjs';
import { defaultSwitchConfiguration, DEFAULT_SWITCH_RETENTION_MILLISECONDS, forwardsIPv4ByDefault, stableGeneratedMacAddress, type HostLabelPolicy, type NodeKind } from '@mdg-filius/core'; import {
defaultSwitchConfiguration,
DEFAULT_SWITCH_RETENTION_MILLISECONDS,
forwardsIPv4ByDefault,
routeTableRows,
stableGeneratedMacAddress,
type HostLabelPolicy,
type ManualRoute,
type NodeKind,
type RouteRow,
} from '@mdg-filius/core';
import { modemPort } from '@mdg-filius/fls'; import { modemPort } from '@mdg-filius/fls';
import { inOrder, ROOTS } from './projectDoc.js'; import { inOrder, ROOTS } from './projectDoc.js';
import { fromY } from './values.js';
import { nodeLabel } from './view.js';
export interface HostSheet { export interface HostSheet {
readonly kind: 'pc' | 'notebook'; readonly kind: 'pc' | 'notebook';
@@ -44,16 +56,73 @@ export interface ModemSheet {
readonly port: number; readonly port: number;
} }
/** A router's or home router's card. */
export interface InterfaceSheet {
readonly portId: string;
readonly ipAddress: string;
readonly subnetMask: string;
readonly macAddress: string;
/** The device at the other end of its cable (its label, as Java names it) and the cable; null without one. */
readonly connected: { readonly device: string; readonly link: string } | null;
}
export interface RouterSheet {
readonly kind: 'router';
readonly name: string;
readonly gateway: string;
readonly rip: boolean;
/** Shown, not changed: Java's panel greys it out. */
readonly ipForwarding: boolean;
readonly interfaces: readonly InterfaceSheet[];
/** The manual rows, in their order. */
readonly routes: readonly ManualRoute[];
/** The whole table as Java shows it (Weiterleitungstabelle.holeTabelle): own addresses and networks, localhost, the manual rows, the gateway. */
readonly table: readonly RouteRow[];
}
export interface HomeRouterSheet {
readonly kind: 'gateway';
readonly name: string;
readonly gateway: string;
readonly ipForwarding: boolean;
/** DHCP for the WAN card. */
readonly dhcpClient: boolean;
readonly wan: InterfaceSheet;
readonly lan: InterfaceSheet;
}
export interface NameSheet { export interface NameSheet {
readonly kind: Exclude<NodeKind, 'pc' | 'notebook' | 'networkSwitch' | 'remoteLink'>; readonly kind: 'unsupported';
readonly name: string; readonly name: string;
} }
export type DeviceSheet = HostSheet | SwitchSheet | ModemSheet | NameSheet; export type DeviceSheet = HostSheet | SwitchSheet | ModemSheet | RouterSheet | HomeRouterSheet | NameSheet;
const settingsOf = (node: Y.Map<unknown>) => node.get('settings') as Y.Map<unknown>; const settingsOf = (node: Y.Map<unknown>) => node.get('settings') as Y.Map<unknown>;
const setting = (node: Y.Map<unknown>, key: string) => settingsOf(node).get(key) as Y.Map<unknown> | undefined; const setting = (node: Y.Map<unknown>, key: string) => settingsOf(node).get(key) as Y.Map<unknown> | undefined;
const text = (map: Y.Map<unknown> | undefined, key: string) => (map?.get(key) as string | undefined) ?? '';
/** A router's or home router's cards, each with what its cable joins. */
function interfacesOf(doc: Y.Doc, id: string, node: Y.Map<unknown>): InterfaceSheet[] {
const all = doc.getMap<Y.Map<unknown>>(ROOTS.nodes);
const links = inOrder(doc.getMap<Y.Map<unknown>>(ROOTS.links));
return (node.get('ports') as Y.Array<Y.Map<unknown>>).toArray().map((port) => {
const portId = port.get('id') as string;
const config = port.get('config') as Y.Map<unknown> | undefined;
const cable = links.find(([, link]) => (link.get('sourceNode') === id && link.get('sourcePort') === portId) || (link.get('targetNode') === id && link.get('targetPort') === portId));
const other =
cable === undefined ? undefined : all.get((cable[1].get('sourceNode') === id && cable[1].get('sourcePort') === portId ? cable[1].get('targetNode') : cable[1].get('sourceNode')) as string);
return {
portId,
ipAddress: text(config, 'ipAddress'),
subnetMask: text(config, 'subnetMask'),
macAddress: (port.get('mac') as string | undefined) ?? stableGeneratedMacAddress(portId),
connected: cable === undefined || other === undefined ? null : { device: nodeLabel(other), link: cable[0] },
};
});
}
/** 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);
@@ -106,7 +175,34 @@ export function deviceSheet(doc: Y.Doc, id: string): DeviceSheet | undefined {
} }
case 'remoteLink': case 'remoteLink':
return { kind, name, port: modemPort(setting(node, 'remoteLink')?.get('pairIdentifier') as string | undefined) }; return { kind, name, port: modemPort(setting(node, 'remoteLink')?.get('pairIdentifier') as string | undefined) };
default: case 'router': {
const interfaces = interfacesOf(doc, id, node);
const gateway = text(setting(node, 'device'), 'defaultGateway');
const routes = (fromY(settingsOf(node).get('routes')) ?? []) as unknown as ManualRoute[];
return {
kind,
name,
gateway,
rip: settingsOf(node).get('rip') === true,
ipForwarding: (settingsOf(node).get('ipForwarding') as boolean | undefined) ?? forwardsIPv4ByDefault(kind),
interfaces,
routes,
table: routeTableRows(interfaces, routes, gateway),
};
}
case 'gateway': {
const [wan, lan] = interfacesOf(doc, id, node);
return {
kind,
name,
gateway: text(setting(node, 'device'), 'defaultGateway'),
ipForwarding: (settingsOf(node).get('ipForwarding') as boolean | undefined) ?? forwardsIPv4ByDefault(kind),
dhcpClient: setting(node, 'dhcpClient')?.get('isEnabled') === true,
wan: wan!,
lan: lan!,
};
}
case 'unsupported':
return { kind, name }; return { kind, name };
} }
} }
+7 -7
View File
@@ -55,8 +55,10 @@ export interface TopologyView {
const settingsOf = (node: Y.Map<unknown>) => node.get('settings') as Y.Map<Y.Map<unknown>> | undefined; const settingsOf = (node: Y.Map<unknown>) => node.get('settings') as Y.Map<Y.Map<unknown>> | undefined;
/** Host.holeAnzeigeName: a PC's or notebook's address and MAC address, as its label policy asks. */ /** Host.holeAnzeigeName: a PC's or notebook's address and MAC address, as its label policy asks; any other device's name. */
function labelOf(node: Y.Map<unknown>, kind: NodeKind, name: string): string { export function nodeLabel(node: Y.Map<unknown>): string {
const kind = node.get('kind') as NodeKind;
const name = node.get('name') as string;
const policy = node.get('labelPolicy') as HostLabelPolicy; const policy = node.get('labelPolicy') as HostLabelPolicy;
if ((kind !== 'pc' && kind !== 'notebook') || policy === 'manual') return name; if ((kind !== 'pc' && kind !== 'notebook') || policy === 'manual') return name;
const ip = (settingsOf(node)?.get('device')?.get('ipAddress') as string | undefined) ?? ''; const ip = (settingsOf(node)?.get('device')?.get('ipAddress') as string | undefined) ?? '';
@@ -80,7 +82,7 @@ export function topologyView(doc: Y.Doc): TopologyView {
id, id,
kind, kind,
name, name,
label: labelOf(node, kind, name), label: nodeLabel(node),
x: node.get('x') as number, x: node.get('x') as number,
y: node.get('y') as number, y: node.get('y') as number,
cloud: kind === 'networkSwitch' && settingsOf(node)?.get('switch')?.get('showsAsCloud') === true, cloud: kind === 'networkSwitch' && settingsOf(node)?.get('switch')?.get('showsAsCloud') === true,
@@ -94,8 +96,7 @@ export function topologyView(doc: Y.Doc): TopologyView {
const targetNode = link.get('targetNode') as string; const targetNode = link.get('targetNode') as string;
return { id, sourceNode, sourcePort: link.get('sourcePort') as string, targetNode, targetPort: link.get('targetPort') as string, wireless: wireless(sourceNode, targetNode) }; return { id, sourceNode, sourcePort: link.get('sourcePort') as string, targetNode, targetPort: link.get('targetPort') as string, wireless: wireless(sourceNode, targetNode) };
}); });
const docs = [...doc.getMap<Y.Map<unknown>>(ROOTS.docs).entries()].map( const docs = [...doc.getMap<Y.Map<unknown>>(ROOTS.docs).entries()].map(([id, item]): DocumentationView => ({
([id, item]): DocumentationView => ({
id, id,
kind: item.get('kind') as DocumentationItemKind, kind: item.get('kind') as DocumentationItemKind,
x: item.get('x') as number, x: item.get('x') as number,
@@ -108,8 +109,7 @@ export function topologyView(doc: Y.Doc): TopologyView {
size: item.get('size') as number, size: item.get('size') as number,
bold: item.get('bold') as boolean, bold: item.get('bold') as boolean,
order: item.get('order') as number, order: item.get('order') as number,
}), }));
);
// The model's render order (inDeterministicRenderOrder): rectangles below texts, then by order, then by id. // The model's render order (inDeterministicRenderOrder): rectangles below texts, then by order, then by id.
const layer = (item: DocumentationView) => (item.kind === 'rectangle' ? 0 : 1); const layer = (item: DocumentationView) => (item.kind === 'rectangle' ? 0 : 1);
docs.sort((left, right) => layer(left) - layer(right) || left.order - right.order || compareIds(left.id, right.id)); docs.sort((left, right) => layer(left) - layer(right) || left.order - right.order || compareIds(left.id, right.id));
+38
View File
@@ -78,3 +78,41 @@ modem port
modem port 080 modem port 080
modem port \u002080 modem port \u002080
modem port 80 modem port 80
router name R1
router name
router ip 10.0.0.1
router ip 010.000.000.001
router ip 10..0.0.1
router ip .0.0.1
router ip +10.0.0.1
router ip 256.0.0.1
router ip 10.0.0.1
router ip 10.0.0.1
router ip
router ip 10.0.0.1.
router mask 255.255.0.0
router mask 255.0.255.0
router mask 256.0.0.0
router mask 255..255.0.0
router mask
router gateway
router gateway 10.0.0.254
router gateway
router gateway abc
router gateway 192.168..0.1
router gateway 10.0.0.256
homeRouter name Heim
homeRouter gateway
homeRouter gateway 42.0.0.1
homeRouter gateway 42.0.0
homeRouter wanIp 42.1.2.3
homeRouter wanIp .1.2.3
homeRouter wanIp 42..1.2
homeRouter wanIp 042.1.2.3
homeRouter wanMask 255.255.0.0
homeRouter wanMask 255.255
homeRouter lanIp 192.168.1.1
homeRouter lanIp 192.168.1.256
homeRouter lanIp 192.168.1.1
homeRouter lanMask 255.255.255.128
homeRouter lanMask abc
+794 -2
View File
@@ -692,12 +692,804 @@
"marked": false, "marked": false,
"stored": "80", "stored": "80",
"shown": "80" "shown": "80"
},
{
"device": "router",
"field": "name",
"input": "R1",
"start": "Vermittlungsrechner",
"marked": false,
"stored": "R1",
"shown": "R1"
},
{
"device": "router",
"field": "name",
"input": "",
"start": "Vermittlungsrechner",
"marked": false,
"stored": "",
"shown": ""
},
{
"device": "router",
"field": "ip",
"input": "10.0.0.1",
"start": "192.168.0.10",
"marked": false,
"stored": "10.0.0.1",
"shown": "10.0.0.1"
},
{
"device": "router",
"field": "ip",
"input": "010.000.000.001",
"start": "192.168.0.10",
"marked": false,
"stored": "10.0.0.1",
"shown": "10.0.0.1"
},
{
"device": "router",
"field": "ip",
"input": "10..0.0.1",
"start": "192.168.0.10",
"marked": true,
"stored": "192.168.0.10",
"shown": "192.168.0.10"
},
{
"device": "router",
"field": "ip",
"input": ".0.0.1",
"start": "192.168.0.10",
"marked": false,
"stored": null,
"shown": "",
"error": "NullPointerException"
},
{
"device": "router",
"field": "ip",
"input": "+10.0.0.1",
"start": "192.168.0.10",
"marked": true,
"stored": "192.168.0.10",
"shown": "192.168.0.10"
},
{
"device": "router",
"field": "ip",
"input": "256.0.0.1",
"start": "192.168.0.10",
"marked": true,
"stored": "192.168.0.10",
"shown": "192.168.0.10"
},
{
"device": "router",
"field": "ip",
"input": " 10.0.0.1",
"start": "192.168.0.10",
"marked": true,
"stored": "192.168.0.10",
"shown": "192.168.0.10"
},
{
"device": "router",
"field": "ip",
"input": "10.0.0.1 ",
"start": "192.168.0.10",
"marked": true,
"stored": "192.168.0.10",
"shown": "192.168.0.10"
},
{
"device": "router",
"field": "ip",
"input": "",
"start": "192.168.0.10",
"marked": true,
"stored": "192.168.0.10",
"shown": "192.168.0.10"
},
{
"device": "router",
"field": "ip",
"input": "10.0.0.1.",
"start": "192.168.0.10",
"marked": true,
"stored": "192.168.0.10",
"shown": "192.168.0.10"
},
{
"device": "router",
"field": "mask",
"input": "255.255.0.0",
"start": "255.255.255.0",
"marked": false,
"stored": "255.255.0.0",
"shown": "255.255.0.0"
},
{
"device": "router",
"field": "mask",
"input": "255.0.255.0",
"start": "255.255.255.0",
"marked": false,
"stored": "255.0.255.0",
"shown": "255.0.255.0"
},
{
"device": "router",
"field": "mask",
"input": "256.0.0.0",
"start": "255.255.255.0",
"marked": true,
"stored": "255.255.255.0",
"shown": "255.255.255.0"
},
{
"device": "router",
"field": "mask",
"input": "255..255.0.0",
"start": "255.255.255.0",
"marked": true,
"stored": "255.255.255.0",
"shown": "255.255.255.0"
},
{
"device": "router",
"field": "mask",
"input": "",
"start": "255.255.255.0",
"marked": true,
"stored": "255.255.255.0",
"shown": "255.255.255.0"
},
{
"device": "router",
"field": "gateway",
"input": "",
"start": "192.168.0.254",
"marked": true,
"stored": "",
"shown": ""
},
{
"device": "router",
"field": "gateway",
"input": "10.0.0.254",
"start": "192.168.0.254",
"marked": false,
"stored": "10.0.0.254",
"shown": "10.0.0.254"
},
{
"device": "router",
"field": "gateway",
"input": " ",
"start": "192.168.0.254",
"marked": true,
"stored": "",
"shown": ""
},
{
"device": "router",
"field": "gateway",
"input": "abc",
"start": "192.168.0.254",
"marked": true,
"stored": "192.168.0.254",
"shown": "192.168.0.254"
},
{
"device": "router",
"field": "gateway",
"input": "192.168..0.1",
"start": "192.168.0.254",
"marked": true,
"stored": "192.168.0.1",
"shown": "192.168.0.1"
},
{
"device": "router",
"field": "gateway",
"input": "10.0.0.256",
"start": "192.168.0.254",
"marked": true,
"stored": "192.168.0.254",
"shown": "192.168.0.254"
},
{
"device": "homeRouter",
"field": "name",
"input": "Heim",
"start": "Heimrouter",
"marked": false,
"stored": "Heim",
"shown": "Heim"
},
{
"device": "homeRouter",
"field": "gateway",
"input": "",
"start": "192.168.0.254",
"marked": true,
"stored": "",
"shown": ""
},
{
"device": "homeRouter",
"field": "gateway",
"input": "42.0.0.1",
"start": "192.168.0.254",
"marked": false,
"stored": "42.0.0.1",
"shown": "42.0.0.1"
},
{
"device": "homeRouter",
"field": "gateway",
"input": "42.0.0",
"start": "192.168.0.254",
"marked": true,
"stored": "192.168.0.254",
"shown": "192.168.0.254"
},
{
"device": "homeRouter",
"field": "wanIp",
"input": "42.1.2.3",
"start": "42.0.0.10",
"marked": false,
"stored": "42.1.2.3",
"shown": "42.1.2.3"
},
{
"device": "homeRouter",
"field": "wanIp",
"input": ".1.2.3",
"start": "42.0.0.10",
"marked": false,
"stored": null,
"shown": ""
},
{
"device": "homeRouter",
"field": "wanIp",
"input": "42..1.2",
"start": "42.0.0.10",
"marked": true,
"stored": "42.0.0.10",
"shown": "42.0.0.10"
},
{
"device": "homeRouter",
"field": "wanIp",
"input": "042.1.2.3",
"start": "42.0.0.10",
"marked": false,
"stored": "42.1.2.3",
"shown": "42.1.2.3"
},
{
"device": "homeRouter",
"field": "wanMask",
"input": "255.255.0.0",
"start": "255.0.0.0",
"marked": false,
"stored": "255.255.0.0",
"shown": "255.255.0.0"
},
{
"device": "homeRouter",
"field": "wanMask",
"input": "255.255",
"start": "255.0.0.0",
"marked": true,
"stored": "255.0.0.0",
"shown": "255.0.0.0"
},
{
"device": "homeRouter",
"field": "lanIp",
"input": "192.168.1.1",
"start": "192.168.0.10",
"marked": false,
"stored": "192.168.1.1",
"shown": "192.168.1.1"
},
{
"device": "homeRouter",
"field": "lanIp",
"input": "192.168.1.256",
"start": "192.168.0.10",
"marked": true,
"stored": "192.168.0.10",
"shown": "192.168.0.10"
},
{
"device": "homeRouter",
"field": "lanIp",
"input": " 192.168.1.1",
"start": "192.168.0.10",
"marked": true,
"stored": "192.168.0.10",
"shown": "192.168.0.10"
},
{
"device": "homeRouter",
"field": "lanMask",
"input": "255.255.255.128",
"start": "255.255.255.0",
"marked": false,
"stored": "255.255.255.128",
"shown": "255.255.255.128"
},
{
"device": "homeRouter",
"field": "lanMask",
"input": "abc",
"start": "255.255.255.0",
"marked": true,
"stored": "255.255.255.0",
"shown": "255.255.255.0"
} }
], ],
"flows": { "flows": {
"useMacAsNameThenOff": "0E:EE:AA:A5:58:21", "useMacAsNameThenOff": "CE:0D:5A:24:8A:C6",
"useIpAsNameThenOff": "PC-A", "useIpAsNameThenOff": "PC-A",
"dhcpClientStopsServer": true, "dhcpClientStopsServer": true,
"dhcpClient": true "dhcpClient": true,
"homeRouterDhcpClientStopsServer": false,
"homeRouterDhcpClient": true
},
"tables": {
"gatewayInSecondNetwork": [
{
"cells": [
"192.168.1.1",
"255.255.255.255",
"127.0.0.1",
"127.0.0.1"
],
"manual": false
},
{
"cells": [
"10.0.0.1",
"255.255.255.255",
"127.0.0.1",
"127.0.0.1"
],
"manual": false
},
{
"cells": [
"192.168.1.0",
"255.255.255.0",
"192.168.1.1",
"192.168.1.1"
],
"manual": false
},
{
"cells": [
"10.0.0.0",
"255.0.0.0",
"10.0.0.1",
"10.0.0.1"
],
"manual": false
},
{
"cells": [
"127.0.0.0",
"255.0.0.0",
"127.0.0.1",
"127.0.0.1"
],
"manual": false
},
{
"cells": [
"172.16.0.0",
"255.255.0.0",
"192.168.1.2",
"192.168.1.1"
],
"manual": true
},
{
"cells": [
"10.20.0.0",
"255.255.0.0",
"10.0.0.2",
"10.0.0.1"
],
"manual": true
},
{
"cells": [
"0.0.0.0",
"0.0.0.0",
"192.168.1.254",
"192.168.1.1"
],
"manual": false
}
],
"gatewayInNoNetwork": [
{
"cells": [
"192.168.1.1",
"255.255.255.255",
"127.0.0.1",
"127.0.0.1"
],
"manual": false
},
{
"cells": [
"10.0.0.1",
"255.255.255.255",
"127.0.0.1",
"127.0.0.1"
],
"manual": false
},
{
"cells": [
"192.168.1.0",
"255.255.255.0",
"192.168.1.1",
"192.168.1.1"
],
"manual": false
},
{
"cells": [
"10.0.0.0",
"255.0.0.0",
"10.0.0.1",
"10.0.0.1"
],
"manual": false
},
{
"cells": [
"127.0.0.0",
"255.0.0.0",
"127.0.0.1",
"127.0.0.1"
],
"manual": false
},
{
"cells": [
"172.16.0.0",
"255.255.0.0",
"192.168.1.2",
"192.168.1.1"
],
"manual": true
},
{
"cells": [
"10.20.0.0",
"255.255.0.0",
"10.0.0.2",
"10.0.0.1"
],
"manual": true
},
{
"cells": [
"0.0.0.0",
"0.0.0.0",
"172.30.0.1",
"10.0.0.1"
],
"manual": false
}
],
"gatewayInBothNetworks": [
{
"cells": [
"10.1.0.1",
"255.255.255.255",
"127.0.0.1",
"127.0.0.1"
],
"manual": false
},
{
"cells": [
"10.0.0.1",
"255.255.255.255",
"127.0.0.1",
"127.0.0.1"
],
"manual": false
},
{
"cells": [
"10.1.0.0",
"255.255.0.0",
"10.1.0.1",
"10.1.0.1"
],
"manual": false
},
{
"cells": [
"10.0.0.0",
"255.0.0.0",
"10.0.0.1",
"10.0.0.1"
],
"manual": false
},
{
"cells": [
"127.0.0.0",
"255.0.0.0",
"127.0.0.1",
"127.0.0.1"
],
"manual": false
},
{
"cells": [
"0.0.0.0",
"0.0.0.0",
"10.1.0.254",
"10.1.0.1"
],
"manual": false
}
],
"noGateway": [
{
"cells": [
"192.168.1.1",
"255.255.255.255",
"127.0.0.1",
"127.0.0.1"
],
"manual": false
},
{
"cells": [
"10.0.0.1",
"255.255.255.255",
"127.0.0.1",
"127.0.0.1"
],
"manual": false
},
{
"cells": [
"192.168.1.0",
"255.255.255.0",
"192.168.1.1",
"192.168.1.1"
],
"manual": false
},
{
"cells": [
"10.0.0.0",
"255.0.0.0",
"10.0.0.1",
"10.0.0.1"
],
"manual": false
},
{
"cells": [
"127.0.0.0",
"255.0.0.0",
"127.0.0.1",
"127.0.0.1"
],
"manual": false
},
{
"cells": [
"172.16.0.0",
"255.255.0.0",
"192.168.1.2",
"192.168.1.1"
],
"manual": true
},
{
"cells": [
"10.20.0.0",
"255.255.0.0",
"10.0.0.2",
"10.0.0.1"
],
"manual": true
}
],
"threeInterfaces": [
{
"cells": [
"172.16.5.1",
"255.255.255.255",
"127.0.0.1",
"127.0.0.1"
],
"manual": false
},
{
"cells": [
"192.168.1.1",
"255.255.255.255",
"127.0.0.1",
"127.0.0.1"
],
"manual": false
},
{
"cells": [
"10.0.0.1",
"255.255.255.255",
"127.0.0.1",
"127.0.0.1"
],
"manual": false
},
{
"cells": [
"172.16.0.0",
"255.255.0.0",
"172.16.5.1",
"172.16.5.1"
],
"manual": false
},
{
"cells": [
"192.168.1.0",
"255.255.255.0",
"192.168.1.1",
"192.168.1.1"
],
"manual": false
},
{
"cells": [
"10.0.0.0",
"255.0.0.0",
"10.0.0.1",
"10.0.0.1"
],
"manual": false
},
{
"cells": [
"127.0.0.0",
"255.0.0.0",
"127.0.0.1",
"127.0.0.1"
],
"manual": false
},
{
"cells": [
"0.0.0.0",
"0.0.0.0",
"10.0.0.254",
"10.0.0.1"
],
"manual": false
}
]
},
"edits": {
"invalidCell": {
"manual": [
[
null,
"255.255.0.0",
"192.168.0.2",
"192.168.0.10"
]
]
},
"emptyCell": {
"manual": [
[
null,
"255.255.0.0",
"192.168.0.2",
"192.168.0.10"
]
]
},
"leadingZeros": {
"manual": [
[
"10.0.0.0",
"255.255.0.0",
"192.168.0.2",
"192.168.0.10"
]
]
},
"dottedTwice": {
"manual": [
[
null,
"255.255.0.0",
"192.168.0.2",
"192.168.0.10"
]
]
},
"maskCell": {
"manual": [
[
"172.16.0.0",
"255.0.255.0",
"192.168.0.2",
"192.168.0.10"
]
]
},
"newRowThreeCells": {
"manual": [
[
"172.16.0.0",
"255.255.0.0",
"192.168.0.2",
"192.168.0.10"
]
]
},
"newRowFourCells": {
"manual": [
[
"172.16.0.0",
"255.255.0.0",
"192.168.0.2",
"192.168.0.10"
],
[
"10.9.0.0",
"255.255.0.0",
"10.0.0.9",
"10.0.0.1"
]
]
},
"newRowInvalidCell": {
"manual": [
[
"172.16.0.0",
"255.255.0.0",
"192.168.0.2",
"192.168.0.10"
]
]
},
"newRowTwice": {
"manual": [
[
"172.16.0.0",
"255.255.0.0",
"192.168.0.2",
"192.168.0.10"
]
],
"rowsShown": 2
},
"removeRow": {
"manual": []
}
} }
} }
+322 -29
View File
@@ -28,25 +28,50 @@ import {
setSwitchCloud, setSwitchCloud,
setSwitchRetention, setSwitchRetention,
setSwitchSsid, setSwitchSsid,
addRoute,
removeRoute,
setInterfaceAddress,
setRip,
setRouteCell,
setRouterGateway,
topologyView, topologyView,
type FieldRule, type FieldRule,
type HomeRouterSheet,
type HostSheet, type HostSheet,
type NewDevice, type NewDevice,
type RouterSheet,
} from '../src/index.js'; } from '../src/index.js';
interface JavaCase { interface JavaCase {
readonly device: 'host' | 'switch' | 'modem'; readonly device: 'host' | 'switch' | 'modem' | 'router' | 'homeRouter';
readonly field: string; readonly field: string;
readonly input: string; readonly input: string;
readonly start: string; readonly start: string;
readonly marked: boolean; readonly marked: boolean;
readonly stored: string; /** null: Java's card lost its address. */
readonly stored: string | null;
readonly shown: string; readonly shown: string;
/** What Java's panel threw, if it did. */
readonly error?: string;
}
interface JavaRow {
readonly cells: readonly (string | null)[];
readonly manual: boolean;
} }
const java = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'sheet.java.json'), 'utf8')) as { const java = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'sheet.java.json'), 'utf8')) as {
readonly cases: readonly JavaCase[]; readonly cases: readonly JavaCase[];
readonly flows: { readonly useMacAsNameThenOff: string; readonly useIpAsNameThenOff: string; readonly dhcpClientStopsServer: boolean; readonly dhcpClient: boolean }; readonly flows: {
readonly useMacAsNameThenOff: string;
readonly useIpAsNameThenOff: string;
readonly dhcpClientStopsServer: boolean;
readonly dhcpClient: boolean;
readonly homeRouterDhcpClientStopsServer: boolean;
readonly homeRouterDhcpClient: boolean;
};
readonly tables: Readonly<Record<string, readonly JavaRow[]>>;
readonly edits: Readonly<Record<string, { readonly manual: readonly (readonly (string | null)[])[]; readonly rowsShown?: number }>>;
}; };
const RULES: Readonly<Record<string, FieldRule>> = { const RULES: Readonly<Record<string, FieldRule>> = {
@@ -60,10 +85,27 @@ const RULES: Readonly<Record<string, FieldRule>> = {
'switch retention': 'seconds', 'switch retention': 'seconds',
'modem name': 'name', 'modem name': 'name',
'modem port': 'port', 'modem port': 'port',
'router name': 'name',
'router ip': 'ipAddress',
'router mask': 'subnetMask',
'router gateway': 'ipAddressOrEmpty',
'homeRouter name': 'name',
'homeRouter gateway': 'ipAddressOrEmpty',
'homeRouter wanIp': 'ipAddress',
'homeRouter wanMask': 'subnetMask',
'homeRouter lanIp': 'ipAddress',
'homeRouter lanMask': 'subnetMask',
}; };
/** Fields whose text is judged without the spaces around it. */ /** Fields whose text is judged without the spaces around it. */
const TRIMMED = new Set(['host ip', 'host mask', 'host gateway', 'host dns', 'switch retention', 'modem port']); const TRIMMED = new Set(
Object.entries(RULES)
.filter(([key, rule]) => rule !== 'name' && rule !== 'ssid')
.map(([key]) => key),
);
/** 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);
/** What Java's model holds in the units of the sheet's value: seconds for the retention time. */ /** What Java's model holds in the units of the sheet's value: seconds for the retention time. */
const javaValue = (each: JavaCase) => (each.field === 'retention' ? String(Number(each.stored) / 1000) : each.stored); const javaValue = (each: JavaCase) => (each.field === 'retention' ? String(Number(each.stored) / 1000) : each.stored);
@@ -88,9 +130,12 @@ describe("a field's rule, against FILIUS' settings panels", () => {
const key = `${each.device} ${each.field}`; const key = `${each.device} ${each.field}`;
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 = verdict.stored !== verdict.start || verdict.input === verdict.start; const took = tookIt(verdict);
// An empty gateway Java's setter takes as "none", though a router's panel marks it (its key listener checks musterIpAdresse).
const emptyTaken = RULES[key] === 'ipAddressOrEmpty' && verdict.input.trim() === '' && took;
// A name is taken as typed. A modem's port Java never marks: it takes any int; here Java's own rule for ports. // A name is taken as typed. A modem's port Java never marks: it takes any int; here Java's own rule for ports.
const expected = RULES[key] === 'name' ? true : key === 'modem port' ? took && /^([1-9][0-9]{0,4})$/.test(verdict.input) && Number(verdict.input) <= 65_535 : !verdict.marked && took; const expected =
RULES[key] === 'name' ? true : key === 'modem port' ? took && /^([1-9][0-9]{0,4})$/.test(verdict.input) && Number(verdict.input) <= 65_535 : emptyTaken || (!verdict.marked && took);
const checked = checkField(RULES[key]!, each.input); const checked = checkField(RULES[key]!, each.input);
expect('value' in checked, `${key} ${JSON.stringify(each.input)}`).toBe(expected); expect('value' in checked, `${key} ${JSON.stringify(each.input)}`).toBe(expected);
if ('value' in checked) expect(checked.value, `${key} ${JSON.stringify(each.input)}`).toBe(RULES[key] === 'name' ? each.input : javaValue(verdict)); if ('value' in checked) expect(checked.value, `${key} ${JSON.stringify(each.input)}`).toBe(RULES[key] === 'name' ? each.input : javaValue(verdict));
@@ -98,39 +143,46 @@ 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 === (each.stored !== each.start)); const disagreeing = java.cases.filter((each) => RULES[`${each.device} ${each.field}`] !== 'name' && each.field !== 'port' && each.marked === tookIt(each));
// Marked red, yet taken; or black, yet refused (the gateway's spaces Java trims to empty). // Marked red, yet taken; or black, yet refused (the gateway's spaces Java trims to empty) — or lost: a card without its address.
expect(disagreeing.map((each) => `${each.field} ${JSON.stringify(each.input)} ${each.marked ? 'marked, taken' : 'refused'}`)).toEqual([ const verdictOf = (each: JavaCase) => (each.marked ? 'marked, taken' : each.stored === null ? `lost${each.error === undefined ? '' : `, ${each.error}`}` : 'refused');
'ip "10..0.0.1" marked, taken', expect(disagreeing.map((each) => `${each.device} ${each.field} ${JSON.stringify(each.input)} ${verdictOf(each)}`)).toEqual([
'ip ".0.0.1" refused', 'host ip "10..0.0.1" marked, taken',
'ip "+10.0.0.1" marked, taken', 'host ip ".0.0.1" refused',
'ip "10.0.0.1." marked, taken', 'host ip "+10.0.0.1" marked, taken',
'ip "١٠.0.0.1" marked, taken', 'host ip "10.0.0.1." marked, taken',
'ip "-0.0.0.1" marked, taken', 'host ip "١٠.0.0.1" marked, taken',
'mask "255..255.0.0" marked, taken', 'host ip "-0.0.0.1" marked, taken',
'gateway " " marked, taken', 'host mask "255..255.0.0" marked, taken',
'gateway "192.168..1.1" marked, taken', 'host gateway " " marked, taken',
'dns " " marked, taken', 'host gateway "192.168..1.1" marked, taken',
'retention "" refused', 'host dns " " marked, taken',
'retention "-5" marked, taken', 'switch retention "" refused',
'retention "99999999999999999999" refused', 'switch retention "-5" marked, taken',
'retention "٣" marked, taken', 'switch retention "99999999999999999999" refused',
'switch retention "٣" marked, taken',
'router ip ".0.0.1" lost, NullPointerException',
'router gateway "" marked, taken',
'router gateway " " marked, taken',
'router gateway "192.168..0.1" marked, taken',
'homeRouter gateway "" marked, taken',
'homeRouter wanIp ".1.2.3" lost',
]); ]);
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']);
}); });
it("shows what the device holds once a field is left, as Java's field does", () => { it("shows what the device holds once a field is left, as Java's field does", () => {
for (const each of java.cases) if (each.field !== 'retention') expect(each.shown, each.input).toBe(each.stored); for (const each of java.cases) if (each.field !== 'retention') expect(each.shown, each.input).toBe(each.stored ?? '');
}); });
}); });
let ids = 0; let ids = 0;
const newId = () => `20000000-0000-0000-0000-${(++ids).toString(16).padStart(12, '0')}`; const newId = () => `20000000-0000-0000-0000-${(++ids).toString(16).padStart(12, '0')}`;
function add(doc: Y.Doc, kind: NewDevice['kind'], name: string = kind): string { function add(doc: Y.Doc, kind: NewDevice['kind'], name: string = kind, interfaces?: number): string {
const id = newId(); const id = newId();
addDevice(doc, { kind, id, name, x: 100, y: 100, newId }, 'me'); addDevice(doc, { kind, id, name, x: 100, y: 100, newId, ...(interfaces === undefined ? {} : { interfaces }) }, 'me');
return id; return id;
} }
@@ -345,7 +397,7 @@ describe("a switch's and a modem's sheet", () => {
expect(deviceSheet(doc, hub)).toMatchObject({ retentionSeconds: 1 }); expect(deviceSheet(doc, hub)).toMatchObject({ retentionSeconds: 1 });
}); });
it("pairs a modem by its port, and names the other kinds' sheets", () => { it('pairs a modem by its port, and renames a router', () => {
const doc = new Y.Doc(); const doc = new Y.Doc();
const modem = add(doc, 'remoteLink', 'Modem'); const modem = add(doc, 'remoteLink', 'Modem');
expect(deviceSheet(doc, modem)).toEqual({ kind: 'remoteLink', name: 'Modem', port: 12_345 }); expect(deviceSheet(doc, modem)).toEqual({ kind: 'remoteLink', name: 'Modem', port: 12_345 });
@@ -355,8 +407,8 @@ describe("a switch's and a modem's sheet", () => {
expect(deviceSheet(doc, modem)).toMatchObject({ port: 80 }); expect(deviceSheet(doc, modem)).toMatchObject({ port: 80 });
const router = add(doc, 'router', 'R1'); const router = add(doc, 'router', 'R1');
renameDevice(doc, router, 'R2', 'me'); renameDevice(doc, router, 'R2', 'me');
expect(deviceSheet(doc, router)).toEqual({ kind: 'router', name: 'R2' }); expect(deviceSheet(doc, router)).toMatchObject({ kind: 'router', name: 'R2' });
expect(deviceSheet(doc, add(doc, 'gateway', 'Heim'))).toEqual({ kind: 'gateway', name: 'Heim' }); expect(deviceSheet(doc, add(doc, 'gateway', 'Heim'))).toMatchObject({ kind: 'gateway', name: 'Heim' });
}); });
}); });
@@ -413,3 +465,244 @@ describe("the sheet's changes", () => {
expect(reopened.remoteLinkConfigurations.get(modemAgain!.id)?.pairIdentifier).toBe('java-modem-port-4242'); expect(reopened.remoteLinkConfigurations.get(modemAgain!.id)?.pairIdentifier).toBe('java-modem-port-4242');
}); });
}); });
const router = (doc: Y.Doc, id: string) => deviceSheet(doc, id) as RouterSheet;
const home = (doc: Y.Doc, id: string) => deviceSheet(doc, id) as HomeRouterSheet;
/** Java's manual rows of the measured tables, as SheetRun added them. */
const JAVA_MANUAL = [
{ destinationNetwork: '172.16.0.0', subnetMask: '255.255.0.0', gateway: '192.168.1.2', interfaceIPAddress: '192.168.1.1' },
{ destinationNetwork: '010.020.0.0', subnetMask: '255.255.0.0', gateway: '10.0.0.2', interfaceIPAddress: '10.0.0.1' },
];
/** The measured tables' routers: their cards (address, mask), gateway and manual rows. */
const TABLES: Readonly<Record<string, { readonly cards: readonly (readonly [string, string])[]; readonly gateway: string; readonly manual: boolean }>> = {
gatewayInSecondNetwork: {
cards: [
['10.0.0.1', '255.0.0.0'],
['192.168.1.1', '255.255.255.0'],
],
gateway: '192.168.1.254',
manual: true,
},
gatewayInNoNetwork: {
cards: [
['10.0.0.1', '255.0.0.0'],
['192.168.1.1', '255.255.255.0'],
],
gateway: '172.30.0.1',
manual: true,
},
gatewayInBothNetworks: {
cards: [
['10.0.0.1', '255.0.0.0'],
['10.1.0.1', '255.255.0.0'],
],
gateway: '10.1.0.254',
manual: false,
},
noGateway: {
cards: [
['10.0.0.1', '255.0.0.0'],
['192.168.1.1', '255.255.255.0'],
],
gateway: '',
manual: true,
},
threeInterfaces: {
cards: [
['10.0.0.1', '255.0.0.0'],
['192.168.1.1', '255.255.255.0'],
['172.16.5.1', '255.255.0.0'],
],
gateway: '10.0.0.254',
manual: false,
},
};
describe("a router's sheet", () => {
it("shows the forwarding table Java shows, in Java's order, its manual rows editable", () => {
expect(Object.keys(java.tables).sort()).toEqual(Object.keys(TABLES).sort());
for (const [name, setup] of Object.entries(TABLES)) {
const doc = new Y.Doc();
const id = add(doc, 'router', 'R', setup.cards.length);
router(doc, id).interfaces.forEach((card, index) => {
expect(setInterfaceAddress(doc, id, card.portId, 'ipAddress', setup.cards[index]![0], 'me')).toEqual({ value: setup.cards[index]![0] });
expect(setInterfaceAddress(doc, id, card.portId, 'subnetMask', setup.cards[index]![1], 'me')).toEqual({ value: setup.cards[index]![1] });
});
setRouterGateway(doc, id, setup.gateway, 'me');
if (setup.manual) for (const row of JAVA_MANUAL) expect('route' in addRoute(doc, id, row, 'me')).toBe(true);
const table = router(doc, id).table.map((row) => ({ cells: [row.destinationNetwork, row.subnetMask, row.nextHop, row.interfaceIPAddress], manual: row.isEditable }));
expect(table, name).toEqual(java.tables[name]);
}
});
it('takes a card’s address as Java keeps it and keeps the old one for a refused text, the first card mirrored in the device', () => {
const doc = new Y.Doc();
const id = add(doc, 'router');
const [first, second] = router(doc, id).interfaces;
expect(setInterfaceAddress(doc, id, first!.portId, 'ipAddress', ' 010.0.0.1 ', 'me')).toEqual({ value: '10.0.0.1' });
expect(setInterfaceAddress(doc, id, first!.portId, 'ipAddress', '.0.0.1', 'me')).toEqual({ invalid: true });
expect(setInterfaceAddress(doc, id, second!.portId, 'subnetMask', '255.255', 'me')).toEqual({ invalid: true });
expect(setInterfaceAddress(doc, id, second!.portId, 'subnetMask', '255.0.255.0', 'me')).toEqual({ value: '255.0.255.0' });
expect(setInterfaceAddress(doc, id, 'no-port', 'ipAddress', '10.0.0.1', 'me')).toEqual({ value: '10.0.0.1' });
expect(router(doc, id).interfaces.map((card) => [card.ipAddress, card.subnetMask])).toEqual([
['10.0.0.1', '255.255.255.0'],
['192.168.0.10', '255.0.255.0'],
]);
// A router keeps its own settings only with a gateway; then they follow its first card.
expect(readProject(doc).deviceConfigurations.has(id)).toBe(false);
expect(setRouterGateway(doc, id, '10.0.0.254', 'me')).toEqual({ value: '10.0.0.254' });
expect(readProject(doc).deviceConfigurations.get(id)).toEqual({ ipAddress: '10.0.0.1', subnetMask: '255.255.255.0', defaultGateway: '10.0.0.254', dnsServer: '' });
setInterfaceAddress(doc, id, first!.portId, 'ipAddress', '10.0.0.2', 'me');
setInterfaceAddress(doc, id, first!.portId, 'subnetMask', '255.0.0.0', 'me');
expect(readProject(doc).deviceConfigurations.get(id)).toMatchObject({ ipAddress: '10.0.0.2', subnetMask: '255.0.0.0' });
expect(setRouterGateway(doc, id, 'abc', 'me')).toEqual({ invalid: true });
expect(router(doc, id).gateway).toBe('10.0.0.254');
expect(setRouterGateway(doc, id, ' ', 'me')).toEqual({ value: '' });
expect(readProject(doc).deviceConfigurations.has(id)).toBe(false);
});
it('switches RIP, and shows IP forwarding on', () => {
const doc = new Y.Doc();
const id = add(doc, 'router');
expect(router(doc, id)).toMatchObject({ rip: false, ipForwarding: true, gateway: '' });
setRip(doc, id, true, 'me');
expect(router(doc, id).rip).toBe(true);
expect(readProject(doc).ripEnabledByNodeId.get(id)).toBe(true);
setRip(doc, id, false, 'me');
expect(readProject(doc).ripEnabledByNodeId.has(id)).toBe(false);
});
it('names what each card is plugged into, by its label', () => {
const doc = new Y.Doc();
const id = add(doc, 'router');
const pc = add(doc, 'pc', 'PC-A');
setHostLabels(doc, pc, { ipAddress: true, macAddress: false }, 'me');
connect(doc, { node: pc }, { node: id }, 'cable', 'me');
expect(router(doc, id).interfaces.map((card) => card.connected)).toEqual([{ device: '192.168.0.10', link: 'cable' }, null]);
expect(router(doc, id).interfaces[0]!.macAddress).toMatch(/^([0-9A-F]{2}:){5}[0-9A-F]{2}$/);
});
});
describe("a router's manual rows", () => {
/** A router with Java's one manual row of SheetRun's edits. */
function edited() {
const doc = new Y.Doc();
const id = add(doc, 'router');
addRoute(doc, id, { destinationNetwork: '172.16.0.0', subnetMask: '255.255.0.0', gateway: '192.168.0.2', interfaceIPAddress: '192.168.0.10' }, 'me');
const rows = () =>
readProject(doc)
.manualRoutesByNodeId.get(id)
?.map((row) => [row.destinationNetwork, row.subnetMask, row.gateway, row.interfaceIPAddress]) ?? [];
return { doc, id, rows };
}
it('take a cell as Java does where Java takes it, and keep it where Java stores null', () => {
for (const [name, column, text] of [
['leadingZeros', 'destinationNetwork', '010.0.0.0'],
['maskCell', 'subnetMask', '255.0.255.0'],
] as const) {
const { doc, id, rows } = edited();
expect('value' in setRouteCell(doc, id, 0, column, text, 'me'), name).toBe(true);
expect(rows(), name).toEqual(java.edits[name]!.manual);
}
for (const [name, text] of [
['invalidCell', 'abc'],
['emptyCell', ''],
['dottedTwice', '10..0.0'],
] as const) {
// Java: the cell becomes null.
expect(java.edits[name]!.manual[0]![0], name).toBeNull();
const { doc, id, rows } = edited();
expect(setRouteCell(doc, id, 0, 'destinationNetwork', text, 'me'), name).toEqual({ invalid: true });
expect(rows()[0]![0], name).toBe('172.16.0.0');
}
});
it('edit and lose the row they are asked for', () => {
const { doc, id, rows } = edited();
addRoute(doc, id, JAVA_MANUAL[0]!, 'me');
expect(setRouteCell(doc, id, 1, 'gateway', '192.168.1.3', 'me')).toEqual({ value: '192.168.1.3' });
expect(rows()).toEqual([
['172.16.0.0', '255.255.0.0', '192.168.0.2', '192.168.0.10'],
['172.16.0.0', '255.255.0.0', '192.168.1.3', '192.168.1.1'],
]);
removeRoute(doc, id, 1, 'me');
expect(rows()).toEqual([['172.16.0.0', '255.255.0.0', '192.168.0.2', '192.168.0.10']]);
});
it('take a new row once all four cells pass, as Java does, and lose a row', () => {
const cells = { destinationNetwork: '10.9.0.0', subnetMask: '255.255.0.0', gateway: '10.0.0.9', interfaceIPAddress: '' };
let { doc, id, rows } = edited();
expect(addRoute(doc, id, cells, 'me')).toEqual({ invalid: ['interfaceIPAddress'] });
expect(rows()).toEqual(java.edits.newRowThreeCells!.manual);
expect(addRoute(doc, id, { ...cells, interfaceIPAddress: '10..0.0.1' }, 'me')).toEqual({ invalid: ['interfaceIPAddress'] });
expect(rows()).toEqual(java.edits.newRowInvalidCell!.manual);
expect(addRoute(doc, id, { ...cells, interfaceIPAddress: '10.0.0.1' }, 'me')).toEqual({ route: { ...cells, interfaceIPAddress: '10.0.0.1' } });
expect(rows()).toEqual(java.edits.newRowFourCells!.manual);
expect(addRoute(doc, 'nobody', { ...cells, interfaceIPAddress: '10.0.0.1' }, 'me')).toEqual({ invalid: [] });
({ doc, id, rows } = edited());
removeRoute(doc, id, 5, 'me');
removeRoute(doc, id, 0, 'me');
expect(rows()).toEqual(java.edits.removeRow!.manual);
expect((doc.getMap<Y.Map<unknown>>(ROOTS.nodes).get(id)!.get('settings') as Y.Map<unknown>).has('routes')).toBe(false);
// Java keeps one new row at a time; the sheet's draft is one row too.
expect(java.edits.newRowTwice!.rowsShown).toBe(2);
});
});
describe("a home router's sheet", () => {
it("shows its WAN and LAN cards, keeps the WAN card in the device's settings, and leaves its DHCP server running", () => {
expect(java.flows).toMatchObject({ homeRouterDhcpClient: true, homeRouterDhcpClientStopsServer: false });
const doc = new Y.Doc();
const id = add(doc, 'gateway', 'Heim');
const sheet = home(doc, id);
expect([sheet.wan.ipAddress, sheet.wan.subnetMask, sheet.lan.ipAddress, sheet.lan.subnetMask, sheet.gateway, sheet.ipForwarding, sheet.dhcpClient]).toEqual([
'42.0.0.10',
'255.0.0.0',
'192.168.0.10',
'255.255.255.0',
'',
true,
false,
]);
expect(setInterfaceAddress(doc, id, sheet.wan.portId, 'ipAddress', '042.1.2.3', 'me')).toEqual({ value: '42.1.2.3' });
expect(setInterfaceAddress(doc, id, sheet.lan.portId, 'subnetMask', '255.255.255.128', 'me')).toEqual({ value: '255.255.255.128' });
expect(setRouterGateway(doc, id, '42.0.0.1', 'me')).toEqual({ value: '42.0.0.1' });
expect(readProject(doc).deviceConfigurations.get(id)).toEqual({ ipAddress: '42.1.2.3', subnetMask: '255.0.0.0', defaultGateway: '42.0.0.1', dnsServer: '' });
// Unlike a router, a home router keeps its own settings without a gateway.
setRouterGateway(doc, id, '', 'me');
expect(readProject(doc).deviceConfigurations.get(id)).toMatchObject({ defaultGateway: '' });
const settings = doc.getMap<Y.Map<unknown>>(ROOTS.nodes).get(id)!.get('settings') as Y.Map<unknown>;
const server = new Y.Map<unknown>();
server.set('isActive', true);
settings.set('dhcpServer', server);
setDhcpClient(doc, id, true, 'me');
expect(home(doc, id).dhcpClient).toBe(true);
expect(server.get('isActive')).toBe(true);
});
it('keeps everything through a saved file, with the router’s rows and RIP', () => {
const doc = new Y.Doc();
const id = add(doc, 'router', 'R', 3);
const homeId = add(doc, 'gateway', 'Heim');
const cards = router(doc, id).interfaces;
setInterfaceAddress(doc, id, cards[2]!.portId, 'ipAddress', '172.16.5.1', 'me');
setRouterGateway(doc, id, '192.168.0.254', 'me');
setRip(doc, id, true, 'me');
addRoute(doc, id, JAVA_MANUAL[1]!, 'me');
setInterfaceAddress(doc, homeId, home(doc, homeId).lan.portId, 'ipAddress', '192.168.7.1', 'me');
setRouterGateway(doc, homeId, '42.0.0.1', 'me');
let counter = 0;
const reopened = openProjectArchive(saveProjectArchive(readProject(doc)).archive, () => `40000000-0000-0000-0000-${(++counter).toString(16).padStart(12, '0')}`);
const [routerAgain, homeAgain] = reopened.graph.nodes;
const address = (node: typeof routerAgain, index: number) => reopened.interfaceConfigurations.get(`${node!.id}/${node!.ports[index]!.id}`)?.ipAddress;
expect(address(routerAgain, 2)).toBe('172.16.5.1');
expect(reopened.deviceConfigurations.get(routerAgain!.id)?.defaultGateway).toBe('192.168.0.254');
expect(reopened.ripEnabledByNodeId.get(routerAgain!.id)).toBe(true);
expect(reopened.manualRoutesByNodeId.get(routerAgain!.id)).toEqual([{ destinationNetwork: '10.20.0.0', subnetMask: '255.255.0.0', gateway: '10.0.0.2', interfaceIPAddress: '10.0.0.1' }]);
expect(address(homeAgain, 1)).toBe('192.168.7.1');
expect(reopened.deviceConfigurations.get(homeAgain!.id)?.defaultGateway).toBe('42.0.0.1');
});
});
+95 -1
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 } from '@mdg-filius/doc'; import type { 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> };
@@ -156,6 +156,100 @@ test("joins a notebook to a switch's wireless network, drawn as wireless", async
await expect.poll(async () => (await drawing(page)).links).toEqual([]); await expect.poll(async () => (await drawing(page)).links).toEqual([]);
}); });
test("shows a router's cards in Java's tabs, draws the open card's cable active, and renames a tab by its address", async ({ page }) => {
await openEditor(page, server.base, 'zwei_rechnernetze_komplett.fls');
const id = (await drawing(page)).nodes.find((node) => node.kind === 'router')!.id;
await choose(page, id);
const values = (await sheetOf(page, id)) as RouterSheet;
const tabs = sheet(page).getByRole('tab');
await expect(tabs).toHaveText(['Allgemein', ...values.interfaces.map((card) => card.ipAddress), 'Weiterleitungstabelle']);
await expect(field(page, 'name')).toHaveValue(values.name);
await expect(sheet(page).getByLabel('IP-Weiterleitung aktivieren')).toBeDisabled();
await expect(sheet(page).getByLabel('IP-Weiterleitung aktivieren')).toBeChecked();
const card = values.interfaces[0]!;
await tabs.nth(1).click();
await expect(sheet(page).locator('.sheet-connected')).toHaveText(`Verbunden mit ${card.connected!.device}`);
await expect(page.locator(`path[data-link="${card.connected!.link}"]`)).toHaveAttribute('data-active', 'true');
// Java's active cable is green (JCablePanel.farbeBlinken).
await expect(page.locator(`path[data-link="${card.connected!.link}"]`)).toHaveCSS('stroke', 'rgb(0, 255, 64)');
await field(page, 'ipAddress').fill('192.168.0.1');
await field(page, 'ipAddress').press('Enter');
await expect(tabs.nth(1)).toHaveText('192.168.0.1');
await tabs.first().click();
await expect(page.locator('path[data-active]')).toHaveCount(0);
await tabs.nth(1).click();
await sheet(page).getByRole('button', { name: 'Schließen' }).click();
await expect(page.locator('path[data-active]')).toHaveCount(0);
});
test("edits a router's forwarding table: a new row once its four cells pass, a cell, the row removed; RIP greys it out", async ({ page }) => {
await openEditor(page, server.base, 'new');
await page.locator('[data-palette="router"]').click();
await page.getByRole('dialog').getByRole('button', { name: '2', exact: true }).click();
const id = (await drawing(page)).nodes[0]!.id;
const tabs = sheet(page).getByRole('tab');
await tabs.last().click();
const rows = sheet(page).locator('.route-table tbody tr');
// Java's own rows: two addresses, two networks (the same twice: both cards are new), localhost.
await expect(rows).toHaveCount(5);
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(field(page, 'route-destinationNetwork')).toHaveAttribute('aria-invalid', 'false');
for (const [column, text] of [
['destinationNetwork', '10.0.0.0'],
['subnetMask', '255.0.0.0'],
['gateway', '192.168.0.1'],
] as const) {
await field(page, `route-${column}`).fill(text);
await field(page, `route-${column}`).press('Enter');
}
expect(((await sheetOf(page, id)) as RouterSheet).routes).toEqual([]);
await field(page, 'route-interfaceIPAddress').fill('192.168.0.10');
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(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').press('Enter');
await expect(sheet(page).locator('tr[data-manual="0"] td').nth(2)).toHaveText('192.168.0.2');
await sheet(page).getByLabel('Alle Einträge anzeigen').uncheck();
await expect(rows).toHaveCount(1);
await sheet(page).getByLabel('Alle Einträge anzeigen').check();
await sheet(page).locator('tr[data-manual="0"]').click();
await sheet(page).getByRole('button', { name: 'Eintrag löschen' }).click();
await expect.poll(async () => ((await sheetOf(page, id)) as RouterSheet).routes).toEqual([]);
await expect(rows).toHaveCount(5);
await tabs.first().click();
await sheet(page).getByLabel('Automatisches Routing').check();
await expect(tabs.last()).toBeDisabled();
await sheet(page).getByLabel('Automatisches Routing').uncheck();
await expect(tabs.last()).toBeEnabled();
// RIP switched on while the table is open (here: taken back by undo) closes it.
await tabs.last().click();
await expect(sheet(page).locator('.route-table')).toBeVisible();
await page.getByRole('button', { name: 'Rückgängig' }).click();
await expect(tabs.last()).toBeDisabled();
await expect(tabs.first()).toHaveAttribute('aria-selected', 'true');
await expect(sheet(page).getByLabel('Automatisches Routing')).toBeChecked();
});
test("shows a home router's LAN and WAN tabs, DHCP closing the WAN card's address", async ({ page }) => {
await openEditor(page, server.base, 'new');
await page.locator('[data-palette="gateway"]').click();
const tabs = sheet(page).getByRole('tab');
await expect(tabs).toHaveText(['Allgemein', 'LAN: 192.168.0.10', 'WAN: 42.0.0.10']);
await expect(sheet(page).locator('.sheet-connected')).toHaveCount(0);
await tabs.nth(2).click();
await expect(sheet(page).locator('.sheet-connected')).toHaveText('Keine Verbindung');
await sheet(page).getByLabel('DHCP zur Konfiguration verwenden').check();
await expect(field(page, 'ipAddress')).toBeDisabled();
await expect(field(page, 'subnetMask')).toBeDisabled();
await tabs.nth(1).click();
await expect(field(page, 'ipAddress')).toBeEnabled();
await field(page, 'ipAddress').fill('192.168.7.1');
await field(page, 'ipAddress').press('Enter');
await expect(tabs.nth(1)).toHaveText('LAN: 192.168.7.1');
});
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 });
+13 -1
View File
@@ -78,6 +78,8 @@ export function Editor({ doc }: { readonly doc: Y.Doc }) {
const [menu, setMenu] = useState<{ readonly target: Selection; readonly at: Point } | null>(null); const [menu, setMenu] = useState<{ readonly target: Selection; readonly at: Point } | null>(null);
const [routerAt, setRouterAt] = useState<Point | null>(null); const [routerAt, setRouterAt] = useState<Point | null>(null);
const [message, setMessage] = useState<string | null>(null); const [message, setMessage] = useState<string | null>(null);
// The cable of the card whose tab is open in the sheet (Java's Kabel.setAktiv).
const [highlightedLink, setHighlightedLink] = useState<string | null>(null);
// A message goes after a while. // A message goes after a while.
useEffect(() => { useEffect(() => {
@@ -203,6 +205,7 @@ export function Editor({ doc }: { readonly doc: Y.Doc }) {
onMoveEnd={() => manager?.stopCapturing()} onMoveEnd={() => manager?.stopCapturing()}
onCableStart={setCableFrom} onCableStart={setCableFrom}
onCable={onCable} onCable={onCable}
highlightedLink={highlightedLink}
onMenu={(target, at) => { onMenu={(target, at) => {
setSelection(target); setSelection(target);
setMenu({ target, at }); setMenu({ target, at });
@@ -247,7 +250,16 @@ export function Editor({ doc }: { readonly doc: Y.Doc }) {
</div> </div>
{/* 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 doc={doc} id={selected.id} label={selected.label} origin={origin} onChanged={() => manager?.stopCapturing()} onMessage={setMessage} onClose={() => setSelection(null)} /> <DeviceSheet
doc={doc}
id={selected.id}
label={selected.label}
origin={origin}
onChanged={() => manager?.stopCapturing()}
onMessage={setMessage}
onHighlight={setHighlightedLink}
onClose={() => setSelection(null)}
/>
)} )}
</div> </div>
</div> </div>
+7 -4
View File
@@ -54,6 +54,8 @@ export interface CanvasProps {
readonly onCableStart?: (from: { readonly id: string; readonly side: Side } | null) => void; readonly onCableStart?: (from: { readonly id: string; readonly side: Side } | null) => void;
readonly onCable?: (from: { readonly id: string; readonly side: Side }, to: { readonly id: string; readonly side: Side }) => void; readonly onCable?: (from: { readonly id: string; readonly side: Side }, to: { readonly id: string; readonly side: Side }) => void;
readonly onMenu?: (target: Selection, at: Point) => void; readonly onMenu?: (target: Selection, at: Point) => void;
/** A cable drawn active, as Java draws the cable of the card whose tab is open (Kabel.setAktiv). */
readonly highlightedLink?: string | null;
} }
/** The label's width in Java's font, measured once per text. */ /** The label's width in Java's font, measured once per text. */
@@ -74,8 +76,7 @@ function useTextWidth(): (text: string) => number {
); );
} }
const rgba = (color: DocumentationView['color']) => const rgba = (color: DocumentationView['color']) => `rgba(${Math.round(color.red * 255)}, ${Math.round(color.green * 255)}, ${Math.round(color.blue * 255)}, ${color.alpha})`;
`rgba(${Math.round(color.red * 255)}, ${Math.round(color.green * 255)}, ${Math.round(color.blue * 255)}, ${color.alpha})`;
function Documentation({ item }: { readonly item: DocumentationView }) { function Documentation({ item }: { readonly item: DocumentationView }) {
if (item.kind === 'rectangle') { if (item.kind === 'rectangle') {
@@ -140,7 +141,7 @@ type Gesture =
| { readonly kind: 'link'; readonly id: string; readonly start: Point; moved: boolean; longPressed: boolean }; | { readonly kind: 'link'; readonly id: string; readonly start: Point; moved: boolean; longPressed: boolean };
export const Canvas = forwardRef<CanvasHandle, CanvasProps>(function Canvas( export const Canvas = forwardRef<CanvasHandle, CanvasProps>(function Canvas(
{ view, tool = 'select', selection = null, cableFrom = null, onSelect, onMoveDevice, onMoveEnd, onCableStart, onCable, onMenu }, { view, tool = 'select', selection = null, cableFrom = null, onSelect, onMoveDevice, onMoveEnd, onCableStart, onCable, onMenu, highlightedLink = null },
handle, handle,
) { ) {
const t = useTranslate(); const t = useTranslate();
@@ -420,14 +421,16 @@ export const Canvas = forwardRef<CanvasHandle, CanvasProps>(function Canvas(
if (source === undefined || target === undefined) return null; if (source === undefined || target === undefined) return null;
const d = cablePath(source.center, target.center); const d = cablePath(source.center, target.center);
const selected = selection?.kind === 'link' && selection.id === link.id; const selected = selection?.kind === 'link' && selection.id === link.id;
const active = highlightedLink === link.id;
return ( return (
<g key={link.id}> <g key={link.id}>
<path <path
data-link={link.id} data-link={link.id}
data-selected={selected ? 'true' : undefined} data-selected={selected ? 'true' : undefined}
data-active={active ? 'true' : undefined}
d={d} d={d}
strokeWidth={selected ? 4 : 2} strokeWidth={selected ? 4 : 2}
stroke={selected ? 'var(--accent)' : link.wireless ? 'var(--canvas-cable-wireless)' : 'var(--canvas-cable)'} stroke={selected ? 'var(--accent)' : active ? 'var(--canvas-cable-active)' : link.wireless ? 'var(--canvas-cable-wireless)' : 'var(--canvas-cable)'}
strokeDasharray={link.wireless ? '10' : undefined} strokeDasharray={link.wireless ? '10' : undefined}
/> />
{/* A wide, invisible line to tap: 2 px is no target for a finger. */} {/* A wide, invisible line to tap: 2 px is no target for a finger. */}
+15
View File
@@ -30,19 +30,28 @@
"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",
"route.destinationNetwork": "Ziel",
"route.gateway": "Nächstes Gateway",
"route.interfaceIPAddress": "Über Schnittstelle",
"route.subnetMask": "Netzmaske",
"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.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.chooseNetwork": "Bitte auswählen", "sheet.chooseNetwork": "Bitte auswählen",
"sheet.close": "Schließen", "sheet.close": "Schließen",
"sheet.cloud": "als Wolke darstellen", "sheet.cloud": "als Wolke darstellen",
"sheet.connectedTo": "Verbunden mit {device}",
"sheet.connection": "Anschluss", "sheet.connection": "Anschluss",
"sheet.defaultGateway": "Gateway", "sheet.defaultGateway": "Gateway",
"sheet.deleteEntry": "Eintrag löschen",
"sheet.dhcpClient": "DHCP zur Konfiguration verwenden", "sheet.dhcpClient": "DHCP zur Konfiguration verwenden",
"sheet.dnsServer": "Domain Name Server", "sheet.dnsServer": "Domain Name Server",
"sheet.empty": "(leer)", "sheet.empty": "(leer)",
"sheet.forwardingTable": "Weiterleitungstabelle",
"sheet.general": "Allgemein",
"sheet.invalidValue": "Dieser Wert ist ungültig.", "sheet.invalidValue": "Dieser Wert ist ungültig.",
"sheet.ipAddress": "IP-Adresse", "sheet.ipAddress": "IP-Adresse",
"sheet.ipAsName": "IP-Adresse als Name verwenden", "sheet.ipAsName": "IP-Adresse als Name verwenden",
@@ -51,9 +60,15 @@
"sheet.macAsName": "MAC-Adresse als Name verwenden", "sheet.macAsName": "MAC-Adresse als Name verwenden",
"sheet.name": "Name", "sheet.name": "Name",
"sheet.network": "Drahtloses Netzwerk (SSID)", "sheet.network": "Drahtloses Netzwerk (SSID)",
"sheet.newEntry": "Neuer Eintrag",
"sheet.newEntryHint": "Die Zeile wird übernommen, sobald alle vier Felder gültig sind.",
"sheet.nic": "Netzwerkkarte {number}",
"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.retention": "Gültigkeitsdauer SAT-Einträge (in Sek.)", "sheet.retention": "Gültigkeitsdauer SAT-Einträge (in Sek.)",
"sheet.rip": "Automatisches Routing",
"sheet.showAllEntries": "Alle Einträge anzeigen",
"sheet.subnetMask": "Netzmaske", "sheet.subnetMask": "Netzmaske",
"sheet.switchSsid": "Name WLAN (SSID)", "sheet.switchSsid": "Name WLAN (SSID)",
"sheet.title": "Einstellungen", "sheet.title": "Einstellungen",
+15
View File
@@ -30,19 +30,28 @@
"menu.delete": "Delete", "menu.delete": "Delete",
"menu.removeAllCables": "Remove all cables", "menu.removeAllCables": "Remove all cables",
"menu.removeCable": "Remove cable", "menu.removeCable": "Remove cable",
"route.destinationNetwork": "Destination",
"route.gateway": "Next gateway",
"route.interfaceIPAddress": "NIC",
"route.subnetMask": "Netmask",
"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.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.chooseNetwork": "Please select", "sheet.chooseNetwork": "Please select",
"sheet.close": "Close", "sheet.close": "Close",
"sheet.cloud": "Display as Cloud", "sheet.cloud": "Display as Cloud",
"sheet.connectedTo": "Connected to {device}",
"sheet.connection": "Connection", "sheet.connection": "Connection",
"sheet.defaultGateway": "Gateway", "sheet.defaultGateway": "Gateway",
"sheet.deleteEntry": "Delete entry",
"sheet.dhcpClient": "Use DHCP for configuration", "sheet.dhcpClient": "Use DHCP for configuration",
"sheet.dnsServer": "Domain Name Server", "sheet.dnsServer": "Domain Name Server",
"sheet.empty": "(empty)", "sheet.empty": "(empty)",
"sheet.forwardingTable": "Forwarding table",
"sheet.general": "General",
"sheet.invalidValue": "This value is invalid.", "sheet.invalidValue": "This value is invalid.",
"sheet.ipAddress": "IP Address", "sheet.ipAddress": "IP Address",
"sheet.ipAsName": "Use IP address as name", "sheet.ipAsName": "Use IP address as name",
@@ -51,9 +60,15 @@
"sheet.macAsName": "Use MAC address as name", "sheet.macAsName": "Use MAC address as name",
"sheet.name": "Name", "sheet.name": "Name",
"sheet.network": "Wireless Network (SSID)", "sheet.network": "Wireless Network (SSID)",
"sheet.newEntry": "New entry",
"sheet.newEntryHint": "The row is taken once all four fields are valid.",
"sheet.nic": "NIC {number}",
"sheet.notConnected": "No Connection",
"sheet.notTaken": "Not taken – still {value}.", "sheet.notTaken": "Not taken – still {value}.",
"sheet.port": "Port", "sheet.port": "Port",
"sheet.retention": "Retention Time SAT Entries (Seconds)", "sheet.retention": "Retention Time SAT Entries (Seconds)",
"sheet.rip": "Automatic Routing",
"sheet.showAllEntries": "Show all entries",
"sheet.subnetMask": "Netmask", "sheet.subnetMask": "Netmask",
"sheet.switchSsid": "Wi-Fi Name (SSID)", "sheet.switchSsid": "Wi-Fi Name (SSID)",
"sheet.title": "Settings", "sheet.title": "Settings",
+13 -29
View File
@@ -1,16 +1,14 @@
// The device sheet (plan §7.1): the settings of the selected device, the // The device sheet (plan §7.1): the settings of the selected device, the
// fields of FILIUS' settings panel of its kind (JHostKonfiguration, // fields of FILIUS' settings panel of its kind (JHostKonfiguration,
// JSwitchKonfiguration, JModemKonfiguration) in the panels' order. Java // JSwitchKonfiguration, JModemKonfiguration; a router's and a home router's
// shows the panel when a device is chosen; so does the sheet, beside the // in RouterSheets.tsx) in the panels' order. Java shows the panel when a
// surface or below it on a phone. Each change is one step of the person's // device is chosen; so does the sheet, beside the surface or below it on a
// undo. A router's and a home router's sheet name the device only, until // phone. Each change is one step of the person's undo.
// their own panels follow (F4.5b).
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import type * as Y from 'yjs'; import type * as Y from 'yjs';
import { import {
deviceSheet, deviceSheet,
renameDevice,
setDhcpClient, setDhcpClient,
setHostAddress, setHostAddress,
setHostLabels, setHostLabels,
@@ -30,6 +28,8 @@ import {
import { iconOf } from '../canvas/layout.js'; import { iconOf } from '../canvas/layout.js';
import { ICON_URLS } from '../canvas/icons.js'; 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 { HomeRouterFields, RouterFields } from './RouterSheets.js';
import { SheetCheck, SheetField } from './SheetField.js'; import { SheetCheck, SheetField } from './SheetField.js';
import './sheet.css'; import './sheet.css';
@@ -55,17 +55,11 @@ export interface DeviceSheetProps {
readonly onChanged: () => void; readonly onChanged: () => void;
readonly onMessage: (message: string) => void; readonly onMessage: (message: string) => void;
readonly onClose: () => void; readonly onClose: () => void;
/** The cable to draw active (a router's card whose tab is open), or none. */
readonly onHighlight: (link: string | null) => void;
} }
interface Change { export function DeviceSheet({ doc, id, label, origin, onChanged, onMessage, onClose, onHighlight }: DeviceSheetProps) {
readonly doc: Y.Doc;
readonly id: string;
readonly origin: object;
/** Runs a change and ends the undo step. */
readonly run: <T>(change: () => T) => T;
}
export function DeviceSheet({ doc, id, label, origin, onChanged, onMessage, onClose }: DeviceSheetProps) {
const t = useTranslate(); const t = useTranslate();
const sheet = useDeviceSheet(doc, id); const sheet = useDeviceSheet(doc, id);
if (sheet === undefined) return null; if (sheet === undefined) return null;
@@ -97,6 +91,10 @@ export function DeviceSheet({ doc, id, label, origin, onChanged, onMessage, onCl
<SwitchFields sheet={sheet} change={change} /> <SwitchFields sheet={sheet} change={change} />
) : sheet.kind === 'remoteLink' ? ( ) : sheet.kind === 'remoteLink' ? (
<ModemFields sheet={sheet} change={change} /> <ModemFields sheet={sheet} change={change} />
) : sheet.kind === 'router' ? (
<RouterFields sheet={sheet} change={change} onHighlight={onHighlight} />
) : sheet.kind === 'gateway' ? (
<HomeRouterFields sheet={sheet} change={change} onHighlight={onHighlight} />
) : ( ) : (
<NameField name={sheet.name} change={change} /> <NameField name={sheet.name} change={change} />
)} )}
@@ -105,20 +103,6 @@ export function DeviceSheet({ doc, id, label, origin, onChanged, onMessage, onCl
); );
} }
function NameField({ name, change, shown, disabled = false }: { readonly name: string; readonly change: Change; readonly shown?: string; readonly disabled?: boolean }) {
const t = useTranslate();
return (
<SheetField
name="name"
label={t('sheet.name')}
value={shown ?? name}
rule="name"
disabled={disabled}
onCommit={(text) => change.run(() => (renameDevice(change.doc, change.id, text, change.origin), { value: text }))}
/>
);
}
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 }: { readonly sheet: HostSheet; readonly label: string; readonly change: Change; readonly onMessage: (message: string) => void }) {
const t = useTranslate(); const t = useTranslate();
const address = const address =
+29
View File
@@ -0,0 +1,29 @@
// What every part of the device sheet shares: the change of one device by
// one person, and the device's name.
import type * as Y from 'yjs';
import { renameDevice } from '@mdg-filius/doc';
import { useTranslate } from '../i18n/i18n.js';
import { SheetField } from './SheetField.js';
export interface Change {
readonly doc: Y.Doc;
readonly id: string;
readonly origin: object;
/** Runs a change and ends the undo step. */
readonly run: <T>(change: () => T) => T;
}
export function NameField({ name, change, shown, disabled = false }: { readonly name: string; readonly change: Change; readonly shown?: string; readonly disabled?: boolean }) {
const t = useTranslate();
return (
<SheetField
name="name"
label={t('sheet.name')}
value={shown ?? name}
rule="name"
disabled={disabled}
onCommit={(text) => change.run(() => (renameDevice(change.doc, change.id, text, change.origin), { value: text }))}
/>
);
}
+266
View File
@@ -0,0 +1,266 @@
// The sheets of a router and a home router, after Java's panels
// (JVermittlungsrechnerKonfiguration, JGatewayConfiguration): tabs for the
// general settings, each card and — on a router — the forwarding table
// (JWeiterleitungsTabelle), greyed out while automatic routing (RIP) runs.
// The card whose tab is open has its cable drawn active, as Java draws it.
// The firewall, the port forwarding, the DHCP server and the change of the
// cards follow with their dialogs (F4.5c).
import { useEffect, useState } from 'react';
import {
addRoute,
checkField,
ROUTE_COLUMNS,
removeRoute,
setDhcpClient,
setInterfaceAddress,
setRip,
setRouteCell,
setRouterGateway,
type CheckedField,
type HomeRouterSheet,
type InterfaceSheet,
type RouteColumn,
type RouterSheet,
} from '@mdg-filius/doc';
import { useTranslate } from '../i18n/i18n.js';
import { NameField, type Change } from './NameField.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. */
function useHighlight(link: string | null, onHighlight: (link: string | null) => void): void {
useEffect(() => {
onHighlight(link);
}, [link, onHighlight]);
useEffect(() => () => onHighlight(null), [onHighlight]);
}
/** A card: what its cable joins, its address and mask, its MAC address. */
function InterfaceFields({ card, change, closed = false }: { readonly card: InterfaceSheet; readonly change: Change; readonly closed?: boolean }) {
const t = useTranslate();
const set = (field: 'ipAddress' | 'subnetMask') => (text: string) => change.run(() => setInterfaceAddress(change.doc, change.id, card.portId, field, text, change.origin));
return (
<>
<p className="sheet-connected" data-connected={card.connected === null ? 'false' : 'true'}>
{card.connected === null ? t('sheet.notConnected') : t('sheet.connectedTo', { device: card.connected.device })}
</p>
<SheetField name="ipAddress" label={t('sheet.ipAddress')} value={card.ipAddress} rule="ipAddress" inputMode="decimal" disabled={closed} onCommit={set('ipAddress')} />
<SheetField name="subnetMask" label={t('sheet.subnetMask')} value={card.subnetMask} rule="subnetMask" inputMode="decimal" disabled={closed} onCommit={set('subnetMask')} />
<SheetField name="macAddress" label={t('sheet.macAddress')} value={card.macAddress} rule="name" readOnly onCommit={() => ({ invalid: true })} />
</>
);
}
function GatewayField({ value, change }: { readonly value: string; readonly change: Change }) {
const t = useTranslate();
return (
<SheetField
name="gateway"
label={t('sheet.defaultGateway')}
value={value}
rule="ipAddressOrEmpty"
inputMode="decimal"
onCommit={(text) => change.run(() => setRouterGateway(change.doc, change.id, text, change.origin))}
/>
);
}
export function RouterFields({ sheet, change, onHighlight }: { readonly sheet: RouterSheet; readonly change: Change; readonly onHighlight: (link: string | null) => void }) {
const t = useTranslate();
const [tab, setTab] = useState('general');
// Java greys the table out while RIP runs; an open table goes back to the general tab.
const shown = tab === 'table' && sheet.rip ? 'general' : tab;
const card = shown.startsWith('card-') ? sheet.interfaces[Number(shown.slice(5))] : undefined;
useHighlight(card?.connected?.link ?? null, onHighlight);
const tabs: Tab[] = [
{ key: 'general', label: t('sheet.general') },
// Java names a card's tab by its address (updateAttribute), "Netzwerkkarte n" without one.
...sheet.interfaces.map((each, index) => ({ key: `card-${index}`, label: each.ipAddress === '' ? t('sheet.nic', { number: index + 1 }) : each.ipAddress })),
{ key: 'table', label: t('sheet.forwardingTable'), disabled: sheet.rip },
];
return (
<>
<SheetTabs tabs={tabs} selected={shown} onSelect={setTab} />
{/* A panel per tab: a text typed on one card is not offered to the next. */}
<div role="tabpanel" key={shown}>
{shown === 'general' ? (
<>
<NameField name={sheet.name} change={change} />
<GatewayField value={sheet.gateway} change={change} />
<SheetCheck name="rip" label={t('sheet.rip')} checked={sheet.rip} onChange={(on) => change.run(() => setRip(change.doc, change.id, on, change.origin))} />
<SheetCheck name="ipForwarding" label={t('sheet.ipForwarding')} checked={sheet.ipForwarding} disabled onChange={() => undefined} />
</>
) : card !== undefined ? (
<InterfaceFields card={card} change={change} />
) : (
<RouteTable sheet={sheet} change={change} />
)}
</div>
</>
);
}
export function HomeRouterFields({ sheet, change, onHighlight }: { readonly sheet: HomeRouterSheet; readonly change: Change; readonly onHighlight: (link: string | null) => void }) {
const t = useTranslate();
const [tab, setTab] = useState('general');
const card = tab === 'lan' ? sheet.lan : tab === 'wan' ? sheet.wan : undefined;
useHighlight(card?.connected?.link ?? null, onHighlight);
// Java's order: general, LAN, WAN; a card's tab says which and its address.
const tabs: Tab[] = [
{ key: 'general', label: t('sheet.general') },
{ key: 'lan', label: `LAN: ${sheet.lan.ipAddress}` },
{ key: 'wan', label: `WAN: ${sheet.wan.ipAddress}` },
];
return (
<>
<SheetTabs tabs={tabs} selected={tab} onSelect={setTab} />
<div role="tabpanel" key={tab}>
{tab === 'general' ? (
<>
<NameField name={sheet.name} change={change} />
<GatewayField value={sheet.gateway} change={change} />
<SheetCheck name="ipForwarding" label={t('sheet.ipForwarding')} checked={sheet.ipForwarding} disabled onChange={() => undefined} />
</>
) : tab === 'lan' ? (
<InterfaceFields card={sheet.lan} change={change} />
) : (
<>
{/* DHCP configures the WAN card; its address and mask are closed meanwhile, as in Java. */}
<InterfaceFields card={sheet.wan} change={change} closed={sheet.dhcpClient} />
<SheetCheck name="dhcpClient" label={t('sheet.dhcpClient')} checked={sheet.dhcpClient} onChange={(on) => change.run(() => setDhcpClient(change.doc, change.id, on, change.origin))} />
</>
)}
</div>
</>
);
}
const routeRule = (column: RouteColumn) => (column === 'subnetMask' ? 'subnetMask' : 'ipAddress');
const EMPTY_ROW: Readonly<Record<RouteColumn, string>> = { destinationNetwork: '', subnetMask: '', gateway: '', interfaceIPAddress: '' };
/**
* 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
* it, one new row at a time, taken once all four cells pass (as Java keeps
* its new row until Weiterleitungstabelle.validate takes it).
*/
function RouteTable({ sheet, change }: { readonly sheet: RouterSheet; readonly change: Change }) {
const t = useTranslate();
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;
const rows = sheet.table.map((row) => ({ row, manual: row.isEditable ? ++manual : null })).filter((each) => showAll || each.manual !== null);
const editing = typeof chosen === 'number' ? sheet.routes[chosen] : undefined;
const draftCell =
(column: RouteColumn) =>
(text: string): CheckedField => {
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 (
<>
<SheetCheck name="showAll" label={t('sheet.showAllEntries')} checked={showAll} onChange={setShowAll} />
<div className="route-table-frame">
<table className="route-table">
<thead>
<tr>
{ROUTE_COLUMNS.map((column) => (
<th key={column} scope="col">
{t(`route.${column}`)}
</th>
))}
</tr>
</thead>
<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}
</>
);
}
+4 -2
View File
@@ -22,16 +22,18 @@ export interface SheetFieldProps {
/** A name for the browser tests and the label's input mode. */ /** A name for the browser tests and the label's input mode. */
readonly name: string; readonly name: string;
readonly inputMode?: 'text' | 'decimal' | 'numeric'; readonly inputMode?: 'text' | 'decimal' | 'numeric';
/** A field still to be filled (a new row's cell): empty and untouched, it is not marked. */
readonly unfilled?: boolean;
} }
export function SheetField({ label, value, rule, onCommit, disabled = false, readOnly = false, name, inputMode = 'text' }: SheetFieldProps) { export function SheetField({ label, value, rule, onCommit, disabled = false, readOnly = false, name, inputMode = 'text', unfilled = false }: SheetFieldProps) {
const t = useTranslate(); const t = useTranslate();
const id = useId(); const id = useId();
// What is typed and not yet taken; null shows the device's value. // What is typed and not yet taken; null shows the device's value.
const [draft, setDraft] = useState<string | null>(null); const [draft, setDraft] = useState<string | null>(null);
const [refused, setRefused] = useState(false); const [refused, setRefused] = useState(false);
const shown = draft ?? value; const shown = draft ?? value;
const marked = !readOnly && 'invalid' in checkField(rule, shown); const marked = !readOnly && !(unfilled && draft === null && shown === '') && 'invalid' in checkField(rule, shown);
const commit = () => { const commit = () => {
if (draft === null || readOnly) return; if (draft === null || readOnly) return;
+98
View File
@@ -148,3 +148,101 @@
padding: 0; padding: 0;
border: none; border: none;
} }
/* Java's tabs (JTabbedPane), as chips that wrap in a narrow sheet. */
.sheet-tabs {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin: 4px 0 8px;
}
.sheet-tabs button,
.sheet-buttons button {
min-height: 44px;
padding: 0 12px;
border: 1px solid var(--border-default);
border-radius: 8px;
background: var(--surface-raised);
color: var(--text-primary);
font: inherit;
font-size: 14px;
cursor: pointer;
}
.sheet-tabs button[aria-selected='true'] {
border-color: var(--accent);
background: var(--accent-soft);
color: var(--accent-on-soft);
}
.sheet-tabs button:disabled,
.sheet-buttons button:disabled {
color: var(--text-muted);
background: var(--surface-sunken);
cursor: default;
}
.sheet-buttons {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin: 8px 0;
}
.sheet-connected,
.sheet-note {
margin: 8px 0;
font-size: 14px;
color: var(--text-secondary);
}
.route-table-frame {
overflow-x: auto;
border: 1px solid var(--border-default);
border-radius: 8px;
}
.route-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
font-variant-numeric: tabular-nums;
}
.route-table th,
.route-table td {
padding: 6px;
text-align: left;
white-space: nowrap;
border-bottom: 1px solid var(--border-subtle);
}
.route-table th {
font-weight: 600;
color: var(--text-secondary);
background: var(--surface-sunken);
}
/* Java greys the rows it makes and lets the manual ones be edited. */
.route-table tbody tr:not([data-manual]):not([data-new]) {
color: var(--text-muted);
}
.route-table tbody tr[data-manual] {
cursor: pointer;
}
.route-table tbody tr[aria-selected='true'] {
background: var(--surface-selected);
}
.route-table td[data-invalid='true'] {
color: var(--danger);
}
/* A manual row is a finger's target. */
.route-table tbody tr[data-manual] td {
padding-top: 13px;
padding-bottom: 13px;
}
+3
View File
@@ -30,6 +30,8 @@
--canvas-bg: #ffffff; --canvas-bg: #ffffff;
--canvas-cable: rgb(64 64 64); --canvas-cable: rgb(64 64 64);
--canvas-cable-wireless: rgb(192 192 192); --canvas-cable-wireless: rgb(192 192 192);
/* Java's active cable (JCablePanel.farbeBlinken): a frame on its way, or the card whose tab is open. */
--canvas-cable-active: rgb(0 255 64);
--canvas-label: #000000; --canvas-label: #000000;
} }
@@ -60,6 +62,7 @@
--canvas-bg: #e9ecf1; --canvas-bg: #e9ecf1;
--canvas-cable: rgb(64 64 64); --canvas-cable: rgb(64 64 64);
--canvas-cable-wireless: rgb(150 150 150); --canvas-cable-wireless: rgb(150 150 150);
--canvas-cable-active: rgb(0 200 50);
--canvas-label: #000000; --canvas-label: #000000;
} }
+15
View File
@@ -56,6 +56,16 @@ const FROM_JAVA: Readonly<Record<string, string>> = {
'sheet.switchSsid': 'jswitchkonfiguration_msg4', 'sheet.switchSsid': 'jswitchkonfiguration_msg4',
'sheet.retention': 'jswitchkonfiguration_msg5', 'sheet.retention': 'jswitchkonfiguration_msg5',
'sheet.port': 'jmodemkonfiguration_msg4', 'sheet.port': 'jmodemkonfiguration_msg4',
'sheet.general': 'jvermittlungsrechnerkonfiguration_msg17',
'sheet.notConnected': 'jvermittlungsrechnerkonfiguration_msg16',
'sheet.rip': 'jvermittlungsrechnerkonfiguration_msg26',
'sheet.forwardingTable': 'jvermittlungsrechnerkonfiguration_msg15',
'sheet.showAllEntries': 'jvermittlungsrechnerkonfiguration_msg11',
'sheet.newEntry': 'jvermittlungsrechnerkonfiguration_msg12',
'route.destinationNetwork': 'jweiterleitungstabelle_msg3',
'route.subnetMask': 'jweiterleitungstabelle_msg4',
'route.gateway': 'jweiterleitungstabelle_msg5',
'route.interfaceIPAddress': 'jweiterleitungstabelle_msg6',
}; };
/** Java's words, mended: the prompt says "Vermittlungsrechnerns" (guicontainer_msg1). */ /** Java's words, mended: the prompt says "Vermittlungsrechnerns" (guicontainer_msg1). */
@@ -63,6 +73,11 @@ const FROM_JAVA_MENDED: Readonly<Record<string, readonly [string, (java: string)
'router.prompt': ['guicontainer_msg1', (java) => java.replace(' \\n ', ' ').replace('Vermittlungsrechnerns', 'Vermittlungsrechners')], 'router.prompt': ['guicontainer_msg1', (java) => java.replace(' \\n ', ' ').replace('Vermittlungsrechnerns', 'Vermittlungsrechners')],
// A label above its list, without the colon of Java's label beside it. // A label above its list, without the colon of Java's label beside it.
'sheet.network': ['jhostkonfiguration_msg15', (java) => java.replace(/:$/, '')], 'sheet.network': ['jhostkonfiguration_msg15', (java) => java.replace(/:$/, '')],
// Java writes "Eintrag Löschen".
'sheet.deleteEntry': ['jvermittlungsrechnerkonfiguration_msg13', (java) => java.replace('Löschen', 'löschen')],
// Java's word, then what it joins or which card.
'sheet.connectedTo': ['jvermittlungsrechnerkonfiguration_msg6', (java) => `${java} {device}`],
'sheet.nic': ['jvermittlungsrechnerkonfiguration_msg10', (java) => `${java} {number}`],
}; };
describe('the catalogs', () => { describe('the catalogs', () => {
+3
View File
@@ -97,3 +97,6 @@ packages/core/src/runtime/apps/gnutella.ts::welt
packages/fls/test/classicsJava.test.ts::gmx packages/fls/test/classicsJava.test.ts::gmx
# Rechner, Vermittlungsrechner …: Java FILIUS' device classes, by which the browser test reads the bounds a file saved. # Rechner, Vermittlungsrechner …: Java FILIUS' device classes, by which the browser test reads the bounds a file saved.
packages/editor/browser/canvas.spec.ts::rechner packages/editor/browser/canvas.spec.ts::rechner
# "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::loeschen
+28
View File
@@ -1129,3 +1129,31 @@ Gemessen (03.10.2026):
Name wird zur MAC-Adresse und bleibt es nach dem Abschalten (bei der Name wird zur MAC-Adresse und bleibt es nach dem Abschalten (bei der
IP-Adresse nicht). IP-Adresse nicht).
* „DHCP zur Konfiguration verwenden“ schaltet den eigenen DHCP-Server ab. * „DHCP zur Konfiguration verwenden“ schaltet den eigenen DHCP-Server ab.
### Vermittlungsrechner und Heimrouter (F4.5b)
`SheetRun` tippt seitdem auch in `JVermittlungsrechnerKonfiguration` (erste
Karte, Gateway) und `JGatewayConfiguration` (Gateway, WAN, LAN). Felder ohne
eigene Prüfmethode markiert es mit dem Muster ihres Tastenhörers. Dazu
schreibt es Javas Weiterleitungstabellen für fünf Aufbauten (`tables`:
`holeTabelle` mit den Kennzeichen der manuellen Zeilen) und Schritte in der
Tabelle des Panels (`edits`: `JWeiterleitungsTabelle`, nur manuelle Zeilen
gezeigt). Ein Panel, das wirft, gehört zur Messung (`error`).
Gemessen (03.10.2026):
* Karte eines Routers: Markieren und Übernehmen stimmen überein (anders als
beim Rechner wird „10..0.0.1“ verworfen), bis auf „.0.0.1“: nicht
markiert, die Adresse wird `null`, und das Panel wirft danach eine
`NullPointerException` (`holeTabelle`). Beim Heimrouter wird „.1.2.3“
ebenso `null`, ohne Ausnahme.
* Gateway von Router und Heimrouter: Der Tastenhörer prüft mit
`musterIpAdresse`, ein leeres Feld ist also rot, wird aber übernommen.
* DHCP am Heimrouter (WAN) lässt den eigenen DHCP-Server laufen.
* Tabelle: eigene Adressen (neueste Karte zuerst), eigene Netze (ebenso),
localhost, die manuellen Zeilen (durch `ipCheck`: „010.020.0.0“ wird
10.20.0.0), das Standard-Gateway über die letzte Karte in seinem Netz,
ohne solche über die erste.
* Tabelle bearbeiten: Eine Zelle, die das Muster nicht besteht („abc“, „“,
„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.
+188 -18
View File
@@ -1,5 +1,6 @@
// Types into FILIUS' own settings panels (JHostKonfiguration, // Types into FILIUS' own settings panels (JHostKonfiguration,
// JSwitchKonfiguration, JModemKonfiguration) and lets them take the text as // JSwitchKonfiguration, JModemKonfiguration, JVermittlungsrechnerKonfiguration,
// JGatewayConfiguration) and lets them take the text as
// they do when a field loses the focus (aenderungenAnnehmen), then writes, // they do when a field loses the focus (aenderungenAnnehmen), then writes,
// for each case, whether the panel marked the text red while typing, what the // for each case, whether the panel marked the text red while typing, what the
// device holds afterwards and what the field shows then (updateAttribute): // device holds afterwards and what the field shows then (updateAttribute):
@@ -8,25 +9,38 @@
// Java-style unicode escapes (backslash, u, four hex digits) for anything // Java-style unicode escapes (backslash, u, four hex digits) for anything
// else, one case a line: // else, one case a line:
// //
// <host|switch|modem> <field> <text> the text may be empty // <host|switch|modem|router|homeRouter> <field> <text> the text may be empty
// //
// Fields: host name, ip, mask, gateway, dns; switch name, ssid, retention; // Fields: host name, ip, mask, gateway, dns; switch name, ssid, retention;
// modem name, port. After the cases, a few flows of the check boxes // modem name, port; router name, ip, mask (its first interface), gateway;
// ("flows"): a host's name while it shows its address or MAC address, and // homeRouter name, gateway, wanIp, wanMask, lanIp, lanMask. A field without
// DHCP switched on while its own DHCP server runs. // a check method of its own is marked as its key listener marks it (the
// pattern). After the cases, a few flows ("flows"): a host's name while it
// 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
// (Weiterleitungstabelle.holeTabelle, "tables") and its rows edited in the
// panel's table (JWeiterleitungsTabelle, "edits").
// //
// 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.netzwerksicht.JKonfiguration; import filius.gui.netzwerksicht.JKonfiguration;
import filius.gui.netzwerksicht.JWeiterleitungsTabelle;
import filius.hardware.NetzwerkInterface;
import filius.hardware.knoten.Gateway;
import filius.hardware.knoten.Host; import filius.hardware.knoten.Host;
import filius.hardware.knoten.Modem; import filius.hardware.knoten.Modem;
import filius.hardware.knoten.Rechner; import filius.hardware.knoten.Rechner;
import filius.hardware.knoten.Switch; import filius.hardware.knoten.Switch;
import filius.hardware.knoten.Vermittlungsrechner;
import filius.rahmenprogramm.EingabenUeberpruefung; import filius.rahmenprogramm.EingabenUeberpruefung;
import filius.software.system.Betriebssystem; import filius.software.system.Betriebssystem;
import filius.software.system.GatewayFirmware;
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.vermittlungsschicht.Weiterleitungstabelle;
import java.io.FileOutputStream; import java.io.FileOutputStream;
import java.lang.reflect.Field; import java.lang.reflect.Field;
@@ -35,17 +49,22 @@ import java.nio.charset.StandardCharsets;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.regex.Pattern;
import javax.swing.JCheckBox; import javax.swing.JCheckBox;
import javax.swing.JTextField; import javax.swing.JTextField;
public class SheetRun { public class SheetRun {
/** A private field of a panel; "name#i" is the i-th of an array. */
static Object get(Object panel, String name) throws Exception { static Object get(Object panel, String name) throws Exception {
Field field = panel.getClass().getDeclaredField(name); String[] parts = name.split("#");
Field field = panel.getClass().getDeclaredField(parts[0]);
field.setAccessible(true); field.setAccessible(true);
return field.get(panel); Object value = field.get(panel);
return parts.length == 1 ? value : ((Object[]) value)[Integer.parseInt(parts[1])];
} }
static void call(Object panel, String name) throws Exception { static void call(Object panel, String name) throws Exception {
@@ -54,19 +73,33 @@ public class SheetRun {
method.invoke(panel); method.invoke(panel);
} }
/** The panel's field and its check (the method its key listener calls), per device and field. */ /** The panel's field, its check (the method its key listener calls) or else the pattern its key listener checks, per device and field. */
static final Map<String, String[]> FIELDS = new LinkedHashMap<>(); static final Map<String, String[]> FIELDS = new LinkedHashMap<>();
static { static {
FIELDS.put("host name", new String[] { "name", null }); FIELDS.put("host name", new String[] { "name", null, null });
FIELDS.put("host ip", new String[] { "ipAdresse", "checkIpAddress" }); FIELDS.put("host ip", new String[] { "ipAdresse", "checkIpAddress", null });
FIELDS.put("host mask", new String[] { "netzmaske", "checkNetmask" }); FIELDS.put("host mask", new String[] { "netzmaske", "checkNetmask", null });
FIELDS.put("host gateway", new String[] { "gateway", "checkGatewayAddress" }); FIELDS.put("host gateway", new String[] { "gateway", "checkGatewayAddress", null });
FIELDS.put("host dns", new String[] { "dns", "checkDnsAddress" }); FIELDS.put("host dns", new String[] { "dns", "checkDnsAddress", null });
FIELDS.put("switch name", new String[] { "name", null }); FIELDS.put("switch name", new String[] { "name", null, null });
FIELDS.put("switch ssid", new String[] { "ssid", "checkSSID" }); FIELDS.put("switch ssid", new String[] { "ssid", "checkSSID", null });
FIELDS.put("switch retention", new String[] { "retentionTime", "checkRetentionTime" }); FIELDS.put("switch retention", new String[] { "retentionTime", "checkRetentionTime", null });
FIELDS.put("modem name", new String[] { "name", null }); FIELDS.put("modem name", new String[] { "name", null, null });
FIELDS.put("modem port", new String[] { "tfPort", null }); FIELDS.put("modem port", new String[] { "tfPort", null, null });
FIELDS.put("router name", new String[] { "name", null, null });
FIELDS.put("router ip", new String[] { "ipAdressen#0", null, "ip" });
FIELDS.put("router mask", new String[] { "netzmasken#0", null, "mask" });
FIELDS.put("router gateway", new String[] { "gateway", null, "ip" });
FIELDS.put("homeRouter name", new String[] { "name", null, null });
FIELDS.put("homeRouter gateway", new String[] { "gatewayName", null, "ip" });
FIELDS.put("homeRouter wanIp", new String[] { "ipAddressWANPort", null, "ip" });
FIELDS.put("homeRouter wanMask", new String[] { "netmaskWANPort", null, "mask" });
FIELDS.put("homeRouter lanIp", new String[] { "ipAddressLANPort", null, "ip" });
FIELDS.put("homeRouter lanMask", new String[] { "netmaskLANPort", null, "mask" });
}
static Pattern pattern(String name) {
return name.equals("ip") ? EingabenUeberpruefung.musterIpAdresse : EingabenUeberpruefung.musterSubNetz;
} }
static String stored(Object device, String key) { static String stored(Object device, String key) {
@@ -88,6 +121,25 @@ public class SheetRun {
case "switch ssid": return firmware.getSSID(); case "switch ssid": return firmware.getSSID();
case "switch retention": return Long.toString(firmware.getRetentionTime()); case "switch retention": return Long.toString(firmware.getRetentionTime());
} }
} else if (device instanceof Vermittlungsrechner) {
Vermittlungsrechner router = (Vermittlungsrechner) device;
NetzwerkInterface first = router.getNetzwerkInterfaces().get(0);
switch (key) {
case "router name": return router.getName();
case "router ip": return first.getIp();
case "router mask": return first.getSubnetzMaske();
case "router gateway": return ((InternetKnotenBetriebssystem) router.getSystemSoftware()).getStandardGateway();
}
} else if (device instanceof Gateway) {
Gateway home = (Gateway) device;
switch (key) {
case "homeRouter name": return home.getName();
case "homeRouter gateway": return ((InternetKnotenBetriebssystem) home.getSystemSoftware()).getStandardGateway();
case "homeRouter wanIp": return home.holeWANInterface().getIp();
case "homeRouter wanMask": return home.holeWANInterface().getSubnetzMaske();
case "homeRouter lanIp": return home.holeLANInterface().getIp();
case "homeRouter lanMask": return home.holeLANInterface().getSubnetzMaske();
}
} else if (device instanceof Modem) { } else if (device instanceof Modem) {
Modem modem = (Modem) device; Modem modem = (Modem) device;
switch (key) { switch (key) {
@@ -114,10 +166,82 @@ public class SheetRun {
return sw; return sw;
} }
case "modem": return new Modem(); case "modem": return new Modem();
case "router": {
Vermittlungsrechner router = new Vermittlungsrechner();
router.setzeAnzahlAnschluesse(2);
((InternetKnotenBetriebssystem) router.getSystemSoftware()).setStandardGateway("192.168.0.254");
return router;
}
case "homeRouter": {
Gateway home = new Gateway();
((InternetKnotenBetriebssystem) home.getSystemSoftware()).setStandardGateway("192.168.0.254");
return home;
}
} }
throw new IllegalArgumentException(kind); throw new IllegalArgumentException(kind);
} }
/** A router's forwarding table (Weiterleitungstabelle.holeTabelle) with its manual flags. */
static List<Object> table(String[] interfaces, String gateway, String[][] manual) {
Vermittlungsrechner router = new Vermittlungsrechner();
router.setzeAnzahlAnschluesse(interfaces.length / 2);
for (int i = 0; i < interfaces.length / 2; i++) {
router.getNetzwerkInterfaces().get(i).setIp(interfaces[2 * i]);
router.getNetzwerkInterfaces().get(i).setSubnetzMaske(interfaces[2 * i + 1]);
}
VermittlungsrechnerBetriebssystem os = (VermittlungsrechnerBetriebssystem) router.getSystemSoftware();
os.setStandardGateway(gateway);
Weiterleitungstabelle table = os.getWeiterleitungstabelle();
for (String[] row : manual) table.addManuellenEintrag(row[0], row[1], row[2], row[3]);
List<Object> rows = new ArrayList<>();
List<String[]> entries = table.holeTabelle();
List<Boolean> flags = table.holeManuelleEintraegeFlags();
for (int i = 0; i < entries.size(); i++) {
Map<String, Object> row = new LinkedHashMap<>();
row.put("cells", Arrays.asList(entries.get(i)));
row.put("manual", flags.get(i));
rows.add(row);
}
return rows;
}
/**
* Steps in the panel's table of a router with one manual row, only the
* manual rows shown: {"<column>", text} sets a cell of the first row,
* {"N<column>", text} of the last row, {"new"} adds a row, {"remove"}
* removes the first; each step taken as the table takes it. The manual
* rows afterwards (and the rows shown, when asked).
*/
static Map<String, Object> edit(String[][] steps, boolean shown) throws Exception {
Vermittlungsrechner router = new Vermittlungsrechner();
router.setzeAnzahlAnschluesse(2);
VermittlungsrechnerBetriebssystem os = (VermittlungsrechnerBetriebssystem) router.getSystemSoftware();
os.getWeiterleitungstabelle().addManuellenEintrag("172.16.0.0", "255.255.0.0", "192.168.0.2", "192.168.0.10");
JKonfiguration panel = JKonfiguration.getInstance(router);
JWeiterleitungsTabelle table = (JWeiterleitungsTabelle) get(panel, "weiterleitungstabelle");
table.setzeAlleEintraegeAnzeigen(false);
table.updateAttribute();
for (String[] step : steps) {
if (step[0].equals("new")) {
table.neuerEintrag();
} else if (step[0].equals("remove")) {
table.setRowSelectionInterval(0, 0);
table.markiertenEintragLoeschen();
} else {
boolean last = step[0].startsWith("N");
int column = Integer.parseInt(last ? step[0].substring(1) : step[0]);
table.setValueAt(step[1], last ? table.getRowCount() - 1 : 0, column);
table.aenderungenAnnehmen();
}
}
Map<String, Object> result = new LinkedHashMap<>();
List<Object> rows = new ArrayList<>();
for (String[] row : os.getWeiterleitungstabelle().getManuelleTabelle()) rows.add(Arrays.asList(row));
result.put("manual", rows);
if (shown) result.put("rowsShown", table.getRowCount());
return result;
}
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 {
@@ -148,9 +272,19 @@ public class SheetRun {
if (names[1] != null) { if (names[1] != null) {
call(panel, names[1]); call(panel, names[1]);
marked = field.getForeground().equals(EingabenUeberpruefung.farbeFalsch); marked = field.getForeground().equals(EingabenUeberpruefung.farbeFalsch);
} else if (names[2] != null) {
marked = !panel.ueberpruefen(pattern(names[2]), field);
} }
// A panel that throws is part of the measurement ("error").
String error = null;
try {
call(panel, "aenderungenAnnehmen"); call(panel, "aenderungenAnnehmen");
panel.updateAttribute(); panel.updateAttribute();
} catch (java.lang.reflect.InvocationTargetException thrown) {
error = thrown.getCause().getClass().getSimpleName();
} catch (RuntimeException thrown) {
error = thrown.getClass().getSimpleName();
}
Map<String, Object> result = new LinkedHashMap<>(); Map<String, Object> result = new LinkedHashMap<>();
result.put("device", words[0]); result.put("device", words[0]);
result.put("field", words[1]); result.put("field", words[1]);
@@ -159,6 +293,7 @@ public class SheetRun {
result.put("marked", marked); result.put("marked", marked);
result.put("stored", stored(device, key)); result.put("stored", stored(device, key));
result.put("shown", field.getText()); result.put("shown", field.getText());
if (error != null) result.put("error", error);
cases.add(result); cases.add(result);
} }
@@ -187,10 +322,45 @@ public class SheetRun {
flows.put("dhcpClientStopsServer", !os.getDHCPServer().isAktiv()); flows.put("dhcpClientStopsServer", !os.getDHCPServer().isAktiv());
flows.put("dhcpClient", os.isDHCPKonfiguration()); flows.put("dhcpClient", os.isDHCPKonfiguration());
} }
// The same on a home router (its WAN card).
{
Gateway home = new Gateway();
GatewayFirmware firmware = (GatewayFirmware) home.getSystemSoftware();
firmware.getDHCPServer().setAktiv(true);
JKonfiguration panel = JKonfiguration.getInstance(home);
((JCheckBox) get(panel, "dhcp")).setSelected(true);
call(panel, "aenderungenAnnehmen");
flows.put("homeRouterDhcpClientStopsServer", !firmware.getDHCPServer().isAktiv());
flows.put("homeRouterDhcpClient", firmware.isDHCPKonfiguration());
}
// Forwarding tables: two interfaces, a gateway, manual rows.
Map<String, Object> tables = new LinkedHashMap<>();
String[][] manual = { { "172.16.0.0", "255.255.0.0", "192.168.1.2", "192.168.1.1" }, { "010.020.0.0", "255.255.0.0", "10.0.0.2", "10.0.0.1" } };
tables.put("gatewayInSecondNetwork", table(new String[] { "10.0.0.1", "255.0.0.0", "192.168.1.1", "255.255.255.0" }, "192.168.1.254", manual));
tables.put("gatewayInNoNetwork", table(new String[] { "10.0.0.1", "255.0.0.0", "192.168.1.1", "255.255.255.0" }, "172.30.0.1", manual));
tables.put("gatewayInBothNetworks", table(new String[] { "10.0.0.1", "255.0.0.0", "10.1.0.1", "255.255.0.0" }, "10.1.0.254", new String[0][]));
tables.put("noGateway", table(new String[] { "10.0.0.1", "255.0.0.0", "192.168.1.1", "255.255.255.0" }, "", manual));
tables.put("threeInterfaces", table(new String[] { "10.0.0.1", "255.0.0.0", "192.168.1.1", "255.255.255.0", "172.16.5.1", "255.255.0.0" }, "10.0.0.254", new String[0][]));
// The panel's table: a manual row edited, a new row, a row removed (only the manual rows shown).
Map<String, Object> edits = new LinkedHashMap<>();
edits.put("invalidCell", edit(new String[][] { { 0 + "", "abc" } }, false));
edits.put("emptyCell", edit(new String[][] { { 0 + "", "" } }, false));
edits.put("leadingZeros", edit(new String[][] { { 0 + "", "010.0.0.0" } }, false));
edits.put("dottedTwice", edit(new String[][] { { 0 + "", "10..0.0" } }, false));
edits.put("maskCell", edit(new String[][] { { 1 + "", "255.0.255.0" } }, false));
edits.put("newRowThreeCells", edit(new String[][] { { "new" }, { "N0", "10.9.0.0" }, { "N1", "255.255.0.0" }, { "N2", "10.0.0.9" } }, false));
edits.put("newRowFourCells", edit(new String[][] { { "new" }, { "N0", "10.9.0.0" }, { "N1", "255.255.0.0" }, { "N2", "10.0.0.9" }, { "N3", "10.0.0.1" } }, false));
edits.put("newRowInvalidCell", edit(new String[][] { { "new" }, { "N0", "10.9.0.0" }, { "N1", "255.255.0.0" }, { "N2", "10.0.0.9" }, { "N3", "10..0.0.1" } }, false));
edits.put("newRowTwice", edit(new String[][] { { "new" }, { "new" } }, true));
edits.put("removeRow", edit(new String[][] { { "remove" } }, false));
Map<String, Object> output = new LinkedHashMap<>(); Map<String, Object> output = new LinkedHashMap<>();
output.put("cases", cases); output.put("cases", cases);
output.put("flows", flows); output.put("flows", flows);
output.put("tables", tables);
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));
} }