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
+128 -5
View File
@@ -1,7 +1,9 @@
// What the device sheet does to the document: a device's settings changed as
// FILIUS' settings panels change them (JHostKonfiguration,
// JSwitchKonfiguration, JModemKonfiguration; measured with SheetRun), each a
// transaction with the person's origin, like the design tools' commands.
// JSwitchKonfiguration, JModemKonfiguration, JVermittlungsrechnerKonfiguration
// 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
// (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
// 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.
// 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 { 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 { connect, type ConnectError } from './commands.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);
}
/** 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 {
const node = nodes(doc).get(id);
if (node === undefined) return;
@@ -96,7 +105,7 @@ export function setDhcpClient(doc: Y.Doc, id: Id, on: boolean, origin: unknown):
return;
}
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);
}
@@ -196,3 +205,117 @@ export function setModemPort(doc: Y.Doc, id: Id, text: string, origin: unknown):
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);
}