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:
@@ -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
@@ -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
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user