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:
@@ -4,7 +4,7 @@
|
||||
// beside the surface on a wide screen, below it on a phone.
|
||||
|
||||
import { expect, test, type Page } from '@playwright/test';
|
||||
import type { HostSheet, RouterSheet } from '@mdg-filius/doc';
|
||||
import type { HomeRouterSheet, HostSheet, RouterSheet } from '@mdg-filius/doc';
|
||||
import { drawing, onDevice, openEditor, serveEditor, sheetOf } from './editorPage.js';
|
||||
|
||||
let server: { readonly base: string; readonly close: () => Promise<void> };
|
||||
@@ -193,7 +193,7 @@ test("edits a router's forwarding table: a new row once its four cells pass, a c
|
||||
// Java's own rows: two addresses, two networks (the same twice: both cards are new), localhost.
|
||||
await expect(rows).toHaveCount(5);
|
||||
await sheet(page).getByRole('button', { name: 'Neuer Eintrag' }).click();
|
||||
await expect(sheet(page).getByText('Die Zeile wird übernommen, sobald alle vier Felder gültig sind.')).toBeVisible();
|
||||
await expect(sheet(page).getByText('Die Zeile wird übernommen, sobald alle Felder gültig sind.')).toBeVisible();
|
||||
await expect(field(page, 'route-destinationNetwork')).toHaveAttribute('aria-invalid', 'false');
|
||||
for (const [column, text] of [
|
||||
['destinationNetwork', '10.0.0.0'],
|
||||
@@ -206,7 +206,9 @@ test("edits a router's forwarding table: a new row once its four cells pass, a c
|
||||
expect(((await sheetOf(page, id)) as RouterSheet).routes).toEqual([]);
|
||||
await field(page, 'route-interfaceIPAddress').fill('192.168.0.10');
|
||||
await field(page, 'route-interfaceIPAddress').press('Enter');
|
||||
await expect.poll(async () => ((await sheetOf(page, id)) as RouterSheet).routes).toEqual([{ destinationNetwork: '10.0.0.0', subnetMask: '255.0.0.0', gateway: '192.168.0.1', interfaceIPAddress: '192.168.0.10' }]);
|
||||
await expect
|
||||
.poll(async () => ((await sheetOf(page, id)) as RouterSheet).routes)
|
||||
.toEqual([{ destinationNetwork: '10.0.0.0', subnetMask: '255.0.0.0', gateway: '192.168.0.1', interfaceIPAddress: '192.168.0.10' }]);
|
||||
await expect(sheet(page).locator('tr[data-manual="0"]')).toHaveAttribute('aria-selected', 'true');
|
||||
await field(page, 'route-gateway').fill('192.168.0.2');
|
||||
await field(page, 'route-gateway').press('Enter');
|
||||
@@ -250,6 +252,102 @@ test("shows a home router's LAN and WAN tabs, DHCP closing the WAN card's addres
|
||||
await expect(tabs.nth(1)).toHaveText('LAN: 192.168.7.1');
|
||||
});
|
||||
|
||||
test("sets up a PC's DHCP server on its own page: base settings as Java determines them, static assignments, back to the fields", async ({ page }) => {
|
||||
await openEditor(page, server.base, 'new');
|
||||
await page.locator('[data-palette="pc"]').click();
|
||||
const id = (await drawing(page)).nodes[0]!.id;
|
||||
await sheet(page).getByRole('button', { name: 'DHCP-Server einrichten' }).click();
|
||||
await expect(sheet(page).getByRole('heading')).toHaveText('DHCP-Server einrichten');
|
||||
await expect(field(page, 'ipAddress')).toBeHidden();
|
||||
await expect(field(page, 'dhcpGateway')).toHaveValue('0.0.0.0');
|
||||
await expect(field(page, 'dhcpGateway')).toHaveAttribute('readonly', '');
|
||||
await expect(field(page, 'dhcpSubnetMask')).toHaveValue('255.255.255.0');
|
||||
await field(page, 'lowerBound').fill('192.168.0.300');
|
||||
await field(page, 'lowerBound').press('Enter');
|
||||
await expect(sheet(page).getByRole('status')).toHaveText('Nicht übernommen – es gilt weiterhin 0.0.0.0.');
|
||||
await field(page, 'lowerBound').fill('192.168.000.100');
|
||||
await field(page, 'lowerBound').press('Enter');
|
||||
await expect(field(page, 'lowerBound')).toHaveValue('192.168.0.100');
|
||||
await sheet(page).getByLabel('Manuelle Einstellungen').check();
|
||||
await expect(field(page, 'dhcpGateway')).not.toHaveAttribute('readonly', '');
|
||||
await field(page, 'dhcpGateway').fill('192.168.0.1');
|
||||
await field(page, 'dhcpGateway').press('Enter');
|
||||
await sheet(page).getByLabel('DHCP aktivieren').check();
|
||||
await expect
|
||||
.poll(async () => ((await sheetOf(page, id)) as HostSheet).dhcpServer)
|
||||
.toMatchObject({ isActive: true, useOwnSettings: true, gateway: '192.168.0.1', lowerBoundIPAddress: '192.168.0.100' });
|
||||
|
||||
await sheet(page).getByRole('tab', { name: 'Statische Adresszuweisung' }).click();
|
||||
await sheet(page).getByRole('button', { name: 'Hinzufügen' }).click();
|
||||
await field(page, 'static-macAddress').fill('aa:bb:cc:dd:ee:01');
|
||||
await field(page, 'static-macAddress').press('Enter');
|
||||
await field(page, 'static-ipAddress').fill('192.168.0.50');
|
||||
await field(page, 'static-ipAddress').press('Enter');
|
||||
await expect(sheet(page).locator('table[data-rows="static"] tr[data-manual="0"] td').first()).toHaveText('AA:BB:CC:DD:EE:01');
|
||||
await sheet(page).getByRole('button', { name: 'Hinzufügen' }).click();
|
||||
await field(page, 'static-macAddress').fill('AA:BB:CC:DD:EE:01');
|
||||
await field(page, 'static-macAddress').press('Enter');
|
||||
await field(page, 'static-ipAddress').fill('192.168.0.51');
|
||||
await field(page, 'static-ipAddress').press('Enter');
|
||||
await expect(sheet(page).getByRole('alert')).toHaveText('Diese MAC-Adresse hat schon eine feste Adresse.');
|
||||
expect(((await sheetOf(page, id)) as HostSheet).dhcpServer.staticAssignments).toHaveLength(1);
|
||||
await sheet(page).locator('table[data-rows="static"] tr[data-manual="0"]').click();
|
||||
await sheet(page).getByRole('button', { name: 'Entfernen' }).click();
|
||||
await expect.poll(async () => ((await sheetOf(page, id)) as HostSheet).dhcpServer.staticAssignments).toEqual([]);
|
||||
|
||||
// Another device starts at its fields; back at the first, its page is closed too.
|
||||
await page.locator('[data-palette="pc"]').click();
|
||||
await expect(sheet(page).getByRole('heading')).toHaveText('Rechner');
|
||||
await expect(field(page, 'ipAddress')).toBeVisible();
|
||||
await choose(page, id);
|
||||
await expect(sheet(page)).toHaveAttribute('data-device', id);
|
||||
await sheet(page).getByRole('button', { name: 'DHCP-Server einrichten' }).click();
|
||||
await sheet(page).getByRole('button', { name: 'Zurück' }).click();
|
||||
await expect(sheet(page).getByRole('heading')).toHaveText('Rechner');
|
||||
await expect(field(page, 'ipAddress')).toBeVisible();
|
||||
await sheet(page).getByLabel('DHCP zur Konfiguration verwenden').check();
|
||||
await expect(sheet(page).getByRole('button', { name: 'DHCP-Server einrichten' })).toBeDisabled();
|
||||
});
|
||||
|
||||
test("keeps a home router's port forwarding on its own page, and shows its DHCP server with the LAN address as gateway", async ({ page }) => {
|
||||
await openEditor(page, server.base, 'new');
|
||||
await page.locator('[data-palette="gateway"]').click();
|
||||
const id = (await drawing(page)).nodes[0]!.id;
|
||||
await sheet(page).getByRole('button', { name: 'Portfreigaben' }).click();
|
||||
await expect(sheet(page).getByRole('heading')).toHaveText('Portfreigaben');
|
||||
await sheet(page).getByRole('button', { name: 'Neuer Eintrag' }).click();
|
||||
await sheet(page).locator('select[name="forwarding-protocolValue"]').selectOption('UDP');
|
||||
for (const [column, text] of [
|
||||
['publicPortValue', '53'],
|
||||
['lanIPAddress', '192.168.0.20'],
|
||||
['lanPortValue', '5353'],
|
||||
] as const) {
|
||||
await field(page, `forwarding-${column}`).fill(text);
|
||||
await field(page, `forwarding-${column}`).press('Enter');
|
||||
}
|
||||
await expect
|
||||
.poll(async () => ((await sheetOf(page, id)) as HomeRouterSheet).portForwarding)
|
||||
.toEqual([{ protocolValue: 'UDP', publicPortValue: '53', lanIPAddress: '192.168.0.20', lanPortValue: '5353', forwarded: true }]);
|
||||
await field(page, 'forwarding-lanPortValue').fill('05353');
|
||||
await field(page, 'forwarding-lanPortValue').press('Enter');
|
||||
await expect(sheet(page).getByRole('status')).toHaveText('Nicht übernommen – es gilt weiterhin 5353.');
|
||||
await sheet(page).locator('select[name="forwarding-protocolValue"]').selectOption('TCP');
|
||||
await expect.poll(async () => ((await sheetOf(page, id)) as HomeRouterSheet).portForwarding[0]!.protocolValue).toBe('TCP');
|
||||
await sheet(page).getByRole('button', { name: 'Eintrag löschen' }).click();
|
||||
await expect.poll(async () => ((await sheetOf(page, id)) as HomeRouterSheet).portForwarding).toEqual([]);
|
||||
await sheet(page).getByRole('button', { name: 'Zurück' }).click();
|
||||
|
||||
await sheet(page).getByRole('tab', { name: 'LAN: 192.168.0.10' }).click();
|
||||
await sheet(page).getByRole('button', { name: 'DHCP-Server einrichten' }).click();
|
||||
await expect(field(page, 'dhcpGateway')).toHaveValue('192.168.0.10');
|
||||
await expect(field(page, 'dhcpGateway')).toHaveAttribute('readonly', '');
|
||||
await expect(field(page, 'dhcpDnsServer')).not.toHaveAttribute('readonly', '');
|
||||
await expect(sheet(page).getByLabel('Manuelle Einstellungen')).toHaveCount(0);
|
||||
await sheet(page).getByRole('button', { name: 'Zurück' }).click();
|
||||
// Back on the tab it was opened from, as Java's panel stays behind its dialog.
|
||||
await expect(sheet(page).getByRole('tab', { name: 'LAN: 192.168.0.10' })).toHaveAttribute('aria-selected', 'true');
|
||||
});
|
||||
|
||||
test.describe('on a phone', () => {
|
||||
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true });
|
||||
|
||||
|
||||
@@ -251,6 +251,7 @@ export function Editor({ doc }: { readonly doc: Y.Doc }) {
|
||||
{/* Java shows the settings of the device that is chosen; closing them lets go of it, as a click beside it does. */}
|
||||
{selected === undefined ? null : (
|
||||
<DeviceSheet
|
||||
key={selected.id}
|
||||
doc={doc}
|
||||
id={selected.id}
|
||||
label={selected.label}
|
||||
|
||||
@@ -27,6 +27,25 @@
|
||||
"device.remoteLink": "Modem",
|
||||
"device.router": "Vermittlungsrechner",
|
||||
"device.unsupported": "Unbekanntes Gerät",
|
||||
"dhcp.active": "DHCP aktivieren",
|
||||
"dhcp.add": "Hinzufügen",
|
||||
"dhcp.basic": "Grundeinstellungen",
|
||||
"dhcp.dnsServer": "DNS-Server",
|
||||
"dhcp.gateway": "Gateway",
|
||||
"dhcp.ipAddress": "IP-Adresse",
|
||||
"dhcp.lowerBound": "Adress-Untergrenze",
|
||||
"dhcp.macAddress": "MAC-Adresse",
|
||||
"dhcp.macTaken": "Diese MAC-Adresse hat schon eine feste Adresse.",
|
||||
"dhcp.ownSettings": "Manuelle Einstellungen",
|
||||
"dhcp.ownSettingsHint": "Standardmäßig werden die gleichen Einstellungen für den DHCP-Server verwendet, die auf dem Host gelten. Eine abweichende Konfiguration ist jedoch möglich bei Aktivieren dieses Kästchens.",
|
||||
"dhcp.remove": "Entfernen",
|
||||
"dhcp.static": "Statische Adresszuweisung",
|
||||
"dhcp.subnetMask": "Netzmaske",
|
||||
"dhcp.upperBound": "Adress-Obergrenze",
|
||||
"forwarding.lanIPAddress": "LAN-Adresse",
|
||||
"forwarding.lanPortValue": "LAN-Port",
|
||||
"forwarding.protocolValue": "Protokoll",
|
||||
"forwarding.publicPortValue": "Portfreigabe",
|
||||
"menu.delete": "Löschen",
|
||||
"menu.removeAllCables": "Alle Verbindungen entfernen",
|
||||
"menu.removeCable": "Verbindung entfernen",
|
||||
@@ -37,6 +56,7 @@
|
||||
"router.cancel": "Abbrechen",
|
||||
"router.prompt": "Wählen Sie die Anzahl der Schnittstellen des Vermittlungsrechners aus!",
|
||||
"router.title": "Anzahl Schnittstellen",
|
||||
"sheet.back": "Zurück",
|
||||
"sheet.cable": "Kabelgebunden (LAN)",
|
||||
"sheet.cablePlugged": "Das Kabel ist noch eingesteckt.",
|
||||
"sheet.cards": "Netzwerkkarten",
|
||||
@@ -48,6 +68,7 @@
|
||||
"sheet.defaultGateway": "Gateway",
|
||||
"sheet.deleteEntry": "Eintrag löschen",
|
||||
"sheet.dhcpClient": "DHCP zur Konfiguration verwenden",
|
||||
"sheet.dhcpServer": "DHCP-Server einrichten",
|
||||
"sheet.dnsServer": "Domain Name Server",
|
||||
"sheet.empty": "(leer)",
|
||||
"sheet.forwardingTable": "Weiterleitungstabelle",
|
||||
@@ -61,11 +82,12 @@
|
||||
"sheet.name": "Name",
|
||||
"sheet.network": "Drahtloses Netzwerk (SSID)",
|
||||
"sheet.newEntry": "Neuer Eintrag",
|
||||
"sheet.newEntryHint": "Die Zeile wird übernommen, sobald alle vier Felder gültig sind.",
|
||||
"sheet.newEntryHint": "Die Zeile wird übernommen, sobald alle Felder gültig sind.",
|
||||
"sheet.nic": "Netzwerkkarte {number}",
|
||||
"sheet.notConnected": "Keine Verbindung",
|
||||
"sheet.notTaken": "Nicht übernommen – es gilt weiterhin {value}.",
|
||||
"sheet.port": "Port",
|
||||
"sheet.portForwarding": "Portfreigaben",
|
||||
"sheet.retention": "Gültigkeitsdauer SAT-Einträge (in Sek.)",
|
||||
"sheet.rip": "Automatisches Routing",
|
||||
"sheet.showAllEntries": "Alle Einträge anzeigen",
|
||||
|
||||
@@ -27,6 +27,25 @@
|
||||
"device.remoteLink": "Modem",
|
||||
"device.router": "Router",
|
||||
"device.unsupported": "Unknown device",
|
||||
"dhcp.active": "Activate DHCP",
|
||||
"dhcp.add": "Add",
|
||||
"dhcp.basic": "Base Settings",
|
||||
"dhcp.dnsServer": "DNS server",
|
||||
"dhcp.gateway": "Gateway",
|
||||
"dhcp.ipAddress": "IP Address",
|
||||
"dhcp.lowerBound": "Lower bound of address",
|
||||
"dhcp.macAddress": "MAC Address",
|
||||
"dhcp.macTaken": "This MAC address has a static address already.",
|
||||
"dhcp.ownSettings": "Manual configuration",
|
||||
"dhcp.ownSettingsHint": "Usually the same settings as configured for the host system will be used for the DHCP server as well. However, different settings are possible if this checkbox is selected.",
|
||||
"dhcp.remove": "Remove",
|
||||
"dhcp.static": "Static Address Assignment",
|
||||
"dhcp.subnetMask": "Netmask",
|
||||
"dhcp.upperBound": "Upper bound of address",
|
||||
"forwarding.lanIPAddress": "LAN Address",
|
||||
"forwarding.lanPortValue": "LAN Port",
|
||||
"forwarding.protocolValue": "Protocol",
|
||||
"forwarding.publicPortValue": "Mapped Port",
|
||||
"menu.delete": "Delete",
|
||||
"menu.removeAllCables": "Remove all cables",
|
||||
"menu.removeCable": "Remove cable",
|
||||
@@ -37,6 +56,7 @@
|
||||
"router.cancel": "Cancel",
|
||||
"router.prompt": "Choose number of NIC of the router!",
|
||||
"router.title": "Number of NIC",
|
||||
"sheet.back": "Back",
|
||||
"sheet.cable": "Cable (LAN)",
|
||||
"sheet.cablePlugged": "The cable is still plugged in.",
|
||||
"sheet.cards": "Network cards",
|
||||
@@ -48,6 +68,7 @@
|
||||
"sheet.defaultGateway": "Gateway",
|
||||
"sheet.deleteEntry": "Delete entry",
|
||||
"sheet.dhcpClient": "Use DHCP for configuration",
|
||||
"sheet.dhcpServer": "DHCP server setup",
|
||||
"sheet.dnsServer": "Domain Name Server",
|
||||
"sheet.empty": "(empty)",
|
||||
"sheet.forwardingTable": "Forwarding table",
|
||||
@@ -61,11 +82,12 @@
|
||||
"sheet.name": "Name",
|
||||
"sheet.network": "Wireless Network (SSID)",
|
||||
"sheet.newEntry": "New entry",
|
||||
"sheet.newEntryHint": "The row is taken once all four fields are valid.",
|
||||
"sheet.newEntryHint": "The row is taken once all fields are valid.",
|
||||
"sheet.nic": "NIC {number}",
|
||||
"sheet.notConnected": "No Connection",
|
||||
"sheet.notTaken": "Not taken – still {value}.",
|
||||
"sheet.port": "Port",
|
||||
"sheet.portForwarding": "Port Mapping",
|
||||
"sheet.retention": "Retention Time SAT Entries (Seconds)",
|
||||
"sheet.rip": "Automatic Routing",
|
||||
"sheet.showAllEntries": "Show all entries",
|
||||
|
||||
@@ -30,6 +30,7 @@ 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';
|
||||
|
||||
@@ -59,10 +60,16 @@ export interface DeviceSheetProps {
|
||||
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,
|
||||
@@ -77,16 +84,22 @@ export function DeviceSheet({ doc, id, label, origin, onChanged, onMessage, onCl
|
||||
return (
|
||||
<aside className="device-sheet" aria-label={t('sheet.title')} data-device={id}>
|
||||
<header>
|
||||
<img src={ICON_URLS[iconOf(sheet.kind, sheet.kind === 'networkSwitch' && sheet.cloud)]} alt="" />
|
||||
<h2>{title}</h2>
|
||||
{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. */}
|
||||
<div key={id}>
|
||||
{/* 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} />
|
||||
<HostFields sheet={sheet} label={label} change={change} onMessage={onMessage} onOpen={onOpen} />
|
||||
) : sheet.kind === 'networkSwitch' ? (
|
||||
<SwitchFields sheet={sheet} change={change} />
|
||||
) : sheet.kind === 'remoteLink' ? (
|
||||
@@ -94,16 +107,37 @@ export function DeviceSheet({ doc, id, label, origin, onChanged, onMessage, onCl
|
||||
) : sheet.kind === 'router' ? (
|
||||
<RouterFields sheet={sheet} change={change} onHighlight={onHighlight} />
|
||||
) : sheet.kind === 'gateway' ? (
|
||||
<HomeRouterFields sheet={sheet} change={change} onHighlight={onHighlight} />
|
||||
<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 }: { readonly sheet: HostSheet; readonly label: string; readonly change: Change; readonly onMessage: (message: string) => void }) {
|
||||
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') =>
|
||||
@@ -168,6 +202,12 @@ function HostFields({ sheet, label, change, onMessage }: { readonly sheet: HostS
|
||||
/>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
addRoute,
|
||||
checkField,
|
||||
ROUTE_COLUMNS,
|
||||
removeRoute,
|
||||
setDhcpClient,
|
||||
@@ -17,7 +16,6 @@ import {
|
||||
setRip,
|
||||
setRouteCell,
|
||||
setRouterGateway,
|
||||
type CheckedField,
|
||||
type HomeRouterSheet,
|
||||
type InterfaceSheet,
|
||||
type RouteColumn,
|
||||
@@ -25,26 +23,10 @@ import {
|
||||
} from '@mdg-filius/doc';
|
||||
import { useTranslate } from '../i18n/i18n.js';
|
||||
import { NameField, type Change } from './NameField.js';
|
||||
import { RowsEditor } from './RowsEditor.js';
|
||||
import { SheetTabs, type Tab } from './SheetTabs.js';
|
||||
import { SheetCheck, SheetField } from './SheetField.js';
|
||||
|
||||
interface Tab {
|
||||
readonly key: string;
|
||||
readonly label: string;
|
||||
readonly disabled?: boolean;
|
||||
}
|
||||
|
||||
function SheetTabs({ tabs, selected, onSelect }: { readonly tabs: readonly Tab[]; readonly selected: string; readonly onSelect: (key: string) => void }) {
|
||||
return (
|
||||
<div role="tablist" className="sheet-tabs">
|
||||
{tabs.map((tab) => (
|
||||
<button key={tab.key} type="button" role="tab" data-tab={tab.key} aria-selected={tab.key === selected} disabled={tab.disabled === true} onClick={() => onSelect(tab.key)}>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Reports the cable of the card whose tab is open, and none once the sheet goes. */
|
||||
function useHighlight(link: string | null, onHighlight: (link: string | null) => void): void {
|
||||
useEffect(() => {
|
||||
@@ -118,7 +100,17 @@ export function RouterFields({ sheet, change, onHighlight }: { readonly sheet: R
|
||||
);
|
||||
}
|
||||
|
||||
export function HomeRouterFields({ sheet, change, onHighlight }: { readonly sheet: HomeRouterSheet; readonly change: Change; readonly onHighlight: (link: string | null) => void }) {
|
||||
export function HomeRouterFields({
|
||||
sheet,
|
||||
change,
|
||||
onHighlight,
|
||||
onOpen,
|
||||
}: {
|
||||
readonly sheet: HomeRouterSheet;
|
||||
readonly change: Change;
|
||||
readonly onHighlight: (link: string | null) => void;
|
||||
readonly onOpen: (page: 'dhcp' | 'forwarding') => void;
|
||||
}) {
|
||||
const t = useTranslate();
|
||||
const [tab, setTab] = useState('general');
|
||||
const card = tab === 'lan' ? sheet.lan : tab === 'wan' ? sheet.wan : undefined;
|
||||
@@ -138,9 +130,21 @@ export function HomeRouterFields({ sheet, change, onHighlight }: { readonly shee
|
||||
<NameField name={sheet.name} change={change} />
|
||||
<GatewayField value={sheet.gateway} change={change} />
|
||||
<SheetCheck name="ipForwarding" label={t('sheet.ipForwarding')} checked={sheet.ipForwarding} disabled onChange={() => undefined} />
|
||||
<div className="sheet-buttons">
|
||||
<button type="button" onClick={() => onOpen('forwarding')}>
|
||||
{t('sheet.portForwarding')}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : tab === 'lan' ? (
|
||||
<InterfaceFields card={sheet.lan} change={change} />
|
||||
<>
|
||||
<InterfaceFields card={sheet.lan} change={change} />
|
||||
<div className="sheet-buttons">
|
||||
<button type="button" onClick={() => onOpen('dhcp')}>
|
||||
{t('sheet.dhcpServer')}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* DHCP configures the WAN card; its address and mask are closed meanwhile, as in Java. */}
|
||||
@@ -158,109 +162,35 @@ const EMPTY_ROW: Readonly<Record<RouteColumn, string>> = { destinationNetwork: '
|
||||
|
||||
/**
|
||||
* The forwarding table: all rows or the manual ones only (Java's "show all
|
||||
* entries", on at first), a manual row chosen to edit its cells or remove
|
||||
* it, one new row at a time, taken once all four cells pass (as Java keeps
|
||||
* its new row until Weiterleitungstabelle.validate takes it).
|
||||
* entries", on at first); the manual rows are kept with RowsEditor (a new
|
||||
* row once all four cells pass, as Java keeps its new row until
|
||||
* Weiterleitungstabelle.validate takes it).
|
||||
*/
|
||||
function RouteTable({ sheet, change }: { readonly sheet: RouterSheet; readonly change: Change }) {
|
||||
const t = useTranslate();
|
||||
const [showAll, setShowAll] = useState(true);
|
||||
const [chosen, setChosen] = useState<number | 'new' | null>(null);
|
||||
const [draft, setDraft] = useState<Record<RouteColumn, string>>(EMPTY_ROW);
|
||||
let manual = -1;
|
||||
const rows = sheet.table.map((row) => ({ row, manual: row.isEditable ? ++manual : null })).filter((each) => showAll || each.manual !== null);
|
||||
const editing = typeof chosen === 'number' ? sheet.routes[chosen] : undefined;
|
||||
|
||||
const draftCell =
|
||||
(column: RouteColumn) =>
|
||||
(text: string): CheckedField => {
|
||||
const checked = checkField(routeRule(column), text);
|
||||
if ('invalid' in checked) return checked;
|
||||
const next = { ...draft, [column]: checked.value };
|
||||
setDraft(next);
|
||||
if (ROUTE_COLUMNS.every((each) => 'value' in checkField(routeRule(each), next[each]))) {
|
||||
const added = change.run(() => addRoute(change.doc, change.id, next, change.origin));
|
||||
if ('route' in added) {
|
||||
setDraft(EMPTY_ROW);
|
||||
setChosen(sheet.routes.length);
|
||||
}
|
||||
}
|
||||
return checked;
|
||||
};
|
||||
|
||||
const rows = sheet.table
|
||||
.map((row) => ({
|
||||
cells: { destinationNetwork: row.destinationNetwork, subnetMask: row.subnetMask, gateway: row.nextHop, interfaceIPAddress: row.interfaceIPAddress },
|
||||
index: row.isEditable ? ++manual : null,
|
||||
}))
|
||||
.filter((row) => showAll || row.index !== null);
|
||||
return (
|
||||
<>
|
||||
<SheetCheck name="showAll" label={t('sheet.showAllEntries')} checked={showAll} onChange={setShowAll} />
|
||||
<div className="route-table-frame">
|
||||
<table className="route-table">
|
||||
<thead>
|
||||
<tr>
|
||||
{ROUTE_COLUMNS.map((column) => (
|
||||
<th key={column} scope="col">
|
||||
{t(`route.${column}`)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map(({ row, manual: index }, place) => (
|
||||
<tr key={place} data-manual={index === null ? undefined : String(index)} aria-selected={index !== null && chosen === index} onClick={index === null ? undefined : () => setChosen(index)}>
|
||||
{[row.destinationNetwork, row.subnetMask, row.nextHop, row.interfaceIPAddress].map((cell, column) => (
|
||||
// A manual cell the rule refuses (from a file) is marked, not mended.
|
||||
<td key={column} data-invalid={index !== null && 'invalid' in checkField(routeRule(ROUTE_COLUMNS[column]!), cell) ? 'true' : undefined}>
|
||||
{cell}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
{chosen === 'new' ? (
|
||||
<tr aria-selected data-new>
|
||||
{ROUTE_COLUMNS.map((column) => (
|
||||
<td key={column}>{draft[column]}</td>
|
||||
))}
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="sheet-buttons">
|
||||
<button type="button" onClick={() => setChosen('new')}>
|
||||
{t('sheet.newEntry')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={chosen === null}
|
||||
onClick={() => {
|
||||
if (chosen === 'new') setDraft(EMPTY_ROW);
|
||||
else if (chosen !== null) change.run(() => removeRoute(change.doc, change.id, chosen, change.origin));
|
||||
setChosen(null);
|
||||
}}
|
||||
>
|
||||
{t('sheet.deleteEntry')}
|
||||
</button>
|
||||
</div>
|
||||
{chosen === 'new' ? (
|
||||
<fieldset className="sheet-group" key="new">
|
||||
<p className="sheet-note">{t('sheet.newEntryHint')}</p>
|
||||
{ROUTE_COLUMNS.map((column) => (
|
||||
<SheetField key={column} name={`route-${column}`} label={t(`route.${column}`)} value={draft[column]} rule={routeRule(column)} inputMode="decimal" unfilled onCommit={draftCell(column)} />
|
||||
))}
|
||||
</fieldset>
|
||||
) : editing !== undefined ? (
|
||||
<fieldset className="sheet-group" key={`row-${String(chosen)}`}>
|
||||
{ROUTE_COLUMNS.map((column) => (
|
||||
<SheetField
|
||||
key={column}
|
||||
name={`route-${column}`}
|
||||
label={t(`route.${column}`)}
|
||||
value={editing[column]}
|
||||
rule={routeRule(column)}
|
||||
inputMode="decimal"
|
||||
onCommit={(text) => change.run(() => setRouteCell(change.doc, change.id, chosen as number, column, text, change.origin))}
|
||||
/>
|
||||
))}
|
||||
</fieldset>
|
||||
) : null}
|
||||
<RowsEditor
|
||||
name="route"
|
||||
columns={ROUTE_COLUMNS.map((column) => ({ key: column, label: t(`route.${column}`), rule: routeRule(column) }))}
|
||||
rows={rows}
|
||||
blank={EMPTY_ROW}
|
||||
keptCount={sheet.routes.length}
|
||||
newLabel={t('sheet.newEntry')}
|
||||
removeLabel={t('sheet.deleteEntry')}
|
||||
onAdd={(cells) => ({ taken: 'route' in change.run(() => addRoute(change.doc, change.id, cells, change.origin)) })}
|
||||
onSetCell={(index, column, text) => change.run(() => setRouteCell(change.doc, change.id, index, column, text, change.origin))}
|
||||
onRemove={(index) => change.run(() => removeRoute(change.doc, change.id, index, change.origin))}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
// A table of rows a person keeps (a router's manual routes, a DHCP server's
|
||||
// static assignments, a home router's port forwarding), after Java's tables
|
||||
// in `JWeiterleitungsTabelle`, `JDHCPKonfiguration` and `JPortForwardingDialog`:
|
||||
// the rows in a compact table, a row chosen to edit its cells or remove it, one
|
||||
// new row at a time, taken once all its cells pass. Rows the person does not
|
||||
// keep (Java's own routes) are shown greyed; a kept cell its rule refuses
|
||||
// (from a file) is marked, not mended.
|
||||
|
||||
import { useState } from 'react';
|
||||
import { checkField, type CheckedField, type FieldRule } from '@mdg-filius/doc';
|
||||
import { useTranslate } from '../i18n/i18n.js';
|
||||
import { SheetField } from './SheetField.js';
|
||||
|
||||
export interface RowsColumn<Key extends string> {
|
||||
readonly key: Key;
|
||||
readonly label: string;
|
||||
/** A text cell's rule; a choice instead offers these values. */
|
||||
readonly rule?: FieldRule;
|
||||
readonly options?: readonly string[];
|
||||
}
|
||||
|
||||
export interface Row<Key extends string> {
|
||||
readonly cells: Readonly<Record<Key, string>>;
|
||||
/** The row's place among the kept rows; null for a row the person does not keep. */
|
||||
readonly index: number | null;
|
||||
/** Kept cells to mark beyond their rules (a row Java would not use). */
|
||||
readonly marked?: boolean;
|
||||
}
|
||||
|
||||
export type AddOutcome = { readonly taken: true } | { readonly taken: false; readonly message?: string };
|
||||
|
||||
export interface RowsEditorProps<Key extends string> {
|
||||
/** A name for the browser tests, the start of each field's name. */
|
||||
readonly name: string;
|
||||
readonly columns: readonly RowsColumn<Key>[];
|
||||
readonly rows: readonly Row<Key>[];
|
||||
readonly blank: Readonly<Record<Key, string>>;
|
||||
readonly onAdd: (cells: Readonly<Record<Key, string>>) => AddOutcome;
|
||||
readonly onSetCell: (index: number, key: Key, text: string) => CheckedField;
|
||||
readonly onRemove: (index: number) => void;
|
||||
readonly newLabel: string;
|
||||
readonly removeLabel: string;
|
||||
/** The number of kept rows, to choose the row just taken. */
|
||||
readonly keptCount: number;
|
||||
}
|
||||
|
||||
export function RowsEditor<Key extends string>({ name, columns, rows, blank, onAdd, onSetCell, onRemove, newLabel, removeLabel, keptCount }: RowsEditorProps<Key>) {
|
||||
const t = useTranslate();
|
||||
const [chosen, setChosen] = useState<number | 'new' | null>(null);
|
||||
const [draft, setDraft] = useState<Record<Key, string>>({ ...blank });
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const editing = typeof chosen === 'number' ? rows.find((row) => row.index === chosen) : undefined;
|
||||
const passes = (column: RowsColumn<Key>, text: string) => (column.options !== undefined ? column.options.includes(text) : 'value' in checkField(column.rule!, text));
|
||||
|
||||
const take = (next: Record<Key, string>) => {
|
||||
setDraft(next);
|
||||
if (!columns.every((column) => passes(column, next[column.key]))) return;
|
||||
const outcome = onAdd(next);
|
||||
if (outcome.taken) {
|
||||
setDraft({ ...blank });
|
||||
setMessage(null);
|
||||
setChosen(keptCount);
|
||||
} else {
|
||||
setMessage(outcome.message ?? null);
|
||||
}
|
||||
};
|
||||
|
||||
const draftCell =
|
||||
(column: RowsColumn<Key>) =>
|
||||
(text: string): CheckedField => {
|
||||
const checked = checkField(column.rule!, text);
|
||||
if ('value' in checked) take({ ...draft, [column.key]: checked.value });
|
||||
return checked;
|
||||
};
|
||||
|
||||
const field = (column: RowsColumn<Key>, value: string, commit: (text: string) => CheckedField, unfilled: boolean) =>
|
||||
column.options !== undefined ? (
|
||||
<label key={column.key} className="sheet-field">
|
||||
<span>{column.label}</span>
|
||||
<select name={`${name}-${column.key}`} value={value} onChange={(event) => commit(event.target.value)}>
|
||||
{column.options.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
) : (
|
||||
<SheetField key={column.key} name={`${name}-${column.key}`} label={column.label} value={value} rule={column.rule!} inputMode="decimal" unfilled={unfilled} onCommit={commit} />
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="route-table-frame">
|
||||
<table className="route-table" data-rows={name}>
|
||||
<thead>
|
||||
<tr>
|
||||
{columns.map((column) => (
|
||||
<th key={column.key} scope="col">
|
||||
{column.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, place) => (
|
||||
<tr
|
||||
key={place}
|
||||
data-manual={row.index === null ? undefined : String(row.index)}
|
||||
aria-selected={row.index !== null && chosen === row.index}
|
||||
onClick={row.index === null ? undefined : () => setChosen(row.index)}
|
||||
>
|
||||
{columns.map((column) => (
|
||||
<td key={column.key} data-invalid={row.index !== null && (row.marked === true || !passes(column, row.cells[column.key])) ? 'true' : undefined}>
|
||||
{row.cells[column.key]}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
{chosen === 'new' ? (
|
||||
<tr aria-selected data-new>
|
||||
{columns.map((column) => (
|
||||
<td key={column.key}>{draft[column.key]}</td>
|
||||
))}
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="sheet-buttons">
|
||||
<button type="button" onClick={() => setChosen('new')}>
|
||||
{newLabel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={chosen === null}
|
||||
onClick={() => {
|
||||
if (chosen === 'new') setDraft({ ...blank });
|
||||
else if (chosen !== null) onRemove(chosen);
|
||||
setChosen(null);
|
||||
setMessage(null);
|
||||
}}
|
||||
>
|
||||
{removeLabel}
|
||||
</button>
|
||||
</div>
|
||||
{chosen === 'new' ? (
|
||||
<fieldset className="sheet-group" key="new">
|
||||
<p className="sheet-note">{t('sheet.newEntryHint')}</p>
|
||||
{columns.map((column) => field(column, draft[column.key], column.options !== undefined ? (text) => (take({ ...draft, [column.key]: text }), { value: text }) : draftCell(column), true))}
|
||||
{message === null ? null : (
|
||||
<p className="sheet-hint" role="alert">
|
||||
{message}
|
||||
</p>
|
||||
)}
|
||||
</fieldset>
|
||||
) : editing !== undefined ? (
|
||||
<fieldset className="sheet-group" key={`row-${String(chosen)}`}>
|
||||
{columns.map((column) => field(column, editing.cells[column.key], (text) => onSetCell(chosen as number, column.key, text), false))}
|
||||
</fieldset>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
// Pages of the device sheet that Java opens as dialogs: a DHCP server's
|
||||
// settings (JDHCPKonfiguration: base settings, static assignments) and a
|
||||
// home router's port forwarding (JPortForwardingDialog). Java takes a
|
||||
// dialog's fields with its OK button; here each field when it is left, as
|
||||
// on the rest of the sheet.
|
||||
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
addDhcpStaticAssignment,
|
||||
addPortForwarding,
|
||||
PORT_FORWARDING_COLUMNS,
|
||||
PORT_FORWARDING_PROTOCOLS,
|
||||
removeDhcpStaticAssignment,
|
||||
removePortForwarding,
|
||||
setDhcpServerAddress,
|
||||
setDhcpServerFlag,
|
||||
setDhcpStaticCell,
|
||||
setPortForwardingCell,
|
||||
STATIC_ASSIGNMENT_COLUMNS,
|
||||
type DhcpServerField,
|
||||
type DhcpServerSheet,
|
||||
type HomeRouterSheet,
|
||||
type PortForwardingColumn,
|
||||
type StaticAssignmentColumn,
|
||||
} from '@mdg-filius/doc';
|
||||
import { useTranslate } from '../i18n/i18n.js';
|
||||
import type { Change } from './NameField.js';
|
||||
import { RowsEditor, type RowsColumn } from './RowsEditor.js';
|
||||
import { SheetCheck, SheetField } from './SheetField.js';
|
||||
import { SheetTabs } from './SheetTabs.js';
|
||||
|
||||
export function DhcpServerPanel({ server, change }: { readonly server: DhcpServerSheet; readonly change: Change }) {
|
||||
const t = useTranslate();
|
||||
const [tab, setTab] = useState('basic');
|
||||
const address = (field: DhcpServerField) => (text: string) => change.run(() => setDhcpServerAddress(change.doc, change.id, field, text, change.origin));
|
||||
const columns: RowsColumn<StaticAssignmentColumn>[] = STATIC_ASSIGNMENT_COLUMNS.map((column) => ({ key: column, label: t(`dhcp.${column}`), rule: column }));
|
||||
return (
|
||||
<>
|
||||
<SheetTabs
|
||||
tabs={[
|
||||
{ key: 'basic', label: t('dhcp.basic') },
|
||||
{ key: 'static', label: t('dhcp.static') },
|
||||
]}
|
||||
selected={tab}
|
||||
onSelect={setTab}
|
||||
/>
|
||||
<div role="tabpanel" key={tab}>
|
||||
{tab === 'basic' ? (
|
||||
<>
|
||||
<SheetField name="lowerBound" label={t('dhcp.lowerBound')} value={server.lowerBoundIPAddress} rule="ipAddress" inputMode="decimal" onCommit={address('lowerBoundIPAddress')} />
|
||||
<SheetField name="upperBound" label={t('dhcp.upperBound')} value={server.upperBoundIPAddress} rule="ipAddress" inputMode="decimal" onCommit={address('upperBoundIPAddress')} />
|
||||
<SheetField name="dhcpSubnetMask" label={t('dhcp.subnetMask')} value={server.subnetMask} rule="name" readOnly onCommit={() => ({ invalid: true })} />
|
||||
{/* What the server hands out: shown as Java determines it, open only where the server's own setting counts. */}
|
||||
<SheetField
|
||||
name="dhcpGateway"
|
||||
label={t('dhcp.gateway')}
|
||||
value={server.gateway}
|
||||
rule="ipAddress"
|
||||
inputMode="decimal"
|
||||
readOnly={!server.gatewayEditable}
|
||||
onCommit={address('gatewayIPAddress')}
|
||||
/>
|
||||
<SheetField
|
||||
name="dhcpDnsServer"
|
||||
label={t('dhcp.dnsServer')}
|
||||
value={server.dnsServer}
|
||||
rule="ipAddress"
|
||||
inputMode="decimal"
|
||||
readOnly={!server.dnsServerEditable}
|
||||
onCommit={address('dnsServerIPAddress')}
|
||||
/>
|
||||
{server.ownSettingsOffered ? (
|
||||
<>
|
||||
<SheetCheck
|
||||
name="ownSettings"
|
||||
label={t('dhcp.ownSettings')}
|
||||
checked={server.useOwnSettings}
|
||||
onChange={(on) => change.run(() => setDhcpServerFlag(change.doc, change.id, 'useOwnSettings', on, change.origin))}
|
||||
/>
|
||||
{/* Java's tooltip; a finger has no hover. */}
|
||||
<p className="sheet-note">{t('dhcp.ownSettingsHint')}</p>
|
||||
</>
|
||||
) : null}
|
||||
<SheetCheck
|
||||
name="dhcpActive"
|
||||
label={t('dhcp.active')}
|
||||
checked={server.isActive}
|
||||
onChange={(on) => change.run(() => setDhcpServerFlag(change.doc, change.id, 'isActive', on, change.origin))}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<RowsEditor
|
||||
name="static"
|
||||
columns={columns}
|
||||
rows={server.staticAssignments.map((cells, index) => ({ cells, index }))}
|
||||
blank={{ macAddress: '', ipAddress: '' }}
|
||||
keptCount={server.staticAssignments.length}
|
||||
newLabel={t('dhcp.add')}
|
||||
removeLabel={t('dhcp.remove')}
|
||||
onAdd={(cells) => {
|
||||
const result = change.run(() => addDhcpStaticAssignment(change.doc, change.id, cells, crypto.randomUUID(), change.origin));
|
||||
return 'assignment' in result ? { taken: true } : { taken: false, ...('error' in result ? { message: t('dhcp.macTaken') } : {}) };
|
||||
}}
|
||||
onSetCell={(index, column, text) => change.run(() => setDhcpStaticCell(change.doc, change.id, index, column, text, change.origin))}
|
||||
onRemove={(index) => change.run(() => removeDhcpStaticAssignment(change.doc, change.id, index, change.origin))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const FORWARDING_RULES = { protocolValue: undefined, publicPortValue: 'port', lanIPAddress: 'ipAddress', lanPortValue: 'port' } as const;
|
||||
|
||||
/** A home router's port forwarding; a row Java's firmware would not forward (from a file) is marked. */
|
||||
export function PortForwardingPanel({ sheet, change }: { readonly sheet: HomeRouterSheet; readonly change: Change }) {
|
||||
const t = useTranslate();
|
||||
const columns: RowsColumn<PortForwardingColumn>[] = PORT_FORWARDING_COLUMNS.map((column) =>
|
||||
column === 'protocolValue'
|
||||
? { key: column, label: t(`forwarding.${column}`), options: PORT_FORWARDING_PROTOCOLS }
|
||||
: { key: column, label: t(`forwarding.${column}`), rule: FORWARDING_RULES[column] },
|
||||
);
|
||||
return (
|
||||
<RowsEditor
|
||||
name="forwarding"
|
||||
columns={columns}
|
||||
rows={sheet.portForwarding.map(({ forwarded, ...cells }, index) => ({ cells, index, marked: !forwarded }))}
|
||||
blank={{ protocolValue: 'TCP', publicPortValue: '', lanIPAddress: '', lanPortValue: '' }}
|
||||
keptCount={sheet.portForwarding.length}
|
||||
newLabel={t('sheet.newEntry')}
|
||||
removeLabel={t('sheet.deleteEntry')}
|
||||
onAdd={(cells) => ({ taken: 'row' in change.run(() => addPortForwarding(change.doc, change.id, cells, change.origin)) })}
|
||||
onSetCell={(index, column, text) => change.run(() => setPortForwardingCell(change.doc, change.id, index, column, text, change.origin))}
|
||||
onRemove={(index) => change.run(() => removePortForwarding(change.doc, change.id, index, change.origin))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// Java's tabs (JTabbedPane) in the device sheet: chips that wrap in a narrow sheet.
|
||||
|
||||
export interface Tab {
|
||||
readonly key: string;
|
||||
readonly label: string;
|
||||
readonly disabled?: boolean;
|
||||
}
|
||||
|
||||
export function SheetTabs({ tabs, selected, onSelect }: { readonly tabs: readonly Tab[]; readonly selected: string; readonly onSelect: (key: string) => void }) {
|
||||
return (
|
||||
<div role="tablist" className="sheet-tabs">
|
||||
{tabs.map((tab) => (
|
||||
<button key={tab.key} type="button" role="tab" data-tab={tab.key} aria-selected={tab.key === selected} disabled={tab.disabled === true} onClick={() => onSelect(tab.key)}>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -246,3 +246,9 @@
|
||||
padding-top: 13px;
|
||||
padding-bottom: 13px;
|
||||
}
|
||||
|
||||
.sheet-group .sheet-hint {
|
||||
margin: 4px 0;
|
||||
font-size: 13px;
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
@@ -66,6 +66,25 @@ const FROM_JAVA: Readonly<Record<string, string>> = {
|
||||
'route.subnetMask': 'jweiterleitungstabelle_msg4',
|
||||
'route.gateway': 'jweiterleitungstabelle_msg5',
|
||||
'route.interfaceIPAddress': 'jweiterleitungstabelle_msg6',
|
||||
'sheet.dhcpServer': 'jhostkonfiguration_msg8',
|
||||
'sheet.portForwarding': 'jgatewayconfiguration_msg19',
|
||||
'dhcp.lowerBound': 'jdhcpkonfiguration_msg1',
|
||||
'dhcp.upperBound': 'jdhcpkonfiguration_msg2',
|
||||
'dhcp.subnetMask': 'jdhcpkonfiguration_msg3',
|
||||
'dhcp.gateway': 'jdhcpkonfiguration_msg4',
|
||||
'dhcp.dnsServer': 'jdhcpkonfiguration_msg5',
|
||||
'dhcp.active': 'jdhcpkonfiguration_msg6',
|
||||
'dhcp.ownSettings': 'jdhcpkonfiguration_msg8',
|
||||
'dhcp.basic': 'jdhcpkonfiguration_msg10',
|
||||
'dhcp.static': 'jdhcpkonfiguration_msg11',
|
||||
'dhcp.macAddress': 'jdhcpkonfiguration_msg12',
|
||||
'dhcp.ipAddress': 'jdhcpkonfiguration_msg13',
|
||||
'dhcp.add': 'jdhcpkonfiguration_msg14',
|
||||
'dhcp.remove': 'jdhcpkonfiguration_msg15',
|
||||
'forwarding.protocolValue': 'jportforwarding_msg4',
|
||||
'forwarding.publicPortValue': 'jportforwarding_msg5',
|
||||
'forwarding.lanIPAddress': 'jportforwarding_msg6',
|
||||
'forwarding.lanPortValue': 'jportforwarding_msg7',
|
||||
};
|
||||
|
||||
/** Java's words, mended: the prompt says "Vermittlungsrechnerns" (guicontainer_msg1). */
|
||||
|
||||
Reference in New Issue
Block a user