feat: F4.6g5 the switch and NAT tables and "Pakete verwerfen", measured against Java's

ViewersRun takes the rows of Java's viewers from the firmware: a switch's
table, a home router's NAT table, each after its reset, and the frames
"Pakete verwerfen" drops while it is held. The core writes a frame as Java
does and resets the NAT's dynamic entries; the room sends both tables as
views, drops while any client holds the button, and sends the dropped
frames, begun anew with each start, with the clock their times of day are
written from; the editor opens a table with a tap on a switch or home
router and holds "Pakete verwerfen" in its toolbar, which now wraps on a
phone. This completes F4.6.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 22:13:48 +00:00
co-authored by Claude Opus 5.5
parent 5224e1a28b
commit 6809a71300
31 changed files with 1370 additions and 31 deletions
+37 -11
View File
@@ -22,6 +22,8 @@ import { DeviceSheet } from './sheet/DeviceSheet.js';
import { DesktopWindow, type DesktopWindowName } from './desktop/DesktopWindow.js';
import { useLanguage, useTranslate } from './i18n/i18n.js';
import { ModeBar } from './simulation/ModeBar.js';
import { DropControl } from './simulation/DropControl.js';
import { ViewerWindow, type ViewerKind } from './simulation/ViewerWindow.js';
import { useSimulation, type SimHostFactory } from './simulation/useSimulation.js';
/** The document's drawing, read anew after every change. */
@@ -73,9 +75,10 @@ const toolButton: CSSProperties = {
/** Whether a key press belongs to a field being typed in. */
const typing = (target: EventTarget | null) => target instanceof HTMLElement && (target.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName));
/** A desktop open on the surface: whose, where its window lies, and how far up (the last one touched on top). */
/** A desktop open on the surface — or a switch's or home router's viewer (`viewer`) —: whose, where its window lies, and how far up (the last one touched on top). */
interface OpenDesktop {
readonly id: string;
readonly viewer?: ViewerKind;
readonly x: number;
readonly y: number;
readonly z: number;
@@ -130,18 +133,22 @@ export function Editor({ doc, createSimHost }: { readonly doc: Y.Doc; readonly c
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [simulation.failed]);
// A desktop whose computer is gone, or is no computer any more, closes (Java: reopenDialogs).
// A desktop whose computer is gone, or is no computer any more, closes (Java: reopenDialogs); so does a viewer whose switch or home router is.
useEffect(() => {
const computers = new Set(view.nodes.filter((node) => node.kind === 'pc' || node.kind === 'notebook').map((node) => node.id));
setDesktops((open) => (open.every((each) => computers.has(each.id)) ? open : open.filter((each) => computers.has(each.id))));
const kinds = new Map(view.nodes.map((node) => [node.id, node.kind]));
const fits = (each: OpenDesktop) => {
const kind = kinds.get(each.id);
return each.viewer === 'sat' ? kind === 'networkSwitch' : each.viewer === 'nat' ? kind === 'gateway' : kind === 'pc' || kind === 'notebook';
};
setDesktops((open) => (open.every(fits) ? open : open.filter(fits)));
}, [view]);
/** Opens a computer's desktop, or brings it to the front. */
const openDesktop = useCallback((id: string) => {
/** Opens a computer's desktop, or a switch's or home router's viewer, or brings it to the front. */
const openDesktop = useCallback((id: string, viewer?: ViewerKind) => {
setDesktops((open) => {
if (open.some((each) => each.id === id)) return toFront(open, id);
// Java stacks a desktop that does not fit beside the others a little further on.
return [...open, { id, x: 24 + 24 * open.length, y: 76 + 24 * open.length, z: Math.max(0, ...open.map((each) => each.z)) + 1, program: null }];
return [...open, { id, ...(viewer === undefined ? {} : { viewer }), x: 24 + 24 * open.length, y: 76 + 24 * open.length, z: Math.max(0, ...open.map((each) => each.z)) + 1, program: null }];
});
}, []);
@@ -275,6 +282,9 @@ export function Editor({ doc, createSimHost }: { readonly doc: Y.Doc; readonly c
onActivate={(id) => {
const kind = view.nodes.find((node) => node.id === id)?.kind;
if (kind === 'pc' || kind === 'notebook') openDesktop(id);
// Java's click on a switch shows its table, on a home router its NAT table.
else if (kind === 'networkSwitch') openDesktop(id, 'sat');
else if (kind === 'gateway') openDesktop(id, 'nat');
}}
onMenu={(target, at) => {
if (simulating) {
@@ -285,9 +295,9 @@ export function Editor({ doc, createSimHost }: { readonly doc: Y.Doc; readonly c
setMenu({ target, at });
}}
/>
<div role="toolbar" style={{ position: 'absolute', left: 12, top: 12, zIndex: 50, display: 'flex', gap: 6, alignItems: 'center' }}>
<div role="toolbar" className="editor-toolbar">
<ModeBar mode={simulation.mode} speed={simulation.speed} onMode={(mode) => (mode === 'simulation' ? simulation.start(language) : simulation.stop())} onSpeed={simulation.setSpeed} />
{simulating ? null : (
{simulating ? <DropControl simulation={simulation} /> : (
<>
<button type="button" style={toolButton} disabled={!canUndo} onClick={() => manager?.undo()} aria-label={t('design.undo')} title={t('design.undo')}>
↶
@@ -299,7 +309,22 @@ export function Editor({ doc, createSimHost }: { readonly doc: Y.Doc; readonly c
)}
</div>
{simulating
? desktops.map((desktop) => (
? desktops.map((desktop) =>
desktop.viewer !== undefined ? (
<ViewerWindow
key={desktop.id}
kind={desktop.viewer}
id={desktop.id}
label={view.nodes.find((node) => node.id === desktop.id)?.label ?? ''}
simulation={simulation}
x={desktop.x}
y={desktop.y}
z={desktop.z}
onMove={(x, y) => setDesktops((open) => open.map((each) => (each.id === desktop.id ? { ...each, x, y } : each)))}
onFront={() => setDesktops((open) => toFront(open, desktop.id))}
onClose={() => setDesktops((open) => open.filter((each) => each.id !== desktop.id))}
/>
) : (
<DesktopWindow
key={desktop.id}
simulation={simulation}
@@ -316,7 +341,8 @@ export function Editor({ doc, createSimHost }: { readonly doc: Y.Doc; readonly c
onFront={() => setDesktops((open) => toFront(open, desktop.id))}
onClose={() => setDesktops((open) => open.filter((each) => each.id !== desktop.id))}
/>
))
),
)
: null}
{menu === null ? null : <ContextMenu at={menu.at} items={menuItems(menu.target)} onClose={() => setMenu(null)} />}
{routerAt === null ? null : (
+90
View File
@@ -1251,3 +1251,93 @@
.gnutella-table tbody tr[tabindex] {
cursor: pointer;
}
/* A switch's or home router's viewer (SatViewer, NatViewer): a table and its
* reset, in a window as small as Java's dialog allows the NAT table's six columns. */
.viewer-window {
width: 600px;
height: 320px;
}
.viewer-body {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
gap: 8px;
padding: 8px;
overflow: auto;
background: var(--surface-card);
color: var(--text-primary);
}
.viewer-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.viewer-table th,
.viewer-table td {
padding: 6px;
border-bottom: 1px solid var(--border-default);
text-align: left;
overflow-wrap: anywhere;
}
.viewer-table th {
font-weight: 600;
}
.viewer-bar button,
.drop-control button {
min-height: 44px;
padding: 0 12px;
border: 1px solid var(--border-default);
border-radius: 8px;
background: var(--surface-raised);
color: var(--text-primary);
font: inherit;
cursor: pointer;
}
/* "Pakete verwerfen" (SimulationControl): held, then the list of dropped frames below. */
.drop-control {
position: relative;
}
.drop-control-bar {
display: flex;
gap: 6px;
}
.drop-control-hold {
touch-action: none;
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
}
.drop-control button[aria-pressed='true'] {
background: var(--danger-soft);
border-color: var(--danger);
color: var(--danger-on-soft);
}
.drop-control-list {
position: absolute;
top: calc(100% + 6px);
left: 0;
width: min(720px, calc(100vw - 32px));
max-height: 240px;
margin: 0;
overflow: auto;
padding: 6px;
border: 1px solid var(--border-default);
border-radius: 8px;
background: var(--surface-card);
color: var(--text-primary);
font: 12px ui-monospace, Menlo, Consolas, monospace;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
+16
View File
@@ -84,6 +84,9 @@
"dnsServer.tabMX": "Mailaustausch (MX)",
"dnsServer.tabNS": "Nameserver (NS)",
"dnsServer.tables": "Einträge",
"drop.hold": "Pakete verwerfen",
"drop.list": "Verworfen: {count}",
"drop.listLabel": "Verworfene Pakete",
"echoServer.log": "Ausgabe",
"echoServer.port": "Port:",
"echoServer.start": "Starten",
@@ -386,6 +389,19 @@
"simpleClient.port": "Server-Port:",
"simpleClient.receiveFailed": "Keine Verbindung hergestellt!",
"simpleClient.send": "Senden",
"tables.lanAddress": "LAN-Adresse",
"tables.lanPort": "LAN-Port",
"tables.lastUpdate": "Letzte Aktualisierung",
"tables.mac": "MAC",
"tables.natReset": "Tabelle zurücksetzen",
"tables.natTitle": "NAT-Tabelle",
"tables.port": "Port",
"tables.portMapping": "Portfreigabe",
"tables.portNumber": "Port",
"tables.protocol": "Protokoll",
"tables.satReset": "Tabelle löschen",
"tables.satTitle": "SAT Tabelle",
"tables.wanAddress": "WAN-Adresse",
"terminal.commands": "Befehle",
"terminal.ctrlC": "Strg+C",
"terminal.input": "Befehl eingeben",
+16
View File
@@ -84,6 +84,9 @@
"dnsServer.tabMX": "Mail exchange (MX)",
"dnsServer.tabNS": "Name server (NS)",
"dnsServer.tables": "Records",
"drop.hold": "Drop Packets",
"drop.list": "Dropped: {count}",
"drop.listLabel": "Dropped packets",
"echoServer.log": "Output",
"echoServer.port": "Port:",
"echoServer.start": "Start",
@@ -386,6 +389,19 @@
"simpleClient.port": "Server port:",
"simpleClient.receiveFailed": "No connection established!",
"simpleClient.send": "Send",
"tables.lanAddress": "LAN Address",
"tables.lanPort": "LAN Port",
"tables.lastUpdate": "Last Update",
"tables.mac": "MAC",
"tables.natReset": "Reset table",
"tables.natTitle": "NAT Table",
"tables.port": "Port",
"tables.portMapping": "Port Mapping",
"tables.portNumber": "Port",
"tables.protocol": "Protocol",
"tables.satReset": "Clear Table",
"tables.satTitle": "SAT table",
"tables.wanAddress": "WAN Address",
"terminal.commands": "Commands",
"terminal.ctrlC": "Ctrl+C",
"terminal.input": "Enter a command",
+20
View File
@@ -7,6 +7,26 @@
display: flex;
}
/* The mode buttons (and the design's undo and redo, the simulation's
* "Pakete verwerfen") over the surface: they wrap on a narrow screen; the
* space between them lets taps through to the surface. */
.editor-toolbar {
position: absolute;
left: 12px;
top: 12px;
right: 12px;
z-index: 50;
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
pointer-events: none;
}
.editor-toolbar > * {
pointer-events: auto;
}
.editor-area {
position: relative;
flex: 1;
@@ -0,0 +1,78 @@
// Java's "Pakete verwerfen" (SimulationControl): while the button is held,
// every cable drops what it carries; below it, the frames dropped in this
// simulation, each with its time of day and Java's words for it
// (EthernetFrame.toString). The button is held with a finger, the mouse, or
// Space or Enter; letting go (it lets the captured pointer go), a cancel or
// leaving the button by keyboard ends it.
//
// Better than Java: the list folds away and shows how many it holds (Java's
// panel takes the bottom of the window).
import { useEffect, useRef, useState, type KeyboardEvent, type PointerEvent as ReactPointerEvent } from 'react';
import { useTranslate } from '../i18n/i18n.js';
import { timeOfDay } from './timeOfDay.js';
import type { Simulation } from './useSimulation.js';
export function DropControl({ simulation }: { readonly simulation: Simulation }) {
const t = useTranslate();
const { watch, drop, dropped, clock } = simulation;
useEffect(() => watch(['dropped']), [watch]);
const [held, setHeld] = useState(false);
const [open, setOpen] = useState(false);
const holding = useRef(false);
const hold = (on: boolean) => {
if (holding.current === on) return;
holding.current = on;
setHeld(on);
drop(on);
};
// Let go when the control goes (the simulation stopped while it was held).
useEffect(
() => () => {
if (holding.current) drop(false);
},
[drop],
);
const onPointerDown = (event: ReactPointerEvent<HTMLButtonElement>) => {
event.currentTarget.setPointerCapture(event.pointerId);
hold(true);
};
const onKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
if ((event.key === ' ' || event.key === 'Enter') && !event.repeat) {
event.preventDefault();
hold(true);
}
};
const onKeyUp = (event: KeyboardEvent<HTMLButtonElement>) => {
if (event.key === ' ' || event.key === 'Enter') hold(false);
};
return (
<div className="drop-control">
<div className="drop-control-bar">
<button
type="button"
className="drop-control-hold"
aria-pressed={held}
onPointerDown={onPointerDown}
onPointerCancel={() => hold(false)}
onLostPointerCapture={() => hold(false)}
onKeyDown={onKeyDown}
onKeyUp={onKeyUp}
onBlur={() => hold(false)}
onContextMenu={(event) => event.preventDefault()}
>
{t('drop.hold')}
</button>
<button type="button" className="drop-control-toggle" aria-expanded={open} onClick={() => setOpen(!open)}>
{t('drop.list', { count: dropped.length })}
</button>
</div>
{open ? (
<pre className="drop-control-list" aria-label={t('drop.listLabel')} tabIndex={0}>
{dropped.map((unit) => `${timeOfDay(clock, unit.timeMilliseconds, true)} : ${unit.text}\n`).join('')}
</pre>
) : null}
</div>
);
}
@@ -0,0 +1,103 @@
// A switch's or a home router's viewer while the simulation runs, after
// Java's SatViewer and NatViewer (measured with tools/java-oracle's
// ViewersRun): a window titled "SAT Tabelle <name>" or "NAT-Tabelle <name>"
// with the table — MAC, port, last update; protocol, port mapping, WAN
// address, LAN address, LAN port, last update — and the table reset. A tap on
// the device opens it, as Java's click does. The tables are the room's.
//
// Better than Java: the reset is a button (Java's is in a menu of the right
// mouse button), and a switch's addresses come in their order (Java's in its
// hash map's).
import { useEffect, useState, type PointerEvent as ReactPointerEvent } from 'react';
import type { NatView, SatView } from '@mdg-filius/protocol';
import { ICON_URLS } from '../canvas/icons.js';
import { useTranslate } from '../i18n/i18n.js';
import { timeOfDay } from './timeOfDay.js';
import type { Simulation } from './useSimulation.js';
export type ViewerKind = 'sat' | 'nat';
export interface ViewerWindowProps {
readonly kind: ViewerKind;
readonly id: string;
readonly label: string;
readonly simulation: Simulation;
readonly x: number;
readonly y: number;
readonly z: number;
readonly onMove: (x: number, y: number) => void;
readonly onFront: () => void;
readonly onClose: () => void;
}
export function ViewerWindow({ kind, id, label, simulation, x, y, z, onMove, onFront, onClose }: ViewerWindowProps) {
const t = useTranslate();
const topic = `app:${id}/${kind}` as const;
const { watch, act, clock } = simulation;
useEffect(() => watch([topic]), [watch, topic]);
const view = simulation.views.get(topic) as SatView | NatView | null | undefined;
const [drag, setDrag] = useState<{ readonly pointer: number; readonly dx: number; readonly dy: number; moved: boolean } | null>(null);
const title = `${t(kind === 'sat' ? 'tables.satTitle' : 'tables.natTitle')} ${label}`;
// The title bar moves the window; the pointer is captured once it moves (pointer-capture trap).
const onPointerDown = (event: ReactPointerEvent<HTMLElement>) => {
if ((event.target as Element).closest('button') !== null) return;
setDrag({ pointer: event.pointerId, dx: event.clientX - x, dy: event.clientY - y, moved: false });
};
const onPointerMove = (event: ReactPointerEvent<HTMLElement>) => {
if (drag === null || drag.pointer !== event.pointerId) return;
if (!drag.moved) {
drag.moved = true;
event.currentTarget.setPointerCapture(event.pointerId);
}
onMove(Math.max(0, event.clientX - drag.dx), Math.max(0, event.clientY - drag.dy));
};
const onPointerUp = () => setDrag(null);
const columns =
kind === 'sat' ? ['tables.mac', 'tables.port', 'tables.lastUpdate'] : ['tables.protocol', 'tables.portMapping', 'tables.wanAddress', 'tables.lanAddress', 'tables.lanPort', 'tables.lastUpdate'];
const rows: readonly (readonly string[])[] =
view === null || view === undefined
? []
: kind === 'sat'
? (view as SatView).rows.map((row) => [row.macAddress, `${t('tables.portNumber')} ${row.port}`, timeOfDay(clock, row.updatedAtMilliseconds)])
: (view as NatView).rows.map((row) => [...row.cells, row.updatedAtMilliseconds === undefined ? '-' : timeOfDay(clock, row.updatedAtMilliseconds)]);
return (
<section className="desktop-window viewer-window" role="dialog" aria-label={title} data-viewer={id} style={{ left: x, top: y, zIndex: 5 + z }} onPointerDownCapture={onFront}>
<header className="desktop-titlebar" onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={onPointerUp} onPointerCancel={onPointerUp}>
<img src={ICON_URLS[kind === 'sat' ? 'switch' : 'router']} alt="" />
<h2>{title}</h2>
<button type="button" className="desktop-close" aria-label={t('sheet.close')} title={t('sheet.close')} onClick={onClose}>
×
</button>
</header>
<div className="viewer-body">
<table className="viewer-table" aria-label={title}>
<thead>
<tr>
{columns.map((column) => (
<th key={column}>{t(column)}</th>
))}
</tr>
</thead>
<tbody>
{rows.map((cells, row) => (
<tr key={row}>
{cells.map((cell, column) => (
<td key={column}>{cell}</td>
))}
</tr>
))}
</tbody>
</table>
<div className="viewer-bar">
<button type="button" onClick={() => act(id, kind, 'reset')}>
{t(kind === 'sat' ? 'tables.satReset' : 'tables.natReset')}
</button>
</div>
</div>
</section>
);
}
@@ -0,0 +1,13 @@
// A time of the simulation as Java's viewers write the wall clock's: its
// hours, minutes and seconds (SimpleDateFormat "HH:mm:ss"), with the
// milliseconds where the list of dropped frames has them
// (Lauscher.formatTimestamp), in the viewer's time zone.
const two = (value: number) => String(value).padStart(2, '0');
/** The time of day of the simulation's time `milliseconds`, its time 0 standing for `clock` (milliseconds since 1970). */
export function timeOfDay(clock: number, milliseconds: number, withMilliseconds = false): string {
const at = new Date(clock + milliseconds);
const text = `${two(at.getHours())}:${two(at.getMinutes())}:${two(at.getSeconds())}`;
return withMilliseconds ? `${text}.${String(at.getMilliseconds()).padStart(3, '0')}` : text;
}
@@ -6,7 +6,7 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type * as Y from 'yjs';
import type { AppKind, NodeSettings, SimHost, SimMode, Topic } from '@mdg-filius/protocol';
import type { AppKind, DroppedUnit, NodeSettings, SimHost, SimMode, Topic } from '@mdg-filius/protocol';
import { LocalSimHost } from '@mdg-filius/sim';
import SimWorker from '@mdg-filius/sim/worker?worker';
import type { Language } from '../i18n/i18n.js';
@@ -40,10 +40,16 @@ export interface Simulation {
readonly views: ReadonlyMap<string, unknown>;
/** Whether the core failed and the room went back to design mode, since the last start. */
readonly failed: boolean;
/** The time of day (milliseconds since 1970) the simulation's time 0 stands for: a viewer's times of day are written from it. */
readonly clock: number;
/** The frames "Pakete verwerfen" dropped in this simulation, while `dropped` is watched. */
readonly dropped: readonly DroppedUnit[];
/** Starts in the language the person runs FILIUS in: the routers' pages come in it, as Java's in its locale. */
start(language?: Language): void;
stop(): void;
setSpeed(value: number): void;
/** Java's "Pakete verwerfen": held (true) or let go (false). */
drop(on: boolean): void;
/** Watches these topics until the returned function is called; a topic two parts watch is sent for once. */
watch(topics: readonly Topic[]): () => void;
/** An action in a program's window (the room answers with its new lines). */
@@ -62,6 +68,8 @@ export function useSimulation(doc: Y.Doc, createHost: SimHostFactory = localSimH
const [failed, setFailed] = useState(false);
const [apps, setApps] = useState<ReadonlyMap<string, AppLines>>(new Map());
const [views, setViews] = useState<ReadonlyMap<string, unknown>>(new Map());
const [clock, setClock] = useState(0);
const [dropped, setDropped] = useState<readonly DroppedUnit[]>([]);
useEffect(() => {
const made = createHost(doc);
@@ -71,6 +79,7 @@ export function useSimulation(doc: Y.Doc, createHost: SimHostFactory = localSimH
case 'state':
setMode(message.mode);
setSpeedState(message.speed);
setClock(message.clock);
if (message.mode === 'design') setActiveLinks(NO_LINKS);
return;
case 'activity':
@@ -88,6 +97,9 @@ export function useSimulation(doc: Y.Doc, createHost: SimHostFactory = localSimH
return new Map(before).set(topic, { lines, live: message.from === 0 ? lines.length : (before.get(topic)?.live ?? 0) });
});
return;
case 'dropped':
setDropped((before) => (message.reset ? message.units : [...before, ...message.units]));
return;
case 'view':
setViews((before) => new Map(before).set(`app:${message.node}/${message.app}`, message.view));
return;
@@ -112,6 +124,7 @@ export function useSimulation(doc: Y.Doc, createHost: SimHostFactory = localSimH
}, []);
const stop = useCallback(() => host.current?.send({ v: 1, kind: 'stop' }), []);
const setSpeed = useCallback((value: number) => host.current?.send({ v: 1, kind: 'speed', value }), []);
const drop = useCallback((on: boolean) => host.current?.send({ v: 1, kind: 'drop', on }), []);
const watch = useCallback((topics: readonly Topic[]) => {
const fresh = topics.filter((topic) => {
const count = watched.current.get(topic) ?? 0;
@@ -134,5 +147,5 @@ export function useSimulation(doc: Y.Doc, createHost: SimHostFactory = localSimH
host.current?.send({ v: 1, kind: 'input', node, app, action, args, reqId: crypto.randomUUID() });
}, []);
return { mode, speed, activeLinks, nodes, apps, views, failed, start, stop, setSpeed, watch, act };
return { mode, speed, activeLinks, nodes, apps, views, failed, clock, dropped, start, stop, setSpeed, drop, watch, act };
}