feat: F5.3b the editor on a project of server F

?project=<uuid> opens the shared document over /sync and the room over
/sim (RemoteSimHost: messages wait for the socket, a lost socket comes
back with what it watched, F's refusal or parting ends it); instead of
the editor, a word why when F refuses or lets go. F tells each
connection its access at once. Who only reads sees everything and
changes nothing: no palette or undo, a locked surface, the sheet's
fields disabled (tabs stay), no starting or stopping; thawed, the
document comes anew. The toolbar names who started the simulation.
Refusals carry no message, so Hocuspocus writes nothing outside F's
JSON log. A browser test runs three people against a real F.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-04 15:00:18 +00:00
co-authored by Claude Opus 5.5
parent 8cfc518f0c
commit f52852493a
28 changed files with 1142 additions and 58 deletions
+100
View File
@@ -0,0 +1,100 @@
// A real server F for the editor's browser tests: bundled as the build
// bundles it (dist/main.mjs and its room thread), started as a process with
// a throwaway Postgres (packages/server/test/database.ts), on the host the
// editor's page comes from, so that F's cookie goes with the page's
// requests. A project is made in F's store straight from a FILIUS file; a
// person signs in as the dashboard would let them, with a ticket at /__auth.
import { spawn, type ChildProcess } from 'node:child_process';
import { mkdtempSync, readFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { randomUUID } from 'node:crypto';
import type { BrowserContext } from '@playwright/test';
import { build } from 'esbuild';
import pg from 'pg';
import * as Y from 'yjs';
import { writeProject } from '@mdg-filius/doc';
import { openProjectArchive } from '@mdg-filius/fls';
import { testDatabase, type TestDatabase } from '../../server/test/database.js';
import { signTicket } from '../../server/test/tickets.js';
const TICKET_SECRET = 'browser-ticket-secret';
export const SERVICE_KEY = 'browser-service-key';
export interface ServerF {
readonly base: string;
/** A project made from a FILIUS file of the corpus; its devices' ids by name. */
project(file: string): Promise<{ readonly id: string; readonly node: (name: string) => string }>;
/** Signs a person in, in this browser context. */
signIn(context: BrowserContext, project: string, person: { readonly name: string; readonly level?: 'read' | 'write'; readonly user?: string }): Promise<void>;
service(method: string, path: string, body?: unknown): Promise<Response>;
close(): Promise<void>;
}
/** Server F on the editor page's host (`editorBase`), for pages from there. */
export async function startServerF(editorBase: string): Promise<ServerF> {
const editor = new URL(editorBase);
const database: TestDatabase = await testDatabase();
const out = mkdtempSync(join(tmpdir(), 'filius-server-f-'));
const source = new URL('../../server/src/', import.meta.url).pathname;
await build({
entryPoints: [join(source, 'main.ts'), join(source, 'simWorker.ts')],
bundle: true,
platform: 'node',
format: 'esm',
target: 'node20',
outdir: out,
outExtension: { '.js': '.mjs' },
external: ['pg-native'],
banner: { js: "import { createRequire } from 'node:module'; const require = createRequire(import.meta.url);" },
logLevel: 'error',
});
const port = 20_000 + Math.floor(Math.random() * 20_000);
const child: ChildProcess = spawn(process.execPath, [join(out, 'main.mjs')], {
env: {
...process.env,
DATABASE_URL: database.url,
FILIUS_TICKET_SECRET: TICKET_SECRET,
FILIUS_SERVICE_KEY: SERVICE_KEY,
FILIUS_ORIGINS: editor.origin,
FILIUS_PORT: String(port),
FILIUS_INSECURE_COOKIE: '1',
},
stdio: ['ignore', 'pipe', 'inherit'],
});
await new Promise<void>((resolve, reject) => {
child.stdout!.on('data', (data: Buffer) => {
if (data.toString().includes('"server.listening"')) resolve();
});
child.once('exit', (code) => reject(new Error(`server F ended (${code})`)));
});
const base = `${editor.protocol}//${editor.hostname}:${port}`;
const sql = new pg.Pool({ connectionString: database.url });
return {
base,
async project(file) {
const id = randomUUID();
const imported = openProjectArchive(new Uint8Array(readFileSync(new URL(`../../../corpus/${file}`, import.meta.url))), () => randomUUID());
const doc = new Y.Doc();
writeProject(doc, imported);
await sql.query('INSERT INTO filius_doc (project_uuid, state) VALUES ($1, $2)', [id, Buffer.from(Y.encodeStateAsUpdate(doc))]);
return { id, node: (name) => imported.graph.nodes.find((each) => each.displayName === name)!.id };
},
async signIn(context, project, { name, level = 'write', user = randomUUID() }) {
const ticket = signTicket({ u: user, n: name.toLowerCase(), d: name, p: project, lvl: level }, TICKET_SECRET);
const page = await context.newPage();
const response = await page.goto(`${base}/__auth?ticket=${encodeURIComponent(ticket)}&next=/healthz`);
if (response?.status() !== 200) throw new Error(`not signed in: ${response?.status()}`);
await page.close();
},
service: (method, path, body) => fetch(`${base}${path}`, { method, headers: { 'x-filius-service-key': SERVICE_KEY }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) }),
async close() {
const ended = new Promise((resolve) => child.once('exit', resolve));
child.kill('SIGTERM');
await ended;
await sql.end();
await database.stop();
},
};
}
+146
View File
@@ -0,0 +1,146 @@
// The editor on a project of a real server F (F5.3b), three people in three
// browsers: Mia and Ben write, Rita only reads. What one moves the others
// see; Rita gets everything shown and nothing to change; Mia starts the
// simulation and it runs for all, named after her; Ben's ping in a command
// line is in Mia's too (one window per computer, as in Java); Rita's typing
// there is refused, and she is told; the project frozen, Ben reads and is
// told, thawed he writes again; his grant ended, he is told and the editor
// goes.
import { expect, test, type Browser, type Page } from '@playwright/test';
import { drag, onDevice, serveEditor } from './editorPage.js';
import { startServerF, type ServerF } from './serverF.js';
let editor: { readonly base: string; readonly close: () => Promise<void> };
let f: ServerF;
test.beforeAll(async () => {
editor = await serveEditor();
f = await startServerF(editor.base);
});
test.afterAll(async () => {
await f?.close();
await editor?.close();
});
/** A person in their own browser context, signed in, the project open. */
async function person(browser: Browser, project: string, name: string, level: 'read' | 'write' = 'write', user?: string): Promise<Page> {
const context = await browser.newContext();
await f.signIn(context, project, { name, level, ...(user === undefined ? {} : { user }) });
const page = await context.newPage();
await page.goto(`${editor.base}?project=${project}&server=${encodeURIComponent(f.base)}`);
await page.waitForSelector('svg.filius-canvas');
return page;
}
const box = (page: Page, id: string) => page.locator(`g.device[data-node="${id}"]`).getAttribute('data-box');
const canvas = (page: Page) => page.locator('svg.filius-canvas');
const desktop = (page: Page, id: string) => page.locator(`section[data-desktop="${id}"]`);
const note = (page: Page, kind: string) => page.locator(`[data-note="${kind}"]`);
async function commandLine(page: Page, id: string) {
const at = await onDevice(page, id);
await page.mouse.click(at.x, at.y);
await desktop(page, id).locator('[data-program="commandPrompt"]').click();
const window = desktop(page, id).locator('[data-program-window="commandPrompt"]');
return { input: window.locator('input[name="terminalInput"]'), text: window.locator('.terminal-text') };
}
test('three people in one project of F: moving, reading only, one simulation for all, a frozen project, an ended grant', async ({ browser }) => {
test.setTimeout(120_000);
const project = await f.project('java-examples/internetwork_komplett.fls');
const benUser = crypto.randomUUID();
const mia = await person(browser, project.id, 'Mia');
const ben = await person(browser, project.id, 'Ben', 'write', benUser);
const rita = await person(browser, project.id, 'Rita', 'read');
const devices = await mia.locator('g.device').count();
expect(devices).toBeGreaterThan(5);
for (const page of [ben, rita]) await expect(page.locator('g.device')).toHaveCount(devices);
// Mia moves a device; Ben and Rita see it where she put it.
const notebook = project.node('Notebook 1.10');
const before = await box(mia, notebook);
const from = await onDevice(mia, notebook);
await drag(mia, from, { x: from.x + 120, y: from.y + 40 });
const moved = await box(mia, notebook);
expect(moved).not.toBe(before);
for (const page of [ben, rita]) await expect(page.locator(`g.device[data-node="${notebook}"]`)).toHaveAttribute('data-box', moved!);
// Rita reads: no palette, no undo, the note; her drag moves nothing; her sheet's fields are disabled; she cannot start.
await expect(note(rita, 'readOnly')).toHaveText('Nur ansehen');
await expect(rita.locator('[data-palette]')).toHaveCount(0);
await expect(rita.getByRole('button', { name: 'Rückgängig' })).toHaveCount(0);
await expect(rita.locator('button[name="simulationMode"]')).toBeDisabled();
const ritaFrom = await onDevice(rita, notebook);
await drag(rita, ritaFrom, { x: ritaFrom.x + 80, y: ritaFrom.y });
await expect(rita.locator(`g.device[data-node="${notebook}"]`)).toHaveAttribute('data-box', moved!);
await rita.mouse.click(ritaFrom.x, ritaFrom.y);
const sheet = rita.getByRole('complementary', { name: 'Einstellungen' });
await expect(sheet).toBeVisible();
await expect(sheet.locator('input[name="ipAddress"]')).toBeDisabled();
await sheet.getByRole('button', { name: 'Schließen' }).click();
await expect(note(mia, 'readOnly')).toHaveCount(0);
// Mia starts: the simulation runs for all, named after her.
await mia.locator('button[name="simulationMode"]').click();
for (const page of [mia, ben, rita]) {
await expect(canvas(page)).toHaveAttribute('data-mode', 'simulation');
await expect(note(page, 'startedBy')).toHaveText('Simulation gestartet von Mia');
}
// Ben pings from the notebook's command line; Mia's command line of it shows the same.
const bens = await commandLine(ben, notebook);
await bens.input.fill('ping 141.99.5.10');
await bens.input.press('Enter');
const mias = await commandLine(mia, notebook);
for (const each of [bens, mias]) await expect(each.text).toContainText('4 Paket(e) gesendet, 4 Paket(e) empfangen', { timeout: 30_000 });
// Rita may look, not type.
const ritas = await commandLine(rita, notebook);
await expect(ritas.text).toContainText('ping 141.99.5.10');
await ritas.input.fill('ipconfig');
await ritas.input.press('Enter');
await expect(rita.getByText('Hier können Sie nur zuschauen.')).toBeVisible();
// Ben stops it, for everyone.
await ben.locator('button[name="designMode"]').click();
for (const page of [mia, ben, rita]) await expect(canvas(page)).toHaveAttribute('data-mode', 'design');
// Frozen: Ben reads and is told; thawed, he writes again.
expect((await f.service('POST', `/svc/projects/${project.id}/freeze`, { frozen: true })).status).toBe(200);
await expect(note(ben, 'readOnly')).toHaveText('Eingefroren – nur ansehen');
await expect(ben.locator('[data-palette]')).toHaveCount(0);
expect((await f.service('POST', `/svc/projects/${project.id}/freeze`, { frozen: false })).status).toBe(200);
await expect(note(ben, 'readOnly')).toHaveCount(0);
await expect(ben.locator('[data-palette]').first()).toBeVisible();
const benFrom = await onDevice(ben, notebook);
await drag(ben, benFrom, { x: benFrom.x - 60, y: benFrom.y });
const movedByBen = await box(ben, notebook);
expect(movedByBen).not.toBe(moved);
await expect(mia.locator(`g.device[data-node="${notebook}"]`)).toHaveAttribute('data-box', movedByBen!);
// Ben's grant ends: he is told, and the editor goes.
expect((await f.service('POST', '/svc/revoke', { user: benUser, project: project.id })).status).toBe(200);
await expect(ben.getByRole('alert')).toHaveText('Ihr Zugang zu diesem Projekt wurde beendet.');
await expect(canvas(ben)).toHaveCount(0);
await expect(canvas(mia)).toBeVisible();
});
test('says why when F ends only the room: the editor goes as when the document is let go', async ({ browser }) => {
const project = await f.project('java-examples/zwei_rechnernetze_komplett.fls');
const context = await browser.newContext();
await f.signIn(context, project.id, { name: 'Mia' });
const page = await context.newPage();
await page.routeWebSocket(/\/sim/, (socket) => socket.close({ code: 4403, reason: 'noAccess' }));
await page.goto(`${editor.base}?project=${project.id}&server=${encodeURIComponent(f.base)}`);
await expect(page.getByRole('alert')).toHaveText('Sie haben keinen Zugang zu diesem Projekt.');
await context.close();
});
test('says why when F refuses: no session, a project it does not know', async ({ page }) => {
const project = await f.project('java-examples/zwei_rechnernetze_komplett.fls');
await page.goto(`${editor.base}?project=${project.id}&server=${encodeURIComponent(f.base)}`);
await expect(page.getByRole('alert')).toHaveText('Die Anmeldung ist abgelaufen. Öffnen Sie das Projekt im Dashboard neu.');
await page.goto(`${editor.base}?project=not-a-project&server=${encodeURIComponent(f.base)}`);
await expect(page.getByRole('alert')).toHaveText('Diese Adresse nennt kein Projekt.');
});
+1
View File
@@ -9,6 +9,7 @@
"build": "vite build"
},
"dependencies": {
"@hocuspocus/provider": "3.4.4",
"@mdg-filius/core": "0.0.0",
"@mdg-filius/doc": "0.0.0",
"@mdg-filius/fls": "0.0.0",
+36 -8
View File
@@ -13,6 +13,12 @@
// 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
// the others in the room.
//
// Who only reads (on F: a read grant, or a frozen project) sees everything
// and changes nothing: no palette, no undo, nothing moves or has a menu, the
// sheet's fields are disabled, the simulation is neither started nor
// stopped by them; desktops and the data exchange window open to watch. The
// room's simulation names who started it.
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
import type * as Y from 'yjs';
@@ -50,7 +56,7 @@ import { Palette, type PaletteKind } from './design/Palette.js';
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 { translate, useLanguage, useTranslate } from './i18n/i18n.js';
import { CaptureWindow } from './simulation/CaptureWindow.js';
import { captureWords, reportTraffic } from './simulation/captureCells.js';
import { ModeBar, type EditorMode } from './simulation/ModeBar.js';
@@ -143,9 +149,13 @@ export interface EditorProps {
readonly createSimHost?: SimHostFactory;
/** The project's name (its file's), for what is exported. */
readonly projectName?: string;
/** This person only reads (on F). */
readonly readOnly?: boolean;
/** …because the project is frozen (its deadline passed). */
readonly frozen?: boolean;
}
export function Editor({ doc, createSimHost, projectName }: EditorProps) {
export function Editor({ doc, createSimHost, projectName, readOnly = false, frozen = false }: EditorProps) {
const t = useTranslate();
const language = useLanguage();
const view = useTopologyView(doc);
@@ -167,6 +177,11 @@ export function Editor({ doc, createSimHost, projectName }: EditorProps) {
// The cable of the card whose tab is open in the sheet (Java's Kabel.setAktiv).
const [highlightedLink, setHighlightedLink] = useState<string | null>(null);
// The room said no: told as a message, once each time.
useEffect(() => {
if (simulation.refusal !== null) setMessage(translate(language, `access.${simulation.refusal.code}Refused`));
}, [simulation.refusal, language]);
// A message goes after a while.
useEffect(() => {
if (message === null) return undefined;
@@ -352,7 +367,7 @@ export function Editor({ doc, createSimHost, projectName }: EditorProps) {
// The keyboard: undo and redo, delete what is selected, Escape lets go.
useEffect(() => {
const key = (event: KeyboardEvent) => {
if (typing(event.target) || simulating) return;
if (typing(event.target) || simulating || readOnly) return;
const command = event.ctrlKey || event.metaKey;
if (command && event.key.toLowerCase() === 'z') {
event.preventDefault();
@@ -371,13 +386,13 @@ export function Editor({ doc, createSimHost, projectName }: EditorProps) {
};
window.addEventListener('keydown', key);
return () => window.removeEventListener('keydown', key);
}, [manager, selection, menu, routerAt, remove, simulating]);
}, [manager, selection, menu, routerAt, remove, simulating, readOnly]);
return (
<div style={{ display: 'flex', height: '100%' }}>
{simulating ? null : documenting ? (
<DocumentationSidebar onItem={onDocumentationItem} onExport={exportImage} onReport={makeReport} />
) : (
<DocumentationSidebar onItem={onDocumentationItem} onExport={exportImage} onReport={makeReport} readOnly={readOnly} />
) : readOnly ? null : (
<Palette
cableTool={cableTool}
onCableTool={(on) => {
@@ -402,6 +417,7 @@ export function Editor({ doc, createSimHost, projectName }: EditorProps) {
onCable={onCable}
highlightedLink={highlightedLink}
mode={editorMode}
locked={readOnly}
onDocFrame={(id, frame) => setDocumentationFrame(doc, id, frame, origin)}
renderDocText={(item, frame) => {
const text = documentationText(doc, item.id);
@@ -426,6 +442,7 @@ export function Editor({ doc, createSimHost, projectName }: EditorProps) {
else if (kind === 'gateway') openDesktop(id, 'nat');
}}
onMenu={(target, at) => {
if (readOnly && !simulating) return;
if (editorMode === 'documentation') {
if (target.kind !== 'doc') return;
setSelection(target);
@@ -441,8 +458,18 @@ export function Editor({ doc, createSimHost, projectName }: EditorProps) {
}}
/>
<div role="toolbar" className="editor-toolbar">
<ModeBar mode={editorMode} speed={simulation.speed} onMode={changeMode} onSpeed={simulation.setSpeed} />
{simulating ? <DropControl simulation={simulation} /> : (
<ModeBar mode={editorMode} speed={simulation.speed} onMode={changeMode} onSpeed={simulation.setSpeed} canRun={!readOnly} />
{simulating && simulation.startedBy !== undefined ? (
<span className="editor-note" data-note="startedBy">
{t('access.startedBy', { name: simulation.startedBy })}
</span>
) : null}
{readOnly ? (
<span className="editor-note" data-note="readOnly">
{t(frozen ? 'access.frozen' : 'access.readOnly')}
</span>
) : null}
{simulating ? (readOnly ? null : <DropControl simulation={simulation} />) : readOnly ? null : (
<>
<button type="button" style={toolButton} disabled={!canUndo} onClick={() => manager?.undo()} aria-label={t('design.undo')} title={t('design.undo')}>
↶
@@ -578,6 +605,7 @@ export function Editor({ doc, createSimHost, projectName }: EditorProps) {
onMessage={setMessage}
onHighlight={setHighlightedLink}
onClose={() => setSelection(null)}
readOnly={readOnly}
/>
)}
</div>
+19 -4
View File
@@ -81,6 +81,12 @@ export interface CanvasProps {
* Java's simulation mode. The documentation mode: see above.
*/
readonly mode?: 'design' | 'simulation' | 'documentation';
/**
* Who only reads (on F): a tap still chooses a device (its sheet shows
* what it holds), but nothing moves, no cable is begun or chosen, no
* documentation item is chosen or changed, and nothing has a menu.
*/
readonly locked?: boolean;
/** A documentation item moved or resized: its new frame. */
readonly onDocFrame?: (id: string, frame: Frame) => void;
/** The field a focused text is typed into, over the surface at its frame. */
@@ -228,6 +234,7 @@ export const Canvas = forwardRef<CanvasHandle, CanvasProps>(function Canvas(
onMenu,
highlightedLink = null,
mode = 'design',
locked = false,
activeLinks = NO_LINKS,
onActivate,
onDocFrame,
@@ -364,6 +371,10 @@ export const Canvas = forwardRef<CanvasHandle, CanvasProps>(function Canvas(
if (pointers.current.size > 2) return;
const target = event.target as Element;
const linkId = target.closest('[data-link-hit]')?.getAttribute('data-link-hit');
if (documenting && locked) {
gesture.current = { kind: 'background', start: point, moved: false, last: point };
return;
}
if (documenting) {
const found = docAt(point);
if (found === null) {
@@ -391,11 +402,11 @@ export const Canvas = forwardRef<CanvasHandle, CanvasProps>(function Canvas(
gesture.current = { kind: 'device', id: device.id, start: point, origin: { x: box.x, y: box.y }, side: device.side, moved: false, longPressed: false };
longPress.current = setTimeout(() => {
const now = gesture.current;
if (now?.kind !== 'device' || now.moved) return;
if (now?.kind !== 'device' || now.moved || (locked && !simulating)) return;
now.longPressed = true;
onMenu?.({ kind: 'device', id: now.id }, now.start);
}, LONG_PRESS_MILLISECONDS);
} else if (linkId !== undefined && linkId !== null && !simulating) {
} else if (linkId !== undefined && linkId !== null && !simulating && !locked) {
gesture.current = { kind: 'link', id: linkId, start: point, moved: false, longPressed: false };
longPress.current = setTimeout(() => {
const now = gesture.current;
@@ -454,7 +465,7 @@ export const Canvas = forwardRef<CanvasHandle, CanvasProps>(function Canvas(
const step = { x: point.x - now.last.x, y: point.y - now.last.y };
now.last = point;
setViewport((before) => shifted(before ?? currentRef.current, step));
} else if (now.kind === 'device' && !now.longPressed && !simulating) {
} else if (now.kind === 'device' && !now.longPressed && !simulating && !locked) {
if (tool === 'cable') {
const from = boxes.get(now.id)!.center;
const target = deviceAt(point);
@@ -501,6 +512,10 @@ export const Canvas = forwardRef<CanvasHandle, CanvasProps>(function Canvas(
if (!now.moved) onActivate?.(now.id);
return;
}
if (locked) {
if (!now.moved) onSelect?.({ kind: 'device', id: now.id });
return;
}
if (tool === 'cable') {
setBand(null);
const from = { id: now.id, side: now.side };
@@ -645,7 +660,7 @@ export const Canvas = forwardRef<CanvasHandle, CanvasProps>(function Canvas(
const corner = toScreen(current, { x: item.x, y: item.y });
return renderDocText(item, { left: corner.x, top: corner.y, width: item.width * current.scale, height: item.height * current.scale, scale: current.scale });
})()}
{menuButton === null || selection === null || simulating ? null : (
{menuButton === null || selection === null || simulating || locked ? null : (
<button
type="button"
className="device-menu-button"
@@ -22,6 +22,8 @@ export interface DocumentationSidebarProps {
readonly onExport: () => Promise<void>;
/** „Report“: the report saved as a PDF; rejected, it failed. */
readonly onReport: () => Promise<void>;
/** Who only reads makes no texts or rectangles; the picture and the report stay. */
readonly readOnly?: boolean;
}
const ITEMS: readonly { readonly kind: DocumentationItemKind; readonly icon: string; readonly label: string }[] = [
@@ -48,7 +50,7 @@ const item: CSSProperties = {
touchAction: 'none',
};
export function DocumentationSidebar({ onItem, onExport, onReport }: DocumentationSidebarProps) {
export function DocumentationSidebar({ onItem, onExport, onReport, readOnly = false }: DocumentationSidebarProps) {
const t = useTranslate();
const [ghost, setGhost] = useState<{ kind: DocumentationItemKind; x: number; y: number } | null>(null);
const press = useRef<{ kind: DocumentationItemKind; x: number; y: number; dragging: boolean } | null>(null);
@@ -106,7 +108,7 @@ export function DocumentationSidebar({ onItem, onExport, onReport }: Documentati
flexShrink: 0,
}}
>
{ITEMS.map((each) => (
{(readOnly ? [] : ITEMS).map((each) => (
<button
key={each.kind}
type="button"
+13
View File
@@ -1,4 +1,9 @@
{
"access.frozen": "Eingefroren – nur ansehen",
"access.frozenRefused": "Das Projekt ist eingefroren; die Simulation startet nicht.",
"access.readOnly": "Nur ansehen",
"access.readOnlyRefused": "Hier können Sie nur zuschauen.",
"access.startedBy": "Simulation gestartet von {name}",
"browser.address": "Adresse",
"browser.errorText": "Es ist ein Fehler aufgetreten (HTTP-Status: {code})",
"browser.errorTitle": "Fehler {message} ({code})",
@@ -352,6 +357,14 @@
"program.textEditor": "Text-Editor",
"program.webBrowser": "Webbrowser",
"program.webServer": "Webserver",
"remote.connecting": "Verbindung zum Projekt …",
"remote.deleted": "Das Projekt wurde gelöscht.",
"remote.noAccess": "Sie haben keinen Zugang zu diesem Projekt.",
"remote.notAProject": "Diese Adresse nennt kein Projekt.",
"remote.notSignedIn": "Die Anmeldung ist abgelaufen. Öffnen Sie das Projekt im Dashboard neu.",
"remote.refused": "Der Filius-Dienst hat die Verbindung abgelehnt ({reason}).",
"remote.revoked": "Ihr Zugang zu diesem Projekt wurde beendet.",
"remote.unknownProject": "Dieses Projekt gibt es nicht mehr.",
"report.apps": "Anwendungen",
"report.baseConfig": "Basiskonfiguration",
"report.components": "Komponenten",
+13
View File
@@ -1,4 +1,9 @@
{
"access.frozen": "Frozen – view only",
"access.frozenRefused": "The project is frozen; the simulation does not start.",
"access.readOnly": "View only",
"access.readOnlyRefused": "You can only watch here.",
"access.startedBy": "Simulation started by {name}",
"browser.address": "Address",
"browser.errorText": "An error occurred (HTTP-Status: {code})",
"browser.errorTitle": "Error {message} ({code})",
@@ -352,6 +357,14 @@
"program.textEditor": "Text editor",
"program.webBrowser": "Webbrowser",
"program.webServer": "Webserver",
"remote.connecting": "Connecting to the project …",
"remote.deleted": "The project was deleted.",
"remote.noAccess": "You have no access to this project.",
"remote.notAProject": "This address names no project.",
"remote.notSignedIn": "Your sign-in has expired. Open the project again from the dashboard.",
"remote.refused": "The Filius service refused the connection ({reason}).",
"remote.revoked": "Your access to this project has ended.",
"remote.unknownProject": "This project no longer exists.",
"report.apps": "Applications",
"report.baseConfig": "Base Configuration",
"report.components": "Components",
+18 -5
View File
@@ -1,8 +1,11 @@
// The editor's page. Embedded in the dashboard (F6) it gets its document and
// language over the bridge; on its own — for development and the browser
// tests — it opens one of FILIUS' examples, an empty project or a file
// (`?example=<name>` opens that example at once, `?example=new` an empty
// project, `?lang=en` and `?theme=dark` set the rest).
// The editor's page. On a project of server F (`?project=<uuid>`, and
// `&server=<address>` when F is not where the page comes from) it opens the
// shared document and the room there (SharedProject); embedded in the
// dashboard (F6) it gets its language and theme over the bridge. On its own
// — for development and the browser tests — it opens one of FILIUS'
// examples, an empty project or a file (`?example=<name>` opens that example
// at once, `?example=new` an empty project, `?lang=en` and `?theme=dark` set
// the rest), with its room in a worker of the page.
import { StrictMode, useEffect, useState } from 'react';
import { createRoot } from 'react-dom/client';
@@ -11,6 +14,7 @@ import { changeFiles, desktopView, deviceSheet, emptyProject, fileText, topology
import { openProjectArchive } from '@mdg-filius/fls';
import { startBridge } from './bridge/dashboardBridge.js';
import { Editor } from './Editor.js';
import { SharedProject } from './remote/SharedProject.js';
import { LanguageProvider, translate, type Language } from './i18n/i18n.js';
import './theme/tokens.css';
@@ -32,6 +36,7 @@ function App() {
/** The open file's name, for what the editor exports. */
const [fileName, setFileName] = useState<string | undefined>(undefined);
const [example, setExample] = useState(params.get('example') ?? '');
const project = params.get('project');
useEffect(() => {
if (params.get('theme') === 'dark') document.documentElement.dataset.theme = 'dark';
@@ -81,6 +86,14 @@ function App() {
}, [doc]);
const t = (key: string) => translate(language, key);
if (project !== null)
return (
<LanguageProvider language={language}>
<main style={{ height: '100%' }}>
<SharedProject server={params.get('server') ?? window.location.origin} project={project} />
</main>
</LanguageProvider>
);
return (
<LanguageProvider language={language}>
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
@@ -0,0 +1,51 @@
// The editor on a project of server F (F5.3b): the shared document over
// /sync and the room over /sim, both signed in by the cookie F set at
// /__auth. Until the document is there, a word that it is coming; when F
// refuses or lets go — of the document or the room — a word why, instead of
// the editor. Who only reads gets the editor locked (Editor's readOnly).
import { useEffect, useMemo, useState } from 'react';
import { Editor } from '../Editor.js';
import { CATALOGS, useLanguage, useTranslate } from '../i18n/i18n.js';
import type { SimHostFactory } from '../simulation/useSimulation.js';
import { RemoteSimHost, simUrl } from './remoteSimHost.js';
import { connectSharedDoc, type SharedDocState } from './sharedDoc.js';
export interface SharedProjectProps {
/** F's address. */
readonly server: string;
readonly project: string;
}
export function SharedProject({ server, project }: SharedProjectProps) {
const t = useTranslate();
const language = useLanguage();
const [state, setState] = useState<SharedDocState>({ kind: 'connecting' });
/** F ended the room for this page: the editor goes as when the document is let go. */
const [roomEnd, setRoomEnd] = useState<string | null>(null);
useEffect(() => connectSharedDoc({ server, project, onState: setState }), [server, project]);
const createSimHost = useMemo<SimHostFactory>(
() => () => {
const host = new RemoteSimHost(simUrl(server, project));
host.onEnd((end) => setRoomEnd(end.reason));
return host;
},
[server, project],
);
const ended = roomEnd ?? (state.kind === 'refused' || state.kind === 'closed' ? state.reason : null);
if (ended !== null) {
const key = `remote.${ended}`;
return (
<p role="alert" className="remote-message">
{key in CATALOGS[language] ? t(key) : t('remote.refused', { reason: ended })}
</p>
);
}
if (state.kind !== 'ready')
return (
<p role="status" className="remote-message">
{t('remote.connecting')}
</p>
);
return <Editor key={state.doc.guid} doc={state.doc} createSimHost={createSimHost} readOnly={state.readOnly} frozen={state.frozen} />;
}
+136
View File
@@ -0,0 +1,136 @@
// The simulation room on server F (plan §6.1): a WebSocket to
// /sim?project=…, the same messages as the room in a worker of the page
// (LocalSimHost). The socket comes back after it is lost (the network, F
// restarting, the room's thread failing), with the topics it watched; F's
// own word ends it for good — refused (no session, no grant) or let go
// (revoked, deleted) — and is told to whoever listens (`onEnd`).
import {
parseHostMessage,
SIM_CLOSED_CODE,
SIM_PROTOCOL_VERSION,
SIM_REFUSED_CODE,
type ClientMessage,
type HostMessage,
type SimHost,
type SyncClosing,
type SyncRefusal,
type Topic,
} from '@mdg-filius/protocol';
/** The first wait before the socket comes back, doubled each time up to RECONNECT_MAXIMUM_MILLISECONDS. */
export const RECONNECT_FIRST_MILLISECONDS = 500;
export const RECONNECT_MAXIMUM_MILLISECONDS = 10_000;
/** Why F ended the room for this page. */
export type RoomEnd = { readonly kind: 'refused'; readonly reason: SyncRefusal } | { readonly kind: 'closed'; readonly reason: SyncClosing };
/** What RemoteSimHost needs of a WebSocket (the browser's, or ws in tests). */
export interface SocketLike {
readonly readyState: number;
send(data: string): void;
close(code?: number, reason?: string): void;
onopen: (() => void) | null;
onmessage: ((event: { readonly data: unknown }) => void) | null;
onclose: ((event: { readonly code: number; readonly reason: string }) => void) | null;
}
export type SocketFactory = (url: string) => SocketLike;
const OPEN = 1;
export class RemoteSimHost implements SimHost {
private socket: SocketLike | undefined;
private readonly listeners = new Set<(message: HostMessage) => void>();
private readonly endListeners = new Set<(end: RoomEnd) => void>();
/** What waits for the socket to open. */
private queue: ClientMessage[] = [];
/** What this page watches: sent again when the socket comes back. */
private readonly topics = new Set<Topic>();
private wait = RECONNECT_FIRST_MILLISECONDS;
private timer: ReturnType<typeof setTimeout> | undefined;
private over = false;
private openedBefore = false;
constructor(
private readonly url: string,
private readonly open: SocketFactory = (address) => new WebSocket(address) as unknown as SocketLike,
) {
this.connect();
}
private connect(): void {
const socket = this.open(this.url);
this.socket = socket;
socket.onopen = () => {
this.wait = RECONNECT_FIRST_MILLISECONDS;
let queued = this.queue;
this.queue = [];
// Back after a loss, in a room that knows nothing of this page: all it watches, before anything else.
if (this.openedBefore) {
if (this.topics.size > 0) socket.send(JSON.stringify({ v: 1, kind: 'sub', topics: [...this.topics] }));
queued = queued.filter((message) => message.kind !== 'sub' && message.kind !== 'unsub');
}
this.openedBefore = true;
for (const message of queued) socket.send(JSON.stringify(message));
};
socket.onmessage = (event) => {
let value: unknown;
try {
value = JSON.parse(String(event.data));
} catch {
value = undefined;
}
const parsed = parseHostMessage(value);
const message: HostMessage = 'message' in parsed ? parsed.message : { v: SIM_PROTOCOL_VERSION, kind: 'error', code: parsed.error.code, detail: parsed.error.detail };
for (const listener of this.listeners) listener(message);
};
socket.onclose = ({ code, reason }) => {
if (this.over) return;
if (code === SIM_REFUSED_CODE || code === SIM_CLOSED_CODE) {
this.over = true;
const end = (code === SIM_REFUSED_CODE ? { kind: 'refused', reason } : { kind: 'closed', reason }) as RoomEnd;
for (const listener of this.endListeners) listener(end);
return;
}
this.timer = setTimeout(() => this.connect(), this.wait);
this.wait = Math.min(RECONNECT_MAXIMUM_MILLISECONDS, this.wait * 2);
};
}
send(message: ClientMessage): void {
if (message.kind === 'sub') for (const topic of message.topics) this.topics.add(topic);
if (message.kind === 'unsub') for (const topic of message.topics) this.topics.delete(topic);
if (this.over) return;
if (this.socket?.readyState === OPEN) this.socket.send(JSON.stringify(message));
// Sent once the socket is open (again).
else this.queue.push(message);
}
onMessage(listener: (message: HostMessage) => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
/** Hears F end the room for this page; returns how to stop hearing. */
onEnd(listener: (end: RoomEnd) => void): () => void {
this.endListeners.add(listener);
return () => this.endListeners.delete(listener);
}
close(): void {
this.over = true;
if (this.timer !== undefined) clearTimeout(this.timer);
this.listeners.clear();
this.endListeners.clear();
this.socket?.close();
}
}
/** The address of a project's room on F, from F's address (http or https). */
export function simUrl(server: string, project: string): string {
const url = new URL('/sim', server);
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
url.searchParams.set('project', project);
return url.href;
}
+105
View File
@@ -0,0 +1,105 @@
// The project's shared document on server F (plan §5): Hocuspocus' client
// on /sync, signed in by the cookie F set at /__auth. What it says is the
// editor's state: still connecting; ready — with the document, whether this
// person only reads, and whether because the project is frozen; refused
// (no session, no grant, no such project); or let go (revoked, deleted).
//
// A person who read and may write again (a frozen project thawed) gets the
// document anew: a change made while reading stayed in the page's copy and
// F did not take it, and Yjs on F would hold back every later change that
// builds on it (measured in F5.2). The editor locks editing while it reads,
// so that nothing gets there; the fresh copy is for what got there anyway.
import { HocuspocusProvider, HocuspocusProviderWebsocket } from '@hocuspocus/provider';
import * as Y from 'yjs';
import { SYNC_CLOSINGS, type AccessMessage } from '@mdg-filius/protocol';
export type SharedDocState =
| { readonly kind: 'connecting' }
| { readonly kind: 'ready'; readonly doc: Y.Doc; readonly readOnly: boolean; readonly frozen: boolean }
/** F refused the connection; the reason is one of SYNC_REFUSALS (or Hocuspocus' own word). */
| { readonly kind: 'refused'; readonly reason: string }
/** F let the connection go; the reason is one of SYNC_CLOSINGS. */
| { readonly kind: 'closed'; readonly reason: string };
export interface SharedDocOptions {
/** F's address (http or https). */
readonly server: string;
readonly project: string;
readonly onState: (state: SharedDocState) => void;
/** A WebSocket other than the page's (tests in Node). */
readonly WebSocketPolyfill?: unknown;
}
/** The address of F's shared documents, from F's address. */
export function syncUrl(server: string): string {
const url = new URL('/sync', server);
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
return url.href;
}
/** Connects to the project's document; returns how to let it go. */
export function connectSharedDoc({ server, project, onState, WebSocketPolyfill }: SharedDocOptions): () => void {
let current: { readonly provider: HocuspocusProvider; readonly socket: HocuspocusProviderWebsocket } | undefined;
const letGo = () => {
current?.provider.destroy();
current?.socket.destroy();
current = undefined;
};
const end = (state: SharedDocState) => {
letGo();
onState(state);
};
const open = () => {
const doc = new Y.Doc();
const socket = new HocuspocusProviderWebsocket({ url: syncUrl(server), ...(WebSocketPolyfill === undefined ? {} : { WebSocketPolyfill }) });
let readOnly = false;
let frozen = false;
let synced = false;
/** F tells the access at once (`access`): ready only with it, so that a reader never sees a writable editor. */
let told = false;
const tell = () => {
if (synced && told) onState({ kind: 'ready', doc, readOnly, frozen });
};
const provider = new HocuspocusProvider({
websocketProvider: socket,
name: project,
document: doc,
// Who comes in is told by the cookie; the token is not looked at.
token: 'cookie',
onSynced: () => {
if (synced) return;
synced = true;
tell();
},
onAuthenticationFailed: ({ reason }) => end({ kind: 'refused', reason }),
onStateless: ({ payload }) => {
let message: Partial<AccessMessage>;
try {
message = JSON.parse(payload) as Partial<AccessMessage>;
} catch {
return;
}
if (message.kind !== 'access' || typeof message.readOnly !== 'boolean') return;
if (readOnly && !message.readOnly && synced) {
letGo();
onState({ kind: 'connecting' });
open();
return;
}
readOnly = message.readOnly;
frozen = message.frozen === true;
told = true;
tell();
},
onClose: ({ event }) => {
if ((SYNC_CLOSINGS as readonly string[]).includes(event.reason)) end({ kind: 'closed', reason: event.reason });
},
});
provider.attach();
current = { provider, socket };
};
onState({ kind: 'connecting' });
open();
return letGo;
}
+24 -7
View File
@@ -34,6 +34,7 @@ import { ConnectionsPanel } from './ConnectionsPanel.js';
import { FirewallPanel } from './FirewallPanel.js';
import { DhcpServerPanel, PortForwardingPanel } from './ServicePanels.js';
import { SheetCheck, SheetField } from './SheetField.js';
import { SheetLock, SheetLocked } from './SheetLock.js';
import './sheet.css';
/** The device's sheet, read anew after every change of the document. */
@@ -60,6 +61,8 @@ export interface DeviceSheetProps {
readonly onClose: () => void;
/** The cable to draw active (a router's card whose tab is open), or none. */
readonly onHighlight: (link: string | null) => void;
/** Who only reads (on F): everything shown, nothing changed (SheetLock). */
readonly readOnly?: boolean;
}
/** A page of the sheet that Java opens as a dialog. */
@@ -67,7 +70,7 @@ 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) {
export function DeviceSheet({ doc, id, label, origin, onChanged, onMessage, onClose, onHighlight, readOnly = false }: DeviceSheetProps) {
const t = useTranslate();
const sheet = useDeviceSheet(doc, id);
// The page open, if any; the editor makes a sheet per device, so another device starts at its fields.
@@ -86,6 +89,7 @@ export function DeviceSheet({ doc, id, label, origin, onChanged, onMessage, onCl
};
const title = t(`device.${sheet.kind}`);
return (
<SheetLocked.Provider value={readOnly}>
<aside className="device-sheet" aria-label={t('sheet.title')} data-device={id}>
<header>
{open === null ? (
@@ -103,17 +107,25 @@ export function DeviceSheet({ doc, id, label, origin, onChanged, onMessage, onCl
{/* A field per device: a text typed for one device is not offered to the next. Kept while a page is open, so that its tab stays as Java's panel does behind a dialog. */}
<div key={id} hidden={open !== null}>
{sheet.kind === 'pc' || sheet.kind === 'notebook' ? (
<HostFields sheet={sheet} label={label} change={change} onMessage={onMessage} onOpen={onOpen} />
<SheetLock>
<HostFields sheet={sheet} label={label} change={change} onMessage={onMessage} onOpen={onOpen} />
</SheetLock>
) : sheet.kind === 'networkSwitch' ? (
<SwitchFields sheet={sheet} change={change} />
<SheetLock>
<SwitchFields sheet={sheet} change={change} />
</SheetLock>
) : sheet.kind === 'remoteLink' ? (
<ModemFields sheet={sheet} change={change} />
<SheetLock>
<ModemFields sheet={sheet} change={change} />
</SheetLock>
) : sheet.kind === 'router' ? (
<RouterFields sheet={sheet} change={change} onHighlight={onHighlight} onOpen={onOpen} />
) : sheet.kind === 'gateway' ? (
<HomeRouterFields sheet={sheet} change={change} onHighlight={onHighlight} onOpen={onOpen} />
) : (
<NameField name={sheet.name} change={change} />
<SheetLock>
<NameField name={sheet.name} change={change} />
</SheetLock>
)}
</div>
{open === 'dhcp' && (sheet.kind === 'pc' || sheet.kind === 'notebook' || sheet.kind === 'gateway') ? (
@@ -126,14 +138,19 @@ export function DeviceSheet({ doc, id, label, origin, onChanged, onMessage, onCl
</div>
) : open === 'connections' && sheet.kind === 'router' ? (
<div key={`${id}-connections`}>
<ConnectionsPanel sheet={sheet} change={change} />
<SheetLock>
<ConnectionsPanel sheet={sheet} change={change} />
</SheetLock>
</div>
) : open === 'forwarding' && sheet.kind === 'gateway' ? (
<div key={`${id}-forwarding`}>
<PortForwardingPanel sheet={sheet} change={change} />
<SheetLock>
<PortForwardingPanel sheet={sheet} change={change} />
</SheetLock>
</div>
) : null}
</aside>
</SheetLocked.Provider>
);
}
+3 -2
View File
@@ -24,6 +24,7 @@ import { useTranslate } from '../i18n/i18n.js';
import type { Change } from './NameField.js';
import { SheetCheck, SheetField, SheetNote } from './SheetField.js';
import { SheetTabs } from './SheetTabs.js';
import { SheetPanel } from './SheetLock.js';
/** Java's protocol names in the rule table (ruleAsVector): "*" for all. */
const PROTOCOL_NAMES: Readonly<Record<FirewallProtocol, string>> = { all: '*', tcp: 'TCP', udp: 'UDP', icmp: 'ICMP' };
@@ -51,7 +52,7 @@ export function FirewallPanel({ firewall, change }: { readonly firewall: Firewal
selected={tab}
onSelect={setTab}
/>
<div role="tabpanel" key={tab}>
<SheetPanel key={tab}>
{tab === 'general' ? (
<>
<SheetCheck name="firewallActive" label={t('firewall.active')} checked={firewall.isActive} onChange={flag('isActive')} />
@@ -174,7 +175,7 @@ export function FirewallPanel({ firewall, change }: { readonly firewall: Firewal
)}
</>
)}
</div>
</SheetPanel>
</>
);
}
+5 -4
View File
@@ -27,6 +27,7 @@ import { NameField, type Change } from './NameField.js';
import { RowsEditor } from './RowsEditor.js';
import { SheetTabs, type Tab } from './SheetTabs.js';
import { SheetCheck, SheetField } from './SheetField.js';
import { SheetPanel } from './SheetLock.js';
/** Reports the cable of the card whose tab is open, and none once the sheet goes. */
function useHighlight(link: string | null, onHighlight: (link: string | null) => void): void {
@@ -94,7 +95,7 @@ export function RouterFields({
<>
<SheetTabs tabs={tabs} selected={shown} onSelect={setTab} />
{/* A panel per tab: a text typed on one card is not offered to the next. */}
<div role="tabpanel" key={shown}>
<SheetPanel key={shown}>
{shown === 'general' ? (
<>
<NameField name={sheet.name} change={change} />
@@ -115,7 +116,7 @@ export function RouterFields({
) : (
<RouteTable sheet={sheet} change={change} />
)}
</div>
</SheetPanel>
</>
);
}
@@ -144,7 +145,7 @@ export function HomeRouterFields({
return (
<>
<SheetTabs tabs={tabs} selected={tab} onSelect={setTab} />
<div role="tabpanel" key={tab}>
<SheetPanel key={tab}>
{tab === 'general' ? (
<>
<NameField name={sheet.name} change={change} />
@@ -175,7 +176,7 @@ export function HomeRouterFields({
<SheetCheck name="dhcpClient" label={t('sheet.dhcpClient')} checked={sheet.dhcpClient} onChange={(on) => change.run(() => setDhcpClient(change.doc, change.id, on, change.origin))} />
</>
)}
</div>
</SheetPanel>
</>
);
}
+3 -2
View File
@@ -28,6 +28,7 @@ import type { Change } from './NameField.js';
import { RowsEditor, type RowsColumn } from './RowsEditor.js';
import { SheetCheck, SheetField } from './SheetField.js';
import { SheetTabs } from './SheetTabs.js';
import { SheetPanel } from './SheetLock.js';
export function DhcpServerPanel({ server, change }: { readonly server: DhcpServerSheet; readonly change: Change }) {
const t = useTranslate();
@@ -44,7 +45,7 @@ export function DhcpServerPanel({ server, change }: { readonly server: DhcpServe
selected={tab}
onSelect={setTab}
/>
<div role="tabpanel" key={tab}>
<SheetPanel key={tab}>
{tab === 'basic' ? (
<>
<SheetField name="lowerBound" label={t('dhcp.lowerBound')} value={server.lowerBoundIPAddress} rule="ipAddress" inputMode="decimal" onCommit={address('lowerBoundIPAddress')} />
@@ -105,7 +106,7 @@ export function DhcpServerPanel({ server, change }: { readonly server: DhcpServe
onRemove={(index) => change.run(() => removeDhcpStaticAssignment(change.doc, change.id, index, change.origin))}
/>
)}
</div>
</SheetPanel>
</>
);
}
+26
View File
@@ -0,0 +1,26 @@
// The sheet of a person who only reads (on F): what it holds is shown, every
// field, box and button that changes something is disabled — a fieldset
// around a sheet's body and around each tab's panel, so that the tabs stay
// to be read through.
import { createContext, useContext, type ReactNode } from 'react';
export const SheetLocked = createContext(false);
/** A sheet's body, disabled while the sheet is locked. */
export function SheetLock({ children }: { readonly children: ReactNode }) {
return (
<fieldset className="sheet-lock" disabled={useContext(SheetLocked)}>
{children}
</fieldset>
);
}
/** A tab's panel, disabled while the sheet is locked. */
export function SheetPanel({ children }: { readonly children: ReactNode }) {
return (
<fieldset className="sheet-lock" role="tabpanel" disabled={useContext(SheetLocked)}>
{children}
</fieldset>
);
}
+27
View File
@@ -27,6 +27,18 @@
pointer-events: auto;
}
/* A word beside the mode buttons: who started the simulation, that this person only reads. */
.editor-note {
display: flex;
align-items: center;
min-height: 44px;
padding: 0 12px;
border: 1px solid var(--border-default);
border-radius: 8px;
background: var(--surface-raised);
color: var(--text-primary);
}
.editor-area {
position: relative;
flex: 1;
@@ -336,3 +348,18 @@
color: var(--text-secondary);
}
/* A locked sheet's body and tab panels (SheetLock): a fieldset without its own frame. */
.sheet-lock {
min-width: 0;
margin: 0;
padding: 0;
border: 0;
}
/* Instead of the editor on a project of F: that it is coming, or why not. */
.remote-message {
margin: 0;
padding: 24px;
color: var(--text-primary);
}
+20 -5
View File
@@ -1,6 +1,7 @@
// Java's mode buttons (GUIMainMenu: documentation, design and simulation
// mode, in Java's order, with Java's icons) and, while the simulation runs, its speed slider with the per cent
// beside it. Java's keys for the modes (Ctrl+D, Ctrl+R) are not offered: in
// beside it. Who only reads (on F) neither starts nor stops the room's
// simulation nor sets its speed; the documentation mode is a view of one's own. Java's keys for the modes (Ctrl+D, Ctrl+R) are not offered: in
// a browser they bookmark and reload the page.
import type { CSSProperties } from 'react';
@@ -25,7 +26,20 @@ const modeButton: CSSProperties = {
/** The editor's modes: the room's two, and the documentation mode, which is the design's for the room. */
export type EditorMode = 'documentation' | 'design' | 'simulation';
export function ModeBar({ mode, speed, onMode, onSpeed }: { readonly mode: EditorMode; readonly speed: number; readonly onMode: (mode: EditorMode) => void; readonly onSpeed: (value: number) => void }) {
export function ModeBar({
mode,
speed,
onMode,
onSpeed,
canRun = true,
}: {
readonly mode: EditorMode;
readonly speed: number;
readonly onMode: (mode: EditorMode) => void;
readonly onSpeed: (value: number) => void;
/** Whether this person may start and stop the simulation (a write grant on F). */
readonly canRun?: boolean;
}) {
const t = useTranslate();
return (
<>
@@ -36,19 +50,20 @@ export function ModeBar({ mode, speed, onMode, onSpeed }: { readonly mode: Edito
aria-pressed={mode === 'documentation'}
aria-label={t('mode.documentation')}
title={t('mode.documentation')}
disabled={!canRun && mode === 'simulation'}
onClick={() => onMode('documentation')}
>
<img src={mode === 'documentation' ? documentationOn : documentation} alt="" width={50} height={50} />
</button>
<button type="button" name="designMode" style={modeButton} aria-pressed={mode === 'design'} aria-label={t('mode.design')} title={t('mode.design')} onClick={() => onMode('design')}>
<button type="button" name="designMode" style={modeButton} aria-pressed={mode === 'design'} aria-label={t('mode.design')} title={t('mode.design')} disabled={!canRun && mode === 'simulation'} onClick={() => onMode('design')}>
<img src={mode === 'design' ? designOn : design} alt="" width={50} height={50} />
</button>
<button type="button" name="simulationMode" style={modeButton} aria-pressed={mode === 'simulation'} aria-label={t('mode.simulation')} title={t('mode.simulation')} onClick={() => onMode('simulation')}>
<button type="button" name="simulationMode" style={modeButton} aria-pressed={mode === 'simulation'} aria-label={t('mode.simulation')} title={t('mode.simulation')} disabled={!canRun} onClick={() => onMode('simulation')}>
<img src={mode === 'simulation' ? simulationOn : simulation} alt="" width={50} height={50} />
</button>
{mode === 'simulation' ? (
<label style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '0 8px', minHeight: 52, borderRadius: 8, background: 'var(--surface-raised)', border: '1px solid var(--border-default)' }}>
<input type="range" name="speed" min={1} max={100} value={speed} aria-label={t('mode.speed')} title={t('mode.speed')} onChange={(event) => onSpeed(Number(event.target.value))} style={{ width: 100 }} />
<input type="range" name="speed" min={1} max={100} value={speed} disabled={!canRun} aria-label={t('mode.speed')} title={t('mode.speed')} onChange={(event) => onSpeed(Number(event.target.value))} style={{ width: 100 }} />
<span title={t('mode.currentSpeed')} style={{ minWidth: 40, fontVariantNumeric: 'tabular-nums' }}>
{speed}%
</span>
@@ -2,8 +2,9 @@
// 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
// 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.
// runs behind a SimHost — on F for everyone in the project (RemoteSimHost),
// or in a worker of this page (LocalSimHost) on the development page; the
// editor keeps no simulation of its own.
import { useCallback, useEffect, useRef, useState } from 'react';
import type * as Y from 'yjs';
@@ -41,6 +42,10 @@ export interface Simulation {
readonly views: ReadonlyMap<string, unknown>;
/** Whether the core failed and the room went back to design mode, since the last start. */
readonly failed: boolean;
/** Who started the simulation that runs, on F. */
readonly startedBy: string | undefined;
/** The room's last no to this person — it only reads, the project is frozen — counted, so that each is told. */
readonly refusal: { readonly code: 'readOnly' | 'frozen'; readonly count: number } | null;
/** The time of day (milliseconds since 1970) the simulation's time 0 stands for: a viewer's times of day are written from it. */
readonly clock: number;
/** The frames "Pakete verwerfen" dropped in this simulation, while `dropped` is watched. */
@@ -75,6 +80,8 @@ export function useSimulation(doc: Y.Doc, createHost: SimHostFactory = localSimH
const [activeLinks, setActiveLinks] = useState<ReadonlySet<string>>(NO_LINKS);
const [nodes, setNodes] = useState<ReadonlyMap<string, NodeSettings>>(new Map());
const [failed, setFailed] = useState(false);
const [startedBy, setStartedBy] = useState<string | undefined>(undefined);
const [refusal, setRefusal] = useState<Simulation['refusal']>(null);
const [apps, setApps] = useState<ReadonlyMap<string, AppLines>>(new Map());
const [views, setViews] = useState<ReadonlyMap<string, unknown>>(new Map());
const [clock, setClock] = useState(0);
@@ -90,6 +97,7 @@ export function useSimulation(doc: Y.Doc, createHost: SimHostFactory = localSimH
setMode(message.mode);
setSpeedState(message.speed);
setClock(message.clock);
setStartedBy(message.startedBy);
if (message.mode === 'design') setActiveLinks(NO_LINKS);
return;
case 'activity':
@@ -125,6 +133,10 @@ export function useSimulation(doc: Y.Doc, createHost: SimHostFactory = localSimH
return;
case 'error':
if (message.code === 'engineFailed') setFailed(true);
if (message.code === 'readOnly' || message.code === 'frozen') {
const code = message.code;
setRefusal((before) => ({ code, count: (before?.count ?? 0) + 1 }));
}
return;
default:
return;
@@ -199,5 +211,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, traces, start, stop, setSpeed, drop, clearCapture, watch, captures, act };
return { mode, speed, activeLinks, nodes, apps, views, failed, startedBy, refusal, clock, dropped, traces, start, stop, setSpeed, drop, clearCapture, watch, captures, act };
}
+166
View File
@@ -0,0 +1,166 @@
// The room on F as the editor reaches it (RemoteSimHost), on sockets made
// by the test and a clock it moves: what is sent before the socket opens
// goes once it does; the room's messages are read as LocalSimHost reads
// them; a lost socket comes back after a wait that doubles (to 10 s at
// most) and watches again what it watched; F's refusal and parting end it
// for good and are told; closing it ends it without a word.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { SIM_CLOSED_CODE, SIM_REFUSED_CODE, SIM_ROOM_FAILED_CODE, type HostMessage } from '@mdg-filius/protocol';
import { RECONNECT_FIRST_MILLISECONDS, RECONNECT_MAXIMUM_MILLISECONDS, RemoteSimHost, simUrl, type RoomEnd, type SocketLike } from '../src/remote/remoteSimHost.js';
class FakeSocket implements SocketLike {
readyState = 0;
readonly sent: unknown[] = [];
closedWith: [number | undefined, string | undefined] | undefined;
onopen: (() => void) | null = null;
onmessage: ((event: { readonly data: unknown }) => void) | null = null;
onclose: ((event: { readonly code: number; readonly reason: string }) => void) | null = null;
constructor(readonly url: string) {}
send(data: string): void {
this.sent.push(JSON.parse(data));
}
close(code?: number, reason?: string): void {
this.closedWith = [code, reason];
this.readyState = 3;
}
opened(): void {
this.readyState = 1;
this.onopen?.();
}
hear(data: unknown): void {
this.onmessage?.({ data: typeof data === 'string' ? data : JSON.stringify(data) });
}
lost(code: number, reason = ''): void {
this.readyState = 3;
this.onclose?.({ code, reason });
}
}
let sockets: FakeSocket[];
const host = () =>
new RemoteSimHost('ws://f.test/sim?project=p', (url) => {
const socket = new FakeSocket(url);
sockets.push(socket);
return socket;
});
beforeEach(() => {
sockets = [];
vi.useFakeTimers();
});
afterEach(() => vi.useRealTimers());
describe('the room on F', () => {
it('sends what came before the socket opened once it does, and then at once', () => {
const room = host();
room.send({ v: 1, kind: 'sub', topics: ['activity'] });
room.send({ v: 1, kind: 'start' });
expect(sockets[0]!.sent).toEqual([]);
sockets[0]!.opened();
room.send({ v: 1, kind: 'stop' });
expect(sockets[0]!.sent).toEqual([
{ v: 1, kind: 'sub', topics: ['activity'] },
{ v: 1, kind: 'start' },
{ v: 1, kind: 'stop' },
]);
expect(sockets[0]!.url).toBe('ws://f.test/sim?project=p');
});
it("reads the room's messages, and names what it cannot read", () => {
const room = host();
const got: HostMessage[] = [];
const stop = room.onMessage((message) => got.push(message));
sockets[0]!.opened();
sockets[0]!.hear({ v: 1, kind: 'state', mode: 'design', speed: 90, t: 0, clock: 5 });
sockets[0]!.hear('not json');
sockets[0]!.hear({ v: 1, kind: 'nonsense' });
stop();
sockets[0]!.hear({ v: 1, kind: 'activity', links: [] });
expect(got).toEqual([
{ v: 1, kind: 'state', mode: 'design', speed: 90, t: 0, clock: 5 },
{ v: 1, kind: 'error', code: 'badMessage', detail: 'not an object' },
{ v: 1, kind: 'error', code: 'unknownKind', detail: 'kind nonsense' },
]);
});
it('comes back after a loss, waiting longer each time, and watches again what it watched', () => {
const room = host();
room.send({ v: 1, kind: 'sub', topics: ['activity', 'node:a'] });
sockets[0]!.opened();
room.send({ v: 1, kind: 'unsub', topics: ['node:a'] });
sockets[0]!.lost(1006);
// Sent while it is away: kept for the room that comes.
room.send({ v: 1, kind: 'sub', topics: ['trace:p1'] });
room.send({ v: 1, kind: 'speed', value: 50 });
vi.advanceTimersByTime(RECONNECT_FIRST_MILLISECONDS - 1);
expect(sockets).toHaveLength(1);
vi.advanceTimersByTime(1);
expect(sockets).toHaveLength(2);
sockets[1]!.opened();
expect(sockets[1]!.sent).toEqual([
{ v: 1, kind: 'sub', topics: ['activity', 'trace:p1'] },
{ v: 1, kind: 'speed', value: 50 },
]);
// Lost again before it opened, again and again: 1 s, 2 s, … up to 10 s; an open socket begins anew.
const waits: number[] = [];
for (let index = 1; index <= 6; index++) {
const before = sockets.length;
sockets.at(-1)!.lost(index % 2 === 0 ? SIM_ROOM_FAILED_CODE : 1001);
let waited = 0;
while (sockets.length === before) {
vi.advanceTimersByTime(100);
waited += 100;
}
waits.push(waited);
}
expect(waits).toEqual([RECONNECT_FIRST_MILLISECONDS, 1_000, 2_000, 4_000, 8_000, RECONNECT_MAXIMUM_MILLISECONDS]);
sockets.at(-1)!.opened();
sockets.at(-1)!.lost(1006);
vi.advanceTimersByTime(RECONNECT_FIRST_MILLISECONDS);
expect(sockets.at(-1)!.readyState).toBe(0);
});
it('ends for good when F refuses or lets go, and says why', () => {
for (const [code, end] of [
[SIM_REFUSED_CODE, { kind: 'refused', reason: 'noAccess' }],
[SIM_CLOSED_CODE, { kind: 'closed', reason: 'revoked' }],
] as const) {
sockets = [];
const room = host();
const ends: RoomEnd[] = [];
room.onEnd((each) => ends.push(each));
sockets[0]!.opened();
sockets[0]!.lost(code, end.reason);
vi.advanceTimersByTime(RECONNECT_MAXIMUM_MILLISECONDS * 2);
expect(sockets).toHaveLength(1);
expect(ends).toEqual([end]);
room.send({ v: 1, kind: 'start' });
expect(sockets[0]!.sent).toEqual([]);
}
});
it('ends without a word when it is closed, and does not come back', () => {
const room = host();
const ends: RoomEnd[] = [];
const stop = room.onEnd((each) => ends.push(each));
sockets[0]!.opened();
room.close();
expect(sockets[0]!.closedWith).toEqual([undefined, undefined]);
sockets[0]!.lost(1000);
vi.advanceTimersByTime(RECONNECT_MAXIMUM_MILLISECONDS * 2);
expect([sockets.length, ends]).toEqual([1, []]);
stop();
// Closed while it waits to come back: it does not.
const other = host();
sockets.at(-1)!.lost(1006);
other.close();
vi.advanceTimersByTime(RECONNECT_MAXIMUM_MILLISECONDS * 2);
expect(sockets).toHaveLength(2);
});
it('makes the address of a room from the address of F', () => {
expect(simUrl('https://filius.hub.example/', 'abc')).toBe('wss://filius.hub.example/sim?project=abc');
expect(simUrl('http://127.0.0.1:8080', 'abc')).toBe('ws://127.0.0.1:8080/sim?project=abc');
});
});