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:
@@ -33,8 +33,12 @@ 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). */
|
||||
export const ROUTER_INTERFACES = { minimum: 2, maximum: 8, standard: 2 } as const;
|
||||
/**
|
||||
* 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'>;
|
||||
|
||||
@@ -27,7 +27,10 @@
|
||||
// taken, and the sheet says why. Nor that Java's firewall table writes a
|
||||
// cell into the rule before it checks it, so that a rule holds "abc" or a
|
||||
// mask of 255.0.255.0 while the table shows the old value, and reads a port
|
||||
// that is no number as "all ports".
|
||||
// that is no number as "all ports". Nor that Java's connections dialog
|
||||
// removes a router's last card too: a router without one keeps no gateway
|
||||
// (setStandardGateway writes it into the cards; SheetRun's "connections"),
|
||||
// nor could a file hold it; here one card stays.
|
||||
|
||||
import * as Y from 'yjs';
|
||||
import {
|
||||
@@ -47,7 +50,7 @@ import {
|
||||
type NodeKind,
|
||||
} from '@mdg-filius/core';
|
||||
import { modemPair } from '@mdg-filius/fls';
|
||||
import { connect, type ConnectError } from './commands.js';
|
||||
import { connect, ROUTER_INTERFACES, type ConnectError } from './commands.js';
|
||||
import { checkField, type CheckedField, type FieldRule } from './fields.js';
|
||||
import { inOrder, ROOTS } from './projectDoc.js';
|
||||
import { fromY, toY, type PlainValue } from './values.js';
|
||||
@@ -295,6 +298,87 @@ export function setRip(doc: Y.Doc, id: Id, on: boolean, origin: unknown): void {
|
||||
}, origin);
|
||||
}
|
||||
|
||||
/** A router's ports, or none for another device. */
|
||||
function routerPorts(doc: Y.Doc, id: Id): Y.Array<Y.Map<unknown>> | undefined {
|
||||
const node = nodes(doc).get(id);
|
||||
return node === undefined || kindOf(node) !== 'router' ? undefined : portsOf(node);
|
||||
}
|
||||
|
||||
/**
|
||||
* A card added to a router after its last (JConnectionsDialog's "+":
|
||||
* hinzuAnschluss, a new NetzwerkInterface with 192.168.0.10/24 and its own
|
||||
* MAC address, here from the port's id), up to eight; says whether it was.
|
||||
*/
|
||||
export function addRouterInterface(doc: Y.Doc, id: Id, portId: Id, origin: unknown): boolean {
|
||||
const ports = routerPorts(doc, id);
|
||||
if (ports === undefined || ports.length >= ROUTER_INTERFACES.maximum) return false;
|
||||
doc.transact(() => {
|
||||
const port = new Y.Map<unknown>();
|
||||
port.set('id', portId);
|
||||
port.set('label', `rt${ports.length + 1}`);
|
||||
port.set('occupied', false);
|
||||
port.set('config', toY({ ipAddress: '192.168.0.10', subnetMask: '255.255.255.0' }));
|
||||
ports.push([port]);
|
||||
}, origin);
|
||||
return true;
|
||||
}
|
||||
|
||||
type PluggedEnd = { readonly linkId: Id; readonly link: Y.Map<unknown>; readonly end: 'sourcePort' | 'targetPort' };
|
||||
|
||||
/** The cables at a device's port, with the end that plugs into it. */
|
||||
function cablesAt(doc: Y.Doc, id: Id, portId: Id): PluggedEnd[] {
|
||||
const ends: PluggedEnd[] = [];
|
||||
for (const [linkId, link] of links(doc).entries()) {
|
||||
if (link.get('sourceNode') === id && link.get('sourcePort') === portId) ends.push({ linkId, link, end: 'sourcePort' });
|
||||
else if (link.get('targetNode') === id && link.get('targetPort') === portId) ends.push({ linkId, link, end: 'targetPort' });
|
||||
}
|
||||
return ends;
|
||||
}
|
||||
|
||||
/** A router's last card removed with its cable (JConnectionsDialog's "-"), down to one; says whether it was. */
|
||||
export function removeRouterInterface(doc: Y.Doc, id: Id, origin: unknown): boolean {
|
||||
const ports = routerPorts(doc, id);
|
||||
if (ports === undefined || ports.length <= ROUTER_INTERFACES.fewest) return false;
|
||||
const last = ports.get(ports.length - 1).get('id') as Id;
|
||||
doc.transact(() => {
|
||||
for (const { linkId } of cablesAt(doc, id, last)) links(doc).delete(linkId);
|
||||
ports.delete(ports.length - 1);
|
||||
}, origin);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Two of a router's cards trade their cables (JConnectionsDialog.swapConnection):
|
||||
* both plugged, the cables swap; one plugged, its cable moves to the other
|
||||
* card; neither, nothing changes. The cables stay the same cables, joining the
|
||||
* same devices. Says whether a cable moved.
|
||||
*/
|
||||
export function swapRouterCables(doc: Y.Doc, id: Id, first: Id, second: Id, origin: unknown): boolean {
|
||||
const ports = routerPorts(doc, id);
|
||||
const has = (portId: Id) => ports?.toArray().some((port) => port.get('id') === portId) === true;
|
||||
if (first === second || !has(first) || !has(second)) return false;
|
||||
const atFirst = cablesAt(doc, id, first);
|
||||
const atSecond = cablesAt(doc, id, second);
|
||||
if (atFirst.length === 0 && atSecond.length === 0) return false;
|
||||
doc.transact(() => {
|
||||
for (const { link, end } of atFirst) link.set(end, second);
|
||||
for (const { link, end } of atSecond) link.set(end, first);
|
||||
}, origin);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* A click on a card's button in Java's connections dialog, with the card
|
||||
* marked before (null for none): the first click marks a card, a click on it
|
||||
* again unmarks it, a click on another card swaps the two cards' cables
|
||||
* (swapRouterCables) and leaves none marked.
|
||||
*/
|
||||
export function connectionsClick(marked: number | null, clicked: number): { readonly marked: number | null; readonly swap?: readonly [number, number] } {
|
||||
if (marked === null) return { marked: clicked };
|
||||
if (marked === clicked) return { marked: null };
|
||||
return { marked: null, swap: [marked, clicked] };
|
||||
}
|
||||
|
||||
/** A column of the forwarding table's manual rows. */
|
||||
export type RouteColumn = keyof ManualRoute;
|
||||
|
||||
|
||||
@@ -88,8 +88,13 @@ export interface InterfaceSheet {
|
||||
readonly ipAddress: string;
|
||||
readonly subnetMask: string;
|
||||
readonly macAddress: string;
|
||||
/** The device at the other end of its cable (its label, as Java names it) and the cable; null without one. */
|
||||
readonly connected: { readonly device: string; readonly link: string } | null;
|
||||
/**
|
||||
* The device at the other end of its cable (its label, as Java names it),
|
||||
* the address of the card the cable plugs into there (a computer's, a
|
||||
* router's; null for a switch or a modem, which have none) and the cable;
|
||||
* null without one.
|
||||
*/
|
||||
readonly connected: { readonly device: string; readonly address: string | null; readonly link: string } | null;
|
||||
}
|
||||
|
||||
/** A router's or home router's firewall as Java's dialog shows it (JFirewallDialog). */
|
||||
@@ -142,6 +147,22 @@ const setting = (node: Y.Map<unknown>, key: string) => settingsOf(node).get(key)
|
||||
|
||||
const text = (map: Y.Map<unknown> | undefined, key: string) => (map?.get(key) as string | undefined) ?? '';
|
||||
|
||||
/** The address of a device's card at a port, as Java's NetzwerkInterface holds it; null for a device without cards (InternetKnoten). */
|
||||
function cardAddress(node: Y.Map<unknown>, portId: string): string | null {
|
||||
switch (node.get('kind') as NodeKind) {
|
||||
case 'pc':
|
||||
case 'notebook':
|
||||
return text(setting(node, 'device'), 'ipAddress');
|
||||
case 'router':
|
||||
case 'gateway': {
|
||||
const port = (node.get('ports') as Y.Array<Y.Map<unknown>>).toArray().find((each) => each.get('id') === portId);
|
||||
return text(port?.get('config') as Y.Map<unknown> | undefined, 'ipAddress');
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** A router's or home router's cards, each with what its cable joins. */
|
||||
function interfacesOf(doc: Y.Doc, id: string, node: Y.Map<unknown>): InterfaceSheet[] {
|
||||
const all = doc.getMap<Y.Map<unknown>>(ROOTS.nodes);
|
||||
@@ -150,14 +171,14 @@ function interfacesOf(doc: Y.Doc, id: string, node: Y.Map<unknown>): InterfaceSh
|
||||
const portId = port.get('id') as string;
|
||||
const config = port.get('config') as Y.Map<unknown> | undefined;
|
||||
const cable = links.find(([, link]) => (link.get('sourceNode') === id && link.get('sourcePort') === portId) || (link.get('targetNode') === id && link.get('targetPort') === portId));
|
||||
const other =
|
||||
cable === undefined ? undefined : all.get((cable[1].get('sourceNode') === id && cable[1].get('sourcePort') === portId ? cable[1].get('targetNode') : cable[1].get('sourceNode')) as string);
|
||||
const near = cable !== undefined && cable[1].get('sourceNode') === id && cable[1].get('sourcePort') === portId;
|
||||
const other = cable === undefined ? undefined : all.get(cable[1].get(near ? 'targetNode' : 'sourceNode') as string);
|
||||
return {
|
||||
portId,
|
||||
ipAddress: text(config, 'ipAddress'),
|
||||
subnetMask: text(config, 'subnetMask'),
|
||||
macAddress: (port.get('mac') as string | undefined) ?? stableGeneratedMacAddress(portId),
|
||||
connected: cable === undefined || other === undefined ? null : { device: nodeLabel(other), link: cable[0] },
|
||||
connected: cable === undefined || other === undefined ? null : { device: nodeLabel(other), address: cardAddress(other, cable[1].get(near ? 'targetPort' : 'sourcePort') as string), link: cable[0] },
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -31,6 +31,10 @@ import {
|
||||
setSwitchRetention,
|
||||
setSwitchSsid,
|
||||
addRoute,
|
||||
addRouterInterface,
|
||||
connectionsClick,
|
||||
removeRouterInterface,
|
||||
swapRouterCables,
|
||||
addFirewallRule,
|
||||
moveFirewallRule,
|
||||
removeFirewallRule,
|
||||
@@ -104,8 +108,28 @@ const java = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)
|
||||
readonly cells: readonly { readonly column: number; readonly input: string; readonly rule: JavaFirewallRule; readonly valid: boolean }[];
|
||||
readonly moves: readonly (readonly [string, number, boolean | null, readonly string[]])[];
|
||||
};
|
||||
readonly connections: {
|
||||
readonly steps: readonly JavaConnectionsStep[];
|
||||
readonly hostPortFree: boolean;
|
||||
readonly switchPortsFree: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
/** A click in Java's connections dialog ("c<i>" a card's button, "+", "-") and what the dialog and the router show then. */
|
||||
interface JavaConnectionsStep {
|
||||
readonly click: string;
|
||||
readonly local: readonly string[];
|
||||
readonly remote: readonly string[];
|
||||
readonly buttons: readonly ('off' | 'marked' | 'plugged' | 'free')[];
|
||||
readonly add: boolean;
|
||||
readonly remove: boolean;
|
||||
/** Address, mask and the device its cable joins ("" for none), per card. */
|
||||
readonly cards: readonly (readonly [string, string, string])[];
|
||||
readonly cables: number;
|
||||
readonly panelError?: string | null;
|
||||
readonly gateway?: string | null;
|
||||
}
|
||||
|
||||
interface JavaFirewallRule {
|
||||
readonly srcIP: string;
|
||||
readonly srcMask: string;
|
||||
@@ -654,7 +678,7 @@ describe("a router's sheet", () => {
|
||||
const pc = add(doc, 'pc', 'PC-A');
|
||||
setHostLabels(doc, pc, { ipAddress: true, macAddress: false }, 'me');
|
||||
connect(doc, { node: pc }, { node: id }, 'cable', 'me');
|
||||
expect(router(doc, id).interfaces.map((card) => card.connected)).toEqual([{ device: '192.168.0.10', link: 'cable' }, null]);
|
||||
expect(router(doc, id).interfaces.map((card) => card.connected)).toEqual([{ device: '192.168.0.10', address: '192.168.0.10', link: 'cable' }, null]);
|
||||
expect(router(doc, id).interfaces[0]!.macAddress).toMatch(/^([0-9A-F]{2}:){5}[0-9A-F]{2}$/);
|
||||
});
|
||||
});
|
||||
@@ -1089,3 +1113,196 @@ describe("a router's firewall", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("a router's connections", () => {
|
||||
/** SheetRun's network: router R with three cards, PC A plugged into the first, switch S into the second. */
|
||||
function network() {
|
||||
const doc = new Y.Doc();
|
||||
const id = add(doc, 'router', 'R', 3);
|
||||
router(doc, id).interfaces.forEach((card, index) => setInterfaceAddress(doc, id, card.portId, 'ipAddress', `10.${index}.0.1`, 'me'));
|
||||
const pc = add(doc, 'pc', 'A');
|
||||
setHostAddress(doc, pc, 'ipAddress', '10.0.0.2', 'me');
|
||||
const sw = add(doc, 'networkSwitch', 'S');
|
||||
expect(connect(doc, { node: pc }, { node: id }, 'cable-a', 'me')).toEqual({ link: 'cable-a' });
|
||||
expect(connect(doc, { node: id, port: router(doc, id).interfaces[1]!.portId }, { node: sw }, 'cable-s', 'me')).toEqual({ link: 'cable-s' });
|
||||
return { doc, id, pc, sw };
|
||||
}
|
||||
|
||||
/** What Java's dialog would show of the router, with the card marked. */
|
||||
function shown(doc: Y.Doc, id: string, marked: number | null) {
|
||||
const cards = router(doc, id).interfaces;
|
||||
const pad = <T,>(each: T[], blank: T) => [...each, ...Array<T>(8 - each.length).fill(blank)];
|
||||
return {
|
||||
local: pad(cards.map((card, index) => `NIC ${index + 1}: ${card.ipAddress}`), ''),
|
||||
remote: pad(cards.map((card) => (card.connected === null ? '' : card.connected.address === null ? card.connected.device : `${card.connected.device} (${card.connected.address})`)), ''),
|
||||
buttons: pad(cards.map((card, index) => (index === marked ? 'marked' : card.connected === null ? 'free' : 'plugged')), 'off'),
|
||||
add: cards.length < 8,
|
||||
remove: cards.length > 1,
|
||||
cards: cards.map((card) => [card.ipAddress, card.subnetMask, card.connected?.device ?? '']),
|
||||
cables: doc.getMap(ROOTS.links).size,
|
||||
};
|
||||
}
|
||||
|
||||
it('swap, add and remove cards as Java’s dialog does, click by click, down to the last card', () => {
|
||||
const { doc, id } = network();
|
||||
let marked: number | null = null;
|
||||
const steps = java.connections.steps;
|
||||
const last = steps.findIndex((step) => step.cards.length === 1);
|
||||
for (const [index, step] of steps.slice(0, last + 1).entries()) {
|
||||
if (step.click.startsWith('c')) {
|
||||
const next = connectionsClick(marked, Number(step.click.slice(1)));
|
||||
if (next.swap !== undefined) {
|
||||
const ports = router(doc, id).interfaces.map((card) => card.portId);
|
||||
swapRouterCables(doc, id, ports[next.swap[0]]!, ports[next.swap[1]]!, 'me');
|
||||
}
|
||||
marked = next.marked;
|
||||
} else if (step.click === '+') {
|
||||
// Java's "+" is greyed out at eight cards; its click does nothing then.
|
||||
expect(addRouterInterface(doc, id, newId(), 'me'), `step ${index}`).toBe(steps[index - 1]!.add);
|
||||
marked = null;
|
||||
} else if (step.click === '-') {
|
||||
expect(removeRouterInterface(doc, id, 'me'), `step ${index}`).toBe(true);
|
||||
marked = null;
|
||||
}
|
||||
const { local, remote, buttons, add: addable, remove, cards, cables } = step;
|
||||
// Java's "-" stays open at one card; here the last card stays (below).
|
||||
expect(shown(doc, id, marked), `step ${index} ${step.click}`).toEqual({ local, remote, buttons, add: addable, remove: index === last ? false : remove, cards, cables });
|
||||
}
|
||||
expect(steps[last]!.remove).toBe(true);
|
||||
expect(steps.at(-1)!.cards).toEqual([]);
|
||||
// Java's router without a card keeps no gateway; the one with a card does.
|
||||
expect([steps[last]!.gateway, steps.at(-1)!.gateway]).toEqual(['10.0.0.254', null]);
|
||||
expect(steps[last]!.panelError).toBeNull();
|
||||
});
|
||||
|
||||
it('keep the last card, with its cable, and its gateway', () => {
|
||||
const doc = new Y.Doc();
|
||||
const id = add(doc, 'router', 'R', 2);
|
||||
const pc = add(doc, 'pc', 'A');
|
||||
connect(doc, { node: pc }, { node: id }, 'cable', 'me');
|
||||
setRouterGateway(doc, id, '192.168.0.254', 'me');
|
||||
expect(removeRouterInterface(doc, id, 'me')).toBe(true);
|
||||
expect(removeRouterInterface(doc, id, 'me')).toBe(false);
|
||||
expect(router(doc, id).interfaces).toHaveLength(1);
|
||||
expect(router(doc, id).interfaces[0]!.connected).toMatchObject({ device: 'A', link: 'cable' });
|
||||
expect(router(doc, id).gateway).toBe('192.168.0.254');
|
||||
});
|
||||
|
||||
it('remove the last card’s cable with it, and free the other device’s port', () => {
|
||||
const { doc, id, sw } = network();
|
||||
const ports = router(doc, id).interfaces.map((card) => card.portId);
|
||||
expect(swapRouterCables(doc, id, ports[1]!, ports[2]!, 'me')).toBe(true);
|
||||
expect(removeRouterInterface(doc, id, 'me')).toBe(true);
|
||||
expect([...doc.getMap<Y.Map<unknown>>(ROOTS.links).keys()]).toEqual(['cable-a']);
|
||||
expect(java.connections.switchPortsFree).toBe(true);
|
||||
// The switch's port takes another cable.
|
||||
const pc = add(doc, 'pc', 'B');
|
||||
expect(connect(doc, { node: pc }, { node: sw }, 'cable-b', 'me')).toEqual({ link: 'cable-b' });
|
||||
expect(java.connections.hostPortFree).toBe(true);
|
||||
});
|
||||
|
||||
it('give a new card Java’s address and mask, its own MAC address, the next label and no cable', () => {
|
||||
const { doc, id } = network();
|
||||
expect(addRouterInterface(doc, id, 'port-4', 'me')).toBe(true);
|
||||
const cards = router(doc, id).interfaces;
|
||||
expect(cards[3]).toMatchObject({ portId: 'port-4', ipAddress: '192.168.0.10', subnetMask: '255.255.255.0', connected: null });
|
||||
expect(new Set(cards.map((card) => card.macAddress)).size).toBe(4);
|
||||
const ports = (doc.getMap<Y.Map<unknown>>(ROOTS.nodes).get(id)!.get('ports') as Y.Array<Y.Map<unknown>>).toArray();
|
||||
expect(ports.map((port) => port.get('label'))).toEqual(['rt1', 'rt2', 'rt3', 'rt4']);
|
||||
expect(ports[3]!.get('occupied')).toBe(false);
|
||||
});
|
||||
|
||||
it('keep each cable’s identity and devices when cards swap, and move nothing between free cards', () => {
|
||||
const { doc, id, pc, sw } = network();
|
||||
const ports = router(doc, id).interfaces.map((card) => card.portId);
|
||||
const ends = () =>
|
||||
[...doc.getMap<Y.Map<unknown>>(ROOTS.links).entries()].map(([linkId, link]) => [linkId, link.get('sourceNode'), link.get('sourcePort'), link.get('targetNode'), link.get('targetPort')]);
|
||||
const before = ends();
|
||||
expect(swapRouterCables(doc, id, ports[0]!, ports[1]!, 'me')).toBe(true);
|
||||
// The PC plugged first (the router is the cable's target), the router into the switch (its source).
|
||||
expect(ends()).toEqual([
|
||||
['cable-a', pc, before[0]![2], id, ports[1]],
|
||||
['cable-s', id, ports[0], sw, before[1]![4]],
|
||||
]);
|
||||
addRouterInterface(doc, id, 'port-4', 'me');
|
||||
const now = ends();
|
||||
expect(swapRouterCables(doc, id, ports[2]!, 'port-4', 'me')).toBe(false);
|
||||
expect(ends()).toEqual(now);
|
||||
});
|
||||
|
||||
it('refuse a card twice, a card not the router’s, and devices that are no router', () => {
|
||||
const { doc, id, pc } = network();
|
||||
const ports = router(doc, id).interfaces.map((card) => card.portId);
|
||||
const updates: unknown[] = [];
|
||||
doc.on('update', (update) => updates.push(update));
|
||||
expect(swapRouterCables(doc, id, ports[0]!, ports[0]!, 'me')).toBe(false);
|
||||
expect(swapRouterCables(doc, id, ports[0]!, 'no-port', 'me')).toBe(false);
|
||||
expect(swapRouterCables(doc, id, 'no-port', ports[0]!, 'me')).toBe(false);
|
||||
const pcPort = (doc.getMap<Y.Map<unknown>>(ROOTS.nodes).get(pc)!.get('ports') as Y.Array<Y.Map<unknown>>).get(0).get('id') as string;
|
||||
expect(swapRouterCables(doc, id, ports[2]!, pcPort, 'me')).toBe(false);
|
||||
expect(swapRouterCables(doc, 'nobody', ports[0]!, ports[1]!, 'me')).toBe(false);
|
||||
const homeId = add(doc, 'gateway');
|
||||
const [wan, lan] = [home(doc, homeId).wan.portId, home(doc, homeId).lan.portId];
|
||||
expect(swapRouterCables(doc, homeId, wan, lan, 'me')).toBe(false);
|
||||
expect(addRouterInterface(doc, homeId, 'port-x', 'me')).toBe(false);
|
||||
expect(removeRouterInterface(doc, homeId, 'me')).toBe(false);
|
||||
expect(addRouterInterface(doc, pc, 'port-x', 'me')).toBe(false);
|
||||
expect(removeRouterInterface(doc, 'nobody', 'me')).toBe(false);
|
||||
const before = updates.length;
|
||||
expect(before).toBeGreaterThan(0);
|
||||
for (let more = 3; more < 8; more++) expect(addRouterInterface(doc, id, `port-${more}`, 'me')).toBe(true);
|
||||
expect(addRouterInterface(doc, id, 'port-9', 'me')).toBe(false);
|
||||
expect(router(doc, id).interfaces).toHaveLength(8);
|
||||
expect(updates).toHaveLength(before + 5);
|
||||
});
|
||||
|
||||
it('click as Java’s dialog does: mark, unmark, swap', () => {
|
||||
expect(connectionsClick(null, 2)).toEqual({ marked: 2 });
|
||||
expect(connectionsClick(2, 2)).toEqual({ marked: null });
|
||||
expect(connectionsClick(2, 0)).toEqual({ marked: null, swap: [2, 0] });
|
||||
});
|
||||
|
||||
it('are each one step of the person’s undo, and go into the file', () => {
|
||||
const { doc, id } = network();
|
||||
const undo = createUndoManager(doc, 'me');
|
||||
const ports = router(doc, id).interfaces.map((card) => card.portId);
|
||||
undo.stopCapturing();
|
||||
addRouterInterface(doc, id, 'port-4', 'me');
|
||||
undo.stopCapturing();
|
||||
swapRouterCables(doc, id, ports[0]!, 'port-4', 'me');
|
||||
undo.stopCapturing();
|
||||
removeRouterInterface(doc, id, 'me');
|
||||
expect(router(doc, id).interfaces.map((card) => card.connected?.device ?? '')).toEqual(['', 'S', '']);
|
||||
undo.undo();
|
||||
expect(router(doc, id).interfaces.map((card) => card.connected?.device ?? '')).toEqual(['', 'S', '', 'A']);
|
||||
undo.undo();
|
||||
expect(router(doc, id).interfaces.map((card) => card.connected?.device ?? '')).toEqual(['A', 'S', '', '']);
|
||||
swapRouterCables(doc, id, ports[0]!, 'port-4', 'me');
|
||||
let counter = 0;
|
||||
const reopened = openProjectArchive(saveProjectArchive(readProject(doc)).archive, () => `70000000-0000-0000-0000-${(++counter).toString(16).padStart(12, '0')}`);
|
||||
const again = reopened.graph.nodes.find((node) => node.displayName === 'R')!;
|
||||
expect(again.ports.map((port) => port.label)).toEqual(['rt1', 'rt2', 'rt3', 'rt4']);
|
||||
const plugged = reopened.graph.links.flatMap((link) => [link.sourcePortId, link.targetPortId]);
|
||||
expect(again.ports.map((port) => plugged.includes(port.id))).toEqual([false, true, false, true]);
|
||||
});
|
||||
|
||||
it('name the address of the card a cable plugs into: a computer’s, a router’s, none for a switch', () => {
|
||||
const { doc, id } = network();
|
||||
const other = add(doc, 'router', 'R2');
|
||||
setInterfaceAddress(doc, other, router(doc, other).interfaces[1]!.portId, 'ipAddress', '10.2.0.2', 'me');
|
||||
connect(doc, { node: id, port: router(doc, id).interfaces[2]!.portId }, { node: other, port: router(doc, other).interfaces[1]!.portId }, 'cable-r', 'me');
|
||||
const homeId = add(doc, 'gateway', 'H');
|
||||
const modem = add(doc, 'remoteLink', 'M');
|
||||
addRouterInterface(doc, id, 'port-4', 'me');
|
||||
addRouterInterface(doc, id, 'port-5', 'me');
|
||||
connect(doc, { node: homeId, port: home(doc, homeId).lan.portId }, { node: id, port: 'port-4' }, 'cable-h', 'me');
|
||||
connect(doc, { node: modem }, { node: id, port: 'port-5' }, 'cable-m', 'me');
|
||||
expect(router(doc, id).interfaces.map((card) => card.connected && [card.connected.device, card.connected.address])).toEqual([
|
||||
['A', '10.0.0.2'],
|
||||
['S', null],
|
||||
['R2', '10.2.0.2'],
|
||||
['H', '192.168.0.10'],
|
||||
['M', null],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -400,6 +400,102 @@ test("sets up a router's firewall on its own page: the switches, the default, ru
|
||||
await expect(sheet(page).getByRole('heading')).toHaveText('Vermittlungsrechner');
|
||||
});
|
||||
|
||||
test("manages a router's connections on its own page: cables swapped with two taps, cards added and removed, the last kept", async ({ page }) => {
|
||||
await openEditor(page, server.base, 'zwei_rechnernetze_komplett.fls');
|
||||
const id = (await drawing(page)).nodes.find((node) => node.kind === 'router')!.id;
|
||||
await choose(page, id);
|
||||
const joined = async () => ((await sheetOf(page, id)) as RouterSheet).interfaces.map((card) => card.connected?.device ?? '');
|
||||
const values = (await sheetOf(page, id)) as RouterSheet;
|
||||
const [one, two] = await joined();
|
||||
const cables = (await drawing(page)).links.length;
|
||||
await sheet(page).getByRole('button', { name: 'Verbindungen verwalten' }).click();
|
||||
await expect(sheet(page).getByRole('heading', { level: 2 })).toHaveText('Verbindungen verwalten');
|
||||
const items = sheet(page).locator('ol.connections li');
|
||||
const port = (index: number) => items.nth(index).getByRole('button');
|
||||
await expect(items.locator('.connection-local')).toHaveText(values.interfaces.map((card, index) => `NIC ${index + 1}: ${card.ipAddress}`));
|
||||
await expect(items.locator('.connection-remote')).toHaveText([one!, two!]);
|
||||
// Java's colours: green for a plugged card, yellow for the marked one, red for a free one.
|
||||
await expect(port(0)).toHaveCSS('background-color', 'rgb(0, 255, 0)');
|
||||
await port(0).click();
|
||||
await expect(port(0)).toHaveAttribute('aria-pressed', 'true');
|
||||
await expect(port(0)).toHaveCSS('background-color', 'rgb(255, 255, 0)');
|
||||
await port(0).click();
|
||||
await expect(port(0)).toHaveAttribute('aria-pressed', 'false');
|
||||
await port(0).click();
|
||||
await port(1).click();
|
||||
await expect.poll(joined).toEqual([two, one]);
|
||||
await expect(items.nth(0)).toHaveAttribute('data-state', 'plugged');
|
||||
|
||||
await sheet(page).getByRole('button', { name: 'Schnittstelle hinzufügen' }).click();
|
||||
await expect(items).toHaveCount(3);
|
||||
await expect(items.nth(2).locator('.connection-local')).toHaveText('NIC 3: 192.168.0.10');
|
||||
await expect(port(2)).toHaveCSS('background-color', 'rgb(255, 0, 0)');
|
||||
await expect(port(2)).toHaveAccessibleName('NIC 3: 192.168.0.10, Keine Verbindung');
|
||||
// A free card takes the marked card's cable.
|
||||
await port(0).click();
|
||||
await port(2).click();
|
||||
await expect.poll(joined).toEqual(['', one, two]);
|
||||
await sheet(page).getByRole('button', { name: 'Schnittstelle entfernen' }).click();
|
||||
await expect.poll(joined).toEqual(['', one]);
|
||||
expect((await drawing(page)).links).toHaveLength(cables - 1);
|
||||
|
||||
for (let count = 2; count < 8; count++) await sheet(page).getByRole('button', { name: 'Schnittstelle hinzufügen' }).click();
|
||||
await expect(items).toHaveCount(8);
|
||||
await expect(sheet(page).getByRole('button', { name: 'Schnittstelle hinzufügen' })).toBeDisabled();
|
||||
await expect(sheet(page).getByText('Maximal 8 Schnittstellen erlaubt!')).toBeVisible();
|
||||
for (let count = 8; count > 1; count--) await sheet(page).getByRole('button', { name: 'Schnittstelle entfernen' }).click();
|
||||
await expect(items).toHaveCount(1);
|
||||
await expect(sheet(page).getByRole('button', { name: 'Schnittstelle entfernen' })).toBeDisabled();
|
||||
await expect(sheet(page).getByText('Eine Schnittstelle bleibt immer.')).toBeVisible();
|
||||
|
||||
await sheet(page).getByRole('button', { name: 'Zurück' }).click();
|
||||
const tabs = sheet(page).getByRole('tab');
|
||||
await expect(tabs).toHaveText(['Allgemein', values.interfaces[0]!.ipAddress, 'Weiterleitungstabelle']);
|
||||
// Undone, the second card comes back with its cable; a card's tab whose card goes again shows the general tab.
|
||||
await page.keyboard.press('Control+z');
|
||||
await expect(tabs).toHaveCount(4);
|
||||
await tabs.nth(2).click();
|
||||
await expect(sheet(page).locator('.sheet-connected')).toHaveText(`Verbunden mit ${one}`);
|
||||
await page.keyboard.press('Control+Shift+z');
|
||||
await expect(tabs).toHaveCount(3);
|
||||
await expect(tabs.first()).toHaveAttribute('aria-selected', 'true');
|
||||
await expect(field(page, 'name')).toHaveValue(values.name);
|
||||
});
|
||||
|
||||
test("names a router's computer by its address, and lets go of a marked card when cards change", async ({ page }) => {
|
||||
await openEditor(page, server.base, 'internetwork_komplett.fls');
|
||||
const routers = (await drawing(page)).nodes.filter((node) => node.kind === 'router');
|
||||
let id = '';
|
||||
for (const each of routers) if (((await sheetOf(page, each.id)) as RouterSheet).interfaces.some((card) => card.connected?.address)) id = each.id;
|
||||
await choose(page, id);
|
||||
const values = (await sheetOf(page, id)) as RouterSheet;
|
||||
await sheet(page).getByRole('button', { name: 'Verbindungen verwalten' }).click();
|
||||
const items = sheet(page).locator('ol.connections li');
|
||||
const port = (index: number) => items.nth(index).getByRole('button');
|
||||
// Java: a computer's or a router's name with the address of its card, a switch's name alone.
|
||||
await expect(items.locator('.connection-remote')).toHaveText(
|
||||
values.interfaces.flatMap((card) => (card.connected === null ? [] : [card.connected.address === null ? card.connected.device : `${card.connected.device} (${card.connected.address})`])),
|
||||
);
|
||||
expect(values.interfaces.some((card) => card.connected?.address !== null && card.connected?.address !== undefined)).toBe(true);
|
||||
const pressed = () => sheet(page).locator('.connection-port[aria-pressed="true"]');
|
||||
await port(0).click();
|
||||
await sheet(page).getByRole('button', { name: 'Schnittstelle hinzufügen' }).click();
|
||||
await expect(pressed()).toHaveCount(0);
|
||||
await port(0).click();
|
||||
await sheet(page).getByRole('button', { name: 'Schnittstelle entfernen' }).click();
|
||||
await expect(pressed()).toHaveCount(0);
|
||||
// A marked card undone away is marked no more: the next tap marks, it swaps nothing.
|
||||
await sheet(page).getByRole('button', { name: 'Schnittstelle hinzufügen' }).click();
|
||||
const count = values.interfaces.length + 1;
|
||||
await port(count - 1).click();
|
||||
await page.keyboard.press('Control+z');
|
||||
await expect(items).toHaveCount(count - 1);
|
||||
const before = ((await sheetOf(page, id)) as RouterSheet).interfaces.map((card) => card.connected?.link ?? null);
|
||||
await port(0).click();
|
||||
await expect(port(0)).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(((await sheetOf(page, id)) as RouterSheet).interfaces.map((card) => card.connected?.link ?? null)).toEqual(before);
|
||||
});
|
||||
|
||||
test("opens a home router's firewall from its general tab", async ({ page }) => {
|
||||
await openEditor(page, server.base, 'new');
|
||||
await page.locator('[data-palette="gateway"]').click();
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 714 B |
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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')} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -9,9 +9,9 @@ import { CATALOGS, translate } from '../src/i18n/i18n.js';
|
||||
|
||||
const bundles = join(dirname(fileURLToPath(import.meta.url)), '../../../tools/java-oracle/.upstream/src/main/resources/filius/messages');
|
||||
|
||||
/** A Java .properties file: ISO-8859-1, \uXXXX escapes, `key = value`. */
|
||||
/** A Java .properties file: ISO-8859-1, \uXXXX escapes, `key = value`, a backslash at a line's end going on in the next without its indent. */
|
||||
function properties(file: string): Map<string, string> {
|
||||
const text = new TextDecoder('latin1').decode(readFileSync(join(bundles, file)));
|
||||
const text = new TextDecoder('latin1').decode(readFileSync(join(bundles, file))).replace(/\\\r?\n[ \t]*/g, '');
|
||||
const entries = new Map<string, string>();
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
const match = /^([\w.]+)\s*=\s*(.*)$/.exec(line);
|
||||
@@ -105,6 +105,10 @@ const FROM_JAVA: Readonly<Record<string, string>> = {
|
||||
'firewall.dropIcmp': 'jfirewalldialog_msg40',
|
||||
'firewall.dropIcmpHint': 'jfirewalldialog_msg41',
|
||||
'firewall.filterSyn': 'jfirewalldialog_msg42',
|
||||
'sheet.connections': 'jvermittlungsrechnerkonfiguration_msg23',
|
||||
'connections.local': 'jvermittlungsrechnerkonfiguration_msg22',
|
||||
'connections.add': 'jvermittlungsrechnerkonfiguration_msg24',
|
||||
'connections.most': 'jvermittlungsrechnerkonfiguration_msg25',
|
||||
};
|
||||
|
||||
/** Java's words, mended: the prompt says "Vermittlungsrechnerns" (guicontainer_msg1). */
|
||||
@@ -124,6 +128,16 @@ const FROM_JAVA_MENDED: Readonly<Record<string, readonly [string, (java: string)
|
||||
// Java's "\n" as line breaks; the last sentence of the German starts with a small letter there.
|
||||
'firewall.intro': ['jfirewalldialog_msg37', (java) => java.replaceAll('\\n', '\n').replace('\nüber', '\nÜber')],
|
||||
'firewall.filterSynHint': ['jfirewalldialog_msg43', (java) => java.replaceAll('\\n', '\n')],
|
||||
// Java's lines without their leading space and the empty one; a step starts with a capital letter.
|
||||
'connections.hint': [
|
||||
'jvermittlungsrechnerkonfiguration_msg19',
|
||||
(java) =>
|
||||
java
|
||||
.split('\\n')
|
||||
.map((line) => line.trim().replace(/^(\d\. )(\p{Ll})/u, (_, step: string, letter: string) => step + letter.toUpperCase()))
|
||||
.filter((line) => line !== '')
|
||||
.join('\n'),
|
||||
],
|
||||
};
|
||||
|
||||
describe('the catalogs', () => {
|
||||
|
||||
Reference in New Issue
Block a user