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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 12:16:04 +00:00
co-authored by Claude Opus 5.5
parent eff9433673
commit c8edb87210
22 changed files with 1525 additions and 143 deletions
+164 -2
View File
@@ -20,12 +20,27 @@
// 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.
// Nor that a DHCP server keeps its bounds as typed (".0.0.1" too), drops a
// second static assignment of a MAC address without a word, and that a home
// router keeps any port forwarding row, valid or not (its firmware then
// forwards only the valid ones): here a value passes its rule or is not
// taken, and the sheet says why.
import * as Y from 'yjs';
import { DEFAULT_REMOTE_LINK_LATENCY_MILLISECONDS, defaultSwitchConfiguration, forwardsIPv4ByDefault, type Id, type ManualRoute, type NodeKind } from '@mdg-filius/core';
import {
DEFAULT_REMOTE_LINK_LATENCY_MILLISECONDS,
defaultSwitchConfiguration,
dhcpServerConfiguration,
forwardsIPv4ByDefault,
type DhcpStaticAssignment,
type GatewayPortForwardingRow,
type Id,
type ManualRoute,
type NodeKind,
} from '@mdg-filius/core';
import { modemPair } from '@mdg-filius/fls';
import { connect, type ConnectError } from './commands.js';
import { checkField, type CheckedField } from './fields.js';
import { checkField, type CheckedField, type FieldRule } from './fields.js';
import { inOrder, ROOTS } from './projectDoc.js';
import { toY } from './values.js';
@@ -319,3 +334,150 @@ export function removeRoute(doc: Y.Doc, id: Id, index: number, origin: unknown):
if (routes.length === 0) settingsOf(node).delete('routes');
}, origin);
}
/** Checks a row's cells by their rules: the values, or the columns that do not pass. */
function checkRow<Column extends string>(
columns: readonly Column[],
rule: (column: Column) => FieldRule,
cells: Readonly<Record<Column, string>>,
): { readonly values: Record<Column, string> } | { readonly invalid: readonly Column[] } {
const checked = columns.map((column) => [column, checkField(rule(column), cells[column])] as const);
const invalid = checked.filter(([, each]) => 'invalid' in each).map(([column]) => column);
if (invalid.length > 0) return { invalid };
return { values: Object.fromEntries(checked.map(([column, each]) => [column, (each as { value: string }).value])) as Record<Column, string> };
}
/** A device's DHCP server, made as Java's constructor leaves it when the device has none in the document. */
function dhcpServerOf(node: Y.Map<unknown>): Y.Map<unknown> {
let server = settingsOf(node).get('dhcpServer') as Y.Map<unknown> | undefined;
if (server === undefined) {
server = toY({ ...dhcpServerConfiguration(), staticAssignments: [] }) as Y.Map<unknown>;
settingsOf(node).set('dhcpServer', server);
}
return server;
}
export type DhcpServerField = 'lowerBoundIPAddress' | 'upperBoundIPAddress' | 'gatewayIPAddress' | 'dnsServerIPAddress';
/** An address of a DHCP server (JDHCPKonfiguration: musterIpAdresse, never empty), as Java's IP.ipCheck writes it. */
export function setDhcpServerAddress(doc: Y.Doc, id: Id, field: DhcpServerField, text: string, origin: unknown): CheckedField {
return setChecked(doc, id, checkField('ipAddress', text), (node, value) => dhcpServerOf(node).set(field, value), origin);
}
/** A DHCP server switched on or off, or handing out its own gateway and DNS server instead of the device's. */
export function setDhcpServerFlag(doc: Y.Doc, id: Id, flag: 'isActive' | 'useOwnSettings', on: boolean, origin: unknown): void {
const node = nodes(doc).get(id);
if (node !== undefined) doc.transact(() => dhcpServerOf(node).set(flag, on), origin);
}
export type StaticAssignmentColumn = 'macAddress' | 'ipAddress';
export const STATIC_ASSIGNMENT_COLUMNS: readonly StaticAssignmentColumn[] = ['macAddress', 'ipAddress'];
const staticRule = (column: StaticAssignmentColumn): FieldRule => (column === 'macAddress' ? 'macAddress' : 'ipAddress');
const staticAssignmentsOf = (node: Y.Map<unknown>) => {
const server = dhcpServerOf(node);
let list = server.get('staticAssignments') as Y.Array<Y.Map<unknown>> | undefined;
if (list === undefined) {
list = new Y.Array<Y.Map<unknown>>();
server.set('staticAssignments', list);
}
return list;
};
/** Whether another static assignment has this MAC address (Java compares ignoring case). */
function macTaken(list: Y.Array<Y.Map<unknown>>, mac: string, except: number): boolean {
return list.toArray().some((entry, index) => index !== except && (entry.get('macAddress') as string).toUpperCase() === mac.toUpperCase());
}
/** A static assignment of the DHCP server: once both cells pass and the MAC address has none yet. */
export function addDhcpStaticAssignment(
doc: Y.Doc,
id: Id,
cells: Readonly<Record<StaticAssignmentColumn, string>>,
assignmentId: Id,
origin: unknown,
): { readonly assignment: DhcpStaticAssignment } | { readonly invalid: readonly StaticAssignmentColumn[] } | { readonly error: 'macTaken' } {
const checked = checkRow(STATIC_ASSIGNMENT_COLUMNS, staticRule, cells);
const node = nodes(doc).get(id);
if ('invalid' in checked || node === undefined) return 'invalid' in checked ? checked : { invalid: [] };
const existing = settingsOf(node).get('dhcpServer') as Y.Map<unknown> | undefined;
const list = existing?.get('staticAssignments') as Y.Array<Y.Map<unknown>> | undefined;
if (list !== undefined && macTaken(list, checked.values.macAddress, -1)) return { error: 'macTaken' };
const assignment = { id: assignmentId, ...checked.values };
doc.transact(() => staticAssignmentsOf(node).push([toY(assignment) as Y.Map<unknown>]), origin);
return { assignment };
}
/** A cell of a static assignment; a MAC address another assignment has is not taken. */
export function setDhcpStaticCell(doc: Y.Doc, id: Id, index: number, column: StaticAssignmentColumn, text: string, origin: unknown): CheckedField {
const node = nodes(doc).get(id);
const list = (node?.get('settings') as Y.Map<unknown> | undefined)?.get('dhcpServer') as Y.Map<unknown> | undefined;
const entries = list?.get('staticAssignments') as Y.Array<Y.Map<unknown>> | undefined;
const checked = checkField(staticRule(column), text);
if (entries === undefined || index < 0 || index >= entries.length) return checked;
if ('value' in checked && column === 'macAddress' && macTaken(entries, checked.value, index)) return { invalid: true };
return setChecked(doc, id, checked, () => entries.get(index).set(column, (checked as { value: string }).value), origin);
}
export function removeDhcpStaticAssignment(doc: Y.Doc, id: Id, index: number, origin: unknown): void {
const node = nodes(doc).get(id);
const entries = ((node?.get('settings') as Y.Map<unknown> | undefined)?.get('dhcpServer') as Y.Map<unknown> | undefined)?.get('staticAssignments') as Y.Array<Y.Map<unknown>> | undefined;
if (entries === undefined || index < 0 || index >= entries.length) return;
doc.transact(() => entries.delete(index, 1), origin);
}
export type PortForwardingColumn = keyof GatewayPortForwardingRow;
export const PORT_FORWARDING_COLUMNS: readonly PortForwardingColumn[] = ['protocolValue', 'publicPortValue', 'lanIPAddress', 'lanPortValue'];
/** Java's protocols of a port forwarding row, as its dialog offers them. */
export const PORT_FORWARDING_PROTOCOLS = ['TCP', 'UDP'] as const;
/** Each column's rule (GatewayFirmware.validateData): a protocol, ports by musterPort, the LAN address by musterIpAdresse. */
const forwardingRule = (column: PortForwardingColumn): FieldRule => (column === 'lanIPAddress' ? 'ipAddress' : column === 'protocolValue' ? 'name' : 'port');
function checkForwardingRow(cells: Readonly<Record<PortForwardingColumn, string>>) {
const checked = checkRow(PORT_FORWARDING_COLUMNS, forwardingRule, cells);
const protocolTaken = (PORT_FORWARDING_PROTOCOLS as readonly string[]).includes(cells.protocolValue);
if ('invalid' in checked) return protocolTaken ? checked : { invalid: ['protocolValue' as const, ...checked.invalid] };
return protocolTaken ? checked : { invalid: ['protocolValue' as const] };
}
const forwardingOf = (node: Y.Map<unknown>) => {
let list = settingsOf(node).get('portForwarding') as Y.Array<Y.Map<unknown>> | undefined;
if (list === undefined) {
list = new Y.Array<Y.Map<unknown>>();
settingsOf(node).set('portForwarding', list);
}
return list;
};
/** A home router's port forwarding row, once all four cells pass (Java keeps any row; its firmware forwards only the valid ones). */
export function addPortForwarding(
doc: Y.Doc,
id: Id,
cells: Readonly<Record<PortForwardingColumn, string>>,
origin: unknown,
): { readonly row: GatewayPortForwardingRow } | { readonly invalid: readonly PortForwardingColumn[] } {
const checked = checkForwardingRow(cells);
const node = nodes(doc).get(id);
if ('invalid' in checked || node === undefined) return 'invalid' in checked ? checked : { invalid: [] };
const row = checked.values as GatewayPortForwardingRow;
doc.transact(() => forwardingOf(node).push([toY({ ...row }) as Y.Map<unknown>]), origin);
return { row };
}
export function setPortForwardingCell(doc: Y.Doc, id: Id, index: number, column: PortForwardingColumn, text: string, origin: unknown): CheckedField {
const checked: CheckedField =
column === 'protocolValue' ? ((PORT_FORWARDING_PROTOCOLS as readonly string[]).includes(text) ? { value: text } : { invalid: true }) : checkField(forwardingRule(column), text);
return setChecked(doc, id, checked, (node, value) => (settingsOf(node).get('portForwarding') as Y.Array<Y.Map<unknown>> | undefined)?.get(index)?.set(column, value), origin);
}
/** Removes a port forwarding row; the list goes with its last row, as the project keeps it. */
export function removePortForwarding(doc: Y.Doc, id: Id, index: number, origin: unknown): void {
const node = nodes(doc).get(id);
const list = node === undefined ? undefined : (settingsOf(node).get('portForwarding') as Y.Array<Y.Map<unknown>> | undefined);
if (node === undefined || list === undefined || index < 0 || index >= list.length) return;
doc.transact(() => {
list.delete(index, 1);
if (list.length === 0) settingsOf(node).delete('portForwarding');
}, origin);
}