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);
}
+102 -6
View File
@@ -1,12 +1,24 @@
// What the device sheet shows of a device, read straight from the document:
// the fields of FILIUS' settings panel of its kind (JHostKonfiguration,
// JSwitchKonfiguration, JModemKonfiguration). A router's and a home router's
// sheet show their name only, until their own panels follow.
// JSwitchKonfiguration, JModemKonfiguration, JVermittlungsrechnerKonfiguration
// with its forwarding table, JGatewayConfiguration).
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 { inOrder, ROOTS } from './projectDoc.js';
import { fromY } from './values.js';
import { nodeLabel } from './view.js';
export interface HostSheet {
readonly kind: 'pc' | 'notebook';
@@ -44,16 +56,73 @@ export interface ModemSheet {
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 {
readonly kind: Exclude<NodeKind, 'pc' | 'notebook' | 'networkSwitch' | 'remoteLink'>;
readonly kind: 'unsupported';
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 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. */
export function deviceSheet(doc: Y.Doc, id: string): DeviceSheet | undefined {
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':
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 };
}
}
+19 -19
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;
/** Host.holeAnzeigeName: a PC's or notebook's address and MAC address, as its label policy asks. */
function labelOf(node: Y.Map<unknown>, kind: NodeKind, name: string): string {
/** Host.holeAnzeigeName: a PC's or notebook's address and MAC address, as its label policy asks; any other device's name. */
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;
if ((kind !== 'pc' && kind !== 'notebook') || policy === 'manual') return name;
const ip = (settingsOf(node)?.get('device')?.get('ipAddress') as string | undefined) ?? '';
@@ -80,7 +82,7 @@ export function topologyView(doc: Y.Doc): TopologyView {
id,
kind,
name,
label: labelOf(node, kind, name),
label: nodeLabel(node),
x: node.get('x') as number,
y: node.get('y') as number,
cloud: kind === 'networkSwitch' && settingsOf(node)?.get('switch')?.get('showsAsCloud') === true,
@@ -94,22 +96,20 @@ export function topologyView(doc: Y.Doc): TopologyView {
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) };
});
const docs = [...doc.getMap<Y.Map<unknown>>(ROOTS.docs).entries()].map(
([id, item]): DocumentationView => ({
id,
kind: item.get('kind') as DocumentationItemKind,
x: item.get('x') as number,
y: item.get('y') as number,
width: item.get('width') as number,
height: item.get('height') as number,
text: (item.get('text') as Y.Text).toString(),
color: { red: item.get('red') as number, green: item.get('green') as number, blue: item.get('blue') as number, alpha: item.get('alpha') as number },
font: item.get('font') as string,
size: item.get('size') as number,
bold: item.get('bold') as boolean,
order: item.get('order') as number,
}),
);
const docs = [...doc.getMap<Y.Map<unknown>>(ROOTS.docs).entries()].map(([id, item]): DocumentationView => ({
id,
kind: item.get('kind') as DocumentationItemKind,
x: item.get('x') as number,
y: item.get('y') as number,
width: item.get('width') as number,
height: item.get('height') as number,
text: (item.get('text') as Y.Text).toString(),
color: { red: item.get('red') as number, green: item.get('green') as number, blue: item.get('blue') as number, alpha: item.get('alpha') as number },
font: item.get('font') as string,
size: item.get('size') as number,
bold: item.get('bold') as boolean,
order: item.get('order') as number,
}));
// The model's render order (inDeterministicRenderOrder): rectangles below texts, then by order, then by id.
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));