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>
241 lines
11 KiB
TypeScript
241 lines
11 KiB
TypeScript
// What the editor's design tools do to the document: a new device as FILIUS'
|
|
// palette makes it, a device moved or removed, a cable plugged or pulled —
|
|
// each a transaction with the person's origin, so that their undo takes back
|
|
// their own changes and nobody else's (plan §5.1; Scratch's lesson 2).
|
|
//
|
|
// A new device is what Java's constructor makes (GUIContainer.neuerKnoten,
|
|
// measured with tools/java-oracle's NewDeviceRun): a PC or notebook with
|
|
// 192.168.0.10/24 and an empty file system, a switch with 24 ports, a router
|
|
// with the interfaces chosen (2 to 8, all 192.168.0.10/24) and its firewall
|
|
// off, a home router with WAN 42.0.0.10/8 and LAN 192.168.0.10/24, a modem
|
|
// on port 12345. Java rolls the MAC addresses and a switch's SSID; here they
|
|
// come from the ids, as for every device without them (stable across saves).
|
|
//
|
|
// A cable follows Java's rules (GUIEvents, createConnection): a device's
|
|
// first free port, a home router's WAN port unless its LAN port is asked
|
|
// for (Java: the right half of its button), a wireless host only to a
|
|
// switch, two devices joined once.
|
|
|
|
import * as Y from 'yjs';
|
|
import {
|
|
DEFAULT_REMOTE_LINK_LATENCY_MILLISECONDS,
|
|
defaultSwitchConfiguration,
|
|
firewallConfiguration,
|
|
interfaceKey,
|
|
VirtualFileSystem,
|
|
type Id,
|
|
type NodeKind,
|
|
type TopologyNode,
|
|
} from '@mdg-filius/core';
|
|
import { DEFAULT_MODEM_PORT, modemPair, type ImportedProject } from '@mdg-filius/fls';
|
|
import { emptyProject, inOrder, nodeEntry, ROOTS } from './projectDoc.js';
|
|
|
|
/** The kinds of the palette, in Java's order (GUIDesignSidebar.addComponentItemsToSidebar; the cable comes first, a tool). */
|
|
export const PALETTE: readonly Exclude<NodeKind, 'unsupported'>[] = ['pc', 'notebook', 'networkSwitch', 'gateway', 'router', 'remoteLink'];
|
|
|
|
/**
|
|
* A router's interfaces, as Java's dialog offers them (2 to 8, 2 when it is
|
|
* closed); its connections dialog removes them down to `fewest` here (Java:
|
|
* none — see settings.ts).
|
|
*/
|
|
export const ROUTER_INTERFACES = { minimum: 2, maximum: 8, standard: 2, fewest: 1 } as const;
|
|
|
|
export interface NewDevice {
|
|
readonly kind: Exclude<NodeKind, 'unsupported'>;
|
|
readonly id: Id;
|
|
/** Java's name of the kind in the editor's language (Knoten.TYPE: "Rechner", "Computer" …). */
|
|
readonly name: string;
|
|
/** The top left corner of its button. */
|
|
readonly x: number;
|
|
readonly y: number;
|
|
/** A router's interfaces. */
|
|
readonly interfaces?: number;
|
|
/** Makes the ports' ids. */
|
|
readonly newId: () => Id;
|
|
}
|
|
|
|
const HOST_INTERFACE = { ipAddress: '192.168.0.10', subnetMask: '255.255.255.0' } as const;
|
|
|
|
function portLabels(kind: NewDevice['kind'], interfaces: number): string[] {
|
|
switch (kind) {
|
|
case 'pc':
|
|
case 'notebook':
|
|
return ['eth0'];
|
|
case 'networkSwitch':
|
|
return Array.from({ length: 24 }, (_, index) => `sw${index + 1}`);
|
|
case 'router':
|
|
return Array.from({ length: interfaces }, (_, index) => `rt${index + 1}`);
|
|
case 'gateway':
|
|
return ['wan0', 'lan0'];
|
|
case 'remoteLink':
|
|
return ['remote0'];
|
|
}
|
|
}
|
|
|
|
/** The new device as a project of its own: Java's constructor's state in the editor's model. */
|
|
export function newDeviceProject(device: NewDevice): ImportedProject {
|
|
const interfaces = Math.min(ROUTER_INTERFACES.maximum, Math.max(ROUTER_INTERFACES.minimum, Math.trunc(device.interfaces ?? ROUTER_INTERFACES.standard)));
|
|
const node: TopologyNode = {
|
|
id: device.id,
|
|
kind: device.kind,
|
|
displayName: device.name,
|
|
hostLabelPolicy: 'manual',
|
|
position: { x: Math.round(device.x), y: Math.round(device.y) },
|
|
ports: portLabels(device.kind, interfaces).map((label) => ({ id: device.newId(), label, isOccupied: false })),
|
|
};
|
|
const project = emptyProject();
|
|
project.graph.nodes.push(node);
|
|
const card = (index: number, address: { ipAddress: string; subnetMask: string }) => project.interfaceConfigurations.set(interfaceKey(device.id, node.ports[index]!.id), address);
|
|
switch (device.kind) {
|
|
case 'pc':
|
|
case 'notebook':
|
|
project.deviceConfigurations.set(device.id, { ...HOST_INTERFACE, defaultGateway: '', dnsServer: '' });
|
|
project.virtualFileSystems.set(device.id, new VirtualFileSystem());
|
|
break;
|
|
case 'networkSwitch':
|
|
project.switchConfigurations.set(device.id, defaultSwitchConfiguration(device.id));
|
|
break;
|
|
case 'router':
|
|
node.ports.forEach((_, index) => card(index, HOST_INTERFACE));
|
|
project.firewallConfigurationsByNodeId.set(device.id, firewallConfiguration());
|
|
break;
|
|
case 'gateway':
|
|
card(0, { ipAddress: '42.0.0.10', subnetMask: '255.0.0.0' });
|
|
card(1, HOST_INTERFACE);
|
|
project.deviceConfigurations.set(device.id, { ipAddress: '42.0.0.10', subnetMask: '255.0.0.0', defaultGateway: '', dnsServer: '' });
|
|
project.firewallConfigurationsByNodeId.set(device.id, firewallConfiguration());
|
|
break;
|
|
case 'remoteLink':
|
|
project.remoteLinkConfigurations.set(device.id, { pairIdentifier: modemPair(DEFAULT_MODEM_PORT), latencyMilliseconds: DEFAULT_REMOTE_LINK_LATENCY_MILLISECONDS, isEnabled: true });
|
|
break;
|
|
}
|
|
return project;
|
|
}
|
|
|
|
const nodes = (doc: Y.Doc) => doc.getMap<Y.Map<unknown>>(ROOTS.nodes);
|
|
const links = (doc: Y.Doc) => doc.getMap<Y.Map<unknown>>(ROOTS.links);
|
|
|
|
/** Adds the device after all others. */
|
|
export function addDevice(doc: Y.Doc, device: NewDevice, origin: unknown): void {
|
|
const project = newDeviceProject(device);
|
|
doc.transact(() => {
|
|
const order = Math.max(-1, ...[...nodes(doc).values()].map((node) => node.get('order') as number)) + 1;
|
|
nodes(doc).set(device.id, nodeEntry(project.graph.nodes[0]!, order, project));
|
|
}, origin);
|
|
}
|
|
|
|
/** Moves a device's button to a place (whole pixels, as Java keeps them). */
|
|
export function moveDevice(doc: Y.Doc, id: Id, x: number, y: number, origin: unknown): void {
|
|
const node = nodes(doc).get(id);
|
|
if (node === undefined) return;
|
|
doc.transact(() => {
|
|
node.set('x', Math.round(x));
|
|
node.set('y', Math.round(y));
|
|
}, origin);
|
|
}
|
|
|
|
/** Removes a device with its cables, as Java does. */
|
|
export function removeDevice(doc: Y.Doc, id: Id, origin: unknown): void {
|
|
doc.transact(() => {
|
|
for (const [linkId, link] of [...links(doc).entries()]) if (link.get('sourceNode') === id || link.get('targetNode') === id) links(doc).delete(linkId);
|
|
nodes(doc).delete(id);
|
|
}, origin);
|
|
}
|
|
|
|
export function removeLink(doc: Y.Doc, id: Id, origin: unknown): void {
|
|
doc.transact(() => links(doc).delete(id), origin);
|
|
}
|
|
|
|
/** Java's "remove all cables" of a device (its context menu). */
|
|
export function removeLinksOf(doc: Y.Doc, id: Id, origin: unknown): void {
|
|
doc.transact(() => {
|
|
for (const [linkId, link] of [...links(doc).entries()]) if (link.get('sourceNode') === id || link.get('targetNode') === id) links(doc).delete(linkId);
|
|
}, origin);
|
|
}
|
|
|
|
/** A cable's end: a device, and the port when one was asked for. */
|
|
export interface CableEnd {
|
|
readonly node: Id;
|
|
readonly port?: Id;
|
|
}
|
|
|
|
/**
|
|
* Why a cable cannot be plugged, with Java's message where it has one:
|
|
* noFreePort guievents_msg1, wanOccupied guievents_msg22, lanOccupied
|
|
* guievents_msg23, wirelessNotPossible guievents_msg26, alreadyConnected
|
|
* guievents_msg12.
|
|
*/
|
|
export type ConnectError = 'unknownDevice' | 'sameDevice' | 'unknownPort' | 'portTaken' | 'noFreePort' | 'wanOccupied' | 'lanOccupied' | 'wirelessNotPossible' | 'alreadyConnected';
|
|
|
|
/** The ports cables use, as `node/port`. */
|
|
function pluggedPorts(doc: Y.Doc): Set<string> {
|
|
return new Set([...links(doc).values()].flatMap((link) => [`${link.get('sourceNode')}/${link.get('sourcePort')}`, `${link.get('targetNode')}/${link.get('targetPort')}`]));
|
|
}
|
|
|
|
/** The port a cable takes at an end, or why there is none. */
|
|
export function cablePort(doc: Y.Doc, end: CableEnd): { readonly port: Id } | { readonly error: ConnectError } {
|
|
const node = nodes(doc).get(end.node);
|
|
if (node === undefined) return { error: 'unknownDevice' };
|
|
const ports = (node.get('ports') as Y.Array<Y.Map<unknown>>).toArray().map((port) => ({ id: port.get('id') as Id, label: port.get('label') as string }));
|
|
const plugged = pluggedPorts(doc);
|
|
const free = (port: Id) => !plugged.has(`${end.node}/${port}`);
|
|
const kind = node.get('kind') as NodeKind;
|
|
if (kind === 'gateway') {
|
|
// WAN unless LAN is asked for; each its own message when taken.
|
|
const wanted = end.port === undefined ? ports[0] : ports.find((port) => port.id === end.port);
|
|
if (wanted === undefined) return { error: 'unknownPort' };
|
|
if (free(wanted.id)) return { port: wanted.id };
|
|
return { error: wanted.label === 'wan0' ? 'wanOccupied' : wanted.label === 'lan0' ? 'lanOccupied' : 'portTaken' };
|
|
}
|
|
if (end.port !== undefined) {
|
|
if (!ports.some((port) => port.id === end.port)) return { error: 'unknownPort' };
|
|
return free(end.port) ? { port: end.port } : { error: 'portTaken' };
|
|
}
|
|
const first = ports.find((port) => free(port.id));
|
|
return first === undefined ? { error: 'noFreePort' } : { port: first.id };
|
|
}
|
|
|
|
/** A host whose card joins a switch's wireless network. */
|
|
function wireless(doc: Y.Doc, id: Id): boolean {
|
|
const node = nodes(doc).get(id)!;
|
|
const kind = node.get('kind') as NodeKind;
|
|
const settings = node.get('settings') as Y.Map<Y.Map<unknown>>;
|
|
return (kind === 'pc' || kind === 'notebook') && settings.get('wireless')?.get('isEnabled') === true;
|
|
}
|
|
|
|
/** Plugs a cable between two devices, from the first to the second, or says why it cannot. */
|
|
export function connect(doc: Y.Doc, from: CableEnd, to: CableEnd, linkId: Id, origin: unknown): { readonly link: Id } | { readonly error: ConnectError } {
|
|
if (!nodes(doc).has(from.node) || !nodes(doc).has(to.node)) return { error: 'unknownDevice' };
|
|
if (from.node === to.node) return { error: 'sameDevice' };
|
|
const source = cablePort(doc, from);
|
|
if ('error' in source) return source;
|
|
const target = cablePort(doc, to);
|
|
if ('error' in target) return target;
|
|
const kindOf = (id: Id) => nodes(doc).get(id)!.get('kind') as NodeKind;
|
|
if ((wireless(doc, from.node) && kindOf(to.node) !== 'networkSwitch') || (wireless(doc, to.node) && kindOf(from.node) !== 'networkSwitch')) return { error: 'wirelessNotPossible' };
|
|
const joined = [...links(doc).values()].some((link) => {
|
|
const ends = [link.get('sourceNode'), link.get('targetNode')];
|
|
return ends.includes(from.node) && ends.includes(to.node);
|
|
});
|
|
if (joined) return { error: 'alreadyConnected' };
|
|
doc.transact(() => {
|
|
const link = new Y.Map<unknown>();
|
|
link.set('order', Math.max(-1, ...inOrder(links(doc)).map(([, each]) => each.get('order') as number)) + 1);
|
|
link.set('sourceNode', from.node);
|
|
link.set('sourcePort', source.port);
|
|
link.set('targetNode', to.node);
|
|
link.set('targetPort', target.port);
|
|
links(doc).set(linkId, link);
|
|
}, origin);
|
|
return { link: linkId };
|
|
}
|
|
|
|
/**
|
|
* A person's undo: their own changes to the devices, cables and
|
|
* documentation items, nobody else's. Changes within a short while (a drag)
|
|
* are taken back at once.
|
|
*/
|
|
export function createUndoManager(doc: Y.Doc, origin: unknown): Y.UndoManager {
|
|
return new Y.UndoManager([nodes(doc), links(doc), doc.getMap(ROOTS.docs)], { trackedOrigins: new Set([origin]), captureTimeout: 500 });
|
|
}
|