Files
mdg-filius/packages/editor/src/sheet/DeviceSheet.tsx
T
julian.kiedaischandClaude Opus 5.5 c8edb87210 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>
2026-10-03 12:16:04 +00:00

254 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// The device sheet (plan §7.1): the settings of the selected device, the
// fields of FILIUS' settings panel of its kind (JHostKonfiguration,
// JSwitchKonfiguration, JModemKonfiguration; a router's and a home router's
// in RouterSheets.tsx) in the panels' order. Java shows the panel when a
// device is chosen; so does the sheet, beside the surface or below it on a
// phone. Each change is one step of the person's undo.
import { useEffect, useState } from 'react';
import type * as Y from 'yjs';
import {
deviceSheet,
setDhcpClient,
setHostAddress,
setHostLabels,
setHostWireless,
setIpForwarding,
setModemPort,
setSwitchCloud,
setSwitchRetention,
setSwitchSsid,
type CheckedField,
type DeviceSheet as Sheet,
type HostSheet,
type ModemSheet,
type SwitchSheet,
type WirelessError,
} from '@mdg-filius/doc';
import { iconOf } from '../canvas/layout.js';
import { ICON_URLS } from '../canvas/icons.js';
import { useTranslate } from '../i18n/i18n.js';
import { NameField, type Change } from './NameField.js';
import { HomeRouterFields, RouterFields } from './RouterSheets.js';
import { DhcpServerPanel, PortForwardingPanel } from './ServicePanels.js';
import { SheetCheck, SheetField } from './SheetField.js';
import './sheet.css';
/** The device's sheet, read anew after every change of the document. */
function useDeviceSheet(doc: Y.Doc, id: string): Sheet | undefined {
const [sheet, setSheet] = useState(() => deviceSheet(doc, id));
useEffect(() => {
setSheet(deviceSheet(doc, id));
const update = () => setSheet(deviceSheet(doc, id));
doc.on('update', update);
return () => doc.off('update', update);
}, [doc, id]);
return sheet;
}
export interface DeviceSheetProps {
readonly doc: Y.Doc;
readonly id: string;
/** What the surface writes under the icon (a host's address or MAC address, when it shows them). */
readonly label: string;
readonly origin: object;
/** After each change: ends the undo step. */
readonly onChanged: () => void;
readonly onMessage: (message: string) => 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;
}
/** A page of the sheet that Java opens as a dialog. */
export type SheetPage = 'dhcp' | 'forwarding';
export function DeviceSheet({ doc, id, label, origin, onChanged, onMessage, onClose, onHighlight }: DeviceSheetProps) {
const t = useTranslate();
const sheet = useDeviceSheet(doc, id);
// The page open, if any; the editor makes a sheet per device, so another device starts at its fields.
const [open, setOpen] = useState<SheetPage | null>(null);
if (sheet === undefined) return null;
const onOpen = (each: SheetPage) => setOpen(each);
const change: Change = {
doc,
id,
origin,
run: (each) => {
const result = each();
onChanged();
return result;
},
};
const title = t(`device.${sheet.kind}`);
return (
<aside className="device-sheet" aria-label={t('sheet.title')} data-device={id}>
<header>
{open === null ? (
<img src={ICON_URLS[iconOf(sheet.kind, sheet.kind === 'networkSwitch' && sheet.cloud)]} alt="" />
) : (
<button type="button" className="sheet-close" onClick={() => setOpen(null)} aria-label={t('sheet.back')} title={t('sheet.back')}>
‹
</button>
)}
<h2>{open === 'dhcp' ? t('sheet.dhcpServer') : open === 'forwarding' ? t('sheet.portForwarding') : title}</h2>
<button type="button" className="sheet-close" onClick={onClose} aria-label={t('sheet.close')} title={t('sheet.close')}>
×
</button>
</header>
{/* A field per device: a text typed for one device is not offered to the next. Kept while a page is open, so that its tab stays as Java's panel does behind a dialog. */}
<div key={id} hidden={open !== null}>
{sheet.kind === 'pc' || sheet.kind === 'notebook' ? (
<HostFields sheet={sheet} label={label} change={change} onMessage={onMessage} onOpen={onOpen} />
) : sheet.kind === 'networkSwitch' ? (
<SwitchFields sheet={sheet} change={change} />
) : sheet.kind === 'remoteLink' ? (
<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} onOpen={onOpen} />
) : (
<NameField name={sheet.name} change={change} />
)}
</div>
{open === 'dhcp' && (sheet.kind === 'pc' || sheet.kind === 'notebook' || sheet.kind === 'gateway') ? (
<div key={`${id}-dhcp`}>
<DhcpServerPanel server={sheet.dhcpServer} change={change} />
</div>
) : open === 'forwarding' && sheet.kind === 'gateway' ? (
<div key={`${id}-forwarding`}>
<PortForwardingPanel sheet={sheet} change={change} />
</div>
) : null}
</aside>
);
}
function HostFields({
sheet,
label,
change,
onMessage,
onOpen,
}: {
readonly sheet: HostSheet;
readonly label: string;
readonly change: Change;
readonly onMessage: (message: string) => void;
readonly onOpen: (page: SheetPage) => void;
}) {
const t = useTranslate();
const address =
(field: 'ipAddress' | 'subnetMask' | 'defaultGateway' | 'dnsServer') =>
(text: string): CheckedField =>
change.run(() => setHostAddress(change.doc, change.id, field, text, change.origin));
// Java's list offers the switches' networks; an SSID none of them has shows as "please choose" (JHostKonfiguration.updateSsid).
const chosen = sheet.networks.includes(sheet.ssid) ? sheet.ssid : '';
const wireless = (on: boolean, ssid: string) => {
const result = change.run(() => setHostWireless(change.doc, change.id, on, ssid, crypto.randomUUID(), change.origin));
if ('error' in result) onMessage(wirelessMessage(t, result.error));
};
const labelled = sheet.labels.ipAddress || sheet.labels.macAddress;
return (
<>
{/* Java's name field shows what the surface writes, and is closed while that is the address. */}
<NameField name={sheet.name} shown={labelled ? label : sheet.name} disabled={labelled} change={change} />
<SheetField name="macAddress" label={t('sheet.macAddress')} value={sheet.macAddress} rule="name" readOnly onCommit={() => ({ invalid: true })} />
<SheetField name="ipAddress" label={t('sheet.ipAddress')} value={sheet.ipAddress} rule="ipAddress" inputMode="decimal" disabled={sheet.dhcpClient} onCommit={address('ipAddress')} />
<SheetField name="subnetMask" label={t('sheet.subnetMask')} value={sheet.subnetMask} rule="subnetMask" inputMode="decimal" disabled={sheet.dhcpClient} onCommit={address('subnetMask')} />
<SheetField
name="defaultGateway"
label={t('sheet.defaultGateway')}
value={sheet.defaultGateway}
rule="ipAddressOrEmpty"
inputMode="decimal"
disabled={sheet.dhcpClient}
onCommit={address('defaultGateway')}
/>
<SheetField name="dnsServer" label={t('sheet.dnsServer')} value={sheet.dnsServer} rule="ipAddressOrEmpty" inputMode="decimal" disabled={sheet.dhcpClient} onCommit={address('dnsServer')} />
<fieldset className="sheet-group" role="radiogroup" aria-label={t('sheet.connection')}>
<label className="sheet-check">
<input type="radio" name="connection" value="cable" checked={!sheet.wireless} onChange={() => wireless(false, chosen)} />
{t('sheet.cable')}
</label>
<label className={sheet.wirelessPossible ? 'sheet-check' : 'sheet-check disabled'}>
<input type="radio" name="connection" value="wireless" checked={sheet.wireless} disabled={!sheet.wirelessPossible} onChange={() => wireless(true, chosen)} />
{t('sheet.wireless')}
</label>
</fieldset>
<label className="sheet-field">
<span>{t('sheet.network')}</span>
<select name="ssid" value={chosen} disabled={!sheet.wireless} onChange={(event) => wireless(sheet.wireless, event.target.value)}>
<option value="">{t('sheet.chooseNetwork')}</option>
{sheet.networks.map((network) => (
<option key={network} value={network}>
{network}
</option>
))}
</select>
</label>
<SheetCheck
name="ipAsName"
label={t('sheet.ipAsName')}
checked={sheet.labels.ipAddress}
onChange={(on) => change.run(() => setHostLabels(change.doc, change.id, { ...sheet.labels, ipAddress: on }, change.origin))}
/>
<SheetCheck
name="macAsName"
label={t('sheet.macAsName')}
checked={sheet.labels.macAddress}
onChange={(on) => change.run(() => setHostLabels(change.doc, change.id, { ...sheet.labels, macAddress: on }, change.origin))}
/>
<SheetCheck name="ipForwarding" label={t('sheet.ipForwarding')} checked={sheet.ipForwarding} onChange={(on) => change.run(() => setIpForwarding(change.doc, change.id, on, change.origin))} />
<SheetCheck name="dhcpClient" label={t('sheet.dhcpClient')} checked={sheet.dhcpClient} onChange={(on) => change.run(() => setDhcpClient(change.doc, change.id, on, change.origin))} />
{/* Java closes the server's dialog while DHCP configures the card. */}
<div className="sheet-buttons">
<button type="button" disabled={sheet.dhcpClient} onClick={() => onOpen('dhcp')}>
{t('sheet.dhcpServer')}
</button>
</div>
</>
);
}
function wirelessMessage(t: ReturnType<typeof useTranslate>, error: WirelessError): string {
return error === 'cablePlugged' ? t('sheet.cablePlugged') : t(`cable.${error}`);
}
function SwitchFields({ sheet, change }: { readonly sheet: SwitchSheet; readonly change: Change }) {
const t = useTranslate();
return (
<>
<NameField name={sheet.name} change={change} />
<SheetField name="ssid" label={t('sheet.switchSsid')} value={sheet.ssid} rule="ssid" onCommit={(text) => change.run(() => setSwitchSsid(change.doc, change.id, text, change.origin))} />
<SheetCheck name="cloud" label={t('sheet.cloud')} checked={sheet.cloud} onChange={(on) => change.run(() => setSwitchCloud(change.doc, change.id, on, change.origin))} />
<SheetField
name="retention"
label={t('sheet.retention')}
value={String(sheet.retentionSeconds)}
rule="seconds"
inputMode="numeric"
onCommit={(text) => change.run(() => setSwitchRetention(change.doc, change.id, text, change.origin))}
/>
</>
);
}
function ModemFields({ sheet, change }: { readonly sheet: ModemSheet; readonly change: Change }) {
const t = useTranslate();
return (
<>
<NameField name={sheet.name} change={change} />
<SheetField
name="port"
label={t('sheet.port')}
value={String(sheet.port)}
rule="port"
inputMode="numeric"
onCommit={(text) => change.run(() => setModemPort(change.doc, change.id, text, change.origin))}
/>
</>
);
}