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
@@ -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);
}