Home

08 frontend components

6. Frontend Components

6.1 WebSocket Hook (useSocket.ts)

import { useEffect, useRef, useCallback } from "react";
import { useLocoStore } from "./store";

type Envelope = { type: string; id?: string; payload?: unknown };

export function useSocket(url: string) {
  const wsRef = useRef<WebSocket | null>(null);
  const applyEvent = useLocoStore((s) => s.applyEvent);

  const connect = useCallback(() => {
    const ws = new WebSocket(url);
    wsRef.current = ws;

    ws.onmessage = (e) => {
      const env: Envelope = JSON.parse(e.data);
      applyEvent(env);
    };
    ws.onclose = () => setTimeout(connect, 1000); // reconnect with backoff
  }, [url, applyEvent]);

  useEffect(() => {
    connect();
    return () => wsRef.current?.close();
  }, [connect]);

  const send = useCallback((env: Envelope) => {
    wsRef.current?.send(JSON.stringify(env));
  }, []);

  return { send };
}

6.2 Zustand Store for Locomotive State

import { create } from "zustand";

type LocoState = {
  addr: number;
  speed: number;
  forward: boolean;
  functions: number[];
};

type Store = {
  locos: Record<number, LocoState>;
  applyEvent: (env: { type: string; payload?: any }) => void;
};

export const useLocoStore = create<Store>((set) => ({
  locos: {},
  applyEvent: (env) => {
    if (env.type === "loco.state") {
      const st = env.payload as LocoState;
      set((s) => ({ locos: { ...s.locos, [st.addr]: st } }));
    }
  },
}));

6.3 Control Component (Material UI)

import { useEffect } from "react";
import {
  Card,
  CardContent,
  CardActions,
  Typography,
  Slider,
  Stack,
  IconButton,
  ToggleButton,
  ToggleButtonGroup,
} from "@mui/material";
import PlayArrowIcon from "@mui/icons-material/PlayArrow";
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import ArrowForwardIcon from "@mui/icons-material/ArrowForward";
import StopIcon from "@mui/icons-material/Stop";

function LocoControl({ addr }: { addr: number }) {
  const { send } = useSocket(`ws://${location.host}/api/v1/ws`);
  const state = useLocoStore((s) => s.locos[addr]);

  useEffect(() => {
    send({ type: "loco.subscribe", payload: { addr } });
    return () => send({ type: "loco.unsubscribe", payload: { addr } });
  }, [addr, send]);

  const setSpeed = (speed: number) =>
    send({
      type: "loco.setSpeed",
      payload: { addr, speed, forward: state?.forward ?? true },
    });

  const setDirection = (forward: boolean) =>
    send({
      type: "loco.setSpeed",
      payload: { addr, speed: state?.speed ?? 0, forward },
    });

  return (
    <Card sx={{ maxWidth: 480, m: 2 }}>
      <CardContent>
        <Typography variant="h5" gutterBottom>
          Loco #{addr}
        </Typography>
        <Typography variant="body2" color="text.secondary" gutterBottom>
          {state?.speed ?? 0} step{state?.forward ? " ▶" : " ◀"}
        </Typography>
        <Slider
          value={state?.speed ?? 0}
          min={0}
          max={127}
          aria-label="Throttle"
          onChange={(_, v) => setSpeed(v as number)}
        />
      </CardContent>
      <CardActions>
        <ToggleButtonGroup
          exclusive
          value={state?.forward ? "fwd" : "rev"}
          onChange={(_, v) => v && setDirection(v === "fwd")}
          size="small"
        >
          <ToggleButton value="rev" aria-label="Reverse">
            <ArrowBackIcon />
          </ToggleButton>
          <ToggleButton value="fwd" aria-label="Forward">
            <ArrowForwardIcon />
          </ToggleButton>
        </ToggleButtonGroup>
        <IconButton color="error" onClick={() => setSpeed(0)} aria-label="Stop">
          <StopIcon />
        </IconButton>
      </CardActions>
    </Card>
  );
}

6.3a Train control in the Throttle overlay

Train driving is not a separate page. It lives inside the same full-screen Throttle overlay as single-vehicle control (ThrottlePageThrottleCockpit, §6.3b). The picker lists vehicles and trains together (useThrottleTargetSelection persists the last target in localStorage).

When a train is selected:

Per-member settings (TrainMemberSettingsDialog)

Owner-only cog popup. Fields depend on whether the member is the leading vehicle:

Field Trailing members Leading vehicle
speedMultiplier (0.05–4.0) editable fixed 1.0 (not sent on PATCH)
excludeFromSpeed checkbox not offered
startDelayMs 0 or 50–1000 ms (step 50) editable
accelRampMs + accelRampMaxSteps 0 or 0.5–5 s (step 0.5 s), 1–10 steps editable
brakeRampMs + brakeRampMaxSteps same ranges as accel ramp editable

When excludeFromSpeed is checked on a trailing member, all timing fields are cleared on save and that vehicle is skipped by train.setSpeed fan-out on the daemon.

Strings live under throttle.jsontrain.memberSettings.* (pl + en).

How dcc-bus applies member timing

Each train.setSpeed is handled by TrainSpeedScheduler (pkgs/bigfred/dcc-bus/service/train_speed_scheduler.go). A new command cancels any pending delay/ramp goroutines for that train.

Per powered member (after computing the effective target speed from the slider, multiplier, and Reversed):

  1. Acceleration ramp — when target > current, accelRampMs > 0, and either the member is already above the consist-start threshold (DCC speed > 1) or startDelayMs == 0. The daemon issues intermediate setSpeed steps in one goroutine: apply first, then sleep between steps. Total ramp duration and max steps come from member settings; step count is reduced until each interval is ≥ 500 ms.
  2. Braking ramp — when target < current and brakeRampMs > 0. Same step/sleep pattern as acceleration (including stop to 0).
  3. Start delay — on a consist start (leading vehicle was at DCC speed ≤ 1), when the member is also at speed ≤ 1, startDelayMs > 0, and acceleration ramp does not apply: a single sleep, then one immediate setSpeed to the target.
  4. Immediate — otherwise the target speed is written synchronously.

Acceleration ramp takes precedence over start delay when both could apply at standstill (start delay only wins when acceleration ramp is disabled or blocked because startDelayMs > 0 and current speed ≤ 1).

The slider witness stays the leading member; trailing speeds may lag during ramps and appear in each accordion header.

// ThrottlePage.tsx (ConnectedThrottle, excerpt — train branch)
const { setTrainSpeed, setFunction, subscribe, states } = useDccBus();
const trainCtx = useSelectedTrainContext(layoutID, trainId);

useEffect(() => {
  void subscribe(trainCtx.poweredMembers.map((m) => m.dccAddress));
}, [trainCtx.poweredMembers, subscribe]);

const witness = states.get(trainCtx.leadingAddr);
const { queueSpeed } = useDebouncedTrainSpeedSend(setTrainSpeed);

// slider → queueSpeed(trainId, speed, forward)
// accordions → states.get(member.dccAddress)?.speed in header;
//              setFunction(member.dccAddress, fn, on) in body

Single-vehicle selection keeps the flat function grid and loco.setSpeed unchanged. <TakeoverThrottleOverlay> still drives one vehicle via the legacy selectedAddress props on ThrottleCockpit.

6.3b Throttle mode – full-screen overlay

Throttle mode (tryb sterowania „Throttle”, see §1) is how a driver or a signalman (after a granted takeover) operates a vehicle or train in real time. It is not a separate application route the user navigates away from: it is a layer above the rest of BigFred that hosts the driving surface (ThrottleSlider, function and script buttons, command-station picker, script console, takeover banners, dead-man's switch affordances).

Entry, exit and shell layout

The sticky top AppBar in AppShell.tsx carries a Throttle button (labelled via vehicle.json, icon: engineer / maszynista) among the account-level controls. Clicking it toggles throttle mode:

Gating: the button is shown to users who may drive at least one vehicle or train in the current session layout (drivers on owned/leased scope; signalmen only while they hold active takeover authority on a target). Exact rules follow the permission matrix in §11.

Controls available inside the overlay

While throttle mode is open and driving authority is held, the operator can:

Throttle commands (loco.*, train.setSpeed, system.estop) travel over the data-plane WebSocket to dcc-bus (§7e.4). Radio Stop and other control-plane actions use /api/v1/ws (§4.2, §7e.7).

Left toolbar – Fullscreen and Radio Stop

The overlay renders a thin toolbar pinned to its left edge (<ThrottleToolbar>), above the vehicle/train driving surface. Two controls live there, left to right:

  1. Fullscreen toggle (<FullscreenButton>) – toggles the browser Fullscreen API on the overlay container (element.requestFullscreen() / document.exitFullscreen()). The icon flips between Fullscreen and FullscreenExit based on document.fullscreenElement. Strings: throttle.fullscreen.enter / throttle.fullscreen.exit. Shown to every operator in throttle mode (no extra gate).
  2. Radio Stop (<RadioStopButton>) – sits immediately to the right of the Fullscreen toggle. Red, radio-handset icon. Rendered only when useCanDriveAny() passes (same gate as the AppBar Throttle toggle and §4.6.2). Pressing it opens <RadioStopConfirmOverlay>.

<RadioStopConfirmOverlay> is a centred MUI Dialog containing a primary red button „Uruchom radiostop” and, below it, a neutral „Anuluj”. Only the former dispatches system.radioStop {} on the control plane (useControlPlane()), then closes; „Anuluj” just dismisses. Strings: throttle.radioStop.button, throttle.radioStop.run, throttle.radioStop.cancel, throttle.radioStop.tooltip.

// ThrottleToolbar.tsx (excerpt)
function ThrottleToolbar({ overlayRef }: { overlayRef: RefObject<HTMLElement> }) {
  const canDrive = useCanDriveAny();
  return (
    <Stack className="throttle-toolbar" direction="row" spacing={1}>
      <FullscreenButton target={overlayRef} />
      {canDrive && <RadioStopButton />}
    </Stack>
  );
}

function RadioStopButton() {
  const { send } = useControlPlane();
  const [open, setOpen] = useState(false);
  return (
    <>
      <IconButton color="error" aria-label={t("throttle:radioStop.button")}
                  onClick={() => setOpen(true)}>
        <SettingsInputAntennaIcon />
      </IconButton>
      <Dialog open={open} onClose={() => setOpen(false)}>
        <Stack spacing={2} sx={{ p: 3, alignItems: "stretch" }}>
          <Button variant="contained" color="error" size="large"
                  onClick={() => { send({ type: "system.radioStop", payload: {} }); setOpen(false); }}>
            {t("throttle:radioStop.run")}
          </Button>
          <Button variant="text" onClick={() => setOpen(false)}>
            {t("throttle:radioStop.cancel")}
          </Button>
        </Stack>
      </Dialog>
    </>
  );
}

Radiostop alarm playback

A small useRadioStopSound() hook (mounted once inside the overlay) subscribes to the control-plane system.radioStop push event and plays the bundled asset at /sounds/radiostop.ogg:

function useRadioStopSound() {
  useControlPlaneEvent("system.radioStop", () => {
    new Audio("/sounds/radiostop.ogg").play().catch(() => {/* autoplay blocked */});
  });
}

The same clip plays on every open throttle session in the layout (the operator who pressed the button and everyone else), so the alarm is heard simultaneously. Non-throttle surfaces (dashboard) receive the same event but only show a toast (throttle.radioStop.toast) — they do not mount the audio hook.

Radio and chat (driver side)

The throttle toolbar carries two more icons next to the Radio Stop button: a radio icon and a chat icon. They are the driver's walkie-talkie surface (§4.4) and are independent of the layout-wide Radio Stop.

Radio icon (<ThrottleRadioButton><ThrottleRadioOverlay>). Opens an on-screen overlay used to send a radio message about the currently driven vehicle/train (that target is the message context). The overlay stacks, top to bottom:

  1. Interlocking picker — a text field with search that filters the layout's interlockings (GET /api/v1/interlockings); the picked box becomes to.interlockingId.
  2. Phrase picker — the same searchable table of the closed RadioPhrase vocabulary used on the signalman side (<RadioPhrasePickerDialog>), so the driver visually finds the phrase.

Selecting a phrase emits radio.send { to:{ interlockingId }, context:{ vehicleId | trainId }, phrase } and plays /sounds/interlockings/radio-sent.ogg on ack.

Chat icon (<ThrottleChatButton><ThrottleChatOverlay>). Opens a popup/overlay showing this driver's chat history with the various signalmen (their own conversations only, §4.4.3), seeded via radio.replay { scope:"user" } (Redis, §4.4.4) and kept live by radio.message. Same line format as the signalman panel.

Unread indicator + incoming alert. When a radio.message arrives for the driver:

A small useRadioSounds() hook (sibling of useRadioStopSound(), §6.3b) plays radio-sent.ogg on the sender's radio.send ack and {phrase}.ogg on each inbound radio.message.

Server as source of truth (multi-pilot sync)

The DCC bus and the backend command station are shared state. A physical throttle on the layout, another browser tab, or an external API/MCP client may change the same locomotive while BigFred is open. The overlay therefore must not treat the last outbound command as ground truth. Instead it renders from server push events — principally loco.state for speed, direction and the runtime functions array — and re-fetches function definitions on vehicle.functionsChanged. When an external pilot moves the speed step or flips a function, every open throttle overlay subscribed to that address converges to the server state within one polling/event round trip (see M1 acceptance criteria).

When a takeover is active, the affected driver's overlay for that target becomes read-only telemetry (controlledBy.kind == "signalman"); the signalman's overlay receives full write access until takeover.released.

Illustrative shell wiring

// AppShell.tsx (excerpt) – Throttle toggle on the top bar
import EngineeringIcon from "@mui/icons-material/Engineering";
import { ThrottleOverlay } from "./ThrottleOverlay";

export function AppShell({ children }: { children: React.ReactNode }) {
  const [throttleOpen, setThrottleOpen] = useState(false);
  const canDrive = useCanDriveAny(); // owned, leased, or takeover-held scope

  return (
    <>
      <AppBar position="sticky">
        <Toolbar>
          <Typography variant="h6" sx={{ flexGrow: 1 }}>BigFred</Typography>
          {canDrive && (
            <IconButton
              color="inherit"
              aria-label={t("vehicle:throttle.open")}
              aria-pressed={throttleOpen}
              onClick={() => setThrottleOpen((v) => !v)}
            >
              <EngineeringIcon />
            </IconButton>
          )}
          {/* account / admin menus, locale toggle … */}
        </Toolbar>
      </AppBar>

      <main>{children}</main>

      {throttleOpen && (
        <ThrottleOverlay onClose={() => setThrottleOpen(false)} />
      )}
    </>
  );
}

ThrottleOverlay hosts vehicle/train selection and mounts ThrottlePage / ThrottleCockpit content from §6.3a and §6.3b.

Dual-WebSocket model (after §7e ships)

When §7e is live, the overlay manages two independent WebSocket connections (see §7e.7 for the full lifecycle):

  1. Control-plane WS to loco-server (/api/v1/ws) — already open since login. Carries session.*, takeover.*, radio.*, script.*, presence, auth.elevationChanged, and the command-station picker (session.setCommandStation / session.commandStationChanged).
  2. Data-plane WS to the picked dcc-bus daemon (ws://host:<port>/ws?token=<jwt>, returned via session.opened.availableCommandStations[i].wsUrl). Carries loco.subscribe / loco.unsubscribe / loco.setSpeed / loco.toggleFn / train.setSpeed / system.estop / ping. Re-opened when the user switches command stations.

<ThrottleCockpit> slider and function toggles dispatch on the data plane via useDccBus(); <ScriptButtons>, the takeover banner and the radio panel keep using the control plane (useSocket). Selecting the right socket is encapsulated; component code does not know about ports.

The command-station dropdown inside <ThrottleHeader> renders status per row (RUNNING / STOPPED / STARTING / DEGRADED) based on availableCommandStations[i].status and disables rows whose wsUrl == null until the user selects them (selection triggers daemon spawn). The <SharedBusChip> lights up when dcc-bus.opened.sharedBus === true to surface §3a.4 rule 9 to the driver.

Settings icon — connection and command-retry feedback

The settings / cog control in <ThrottleCockpit> (top-right of the driving header) opens <ThrottleSetupDialog>: command-station picker, control-plane and data-plane connection chips, and spawn error retry (§17). Its icon reflects transient reliability state instead of showing reconnect toasts in the overlay.

Visual Condition aria-label key
SettingsIcon (default) Data plane connected; no command retry in flight throttle:setup.open
CircularProgress (small) Data-plane reconnect after a prior successful open (connectionLost: dccReconnecting, or status closed / error) throttle:reconnecting
Rotating SyncIcon A driving command or Radio Stop is being resent (commandRetrying from speed / train-speed / function hooks, or radioStopRetrying from <RadioStopButton onRetryingChange>) throttle:commandRetrying

Priority: connection lost beats command retry; the cog is disabled while either spinner is shown so setup cannot be opened mid-handshake.

ThrottlePage passes connectionLost and commandRetrying into <ThrottleCockpit>; radio-stop retry state is collected inside the cockpit from the left-toolbar <RadioStopButton>. Full retry budgets and WebSocket backoff are documented in §17 Reliability.

6.3e Vehicle catalogue and function editor

Route for the owner's vehicle catalogue (lista pojazdów / lokomotyw): /vehicles (LocoListPage.tsx). The page lists every vehicle the caller owns (GET /api/v1/vehicles, filtered to ownerUserId == me), with columns for kind, DCC address, name and number. Each row exposes two owner-only actions in the trailing action column:

Control Icon (MUI) Behaviour
Edytuj Edit Opens VehicleDialog — metadata form (name, kind, number, optional DCC address, Rp1 function default F2, emergency lights function default F0, Dead Man's Switch option) persisted on domain.Vehicle and copied into the allowed_vehicles Redis snapshot for dcc-bus (§7e.5).
Edytuj funkcje Tune (or Functions) Navigates to /vehicles/{addr}/functions — the function-definition editor described below. Tooltip and aria-label come from vehicle.json (vehicle.functions.edit).

Lessees and non-owners never see either action. Vehicles without a DCC address (dummy) may still open the function editor (definitions are stored for when an address is added later), but the throttle will not emit DCC for them until dccAddress is set.

Function editor page (VehicleFunctionsPage.tsx)

Route: /vehicles/{addr}/functions. Header shows vehicle name and DCC address; a back link returns to /vehicles.

The page edits the resolved function list for that vehicle (GET /api/v1/vehicles/{addr}/functions). When source: "template" the UI shows a read-only banner (“Lista dziedziczona ze szablonu …”) until the first mutation, which triggers server-side copy-on-write (§3a.6).

Adding a slot — toolbar button Dodaj funkcję opens a dialog:

Confirming calls PUT …/functions/{num}.

Editing — each list row is editable for title and icon; changes debounce to the same PUT endpoint.

Removing — row action UsuńDELETE …/functions/{num}.

Reordering — the list is a drag-and-drop sortable (@dnd-kit or equivalent). On drop the client posts POST …/functions/reorder { positions: [{ num, position }, …] }. position is dense 0..n-1 in display order.

The list is sorted by position ascending at all times. The same order is used in throttle mode: <FunctionButtons> renders one button per registered function, left-to-right / top-to-bottom in position order. Reordering on this page therefore immediately changes how the driver sees functions in the Throttle overlay (after refetch or vehicle.functionsChanged).

Throttle visibility — every function row the owner registered for this vehicle appears in <FunctionButtons> for that vehicle inside throttle mode (§6.3b). There is no separate “favourites” subset: the catalogue on this page is the throttle button row (scripts from §6.7 still append after the function buttons). Lessees and signalmen with driving authority see the same buttons but cannot open this editor.

// VehicleFunctionsPage.tsx (structure sketch)
function VehicleFunctionsPage() {
  const { addr } = useParams();
  const { data: fns = [], refetch } = useQuery({
    queryKey: ["vehicle-functions", addr],
    queryFn: () => fetch(`/api/v1/vehicles/${addr}/functions`).then((r) => r.json()),
  });
  const icons = useFunctionIcons(); // GET /api/v1/function-icons, cached

  const onReorder = (ordered: ResolvedFunction[]) =>
    fetch(`/api/v1/vehicles/${addr}/functions/reorder`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        positions: ordered.map((f, i) => ({ num: f.num, position: i })),
      }),
    }).then(() => refetch());

  return (
    <Container>
      <FunctionList
        items={fns.sort((a, b) => a.position - b.position)}
        icons={icons}
        onReorder={onReorder}
        onSave={(f) => putFunction(addr, f)}
        onDelete={(num) => deleteFunction(addr, num)}
      />
    </Container>
  );
}

FunctionButtons.tsx already sorts by position when rendering; no second sort key is applied in the throttle.

6.3c Layout dashboard (HomePage)

After login the default route is / (HomePage.tsx). This dashboard (pulpit makiety, see §1) is the operational home screen for the layout the user picked on the login form. It renders three MUI DataGrid / Table panels stacked vertically (or tabbed on very small screens), each fed by REST on mount and kept fresh by WebSocket fan-out (§4.2).

1. Layout vehicle roster

Default view: vehicles added to this layout (GET /api/v1/layouts/{layoutId}/vehicles). Columns include at minimum DCC address, name, owner login.

Toolbar actions (i18n keys in home.json / vehicle.json):

Row actions (owner only): remove from layout (DELETE …/vehicles/{addr}).

2. Online users

Live table of everyone currently connected to the layout (GET /api/v1/layouts/{layoutId}/presence, updated on layout.presenceChanged). Columns:

Column Content
Login login
Role effective role in this layout (driver / signalman / admin, via role namespace)
Interlocking if the user occupies a signal box: interlocking name; otherwise em dash

One row per user, not per tab – multiple WS sessions from the same login collapse into a single row.

3. Interlockings

Table of interlockings whitelisted in this layout (GET /api/v1/interlockings, enriched with occupant). Columns: name, location, Obstawia (staffed by – occupant login or "wolna" / vacant). Rows are clickable: navigation to /interlockings/:id (InterlockingPage).

All three panels share the active layoutId from useMe(); there is no layout switcher on this page (layout is immutable for the session).

// HomePage.tsx (structure sketch)
function HomePage() {
  const me = useMe().data!;
  const layoutId = me.layoutId;
  const [showMine, setShowMine] = useState(false);

  const vehicles = useQuery({
    queryKey: ["layout-vehicles", layoutId, showMine],
    queryFn: () =>
      fetch(
        showMine
          ? `/api/v1/layouts/${layoutId}/vehicles/mine`
          : `/api/v1/layouts/${layoutId}/vehicles`,
      ).then((r) => r.json()),
  });
  // presence + interlockings analogous; useSocket merges WS events

  return (
    <Container maxWidth="lg">
      <LayoutVehiclesTable data={vehicles.data} showMine={showMine}
        onToggleMine={() => setShowMine((v) => !v)} layoutId={layoutId} />
      <OnlineUsersTable layoutId={layoutId} />
      <InterlockingsTable layoutId={layoutId} onRowClick={(id) => navigate(`/interlockings/${id}`)} />
    </Container>
  );
}

6.3d Interlocking view and occupation

Route: /interlockings/:id (InterlockingPage.tsx). Opened from the dashboard interlockings table (§6.3c) or via direct link. Visible to every authenticated user in the layout; occupation controls are enabled only for users with the layout-scoped signalman role.

Layout of the page

  1. Header – interlocking name, location, current occupant (live via interlocking.occupantChanged).
  2. Action bar (signalmen only):
  3. Obsadź nastawnię (Occupy interlocking) – visible when the caller is not the active occupant. Calls POST /api/v1/interlockings/{id}/join. If the box is vacant the join succeeds immediately. If another signalman is already staffing it, the UI shows a confirmation dialog naming the incumbent and explaining that they will be displaced; on confirm the client retries with { force: true }. This prevents a forgotten session from blocking the interlocking indefinitely while still requiring an explicit human decision.
  4. Opuść nastawnię (Leave interlocking) – visible when the caller is the active occupant. Calls POST /api/v1/interlockings/{id}/leave.
  5. Command-station picker (cog button) – a settings / cog icon (Settings, identical to the throttle, §6.3b) that opens a setup dialog (<ThrottleSetupDialog> reused) hosting the command-station dropdown + connection status. The signalman needs a picked command station to drive a taken-over target in the throttle overlay (the throttle dispatch invariant requires session.CommandStationID != nil, §4.5.1) and to scope a per-target „Zatrzymaj skład" estop. It is populated from session.opened.availableCommandStations, fires session.setCommandStation on change, and re-renders on session.commandStationChanged / layout.commandStationsChanged — exactly like the throttle picker. When the list has a single entry the UI MAY auto-pick it.
  6. Radio Stop bar – directly above the panels the staffed box renders a Radio Stop button (<RadioStopButton variant="bar">). It uses the same red radio-handset icon as the throttle button (§6.3b) but, unlike the icon-only throttle control, it shows the text label „Radio stop" next to the icon. Pressing it opens the same <RadioStopConfirmOverlay> and, on confirm, dispatches system.radioStop {} on the control plane (layout-wide halt, §4.6). It is shown to any signalman staffing the box (see the extended authorization in §4.6.2), independent of whether they currently hold a takeover.
  7. Three-panel work area – below the Radio Stop bar the staffed box renders a three-column layout: a radio chat panel on the left, a vehicle/train roster panel in the centre, and a train announcements panel on the right (details below). On narrow screens the three collapse into tabs (Radio | Składy | Zapowiedzi).
Left panel – radio chat (<InterlockingChatPanel>)

The signalman's group chat with every driver in the layout. It is a fixed-width, vertically scrollable column (overflow-y: auto) showing all traffic exchanged with all drivers (§4.4.3) ordered by time.

Centre panel – vehicle/train roster (<InterlockingRosterPanel>)

A fixed-width, scrollable table of the vehicles and trains on the current layout, with a live "in motion" indicator (derived from each target's loco.state.speed != 0). The table header carries a search box that filters rows client-side.

Columns:

Column Content
Skład ({driverLogin}) {vehicle or train name} — the owner/driver login plus the train (skład) or single vehicle (lokomotywa) name. A small chip shows „w ruchu" when the target is moving.
Akcje three icon buttons (left→right): Radio, Stop, Przejęcie kontroli.

Action icons:

Right panel – train announcements (<InterlockingTrainAnnouncementsPanel>)

A fixed-width, scrollable list of pre-configured station PA messages for the current interlocking. Each row shows a human-readable label (translated via i18n) and acts as a play button — there is no confirmation step.

Example entries (labels in Polish; sound keys are kebab-case filenames without the .ogg extension):

Label (PL) soundKey
Po torze 1 przejedzie pociąg towarowy track-1-freight
Odjazd pociągu do st. Głuszyca departure-gluszyca
Odjazd pociągu do st. Wrocław departure-wroclaw
Odjazd pociągu do st. Warszawa Centralna departure-warszawa-centralna

Assets live under web/public/sounds/train-announcements/. To add or change announcements, edit the manifest, the matching i18n keys and drop the Ogg file — no migration or admin UI required.

Takeover throttle overlay (signalman drives without leaving the box)

When a takeover is granted (§4.3), the signalman does not leave the interlocking view. Instead a closable throttle overlay (<TakeoverThrottleOverlay>) opens on top of the interlocking view, hosting the same ThrottleCockpit driving surface (§6.3a, §6.3b) for the taken-over vehicle. The interlocking chat + roster stay mounted underneath.

Leaving the view while still occupying

If the active occupant navigates away from /interlockings/:id (back to the dashboard, admin page, browser back, …) while still holding an InterlockingSession, the router blocks the transition and shows a dialog:

You are staffing this interlocking. Leave the interlocking?

Implementation: React Router useBlocker (or equivalent) keyed off "am I the occupant?" local state synced from REST + WS. Closing the browser tab does not auto-leave (the session stays until explicit leave, displacement, or logout) – only in-app navigation triggers the prompt.

Displaced occupant UX

When interlocking.occupantChanged { reason:"displaced" } targets the current user, show a non-blocking toast, clear occupation state, and disable takeover/radio actions that require active occupation until they re-join or navigate away.

// InterlockingPage.tsx (occupation hook sketch)
function useInterlockingOccupation(interlockingId: number) {
  const me = useMe().data!;
  const isSignalman = /* effective role in layout includes signalman */;

  const join = async (force = false) => {
    const res = await fetch(`/api/v1/interlockings/${interlockingId}/join`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ force }),
    });
    if (res.status === 409 && !force) {
      const incumbent = await res.json(); // { occupant: { login } }
      const ok = await confirmDisplaceDialog(incumbent);
      if (ok) return join(true);
      return;
    }
    // refresh local occupant state …
  };

  // useBlocker: when isOccupying && navigating away → leave dialog
  return { isSignalman, join, leave, isOccupying, … };
}

6.4 MUI Setup – Theme, Roboto Font, App Shell

Following MUI's installation guide, install the core package, the styled-engine, the icons package, and the Roboto font:

npm install @mui/material @emotion/react @emotion/styled
npm install @mui/icons-material
npm install @fontsource/roboto

src/theme.ts – central theme configuration. Material UI ships with sensible defaults and a responsive 12-column grid; here we just tweak palette and breakpoints to suit a throttle-style UI that must work on small touchscreens:

import { createTheme } from "@mui/material/styles";

export const theme = createTheme({
  palette: {
    mode: "dark", // a command station console is easier to read in dark mode
    primary: { main: "#90caf9" },
    error: { main: "#ef5350" },
  },
  shape: { borderRadius: 12 },
  components: {
    MuiSlider: {
      styleOverrides: {
        thumb: { width: 28, height: 28 }, // larger touch targets on phones
      },
    },
  },
});

src/main.tsx – wire up ThemeProvider + CssBaseline (CSS reset) and the Roboto font once at the root:

import "@fontsource/roboto/300.css";
import "@fontsource/roboto/400.css";
import "@fontsource/roboto/500.css";
import "@fontsource/roboto/700.css";

import { createRoot } from "react-dom/client";
import { ThemeProvider, CssBaseline } from "@mui/material";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { theme } from "./theme";
import { App } from "./App";

const queryClient = new QueryClient();

createRoot(document.getElementById("root")!).render(
  <ThemeProvider theme={theme}>
    <CssBaseline />
    <QueryClientProvider client={queryClient}>
      <App />
    </QueryClientProvider>
  </ThemeProvider>,
);

src/components/AppShell.tsx – top-level navigation that adapts to phone vs. desktop via MUI's useMediaQuery and breakpoint system:

import { AppBar, Toolbar, Typography, IconButton, Drawer, useMediaQuery, useTheme } from "@mui/material";
import MenuIcon from "@mui/icons-material/Menu";
import { useState } from "react";

export function AppShell({ children }: { children: React.ReactNode }) {
  const theme = useTheme();
  const isMobile = useMediaQuery(theme.breakpoints.down("md"));
  const [open, setOpen] = useState(!isMobile);

  return (
    <>
      <AppBar position="sticky">
        <Toolbar>
          {isMobile && (
            <IconButton color="inherit" onClick={() => setOpen((v) => !v)} edge="start">
              <MenuIcon />
            </IconButton>
          )}
          <Typography variant="h6">BigFred Control</Typography>
        </Toolbar>
      </AppBar>
      <Drawer
        variant={isMobile ? "temporary" : "permanent"}
        open={open}
        onClose={() => setOpen(false)}
      >
        {/* loco list / nav */}
      </Drawer>
      <main>{children}</main>
    </>
  );
}

6.5 Why Material UI Fits This Project

6.6 REST via TanStack Query (List / Edit)

export const useLocos = () =>
  useQuery({
    queryKey: ["locos"],
    queryFn: () => fetch("/api/v1/locos").then((r) => r.json()),
  });

6.7 Script Buttons and Console (browser side)

With execution moved to the server (§3a.7), the frontend's job is trivial: render a button per attached script that emits script.run / script.stop, and a console pane that subscribes to script.log events for the currently-displayed throttle. No PyScript, no Web Worker, no Python source files. Goja runs on the server; the browser just operates the play/stop button.

// ScriptButtons.tsx
function ScriptButtons({ vehicle }: { vehicle: Vehicle }) {
  const { data: scripts = [] } = useQuery({
    queryKey: ["vehicle-scripts", vehicle.addr],
    queryFn: () => fetch(`/api/v1/vehicles/${vehicle.addr}/scripts`).then(r => r.json()),
  });
  const { send } = useSocket();
  const activeRuns = useScriptStore((s) => s.activeRuns); // map<attachmentId, runId>

  return (
    <Stack direction="row" spacing={1}>
      {scripts.map((s) => {
        const runId = activeRuns[s.attachmentId];
        const running = !!runId;
        return (
          <IconButton
            key={s.attachmentId}
            color={running ? "secondary" : "primary"}
            onClick={() => {
              if (running) send({ type: "script.stop", payload: { runId } });
              else send({ type: "script.run",  payload: { scriptId: s.id, attachmentId: s.attachmentId } });
            }}
          >
            <FunctionIcon name={s.icon} />
          </IconButton>
        );
      })}
    </Stack>
  );
}

useScriptStore is a tiny Zustand slice that listens for script.runStarted / script.runStopped events on the existing WS and keeps activeRuns[attachmentId] = runId. That's the entire client-side state. Stop on the phone is just send({ type:"script.stop", payload:{ runId } }) – the server forwards it to the executor, which interrupts the VM.

ScriptConsole.tsx is a <List> that subscribes to script.log events for the active throttle's runId and script.runStopped events to flush the buffer with the final { reason, durationMs } line. The editor (ScriptEditor.tsx) on the Scripts page uses @monaco-editor/react with language="javascript", posts the edited source via PUT /api/v1/scripts/{id}, and otherwise does nothing executable.

6.8 Internationalization (pointer)

Every user-visible string in the components above (button labels, error toasts, table headers, plural counters) is rendered through react-i18next with namespace catalogues bundled into web/dist. Backend codes (ApiError.code, RadioPhrase, FunctionIcon, AuditAction, …) map 1:1 to translation keys; user-entered names and audit-log denormalized snapshots are rendered verbatim. The I18nextProvider wraps the app above ThemeProvider and QueryClientProvider in main.tsx. The full specification — namespace layout, key naming, plural rules, locale persistence, type-safe key generation — lives in §7c i18n. Components in this section omit the boilerplate t("…") calls in their snippets for brevity; in real code, no string literal that reaches the DOM is hard-coded.