feat(editor): F4.5e a router's connections in the device sheet

SheetRun now clicks through Java's JConnectionsDialog with a router, a
computer and a switch cabled in the GUIContainer: a click marks a card,
the same click unmarks it, another card swaps the cables or moves the
one; "+" adds 192.168.0.10/24 up to eight cards, "-" removes the last
card with its cable down to none, and a router without a card keeps no
gateway.

The document adds, removes and swaps a router's cards (the same cables,
the same devices), Java's click logic is one function for the test and
the page, and the sheet names the address at a cable's far end. The
page "Verbindungen verwalten" shows Java's RJ45 buttons in Java's
colours with each card's far end below. Not adopted: the last card
stays. A card tab or a mark whose card is undone away falls back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 13:12:27 +00:00
co-authored by Claude Opus 5.5
parent 4e062bb347
commit 6e97e3565d
21 changed files with 2780 additions and 41 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 714 B

+4
View File
@@ -7,7 +7,11 @@ import laptop from '../assets/hardware/laptop.png';
import modem from '../assets/hardware/modem.png';
import router from '../assets/hardware/router.png';
import server from '../assets/hardware/server.png';
import rj45 from '../assets/hardware/rj45.png';
import networkSwitch from '../assets/hardware/switch.png';
import type { IconName } from './layout.js';
export const ICON_URLS: Readonly<Record<IconName, string>> = { cloud, gateway, laptop, modem, router, server, switch: networkSwitch };
/** A card's socket, on the buttons of a router's connections (JConnectionsDialog). */
export const RJ45_URL = rj45;
+8
View File
@@ -11,6 +11,13 @@
"cable.wanOccupied": "WAN-Anschluss (extern) bereits belegt!",
"cable.wirelessNotPossible": "Drahtlose Verbindung nicht möglich",
"canvas.label": "Netzwerk",
"connections.add": "Schnittstelle hinzufügen",
"connections.fewest": "Eine Schnittstelle bleibt immer.",
"connections.hint": "Hinweis: Zum Umstecken von Verbindungen:\n1. Verbindung auswählen durch Klick auf Schaltfläche links neben der Schnittstellenbezeichnung\n2. Auswählen der neuen Schnittstelle durch Klick auf die entsprechende Schaltfläche\nSollte die Zielschnittstelle bereits belegt sein, so werden die Verbindungen vertauscht.",
"connections.local": "Lokale Schnittstellen",
"connections.most": "Maximal 8 Schnittstellen erlaubt!",
"connections.nic": "NIC {number}: {address}",
"connections.remove": "Schnittstelle entfernen",
"design.actions": "Aktionen",
"design.palette": "Komponenten",
"design.redo": "Wiederholen",
@@ -88,6 +95,7 @@
"sheet.cloud": "als Wolke darstellen",
"sheet.connectedTo": "Verbunden mit {device}",
"sheet.connection": "Anschluss",
"sheet.connections": "Verbindungen verwalten",
"sheet.defaultGateway": "Gateway",
"sheet.deleteEntry": "Eintrag löschen",
"sheet.dhcpClient": "DHCP zur Konfiguration verwenden",
+8
View File
@@ -11,6 +11,13 @@
"cable.wanOccupied": "WAN port (external) already occupied!",
"cable.wirelessNotPossible": "Wireless connection not possible",
"canvas.label": "Network",
"connections.add": "Add interface",
"connections.fewest": "One interface always stays.",
"connections.hint": "Note: For reorganising cable connections:\n1. Choose cable connection by clicking the button next to the interface name\n2. Choose new interface by clicking according button\nIf the target interface is already occupied by another connection, then those cables will be swapped.",
"connections.local": "Local interfaces",
"connections.most": "At most 8 interfaces supported!",
"connections.nic": "NIC {number}: {address}",
"connections.remove": "Remove interface",
"design.actions": "Actions",
"design.palette": "Components",
"design.redo": "Redo",
@@ -88,6 +95,7 @@
"sheet.cloud": "Display as Cloud",
"sheet.connectedTo": "Connected to {device}",
"sheet.connection": "Connection",
"sheet.connections": "Manage connections",
"sheet.defaultGateway": "Gateway",
"sheet.deleteEntry": "Delete entry",
"sheet.dhcpClient": "Use DHCP for configuration",
@@ -0,0 +1,92 @@
// A router's connections as a page of the device sheet, after Java's dialog
// (JConnectionsDialog): each card with its RJ45 button left of its name and,
// below, what its cable joins; a card's button marks it, another card's
// button then swaps the two cards' cables (or moves the one), as Java's
// buttons do; "+" adds a card after the last, "−" removes the last with its
// cable — down to one card, not to none as in Java. Java's colours on the
// buttons: green plugged, red free, yellow marked. Java draws its cables
// between two columns; a narrow sheet has room for one.
import { useState } from 'react';
import { addRouterInterface, connectionsClick, removeRouterInterface, ROUTER_INTERFACES, swapRouterCables, type RouterSheet } from '@mdg-filius/doc';
import { RJ45_URL } from '../canvas/icons.js';
import { useTranslate } from '../i18n/i18n.js';
import type { Change } from './NameField.js';
import { SheetNote } from './SheetField.js';
export function ConnectionsPanel({ sheet, change }: { readonly sheet: RouterSheet; readonly change: Change }) {
const t = useTranslate();
const [chosen, setChosen] = useState<number | null>(null);
const cards = sheet.interfaces;
// A card another person removed is no longer marked.
const marked = chosen !== null && chosen < cards.length ? chosen : null;
const click = (index: number) => {
const next = connectionsClick(marked, index);
if (next.swap !== undefined) {
const [first, second] = next.swap;
change.run(() => swapRouterCables(change.doc, change.id, cards[first]!.portId, cards[second]!.portId, change.origin));
}
setChosen(next.marked);
};
return (
<>
<h3 className="sheet-subtitle">{t('connections.local')}</h3>
<ol className="connections">
{cards.map((card, index) => {
const name = t('connections.nic', { number: index + 1, address: card.ipAddress });
const joined = card.connected;
return (
<li key={card.portId} data-state={index === marked ? 'marked' : joined === null ? 'free' : 'plugged'}>
<button
type="button"
className="connection-port"
aria-pressed={index === marked}
aria-label={joined === null ? `${name}, ${t('sheet.notConnected')}` : name}
onClick={() => click(index)}
>
<img src={RJ45_URL} alt="" />
</button>
<span className="connection-local">{name}</span>
{joined === null ? null : <span className="connection-remote">{joined.address === null ? joined.device : `${joined.device} (${joined.address})`}</span>}
</li>
);
})}
</ol>
<div className="sheet-buttons">
<button
type="button"
name="addInterface"
aria-label={t('connections.add')}
title={t('connections.add')}
disabled={cards.length >= ROUTER_INTERFACES.maximum}
onClick={() => {
change.run(() => addRouterInterface(change.doc, change.id, crypto.randomUUID(), change.origin));
setChosen(null);
}}
>
+
</button>
<button
type="button"
name="removeInterface"
aria-label={t('connections.remove')}
title={t('connections.remove')}
disabled={cards.length <= ROUTER_INTERFACES.fewest}
onClick={() => {
change.run(() => removeRouterInterface(change.doc, change.id, change.origin));
setChosen(null);
}}
>
−
</button>
</div>
{cards.length >= ROUTER_INTERFACES.maximum ? (
<p className="sheet-note">{t('connections.most')}</p>
) : cards.length <= ROUTER_INTERFACES.fewest ? (
<p className="sheet-note">{t('connections.fewest')}</p>
) : null}
{/* Below the cards, as in Java's dialog. */}
<SheetNote text={t('connections.hint')} />
</>
);
}
+9 -2
View File
@@ -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 { ConnectionsPanel } from './ConnectionsPanel.js';
import { FirewallPanel } from './FirewallPanel.js';
import { DhcpServerPanel, PortForwardingPanel } from './ServicePanels.js';
import { SheetCheck, SheetField } from './SheetField.js';
@@ -62,7 +63,9 @@ export interface DeviceSheetProps {
}
/** A page of the sheet that Java opens as a dialog. */
export type SheetPage = 'dhcp' | 'forwarding' | 'firewall';
export type SheetPage = 'dhcp' | 'forwarding' | 'firewall' | 'connections';
const PAGE_TITLES: Readonly<Record<SheetPage, string>> = { dhcp: 'sheet.dhcpServer', forwarding: 'sheet.portForwarding', firewall: 'sheet.firewall', connections: 'sheet.connections' };
export function DeviceSheet({ doc, id, label, origin, onChanged, onMessage, onClose, onHighlight }: DeviceSheetProps) {
const t = useTranslate();
@@ -92,7 +95,7 @@ export function DeviceSheet({ doc, id, label, origin, onChanged, onMessage, onCl
‹
</button>
)}
<h2>{open === 'dhcp' ? t('sheet.dhcpServer') : open === 'forwarding' ? t('sheet.portForwarding') : open === 'firewall' ? t('sheet.firewall') : title}</h2>
<h2>{open === null ? title : t(PAGE_TITLES[open])}</h2>
<button type="button" className="sheet-close" onClick={onClose} aria-label={t('sheet.close')} title={t('sheet.close')}>
×
</button>
@@ -121,6 +124,10 @@ export function DeviceSheet({ doc, id, label, origin, onChanged, onMessage, onCl
<div key={`${id}-firewall`}>
<FirewallPanel firewall={sheet.firewall} change={change} />
</div>
) : open === 'connections' && sheet.kind === 'router' ? (
<div key={`${id}-connections`}>
<ConnectionsPanel sheet={sheet} change={change} />
</div>
) : open === 'forwarding' && sheet.kind === 'gateway' ? (
<div key={`${id}-forwarding`}>
<PortForwardingPanel sheet={sheet} change={change} />
+5 -18
View File
@@ -22,7 +22,7 @@ import {
import type { FirewallAction, FirewallProtocol, FirewallRule } from '@mdg-filius/core';
import { useTranslate } from '../i18n/i18n.js';
import type { Change } from './NameField.js';
import { SheetCheck, SheetField } from './SheetField.js';
import { SheetCheck, SheetField, SheetNote } from './SheetField.js';
import { SheetTabs } from './SheetTabs.js';
/** Java's protocol names in the rule table (ruleAsVector): "*" for all. */
@@ -32,19 +32,6 @@ const OFFERED: readonly FirewallProtocol[] = ['all', 'tcp', 'udp'];
const cellText = (rule: FirewallRule, column: (typeof FIREWALL_RULE_COLUMNS)[number]) => (column === 'port' ? (rule.port < 0 ? '' : String(rule.port)) : rule[column]);
/** A text with Java's line breaks as paragraphs. */
function Note({ text }: { readonly text: string }) {
return (
<>
{text.split('\n').map((line, index) => (
<p key={index} className="sheet-note">
{line}
</p>
))}
</>
);
}
export function FirewallPanel({ firewall, change }: { readonly firewall: FirewallSheet; readonly change: Change }) {
const t = useTranslate();
const [tab, setTab] = useState('general');
@@ -68,15 +55,15 @@ export function FirewallPanel({ firewall, change }: { readonly firewall: Firewal
{tab === 'general' ? (
<>
<SheetCheck name="firewallActive" label={t('firewall.active')} checked={firewall.isActive} onChange={flag('isActive')} />
<Note text={t('firewall.activeHint')} />
<SheetNote text={t('firewall.activeHint')} />
<SheetCheck name="dropIcmp" label={t('firewall.dropIcmp')} checked={firewall.dropIcmp} onChange={flag('dropIcmp')} />
<Note text={t('firewall.dropIcmpHint')} />
<SheetNote text={t('firewall.dropIcmpHint')} />
<SheetCheck name="filterSyn" label={t('firewall.filterSyn')} checked={firewall.filterSynSegmentsOnly} onChange={flag('filterSynSegmentsOnly')} />
<Note text={t('firewall.filterSynHint')} />
<SheetNote text={t('firewall.filterSynHint')} />
</>
) : (
<>
<Note text={t('firewall.intro')} />
<SheetNote text={t('firewall.intro')} />
<label className="sheet-field">
<span>{t('firewall.defaultPolicy')}</span>
<select
+10 -5
View File
@@ -3,8 +3,9 @@
// general settings, each card and — on a router — the forwarding table
// (JWeiterleitungsTabelle), greyed out while automatic routing (RIP) runs.
// The card whose tab is open has its cable drawn active, as Java draws it.
// The firewall, the port forwarding, the DHCP server and the change of the
// cards follow with their dialogs (F4.5c).
// The firewall, the port forwarding, the DHCP server and the router's
// connections open as pages of the sheet (ServicePanels, FirewallPanel,
// ConnectionsPanel).
import { useEffect, useState } from 'react';
import {
@@ -74,12 +75,13 @@ export function RouterFields({
readonly sheet: RouterSheet;
readonly change: Change;
readonly onHighlight: (link: string | null) => void;
readonly onOpen: (page: 'firewall') => void;
readonly onOpen: (page: 'firewall' | 'connections') => void;
}) {
const t = useTranslate();
const [tab, setTab] = useState('general');
// Java greys the table out while RIP runs; an open table goes back to the general tab.
const shown = tab === 'table' && sheet.rip ? 'general' : tab;
// Java greys the table out while RIP runs; an open table goes back to the general tab, as does the tab of a card removed.
const gone = tab.startsWith('card-') && Number(tab.slice(5)) >= sheet.interfaces.length;
const shown = (tab === 'table' && sheet.rip) || gone ? 'general' : tab;
const card = shown.startsWith('card-') ? sheet.interfaces[Number(shown.slice(5))] : undefined;
useHighlight(card?.connected?.link ?? null, onHighlight);
const tabs: Tab[] = [
@@ -103,6 +105,9 @@ export function RouterFields({
<button type="button" onClick={() => onOpen('firewall')}>
{t('sheet.firewall')}
</button>
<button type="button" onClick={() => onOpen('connections')}>
{t('sheet.connections')}
</button>
</div>
</>
) : card !== undefined ? (
+13
View File
@@ -110,3 +110,16 @@ export function SheetCheck({
</label>
);
}
/** A text with Java's line breaks as paragraphs. */
export function SheetNote({ text }: { readonly text: string }) {
return (
<>
{text.split('\n').map((line, index) => (
<p key={index} className="sheet-note">
{line}
</p>
))}
</>
);
}
+64
View File
@@ -252,3 +252,67 @@
font-size: 13px;
color: var(--danger);
}
.sheet-subtitle {
margin: 12px 0 4px;
font-size: 14px;
font-weight: 600;
}
/* A router's cards: Java's RJ45 button left of the card's name, what its
* cable joins below, joined by a line like Java's cable. */
.connections {
margin: 0;
padding: 0;
list-style: none;
}
.connections li {
display: grid;
grid-template-columns: 52px 1fr;
column-gap: 10px;
align-items: center;
padding: 4px 0;
}
.connection-port {
grid-row: span 2;
width: 52px;
height: 44px;
padding: 0;
border: 1px solid var(--border-strong);
border-radius: 8px;
cursor: pointer;
}
.connections li[data-state='plugged'] .connection-port {
background: var(--connection-plugged);
}
.connections li[data-state='free'] .connection-port {
background: var(--connection-free);
}
.connections li[data-state='marked'] .connection-port {
background: var(--connection-marked);
outline: 2px solid var(--border-focus);
outline-offset: 1px;
}
.connection-port img {
display: block;
margin: auto;
}
.connection-local {
font-size: 14px;
font-variant-numeric: tabular-nums;
}
.connection-remote {
padding-left: 10px;
border-left: 2px solid var(--canvas-cable);
font-size: 13px;
color: var(--text-secondary);
}
+7
View File
@@ -33,6 +33,10 @@
/* Java's active cable (JCablePanel.farbeBlinken): a frame on its way, or the card whose tab is open. */
--canvas-cable-active: rgb(0 255 64);
--canvas-label: #000000;
/* Java's buttons of a router's connections (JConnectionsDialog): a card plugged, free, marked. */
--connection-plugged: rgb(0 255 0);
--connection-free: rgb(255 0 0);
--connection-marked: rgb(255 255 0);
}
:root[data-theme='dark'] {
@@ -64,6 +68,9 @@
--canvas-cable-wireless: rgb(150 150 150);
--canvas-cable-active: rgb(0 200 50);
--canvas-label: #000000;
--connection-plugged: rgb(0 190 0);
--connection-free: rgb(210 40 40);
--connection-marked: rgb(235 215 0);
}
html,