feat: F4.7 the data exchange window, measured against Java's
One row per frame (its highest layer's), Java's detail tree, the table as Java's text file, "Tabellen löschen" for every card (clearCapture in the room), a tab per card with Java's title, Java's layer colours as tokens, the chosen frame and autoscroll per tab. New oracle ExchangeWindowRun. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,204 @@
|
||||
// The data exchange window in Chromium (F4.7), on browser/fixtures/web-pages.fls:
|
||||
// Java's menu offers a computer's desktop and each card's data exchange with
|
||||
// its address; the browser's card while it fetches a page — a row per frame
|
||||
// in its layer's colour, the chosen frame's details, the table as a text
|
||||
// file named after the tab —, a router's two cards beside it, "Tabellen
|
||||
// löschen", "Automatisches Scrollen" off, a tab closed, the window gone with
|
||||
// the design and back with the next start without the tab of a computer
|
||||
// removed; and on a phone.
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { expect, test, type Page } from '@playwright/test';
|
||||
import { drawing, onDevice, serveEditor } from './editorPage.js';
|
||||
|
||||
let server: { readonly base: string; readonly close: () => Promise<void> };
|
||||
|
||||
test.beforeAll(async () => {
|
||||
server = await serveEditor();
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await server.close();
|
||||
});
|
||||
|
||||
const FIXTURE = join(dirname(fileURLToPath(import.meta.url)), 'fixtures/web-pages.fls');
|
||||
|
||||
async function openPages(page: Page): Promise<void> {
|
||||
await page.goto(`${server.base}?example=new`);
|
||||
await page.waitForSelector('svg.filius-canvas');
|
||||
await page.locator('input[type="file"]').setInputFiles(FIXTURE);
|
||||
await expect.poll(async () => (await drawing(page).catch(() => ({ nodes: [] }))).nodes.length).toBe(4);
|
||||
}
|
||||
|
||||
async function start(page: Page): Promise<void> {
|
||||
await page.getByRole('button', { name: 'Wechselt in den Aktionsmodus' }).click();
|
||||
await expect(page.locator('svg.filius-canvas')).toHaveAttribute('data-mode', 'simulation');
|
||||
}
|
||||
|
||||
const idOf = async (page: Page, name: string) => (await drawing(page)).nodes.find((node) => node.name === name)!.id;
|
||||
|
||||
/** Java's menu of a device in the simulation, by a long press. */
|
||||
async function menuOf(page: Page, name: string) {
|
||||
const at = await onDevice(page, await idOf(page, name));
|
||||
await page.mouse.move(at.x, at.y);
|
||||
await page.mouse.down();
|
||||
await page.waitForTimeout(700);
|
||||
await page.mouse.up();
|
||||
return page.getByRole('menuitem');
|
||||
}
|
||||
|
||||
test("shows a card's frames as Java's data exchange window does, with their details, as a text file, and empties every card's", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await openPages(page);
|
||||
await start(page);
|
||||
|
||||
// Java's menu: the desktop, then the card's data exchange with its address.
|
||||
await expect(await menuOf(page, 'Browser')).toHaveText(['Desktop anzeigen', 'Datenaustausch anzeigen (192.168.0.10)']);
|
||||
await page.getByRole('menuitem', { name: 'Datenaustausch anzeigen (192.168.0.10)' }).click();
|
||||
const window = page.locator('section[data-capture]');
|
||||
await expect(window.locator('h2')).toHaveText('Datenaustausch');
|
||||
await expect(window.getByRole('tab')).toHaveText(['Browser - 192.168.0.10']);
|
||||
await expect(window.locator('th')).toHaveText(['Nr.', 'Zeit', 'Quelle', 'Ziel', 'Protokoll', 'Schicht', 'Bemerkungen / Details']);
|
||||
await expect(window.locator('tbody tr')).toHaveCount(0);
|
||||
// Closed, it keeps its tab (Java hides its dialog); it lies over the devices.
|
||||
await window.locator('.desktop-close').click();
|
||||
await expect(window).toHaveCount(0);
|
||||
|
||||
// The browser fetches the server's page: a row per frame, its highest layer's, in its colour.
|
||||
const at = await onDevice(page, await idOf(page, 'Browser'));
|
||||
await page.mouse.click(at.x, at.y);
|
||||
const desktop = page.locator('section[data-desktop]');
|
||||
await desktop.locator('[data-program="webBrowser"]').click();
|
||||
const address = desktop.getByRole('textbox', { name: 'Adresse' });
|
||||
await address.fill('192.168.0.20');
|
||||
await address.press('Enter');
|
||||
await expect(desktop.locator('.program-titlebar h3')).toHaveText('Seiten & Formulare', { timeout: 30_000 });
|
||||
await desktop.locator('.desktop-close').click();
|
||||
await (await menuOf(page, 'Browser')).filter({ hasText: 'Datenaustausch' }).click();
|
||||
await expect(window.getByRole('tab')).toHaveText(['Browser - 192.168.0.10']);
|
||||
const rows = window.locator('tbody tr');
|
||||
await expect(rows.first().locator('td')).toHaveText(['1', /^\d\d:\d\d:\d\d\.\d\d\d$/, '192.168.0.10', '192.168.0.20', 'ARP', 'Vermittlung', /^Suche nach MAC für 192\.168\.0\.20 \[op=REQUEST/]);
|
||||
await expect(rows.first()).toHaveClass(/capture-internet/);
|
||||
const get = rows.filter({ hasText: 'GET / HTTP/1.1' }).first();
|
||||
await expect(get.locator('td').nth(4)).toHaveText('HTTP');
|
||||
await expect(get.locator('td').nth(5)).toHaveText('Anwendung');
|
||||
await expect(get.locator('td').nth(2)).toHaveText(/^192\.168\.0\.10:\d{5}$/);
|
||||
await expect(get).toHaveClass(/capture-application/);
|
||||
// Java's colours (LayerColorVar): application #FFD966, transport #F4B183.
|
||||
expect(await get.evaluate((row) => getComputedStyle(row).backgroundColor)).toBe('rgb(255, 217, 102)');
|
||||
const syn = rows.filter({ hasText: /^.*SYN, SEQ: [\d.]+$/ }).first();
|
||||
await expect(syn).toHaveClass(/capture-transport/);
|
||||
expect(await syn.evaluate((row) => getComputedStyle(row).backgroundColor)).toBe('rgb(244, 177, 131)');
|
||||
const numbers = await rows.locator('td:first-child').allTextContents();
|
||||
expect(numbers).toEqual(numbers.map((_, index) => String(index + 1)));
|
||||
// A line break in a comment shows as a space (LauscherTableCellRenderer).
|
||||
expect(
|
||||
await get
|
||||
.locator('td')
|
||||
.nth(6)
|
||||
.evaluate((cell) => cell.textContent),
|
||||
).toMatch(/^GET \/ HTTP\/1\.1 Host: 192\.168\.0\.20 /);
|
||||
// "Automatisches Scrollen" (on): the table at its end.
|
||||
const scroller = window.locator('.capture-table');
|
||||
const scrolled = () =>
|
||||
scroller.evaluate((element) => ({
|
||||
overflows: element.scrollHeight > element.clientHeight + 20,
|
||||
atEnd: element.scrollTop + element.clientHeight >= element.scrollHeight - 2,
|
||||
top: element.scrollTop,
|
||||
}));
|
||||
await expect.poll(scrolled).toMatchObject({ overflows: true, atEnd: true });
|
||||
|
||||
// The request chosen: white on black, its details below, every layer.
|
||||
await get.click();
|
||||
await expect(get).toHaveAttribute('aria-selected', 'true');
|
||||
expect(await get.evaluate((row) => [getComputedStyle(row).backgroundColor, getComputedStyle(row).color])).toEqual(['rgb(0, 0, 0)', 'rgb(255, 255, 255)']);
|
||||
const details = window.getByRole('list', { name: 'Bemerkungen / Details' }).locator('li');
|
||||
await expect(details.first()).toHaveText(/^Nr\.: \d+ \/ Zeit: \d\d:\d\d:\d\d\.\d\d\d$/);
|
||||
const lines = await details.allTextContents();
|
||||
expect(lines.filter((line) => ['Netzzugang', 'Vermittlung', 'Transport', 'Anwendung'].includes(line))).toEqual(['Netzzugang', 'Vermittlung', 'Transport', 'Anwendung']);
|
||||
expect(lines).toContain('Protokoll: HTTP');
|
||||
expect(lines.some((line) => line.startsWith('Bemerkungen / Details (') && /^[^(]+\(\d+ Bytes\): $/.test(line))).toBe(true);
|
||||
expect(lines.some((line) => line.startsWith('GET / HTTP/1.1\n'))).toBe(true);
|
||||
|
||||
// The table as Java's text file, named after the tab.
|
||||
const [download] = await Promise.all([page.waitForEvent('download'), window.getByRole('button', { name: 'Als Textdatei exportieren' }).click()]);
|
||||
expect(download.suggestedFilename()).toBe('Browser - 192.168.0.10.txt');
|
||||
const text = readFileSync((await download.path())!, 'utf8');
|
||||
expect(text.split('\r\n').slice(0, 3)).toEqual([
|
||||
`+${'='.repeat(12)}${`+${'='.repeat(22)}`.repeat(5)}+${'='.repeat(42)}+`,
|
||||
`| Nr. | Zeit | Quelle | Ziel | Protokoll | Schicht | ${'Bemerkungen / Details'.padEnd(40)} | `,
|
||||
`+${'='.repeat(12)}${`+${'='.repeat(22)}`.repeat(5)}+${'='.repeat(42)}+`,
|
||||
]);
|
||||
expect(text).toContain('| 1 | ');
|
||||
expect(text.match(/^\+-/gm)?.length).toBe(await rows.count());
|
||||
|
||||
// A router's cards: one tab each, the one chosen in front.
|
||||
await window.locator('.desktop-close').click();
|
||||
await expect(await menuOf(page, 'Router')).toHaveText(['Datenaustausch anzeigen (192.168.0.1)', 'Datenaustausch anzeigen (10.0.0.1)']);
|
||||
await page.getByRole('menuitem', { name: 'Datenaustausch anzeigen (192.168.0.1)' }).click();
|
||||
await expect(window.getByRole('tab')).toHaveText(['Browser - 192.168.0.10', 'Router - 192.168.0.1']);
|
||||
await expect(window.getByRole('tab', { selected: true })).toHaveText('Router - 192.168.0.1');
|
||||
await expect(window.locator('th')).toHaveCount(7);
|
||||
// The router's card heard the ARP broadcast.
|
||||
await expect(rows.first().locator('td').nth(4)).toHaveText('ARP');
|
||||
|
||||
// "Tabellen löschen": every card's list, numbered anew.
|
||||
await window.getByRole('button', { name: 'Tabellen löschen' }).click();
|
||||
await expect(rows).toHaveCount(0);
|
||||
await window.getByRole('tab', { name: 'Browser - 192.168.0.10' }).click();
|
||||
await expect(rows).toHaveCount(0);
|
||||
await expect(window.getByRole('list', { name: 'Bemerkungen / Details' }).locator('li')).toHaveCount(0);
|
||||
|
||||
// Without "Automatisches Scrollen", fetched again (its tab kept over the window closed): the table stays at its start, nothing chosen.
|
||||
await window.getByLabel('Automatisches Scrollen').uncheck();
|
||||
await window.locator('.desktop-close').click();
|
||||
await page.mouse.click(at.x, at.y);
|
||||
await desktop.locator('[data-program="webBrowser"]').click();
|
||||
await address.fill('192.168.0.20');
|
||||
await address.press('Enter');
|
||||
await page.waitForTimeout(6_000);
|
||||
await desktop.locator('.desktop-close').click();
|
||||
await (await menuOf(page, 'Browser')).filter({ hasText: 'Datenaustausch' }).click();
|
||||
await expect(window.getByLabel('Automatisches Scrollen')).not.toBeChecked();
|
||||
await expect.poll(() => rows.count()).toBeGreaterThan(15);
|
||||
await expect(rows.first().locator('td').first()).toHaveText('1');
|
||||
await expect(window.locator('tbody tr[aria-selected="true"]')).toHaveCount(0);
|
||||
expect(await scrolled()).toMatchObject({ overflows: true, top: 0 });
|
||||
|
||||
// A third tab; a tab closed beside the chosen one leaves it chosen.
|
||||
await window.locator('.desktop-close').click();
|
||||
await (await menuOf(page, 'Server')).filter({ hasText: 'Datenaustausch' }).click();
|
||||
await expect(window.getByRole('tab')).toHaveText(['Browser - 192.168.0.10', 'Router - 192.168.0.1', 'Server - 192.168.0.20']);
|
||||
await window.getByRole('tab', { name: 'Browser - 192.168.0.10' }).click();
|
||||
await window.getByRole('button', { name: 'Reiter schließen: Router - 192.168.0.1' }).click();
|
||||
await expect(window.getByRole('tab')).toHaveText(['Browser - 192.168.0.10', 'Server - 192.168.0.20']);
|
||||
await expect(window.getByRole('tab', { selected: true })).toHaveText('Browser - 192.168.0.10');
|
||||
|
||||
// The window goes with the design and comes back with the next start; the tab of a computer removed in the design is gone.
|
||||
await page.getByRole('button', { name: 'Wechselt in den Entwurfsmodus' }).click();
|
||||
await expect(window).toHaveCount(0);
|
||||
const server = await onDevice(page, await idOf(page, 'Server'));
|
||||
await page.mouse.click(server.x, server.y);
|
||||
await page.keyboard.press('Delete');
|
||||
await expect.poll(async () => (await drawing(page)).nodes.length).toBe(3);
|
||||
await start(page);
|
||||
await expect(window.getByRole('tab')).toHaveText(['Browser - 192.168.0.10']);
|
||||
// Its last tab closed, the window shows nothing.
|
||||
await window.getByRole('button', { name: 'Reiter schließen: Browser - 192.168.0.10' }).click();
|
||||
await expect(window).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('fills a phone with the data exchange window, its bar in reach', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 360, height: 740 });
|
||||
await openPages(page);
|
||||
await start(page);
|
||||
await (await menuOf(page, 'Browser')).filter({ hasText: 'Datenaustausch' }).click();
|
||||
const window = page.locator('section[data-capture]');
|
||||
for (const name of ['Tabellen löschen', 'Als Textdatei exportieren']) await expect(window.getByRole('button', { name })).toBeInViewport({ ratio: 1 });
|
||||
await expect(window.getByLabel('Automatisches Scrollen')).toBeChecked();
|
||||
const box = (await window.boundingBox())!;
|
||||
expect(box.x).toBeGreaterThanOrEqual(0);
|
||||
expect(box.x + box.width).toBeLessThanOrEqual(360);
|
||||
});
|
||||
@@ -4,7 +4,8 @@
|
||||
// in Java — no palette, no sheet, nothing moves — the cables that carry a
|
||||
// frame light up, and a tap on a computer opens its desktop. Desktops left
|
||||
// open stay over a return to the design and show again at the next start,
|
||||
// as Java's do (GUIContainer.closeDialogs, reopenDialogs).
|
||||
// as Java's do (GUIContainer.closeDialogs, reopenDialogs); so does the data
|
||||
// exchange window with its tabs (F4.7).
|
||||
//
|
||||
// Every change goes through packages/doc's commands with this editor's own
|
||||
// origin, so that its undo takes back what this person did and nothing of
|
||||
@@ -12,7 +13,7 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
|
||||
import type * as Y from 'yjs';
|
||||
import { addDevice, connect, createUndoManager, moveDevice, removeDevice, removeLink, removeLinksOf, topologyView, type TopologyView } from '@mdg-filius/doc';
|
||||
import { addDevice, captureCards, connect, createUndoManager, moveDevice, removeDevice, removeLink, removeLinksOf, topologyView, type TopologyView } from '@mdg-filius/doc';
|
||||
import { Canvas, type CanvasHandle, type Selection, type Side } from './canvas/Canvas.js';
|
||||
import type { Point } from './canvas/viewport.js';
|
||||
import { ContextMenu, type MenuItem } from './design/ContextMenu.js';
|
||||
@@ -21,6 +22,7 @@ import { RouterDialog } from './design/RouterDialog.js';
|
||||
import { DeviceSheet } from './sheet/DeviceSheet.js';
|
||||
import { DesktopWindow, type DesktopWindowName } from './desktop/DesktopWindow.js';
|
||||
import { useLanguage, useTranslate } from './i18n/i18n.js';
|
||||
import { CaptureWindow } from './simulation/CaptureWindow.js';
|
||||
import { ModeBar } from './simulation/ModeBar.js';
|
||||
import { DropControl } from './simulation/DropControl.js';
|
||||
import { ViewerWindow, type ViewerKind } from './simulation/ViewerWindow.js';
|
||||
@@ -75,16 +77,31 @@ 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 — 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). */
|
||||
/** The data exchange window's place among the desktops (it is one window, with a tab per card). */
|
||||
const CAPTURE_WINDOW = 'capture';
|
||||
|
||||
/**
|
||||
* A desktop open on the surface — or a switch's or home router's viewer
|
||||
* (`viewer`), or the data exchange window (`capture`) —: whose, where its
|
||||
* window lies, and how far up (the last one touched on top).
|
||||
*/
|
||||
interface OpenDesktop {
|
||||
readonly id: string;
|
||||
readonly viewer?: ViewerKind;
|
||||
readonly viewer?: ViewerKind | 'capture';
|
||||
readonly x: number;
|
||||
readonly y: number;
|
||||
readonly z: number;
|
||||
readonly program: DesktopWindowName | null;
|
||||
}
|
||||
|
||||
/** A card's tab in the data exchange window: whose card, the frame chosen in it, whether it scrolls along (AggregatedMessageTable's own). */
|
||||
interface OpenCaptureTab {
|
||||
readonly node: string;
|
||||
readonly port: string;
|
||||
readonly chosen: number | undefined;
|
||||
readonly autoscroll: boolean;
|
||||
}
|
||||
|
||||
/** Brings a desktop to the top by its stacking, not its place in the page: moved in the page, it would lose the press on it. */
|
||||
const toFront = (open: readonly OpenDesktop[], id: string): readonly OpenDesktop[] => {
|
||||
const top = Math.max(0, ...open.map((each) => each.z));
|
||||
@@ -138,13 +155,30 @@ export function Editor({ doc, createSimHost }: { readonly doc: Y.Doc; readonly c
|
||||
const kinds = new Map(view.nodes.map((node) => [node.id, node.kind]));
|
||||
const fits = (each: OpenDesktop) => {
|
||||
const kind = kinds.get(each.id);
|
||||
if (each.viewer === 'capture') return true;
|
||||
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 a switch's or home router's viewer, or brings it to the front. */
|
||||
const openDesktop = useCallback((id: string, viewer?: ViewerKind) => {
|
||||
// The data exchange window's tabs (Java keeps them when the window is closed); a card that is gone loses its tab
|
||||
// (Java: clearUnavailableComponents). Without a tab the window shows nothing; it keeps its place, as Java's dialog does.
|
||||
const [captureTabs, setCaptureTabs] = useState<{ readonly tabs: readonly OpenCaptureTab[]; readonly selected: string | null }>({ tabs: [], selected: null });
|
||||
const changeCaptureTab = useCallback(
|
||||
(port: string, change: Partial<OpenCaptureTab>) => setCaptureTabs((before) => ({ ...before, tabs: before.tabs.map((tab) => (tab.port === port ? { ...tab, ...change } : tab)) })),
|
||||
[],
|
||||
);
|
||||
useEffect(() => {
|
||||
const cards = new Set(view.nodes.flatMap((node) => captureCards(doc, node.id).map((card) => `${node.id}/${card.portId}`)));
|
||||
setCaptureTabs((before) => {
|
||||
const tabs = before.tabs.filter((tab) => cards.has(`${tab.node}/${tab.port}`));
|
||||
if (tabs.length === before.tabs.length) return before;
|
||||
return { tabs, selected: tabs.some((tab) => tab.port === before.selected) ? before.selected : (tabs.at(-1)?.port ?? null) };
|
||||
});
|
||||
}, [view, doc]);
|
||||
|
||||
/** Opens a computer's desktop, or a switch's or home router's viewer, or the data exchange window, or brings it to the front. */
|
||||
const openDesktop = useCallback((id: string, viewer?: ViewerKind | 'capture') => {
|
||||
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.
|
||||
@@ -200,11 +234,25 @@ export function Editor({ doc, createSimHost }: { readonly doc: Y.Doc; readonly c
|
||||
[doc, origin, manager],
|
||||
);
|
||||
|
||||
/** Shows a card's frames in the data exchange window: its tab chosen, added where it has none (AggregatedExchangePanel.addTable). */
|
||||
const openCapture = useCallback(
|
||||
(node: string, port: string) => {
|
||||
setCaptureTabs((before) => ({ tabs: before.tabs.some((tab) => tab.port === port) ? before.tabs : [...before.tabs, { node, port, chosen: undefined, autoscroll: true }], selected: port }));
|
||||
openDesktop(CAPTURE_WINDOW, 'capture');
|
||||
},
|
||||
[openDesktop],
|
||||
);
|
||||
|
||||
const menuItems = (target: Selection): MenuItem[] => {
|
||||
if (simulating) {
|
||||
// Java's menu in the simulation: a computer's desktop (its data exchange follows with the capture, F4.7).
|
||||
// Java's menu in the simulation: a computer's desktop, then each card's data exchange with its address.
|
||||
if (target.kind !== 'device') return [];
|
||||
const node = view.nodes.find((each) => each.id === target.id);
|
||||
return target.kind === 'device' && (node?.kind === 'pc' || node?.kind === 'notebook') ? [{ key: 'showDesktop', label: t('menu.showDesktop'), onChoose: () => openDesktop(target.id) }] : [];
|
||||
const items: MenuItem[] = node?.kind === 'pc' || node?.kind === 'notebook' ? [{ key: 'showDesktop', label: t('menu.showDesktop'), onChoose: () => openDesktop(target.id) }] : [];
|
||||
for (const card of captureCards(doc, target.id)) {
|
||||
items.push({ key: `showCapture-${card.portId}`, label: `${t('menu.showCapture')} (${card.ipAddress})`, onChoose: () => openCapture(target.id, card.portId) });
|
||||
}
|
||||
return items;
|
||||
}
|
||||
if (target.kind === 'link') return [{ key: 'removeCable', label: t('menu.removeCable'), onChoose: () => remove(target) }];
|
||||
const node = view.nodes.find((each) => each.id === target.id);
|
||||
@@ -310,7 +358,36 @@ export function Editor({ doc, createSimHost }: { readonly doc: Y.Doc; readonly c
|
||||
</div>
|
||||
{simulating
|
||||
? desktops.map((desktop) =>
|
||||
desktop.viewer !== undefined ? (
|
||||
desktop.viewer === 'capture' ? (
|
||||
captureTabs.selected === null ? null : (
|
||||
<CaptureWindow
|
||||
key={desktop.id}
|
||||
tabs={captureTabs.tabs.map((tab) => ({ ...tab, title: captureCards(doc, tab.node).find((card) => card.portId === tab.port)?.title ?? '' }))}
|
||||
selected={captureTabs.selected}
|
||||
simulation={simulation}
|
||||
x={desktop.x}
|
||||
y={desktop.y}
|
||||
z={desktop.z}
|
||||
onSelect={(port) => setCaptureTabs((before) => ({ ...before, selected: port }))}
|
||||
onCloseTab={(port) =>
|
||||
setCaptureTabs((before) => {
|
||||
const tabs = before.tabs.filter((tab) => tab.port !== port);
|
||||
return { tabs, selected: before.selected === port ? (tabs.at(-1)?.port ?? null) : before.selected };
|
||||
})
|
||||
}
|
||||
onChoose={(port, number) => changeCaptureTab(port, { chosen: number })}
|
||||
onAutoscroll={(port, autoscroll) => changeCaptureTab(port, { autoscroll })}
|
||||
onClear={() => {
|
||||
// Java's tables begin anew, with nothing chosen.
|
||||
setCaptureTabs((before) => ({ ...before, tabs: before.tabs.map((tab) => ({ ...tab, chosen: undefined })) }));
|
||||
simulation.clearCapture();
|
||||
}}
|
||||
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))}
|
||||
/>
|
||||
)
|
||||
) : desktop.viewer !== undefined ? (
|
||||
<ViewerWindow
|
||||
key={desktop.id}
|
||||
kind={desktop.viewer}
|
||||
|
||||
@@ -1341,3 +1341,180 @@
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* The data exchange window (AggregatedExchangeDialog): Java's is three
|
||||
* quarters of the screen wide and four fifths high; the tabs, a bar for what
|
||||
* Java's right mouse button offers, the table (a row per frame in its layer's
|
||||
* colour, Java's fonts by column) and the chosen frame's details below. */
|
||||
.capture-window {
|
||||
width: 960px;
|
||||
height: 640px;
|
||||
}
|
||||
|
||||
.capture-tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
padding: 6px 8px 0;
|
||||
background: var(--surface-raised);
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
}
|
||||
|
||||
.capture-tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border: 1px solid var(--border-default);
|
||||
border-bottom: none;
|
||||
border-radius: 6px 6px 0 0;
|
||||
background: var(--surface-sunken);
|
||||
}
|
||||
|
||||
.capture-tab button {
|
||||
min-height: 36px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.capture-tab button[aria-selected='true'] {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.capture-tab:has(button[aria-selected='true']) {
|
||||
background: var(--surface-card);
|
||||
}
|
||||
|
||||
.capture-tab-close {
|
||||
min-width: 36px;
|
||||
color: var(--text-secondary) !important;
|
||||
font-size: 18px !important;
|
||||
}
|
||||
|
||||
.capture-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
background: var(--surface-card);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.capture-bar 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;
|
||||
}
|
||||
|
||||
.capture-bar label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.capture-bar input {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.capture-table {
|
||||
flex: 3;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
background: var(--surface-sunken);
|
||||
}
|
||||
|
||||
.capture-table table {
|
||||
width: 100%;
|
||||
min-width: 760px;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0 2px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.capture-table th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
padding: 6px;
|
||||
background: var(--surface-raised);
|
||||
color: var(--text-primary);
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.capture-row {
|
||||
color: var(--capture-on-layer);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.capture-row td {
|
||||
padding: 4px 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Java's fonts: number and time plain, addresses and comment monospaced, the protocol bold, the layer bold italic. */
|
||||
.capture-row td:nth-child(3),
|
||||
.capture-row td:nth-child(4),
|
||||
.capture-row td:nth-child(7) {
|
||||
font-family: ui-monospace, Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
.capture-row td:nth-child(5) {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.capture-row td:nth-child(6) {
|
||||
font-weight: 700;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.capture-networkAccess {
|
||||
background: var(--capture-network-access);
|
||||
}
|
||||
|
||||
.capture-internet {
|
||||
background: var(--capture-internet);
|
||||
}
|
||||
|
||||
.capture-transport {
|
||||
background: var(--capture-transport);
|
||||
}
|
||||
|
||||
.capture-application {
|
||||
background: var(--capture-application);
|
||||
}
|
||||
|
||||
.capture-row[aria-selected='true'] {
|
||||
background: var(--capture-chosen);
|
||||
color: var(--capture-on-chosen);
|
||||
}
|
||||
|
||||
.capture-row:focus-visible {
|
||||
outline: 2px solid var(--border-focus);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
/* MessageDetailsPanel: a tree in bold monospace, a long comment on lines of its own. */
|
||||
.capture-details {
|
||||
flex: 2;
|
||||
min-height: 0;
|
||||
margin: 0;
|
||||
padding: 6px;
|
||||
overflow: auto;
|
||||
list-style: none;
|
||||
border-top: 1px solid var(--border-strong);
|
||||
background: var(--surface-card);
|
||||
color: var(--text-primary);
|
||||
font: 700 12px ui-monospace, Menlo, Consolas, monospace;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,25 @@
|
||||
"cable.wanOccupied": "WAN-Anschluss (extern) bereits belegt!",
|
||||
"cable.wirelessNotPossible": "Drahtlose Verbindung nicht möglich",
|
||||
"canvas.label": "Netzwerk",
|
||||
"capture.application": "Anwendung",
|
||||
"capture.autoscroll": "Automatisches Scrollen",
|
||||
"capture.clear": "Tabellen löschen",
|
||||
"capture.closeTab": "Reiter schließen",
|
||||
"capture.comment": "Bemerkungen / Details",
|
||||
"capture.destination": "Ziel",
|
||||
"capture.export": "Als Textdatei exportieren",
|
||||
"capture.internet": "Vermittlung",
|
||||
"capture.layer": "Schicht",
|
||||
"capture.macIs": "MAC ist",
|
||||
"capture.networkAccess": "Netzzugang",
|
||||
"capture.number": "Nr.",
|
||||
"capture.protocol": "Protokoll",
|
||||
"capture.protocolWord": "Protokoll",
|
||||
"capture.searchForMac": "Suche nach MAC für",
|
||||
"capture.source": "Quelle",
|
||||
"capture.time": "Zeit",
|
||||
"capture.title": "Datenaustausch",
|
||||
"capture.transport": "Transport",
|
||||
"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.",
|
||||
@@ -272,6 +291,7 @@
|
||||
"menu.delete": "Löschen",
|
||||
"menu.removeAllCables": "Alle Verbindungen entfernen",
|
||||
"menu.removeCable": "Verbindung entfernen",
|
||||
"menu.showCapture": "Datenaustausch anzeigen",
|
||||
"menu.showDesktop": "Desktop anzeigen",
|
||||
"mode.currentSpeed": "Aktuelle Simulationsgeschwindigkeit",
|
||||
"mode.design": "Wechselt in den Entwurfsmodus",
|
||||
|
||||
@@ -18,6 +18,25 @@
|
||||
"cable.wanOccupied": "WAN port (external) already occupied!",
|
||||
"cable.wirelessNotPossible": "Wireless connection not possible",
|
||||
"canvas.label": "Network",
|
||||
"capture.application": "Application",
|
||||
"capture.autoscroll": "Autoscroll",
|
||||
"capture.clear": "Clear tables",
|
||||
"capture.closeTab": "Close tab",
|
||||
"capture.comment": "Comment / Details",
|
||||
"capture.destination": "Destination",
|
||||
"capture.export": "Export as text file",
|
||||
"capture.internet": "Internet",
|
||||
"capture.layer": "Layer",
|
||||
"capture.macIs": "MAC is",
|
||||
"capture.networkAccess": "Network",
|
||||
"capture.number": "No.",
|
||||
"capture.protocol": "Protocol",
|
||||
"capture.protocolWord": "Protocol",
|
||||
"capture.searchForMac": "Search for MAC",
|
||||
"capture.source": "Source",
|
||||
"capture.time": "Time",
|
||||
"capture.title": "Data exchange",
|
||||
"capture.transport": "Transport",
|
||||
"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.",
|
||||
@@ -272,6 +291,7 @@
|
||||
"menu.delete": "Delete",
|
||||
"menu.removeAllCables": "Remove all cables",
|
||||
"menu.removeCable": "Remove cable",
|
||||
"menu.showCapture": "Show data exchange",
|
||||
"menu.showDesktop": "Show desktop",
|
||||
"mode.currentSpeed": "Current Simulation Velocity",
|
||||
"mode.design": "Change to Design Mode",
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
// The data exchange window while the simulation runs, after Java's
|
||||
// AggregatedExchangeDialog (measured with tools/java-oracle's
|
||||
// ExchangeWindowRun): a tab per card ("<label> - <address>", or the address
|
||||
// where the label is it), each with a close button; the card's frames, one
|
||||
// row per frame in its highest layer's colour (Java's LayerColorVar); the
|
||||
// chosen frame's details below; „Tabellen löschen“ (every card's list),
|
||||
// „Als Textdatei exportieren“ (named after the tab, as Java offers) and
|
||||
// „Automatisches Scrollen“ (on, as in Java). The frames are the room's.
|
||||
//
|
||||
// Better than Java: the three are buttons and a box in a bar (Java's are in a
|
||||
// menu of the right mouse button); a frame is chosen by its number (Java's
|
||||
// details take the row's index for it); an empty comment is exported empty
|
||||
// (Java writes "null").
|
||||
|
||||
import { memo, useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from 'react';
|
||||
import type { TraceFrame } from '@mdg-filius/protocol';
|
||||
import { useLanguage, useTranslate } from '../i18n/i18n.js';
|
||||
import { captureWords, exportText, frameCells, frameDetails, type CaptureWords } from './captureCells.js';
|
||||
import type { Simulation } from './useSimulation.js';
|
||||
|
||||
/** A card's tab: its title, the frame chosen in it and whether it scrolls along — each table's own, as in Java, kept while the window is closed. */
|
||||
export interface CaptureTab {
|
||||
readonly port: string;
|
||||
readonly title: string;
|
||||
readonly chosen: number | undefined;
|
||||
readonly autoscroll: boolean;
|
||||
}
|
||||
|
||||
export interface CaptureWindowProps {
|
||||
readonly tabs: readonly CaptureTab[];
|
||||
readonly selected: string;
|
||||
readonly simulation: Simulation;
|
||||
readonly x: number;
|
||||
readonly y: number;
|
||||
readonly z: number;
|
||||
readonly onSelect: (port: string) => void;
|
||||
readonly onCloseTab: (port: string) => void;
|
||||
readonly onChoose: (port: string, number: number) => void;
|
||||
readonly onAutoscroll: (port: string, on: boolean) => void;
|
||||
/** "Tabellen löschen": every card's frames, and what was chosen in them. */
|
||||
readonly onClear: () => void;
|
||||
readonly onMove: (x: number, y: number) => void;
|
||||
readonly onFront: () => void;
|
||||
readonly onClose: () => void;
|
||||
}
|
||||
|
||||
const FrameRow = memo(function FrameRow({
|
||||
frame,
|
||||
clock,
|
||||
words,
|
||||
chosen,
|
||||
onChoose,
|
||||
}: {
|
||||
readonly frame: TraceFrame;
|
||||
readonly clock: number;
|
||||
readonly words: CaptureWords;
|
||||
readonly chosen: boolean;
|
||||
readonly onChoose: (number: number) => void;
|
||||
}) {
|
||||
const cells = frameCells(frame, clock, words);
|
||||
const layer = frame.rows.at(-1)!.layer;
|
||||
return (
|
||||
<tr
|
||||
className={`capture-row capture-${layer}`}
|
||||
aria-selected={chosen}
|
||||
tabIndex={0}
|
||||
onClick={() => onChoose(frame.number)}
|
||||
onKeyDown={(event) => (event.key === 'Enter' || event.key === ' ') && onChoose(frame.number)}
|
||||
>
|
||||
{cells.map((cell, column) => (
|
||||
// Java's cell shows a line break as a space.
|
||||
<td key={column}>{cell.replaceAll('\n', ' ')}</td>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
});
|
||||
|
||||
export function CaptureWindow({ tabs, selected, simulation, x, y, z, onSelect, onCloseTab, onChoose, onAutoscroll, onClear, onMove, onFront, onClose }: CaptureWindowProps) {
|
||||
const t = useTranslate();
|
||||
const language = useLanguage();
|
||||
const words = useMemo(() => captureWords(t, language), [t, language]);
|
||||
const { watch, clock } = simulation;
|
||||
useEffect(() => watch([`trace:${selected}`]), [watch, selected]);
|
||||
const frames = simulation.traces.get(selected) ?? [];
|
||||
const scroller = useRef<HTMLDivElement>(null);
|
||||
const [drag, setDrag] = useState<{ readonly pointer: number; readonly dx: number; readonly dy: number; moved: boolean } | null>(null);
|
||||
const title = t('capture.title');
|
||||
const tab = tabs.find((each) => each.port === selected);
|
||||
const chosenFrame = frames.find((frame) => frame.number === tab?.chosen);
|
||||
const autoscroll = tab?.autoscroll ?? true;
|
||||
|
||||
// New frames scroll the table to its end while "Automatisches Scrollen" is on.
|
||||
useEffect(() => {
|
||||
if (autoscroll && scroller.current !== null) scroller.current.scrollTop = scroller.current.scrollHeight;
|
||||
}, [autoscroll, frames.length, selected]);
|
||||
|
||||
// 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 choose = useMemo(() => (number: number) => onChoose(selected, number), [onChoose, selected]);
|
||||
|
||||
const download = () => {
|
||||
const url = URL.createObjectURL(new Blob([exportText(frames, clock, words)], { type: 'text/plain;charset=utf-8' }));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `${tab?.title ?? title}.txt`;
|
||||
link.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 0);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="desktop-window capture-window" role="dialog" aria-label={title} data-capture="" style={{ left: x, top: y, zIndex: 5 + z }} onPointerDownCapture={onFront}>
|
||||
<header className="desktop-titlebar" onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={onPointerUp} onPointerCancel={onPointerUp}>
|
||||
<h2>{title}</h2>
|
||||
<button type="button" className="desktop-close" aria-label={t('sheet.close')} title={t('sheet.close')} onClick={onClose}>
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
<div className="capture-tabs" role="tablist">
|
||||
{tabs.map((each) => (
|
||||
<span key={each.port} className="capture-tab">
|
||||
<button type="button" role="tab" aria-selected={each.port === selected} onClick={() => onSelect(each.port)}>
|
||||
{each.title}
|
||||
</button>
|
||||
<button type="button" className="capture-tab-close" aria-label={`${t('capture.closeTab')}: ${each.title}`} title={t('capture.closeTab')} onClick={() => onCloseTab(each.port)}>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="capture-bar">
|
||||
<button type="button" onClick={onClear}>
|
||||
{t('capture.clear')}
|
||||
</button>
|
||||
<button type="button" onClick={download}>
|
||||
{t('capture.export')}
|
||||
</button>
|
||||
<label>
|
||||
<input type="checkbox" checked={autoscroll} onChange={(event) => onAutoscroll(selected, event.target.checked)} />
|
||||
{t('capture.autoscroll')}
|
||||
</label>
|
||||
</div>
|
||||
<div className="capture-table" ref={scroller}>
|
||||
<table aria-label={tab?.title ?? title}>
|
||||
<thead>
|
||||
<tr>
|
||||
{words.header.map((column) => (
|
||||
<th key={column}>{column}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{frames.map((frame) => (
|
||||
<FrameRow key={frame.number} frame={frame} clock={clock} words={words} chosen={frame === chosenFrame} onChoose={choose} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<ul className="capture-details" aria-label={words.detail.comment}>
|
||||
{chosenFrame === undefined
|
||||
? null
|
||||
: frameDetails(chosenFrame, clock, words).map((line, index) => (
|
||||
<li key={index} style={{ paddingLeft: 4 + 16 * line.depth }}>
|
||||
{line.text}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// What the data exchange window writes of a frame, in the viewer's words: the
|
||||
// seven cells of its row (Java's columns: number, time, source, destination,
|
||||
// protocol, layer, comment), its details, the table as a text file. The
|
||||
// rules are the core's (captureWindow.ts, measured against Java); the words,
|
||||
// the number format and the time of day are the viewer's, as Java's are its
|
||||
// locale's and its wall clock's.
|
||||
|
||||
import {
|
||||
captureCommentText,
|
||||
captureDetailLines,
|
||||
exchangeExportText,
|
||||
exchangeRow,
|
||||
type CaptureCommentWords,
|
||||
type CaptureDetailLine,
|
||||
type CaptureDetailWords,
|
||||
type CaptureLayer,
|
||||
type CaptureRow,
|
||||
} from '@mdg-filius/core';
|
||||
import type { TraceFrame } from '@mdg-filius/protocol';
|
||||
import { timeOfDay } from './timeOfDay.js';
|
||||
|
||||
export interface CaptureWords {
|
||||
readonly header: readonly string[];
|
||||
readonly layers: Readonly<Record<CaptureLayer, string>>;
|
||||
readonly comment: CaptureCommentWords;
|
||||
readonly detail: CaptureDetailWords;
|
||||
/** The locale's numbers (Java: NumberFormat of its locale, "1.000.000" in German). */
|
||||
readonly formatNumber: (value: number) => string;
|
||||
}
|
||||
|
||||
/** The words of the editor's catalog in its language. */
|
||||
export function captureWords(t: (key: string) => string, language: string): CaptureWords {
|
||||
const header = ['number', 'time', 'source', 'destination', 'protocol', 'layer', 'comment'].map((key) => t(`capture.${key}`));
|
||||
const numbers = new Intl.NumberFormat(language === 'de' ? 'de-DE' : 'en-GB');
|
||||
return {
|
||||
header,
|
||||
layers: { networkAccess: t('capture.networkAccess'), internet: t('capture.internet'), transport: t('capture.transport'), application: t('capture.application') },
|
||||
comment: { protocol: t('capture.protocolWord'), searchForMac: t('capture.searchForMac'), macIs: t('capture.macIs') },
|
||||
detail: { number: header[0]!, time: header[1]!, source: header[2]!, destination: header[3]!, protocol: header[4]!, comment: header[6]! },
|
||||
formatNumber: (value) => numbers.format(value),
|
||||
};
|
||||
}
|
||||
|
||||
const commentOf = (row: CaptureRow, words: CaptureWords) => captureCommentText(row.comment, words.comment, words.formatNumber);
|
||||
|
||||
/** The frame's row, its highest layer's, as seven cells; the comment as it is (with its line breaks). */
|
||||
export function frameCells(frame: TraceFrame, clock: number, words: CaptureWords): string[] {
|
||||
const row = exchangeRow(frame.rows);
|
||||
return [String(row.number), timeOfDay(clock, row.timeMilliseconds, true), row.source ?? '', row.destination ?? '', row.protocol, words.layers[row.layer], commentOf(row, words)];
|
||||
}
|
||||
|
||||
/** The frame's details, from its layered rows. */
|
||||
export function frameDetails(frame: TraceFrame, clock: number, words: CaptureWords): CaptureDetailLine[] {
|
||||
const first = frame.layeredRows[0];
|
||||
if (first === undefined) return [];
|
||||
return captureDetailLines(
|
||||
frame.layeredRows,
|
||||
timeOfDay(clock, first.timeMilliseconds, true),
|
||||
words.detail,
|
||||
(row) => words.layers[row.layer],
|
||||
(row) => commentOf(row, words),
|
||||
);
|
||||
}
|
||||
|
||||
/** The card's table as Java's text file. */
|
||||
export function exportText(frames: readonly TraceFrame[], clock: number, words: CaptureWords): string {
|
||||
return exchangeExportText(
|
||||
words.header,
|
||||
frames.map((frame) => frameCells(frame, clock, words)),
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
// The editor's side of the simulation room (plan §6): its mode and speed,
|
||||
// the cables that carry frames, a computer's network as it runs, the lines
|
||||
// and views of the programs' windows and the actions taken in them. The room
|
||||
// and views of the programs' windows and the actions taken in them, the
|
||||
// cards' frames for the data exchange window. The room
|
||||
// runs behind a SimHost — in a worker of this page (LocalSimHost) until F
|
||||
// holds it (RemoteSimHost, F5); the editor keeps no simulation of its own.
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type * as Y from 'yjs';
|
||||
import type { AppKind, DroppedUnit, NodeSettings, SimHost, SimMode, Topic } from '@mdg-filius/protocol';
|
||||
import type { AppKind, DroppedUnit, NodeSettings, SimHost, SimMode, Topic, TraceFrame } 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';
|
||||
@@ -44,12 +45,16 @@ export interface Simulation {
|
||||
readonly clock: number;
|
||||
/** The frames "Pakete verwerfen" dropped in this simulation, while `dropped` is watched. */
|
||||
readonly dropped: readonly DroppedUnit[];
|
||||
/** Each watched card's frames as the data exchange window shows them, by its port. */
|
||||
readonly traces: ReadonlyMap<string, readonly TraceFrame[]>;
|
||||
/** 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;
|
||||
/** Java's "Tabellen löschen": every card's frames and the dropped ones are forgotten. */
|
||||
clearCapture(): 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). */
|
||||
@@ -70,6 +75,7 @@ export function useSimulation(doc: Y.Doc, createHost: SimHostFactory = localSimH
|
||||
const [views, setViews] = useState<ReadonlyMap<string, unknown>>(new Map());
|
||||
const [clock, setClock] = useState(0);
|
||||
const [dropped, setDropped] = useState<readonly DroppedUnit[]>([]);
|
||||
const [traces, setTraces] = useState<ReadonlyMap<string, readonly TraceFrame[]>>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
const made = createHost(doc);
|
||||
@@ -97,6 +103,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 'trace':
|
||||
setTraces((before) => new Map(before).set(message.portId, message.reset ? message.frames : (before.get(message.portId) ?? []).concat(message.frames)));
|
||||
return;
|
||||
case 'dropped':
|
||||
setDropped((before) => (message.reset ? message.units : [...before, ...message.units]));
|
||||
return;
|
||||
@@ -125,6 +134,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 clearCapture = useCallback(() => host.current?.send({ v: 1, kind: 'clearCapture' }), []);
|
||||
const watch = useCallback((topics: readonly Topic[]) => {
|
||||
const fresh = topics.filter((topic) => {
|
||||
const count = watched.current.get(topic) ?? 0;
|
||||
@@ -147,5 +157,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, clock, dropped, start, stop, setSpeed, drop, watch, act };
|
||||
return { mode, speed, activeLinks, nodes, apps, views, failed, clock, dropped, traces, start, stop, setSpeed, drop, clearCapture, watch, act };
|
||||
}
|
||||
|
||||
@@ -37,6 +37,14 @@
|
||||
--connection-plugged: rgb(0 255 0);
|
||||
--connection-free: rgb(255 0 0);
|
||||
--connection-marked: rgb(255 255 0);
|
||||
/* Java's layer colours of the data exchange window (LayerColorVar), black text; a chosen row white on black. */
|
||||
--capture-network-access: #c9c9c9;
|
||||
--capture-internet: #8faadc;
|
||||
--capture-transport: #f4b183;
|
||||
--capture-application: #ffd966;
|
||||
--capture-on-layer: #000000;
|
||||
--capture-chosen: #000000;
|
||||
--capture-on-chosen: #ffffff;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] {
|
||||
@@ -71,6 +79,13 @@
|
||||
--connection-plugged: rgb(0 190 0);
|
||||
--connection-free: rgb(210 40 40);
|
||||
--connection-marked: rgb(235 215 0);
|
||||
--capture-network-access: #3a3f47;
|
||||
--capture-internet: #2c4369;
|
||||
--capture-transport: #6b4128;
|
||||
--capture-application: #5f4f17;
|
||||
--capture-on-layer: #e6e9ef;
|
||||
--capture-chosen: #e6e9ef;
|
||||
--capture-on-chosen: #14171c;
|
||||
}
|
||||
|
||||
html,
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
// What the data exchange window writes of a frame in the viewer's language:
|
||||
// Java's words of both bundles, the locale's numbers ("1.000.000" in German,
|
||||
// "1,000,000" in English), the time of day with milliseconds, the row of the
|
||||
// highest layer, the details from the layered rows, the text file.
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { captureRows, TCP_FLAG, type CapturedFrame, type EthernetFrame } from '@mdg-filius/core';
|
||||
import type { TraceFrame } from '@mdg-filius/protocol';
|
||||
import { translate } from '../src/i18n/i18n.js';
|
||||
import { captureWords, exportText, frameCells, frameDetails } from '../src/simulation/captureCells.js';
|
||||
|
||||
/** A TCP segment with data from 10.0.0.1:50000 to 10.0.0.2:80, as the room sends it. */
|
||||
function frame(number: number, timeMilliseconds: number): TraceFrame {
|
||||
const ethernet: EthernetFrame = {
|
||||
identity: number,
|
||||
sourceMacAddress: '02:00:00:00:00:01',
|
||||
destinationMacAddress: '02:00:00:00:00:02',
|
||||
payload: {
|
||||
kind: 'ipv4',
|
||||
packet: {
|
||||
identity: 9,
|
||||
senderIPAddress: '10.0.0.1',
|
||||
receiverIPAddress: '10.0.0.2',
|
||||
timeToLive: 64,
|
||||
protocol: 'tcp',
|
||||
payload: {
|
||||
kind: 'tcp',
|
||||
segment: {
|
||||
sourcePort: 50_000,
|
||||
destinationPort: 80,
|
||||
sequenceNumber: 2_000_001,
|
||||
acknowledgementNumber: 1_000_001,
|
||||
flags: TCP_FLAG.acknowledgement,
|
||||
payload: new TextEncoder().encode('GET / HTTP/1.1\nHost: x\n'),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const captured: CapturedFrame[] = [{ number, timeMilliseconds, frame: ethernet }];
|
||||
return { number, rows: captureRows(captured, { withAddresses: true }), layeredRows: captureRows(captured, { withAddresses: false }) };
|
||||
}
|
||||
|
||||
const words = (language: 'de' | 'en') => captureWords((key) => translate(language, key), language);
|
||||
const clock = new Date(2026, 9, 4, 8, 30, 0, 0).getTime();
|
||||
|
||||
describe("the data exchange window's cells", () => {
|
||||
it("writes a frame's row by its highest layer, in either language", () => {
|
||||
expect(frameCells(frame(3, 1_234), clock, words('de'))).toEqual(['3', '08:30:01.234', '10.0.0.1:50000', '10.0.0.2:80', 'HTTP', 'Anwendung', 'GET / HTTP/1.1\nHost: x\n']);
|
||||
expect(frameCells(frame(3, 1_234), clock, words('en'))).toEqual(['3', '08:30:01.234', '10.0.0.1:50000', '10.0.0.2:80', 'HTTP', 'Application', 'GET / HTTP/1.1\nHost: x\n']);
|
||||
expect(words('de').header).toEqual(['Nr.', 'Zeit', 'Quelle', 'Ziel', 'Protokoll', 'Schicht', 'Bemerkungen / Details']);
|
||||
expect(words('en').header).toEqual(['No.', 'Time', 'Source', 'Destination', 'Protocol', 'Layer', 'Comment / Details']);
|
||||
});
|
||||
|
||||
it("writes the details with the locale's numbers and Java's words", () => {
|
||||
const german = frameDetails(frame(3, 0), clock, words('de')).map((line) => line.text);
|
||||
expect(german[0]).toBe('Nr.: 3 / Zeit: 08:30:00.000');
|
||||
expect(german).toContain('Bemerkungen / Details: SEQ: 2.000.001, ACK: 1.000.001');
|
||||
expect(german).toContain('Bemerkungen / Details: Protokoll: 6, TTL: 64');
|
||||
expect(german).toContain('Bemerkungen / Details (23 Bytes): ');
|
||||
const english = frameDetails(frame(3, 0), clock, words('en')).map((line) => line.text);
|
||||
expect(english[0]).toBe('No.: 3 / Time: 08:30:00.000');
|
||||
expect(english).toContain('Comment / Details: SEQ: 2,000,001, ACK: 1,000,001');
|
||||
expect(english).toContain('Comment / Details: Protocol: 6, TTL: 64');
|
||||
expect(frameDetails({ number: 1, rows: [], layeredRows: [] }, clock, words('de'))).toEqual([]);
|
||||
});
|
||||
|
||||
it('exports the table with the words of its header', () => {
|
||||
const text = exportText([frame(1, 0), frame(2, 5)], clock, words('en'));
|
||||
expect(text.split('\r\n')[1]).toBe(
|
||||
`| No. | Time | Source | Destination | Protocol | Layer | ${'Comment / Details'.padEnd(40)} | `,
|
||||
);
|
||||
expect(text.split('\r\n')[3]).toBe(
|
||||
`| 1 | 08:30:00.000 | 10.0.0.1:50000 | 10.0.0.2:80 | HTTP | Application | ${'GET / HTTP/1.1 Host: x'.padEnd(40)} | `,
|
||||
);
|
||||
expect(text.match(/^\+-/gm)).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -244,6 +244,26 @@ const FROM_JAVA: Readonly<Record<string, string>> = {
|
||||
'installation.apply': 'installationsdialog_msg2',
|
||||
'mode.currentSpeed': 'guimainmemu_msg15',
|
||||
'menu.showDesktop': 'guievents_msg3',
|
||||
'capture.title': 'lauscherdialog_msg1',
|
||||
'capture.number': 'rp_lauscher_msg1',
|
||||
'capture.time': 'rp_lauscher_msg2',
|
||||
'capture.source': 'rp_lauscher_msg3',
|
||||
'capture.destination': 'rp_lauscher_msg4',
|
||||
'capture.protocol': 'rp_lauscher_msg5',
|
||||
'capture.layer': 'rp_lauscher_msg6',
|
||||
'capture.comment': 'rp_lauscher_msg7',
|
||||
'capture.networkAccess': 'rp_lauscher_msg8',
|
||||
'capture.internet': 'rp_lauscher_msg9',
|
||||
'capture.transport': 'rp_lauscher_msg10',
|
||||
'capture.application': 'rp_lauscher_msg11',
|
||||
'capture.protocolWord': 'rp_lauscher_msg12',
|
||||
'capture.searchForMac': 'rp_lauscher_msg13',
|
||||
'capture.macIs': 'rp_lauscher_msg14',
|
||||
'capture.clear': 'nachrichtentabelle_msg7',
|
||||
'capture.autoscroll': 'nachrichtentabelle_msg8',
|
||||
'capture.export': 'nachrichtentabelle_msg9',
|
||||
'capture.closeTab': 'buttontabcomponent_msg1',
|
||||
'menu.showCapture': 'guievents_msg4',
|
||||
'desktop.home': 'desktoppanel_application',
|
||||
'desktop.installation': 'desktoppanel_msg1',
|
||||
'network.ipAddress': 'network_msg1',
|
||||
|
||||
Reference in New Issue
Block a user