6.0 KiB
| name | description |
|---|---|
| frontend | Use when adding/modifying anything in FRONTEND/ — new components, modals, socket events, state, routing, Mantine UI, terminal/xterm work. Covers conventions, the App.tsx state-fanout pattern, the SocketContext, the line-buffering renderer, and the modal stacking rules. |
FRONTEND skill
Scope: everything under FRONTEND/ (React 19 + Vite + TypeScript + Mantine 8 + socket.io-client + xterm).
Run from FRONTEND/:
npm run dev(Vite, port 5173)npm run build(tsc -b && vite build)npm run lint(flat-config ESLint)
Env: VITE_BACKEND_URL (REST), VITE_SOCKET_SERVER (Socket.IO; default http://localhost:8989/).
Conventions to preserve
- Folder typo is intentional:
src/untils/(notutils). Imports across the app reference../untils/...— do not rename. - Mantine 8 only. Don't reach for raw HTML form elements or other UI kits when a Mantine component exists. Same for
@tabler/icons-reactfor icons. - Comments mix Vietnamese and English. Preserve existing language in nearby code.
localStorage.useris the single source of truth for the logged-in user. The shape is parsed JSON{ id, userName, email, ... }. UseisJsonStringfromuntils/helperbefore parsing.apiUrlpattern: every file that calls REST doesconst apiUrl = import.meta.env.VITE_BACKEND_URL;at the top, thenaxios.get(apiUrl + "api/..."). Match it.
App.tsx is the canonical store
src/App.tsx holds stations: TStation[] and is the only place that owns line-level state. Everything else reads from props.
When you handle a new socket event that updates a line, always go through updateValueLineStation(lineId, updates, stationId) — it fans the update out to selectedLine and selectedLines in lockstep. Manually calling setStations will desync the terminal modal from the cards.
line_output is the exception: it's batched in lineBuffersRef: Map<lineId, string> and flushed every ~50ms by flushBuffers. If you add a new high-frequency stream (anything that fires more than ~10 Hz), reuse this batch path — don't dispatch per event, you'll cause per-character React renders that lock up the UI.
SocketContext is the only place that creates a socket
src/context/SocketContext.tsx instantiates one socket.io-client connection authed with { userId, userName } and exposes it via useSocket(). Never call io(...) from a component. New components consume the socket via useSocket() or accept a socket: Socket | null prop from a parent that already has it.
When subscribing in a component, the rules are:
- Always
socket.off("event", handler)in the effect cleanup with the same handler reference. - Don't subscribe to events that
App.tsxalready listens to —App.tsx's handler will fire too and you'll get double updates. Filter inside your handler instead (e.g. bystationId/lineId). - Re-subscribe when the relevant ID prop changes — include
stationId/lineIdin the dependency array.
Adding a new socket round-trip (FE side)
Pattern, copy-pasted from ModalLineHistory.tsx:
useEffect(() => {
if (!socket || !opened) return;
const handler = (data: { stationId: number; lineId: number; ...: ... }) => {
if (data?.stationId !== stationId || data?.lineId !== lineId) return;
// setState(...)
};
socket.on("event_name", handler);
return () => { socket.off("event_name", handler); };
}, [socket, opened, stationId, lineId]);
useEffect(() => {
if (!socket || !opened || !stationId || !lineId) return;
socket.emit("event_name_request", { stationId, lineId });
}, [socket, opened, stationId, lineId]);
The filter data.stationId !== stationId || data.lineId !== lineId is mandatory — the backend broadcasts to the requesting socket, but a single socket can have multiple modals open.
Modals: prefer <Modal> from Mantine
Look at ModalLineHistory.tsx for the canonical small modal, ModalTerminal.tsx for the large multi-panel one.
- Stacking: the terminal modal uses
zIndex={95}. Modals opened from inside it must use a higherzIndex(use1000000to stay above any portaled content). - Close-guarding: when adding a new inner modal opened from
ModalTerminal, add itsopenflag to the close-guard around line560ofModalTerminal.tsxso Escape on the inner modal doesn't bubble up and close the terminal too:if (openSelectIos || openLineHistory || /* your flag */ || ...) return; - Don't roll a custom backdrop. The previous version of
ModalLineHistory.tsxused a fixed-position<div>overlay — that's been replaced with<Modal>. Don't reintroduce the pattern.
Types live in src/untils/types.ts
TLine, TStation, TUser, IScenario, TBrands, TCategories, TextFSM, etc. all come from there. Backend can send the same field in two casings — lineNumber vs line_number, stationId vs station_id, apcName vs apc_name. Always read both: line.lineNumber || line.line_number. When passing into a new socket emit, normalize to one form.
Terminal (xterm)
src/components/TerminalXTerm.tsx is the only consumer of xterm + @xterm/addon-fit. It receives content (latest delta), initContent (full buffer for hydration), loadingClearTerminal, and isClearKeepScrollBack from ModalTerminal. The [CLEAR_TERMINAL_SCROLL_BACK] sentinel string in the buffer is the contract for "clear scrollback but keep history" — if you add a new clear mode, follow the same sentinel approach rather than adding a new prop.
DnD-Kit station tabs
src/components/DragTabs.tsx owns the draggable station header. If you add a new tab-level action, plug it in here, not in App.tsx.
Don't
- Don't fix the
untilstypo. - Don't add a new socket connection.
- Don't bypass
updateValueLineStationto mutatestationsdirectly when a line update arrives. - Don't add a per-message render — batch via
lineBuffersRefif frequency is high. - Don't mock UI/UX changes only with type-checks; for any visible change, run
npm run devand walk through the affected flow in the browser before reporting done.