diff --git a/.claude/skills/backend/SKILL.md b/.claude/skills/backend/SKILL.md new file mode 100644 index 0000000..03b18ff --- /dev/null +++ b/.claude/skills/backend/SKILL.md @@ -0,0 +1,123 @@ +--- +name: backend +description: Use when adding/modifying anything in BACKEND/ — REST controllers, models, migrations, socket events, TCP session classes (line/station/APC/switch), Redis state, AdonisJS providers/middleware. Covers the socket_io_provider architecture, the handleLineOperation pattern, Redis history keys, idle-timeout vs keep-alive intervals, and the import alias rules. +--- + +# BACKEND skill + +Scope: everything under `BACKEND/` (AdonisJS 6, TypeScript, ESM, MySQL via Lucid, Redis, Socket.IO, raw TCP/Telnet to lab hardware). + +Run from `BACKEND/`: +- `npm run dev` — `node ace serve --hmr` (HTTP + Socket.IO providers boot together). +- `npm start` — `node bin/server.js` (production; the Socket.IO provider only opens its WS port when entry is `server.js`). +- `npm test` — Japa. Single test: `node ace test --files "tests/unit/foo.spec.ts"`. +- `npm run typecheck` / `npm run lint` / `npm run format`. +- `node ace migration:run` to apply migrations from `database/migrations/`. +- `node ace db:seed` for seeders in `database/seeders/`. + +Env: `SOCKET_PORT` (default 8989), `FRONTEND_URL` (CORS origin), MySQL/Redis creds, `ZULIP_STREAM_AU/US`, `ZULIP_TOPIC_AU`, `LINK_WIKI`, `TIME_ZONE`. Local Redis is required. + +## Conventions to preserve + +- **Folder typo is intentional:** `app/ultils/` (not `utils`). Imports across the project reference `../ultils/...` and `#models/...`-style aliases. Do not rename. +- **Always use AdonisJS subpath imports** declared in `package.json#imports`: `#controllers/*`, `#models/*`, `#services/*`, `#middleware/*`, `#start/*`, `#config/*`, `#exceptions/*`, `#validators/*`, `#providers/*`, `#policies/*`, `#abilities/*`, `#database/*`, `#tests/*`. **Exception:** files outside `app/` (e.g. `providers/socket_io_provider.ts`) reach into `app/` with **relative paths** (`../app/services/line_connection.js`). Match the convention of the file you're editing. +- **All ESM imports keep the `.js` extension** even when importing `.ts` source — that's required by AdonisJS's NodeNext resolution. `import { foo } from './bar.js'` resolves to `./bar.ts` at build time. +- **Comments mix Vietnamese and English.** Preserve existing language in nearby code. +- **Error handling style:** existing code wraps risky blocks in `try/catch` and `console.log`s the error rather than rethrowing. Match it for socket handlers — an unhandled throw inside a `socket.on` handler crashes the whole event loop. + +## `providers/socket_io_provider.ts` is the engine + +The HTTP API (controllers in `app/controllers/`) is intentionally thin. Real behavior lives in `WebSocketIo` inside `socket_io_provider.ts`. + +Key invariants: +- The class only boots its WS server when the process entry is `server.js` (`if (process.argv[1].includes('server.js'))` in `ready()`). `node ace migration:run` won't open the socket port — don't "fix" this. +- It runs an **independent** `http.createServer()` on `SOCKET_PORT`, separate from the AdonisJS HTTP port. +- `SocketIoProvider.io` (static) is the way other modules reach the socket server post-boot. + +In-memory maps that ARE the live system state: +- `lineMap: Map` +- `stationMap: Map` +- `apcsControl: Map` +- `switchControl: Map` +- `userConnecting: Map` + +`saveState()` runs every 10s and serializes a slimmed `lineMap` (status forced to `disconnected`, output truncated to last 5000 chars, CLI ownership cleared) into Redis under key `socket_state`. `restoreState()` rehydrates on boot. **If you add per-line state that should survive restart, extend both methods.** + +## Adding a new socket event (the standard pattern) + +1. Add `socket.on('your_event', async (data) => { ... })` inside the `connection` handler in `socket_io_provider.ts`. +2. **Validate `stationId` is active** when the event triggers hardware: `const ok = await checkStationActive(stationId); if (!ok) return;`. +3. **Route hardware actions through `handleLineOperation` or `handleStationOperation`.** They auto-reconnect a dropped TCP session and arm the idle-timeout interval. Never call `lineConn.writeCommand(...)` directly from a socket handler. +4. To respond to the **requesting socket only**, use `io.to(socket.id).emit('event_response', payload)`. To broadcast (e.g. ticket updates), use `io.emit(...)`. +5. Cleanup: if your event creates any `setInterval`/`setTimeout`, store it in the existing `intervalMap` / `intervalKeepConnect` dictionaries keyed by `lineId`/`ip` — not local closure variables — so it can be cleared on disconnect/reconnect. + +Example shape (matches `get_line_history`): + +```ts +socket.on('your_event', async (data) => { + const stationId = Number(data?.stationId) + const lineId = Number(data?.lineId) + if (!stationId || !lineId) { + return io.to(socket.id).emit('your_event_response', { ok: false }) + } + const result = await this.someHelper(stationId, lineId) + io.to(socket.id).emit('your_event_response', { stationId, lineId, result }) +}) +``` + +## TCP session classes (`app/services/`) + +Hierarchy: each class wraps `net.Socket` to a piece of lab hardware and exposes a mutable `config` object that the Socket.IO layer broadcasts. + +- `station_connection.ts` — top-level Cisco station console. Used for `clear line N`, `show line`, etc. +- `line_connection.ts` — individual serial line. Owns scenario execution (`runScript`), DPELP flow, IOS/license loading, physical-port testing (`PhysicalPortTest` from `physical_test_service.ts`), AI log classification (uses `PromptAi` model + axios). +- `apc_connection.ts` — APC PDU outlet control. +- `switch_connection.ts` — managed switch port + PoE control. + +When extending one of these: +- Mutate `this.config` rather than creating parallel state — the WebSocket layer reads `lineMap.get(id)?.config` directly. +- Append to `this.config.output` and slice to the last 5000 chars (`config.output = config.output.slice(-5000)`). Unbounded buffers blow memory and break Redis snapshots. +- For long output streams, also call `appendLog(cleanData(message), id, name, ip, lineNumber)` from `app/ultils/helper.ts` — that writes the system log file under `storage/system_logs/`. +- Always provide a `disconnect()` method, even if it just calls `this.client.destroy()`. `setTimeoutConnect` in the provider calls it. + +## The two interval lifecycles (don't forget the second one) + +Every long-lived TCP session needs both: +1. **Idle disconnect** — `setTimeoutConnect(id, conn, 8h_default)` in the provider. Stored in `intervalMap[id]`. Resets on each operation through `handleLineOperation`. +2. **Keep-alive** — `keepConnectAPC` (40s ENTER) / `keepConnectStation` (120s `\r\n`). Stored in `intervalKeepConnect[ip|id]`. + +If you add a new connection type and skip the keep-alive, sessions die silently after the firewall/device idle window. If you skip the idle disconnect, dead sockets pile up. + +## Redis keys in use + +- `socket_state` — full `lineMap` snapshot (slimmed). Written every 10s. +- `station:{stationId}:line:{lineId}:history` — sorted set, score = timestamp ms, value = JSON `{ pid, vid, sn, scenario, id, number, stationId, timestamp }`. Write via `LineConnection.addHistory`, read via `LineConnection.getHistory` or the private `getHistory` in `WebSocketIo`. Auto-pruned to last 96h on each `addHistory`. + +When adding new Redis keys, follow the colon-namespaced convention and document them here. + +## Routes (`start/routes.ts`) + +REST is grouped by resource: `api/stations`, `api/lines`, `api/logs`, `api/users`, `api/models`, `api/scenarios`, `api/auth`, `api/ticket`, `api/brands`, `api/categories`, `api/config-ram`, `api/keywords`, `api/prompt-ai`, `atc/health-check`, `api/ios`, `api/license`. Verb pattern is `GET /` for list, `POST create`, `POST update`, `POST delete` (or `DELETE delete/:id` for tickets). Match this when adding a new resource. + +Auth is mostly absent — routes are not currently behind the `auth` named middleware. Don't assume `request.user` is present unless you've added the middleware to that route group. + +## Migrations & models + +- New table: `node ace make:migration ` then `node ace make:model `. Migrations are timestamped; never edit a migration that's been run on a shared environment — write a new one. +- Models extend `BaseModel` from `@adonisjs/lucid/orm` and use `@column()` decorators. See `app/models/line.ts` as the reference. +- `database/seeders/index.ts` is wired up; add new seeders alongside `prompt_ai_seeder.ts` and import them there. + +## Hot-reload boundaries + +`hotHook.boundaries` in `package.json` is `app/controllers/**/*.ts` and `app/middleware/*.ts`. Editing a service, model, provider, or `start/*.ts` requires a full restart (`Ctrl+C`, `npm run dev` again) — HMR will not pick up the change. + +## Don't + +- Don't fix the `ultils` typo. +- Don't open a second Socket.IO server or HTTP server in another provider. +- Don't `throw` from a `socket.on` handler — wrap in `try/catch` and `console.log`. +- Don't bypass `handleLineOperation`/`handleStationOperation` for hardware ops; you'll skip the auto-reconnect and idle-timer reset. +- Don't store long-lived intervals in local closure variables; put them in `intervalMap`/`intervalKeepConnect`. +- Don't append to `config.output` without truncating — runaway buffers crash Redis snapshots. +- Don't import without the `.js` extension; NodeNext ESM will fail at runtime. +- Don't edit a previously-applied migration; add a new one. diff --git a/.claude/skills/frontend/SKILL.md b/.claude/skills/frontend/SKILL.md new file mode 100644 index 0000000..28f4dda --- /dev/null +++ b/.claude/skills/frontend/SKILL.md @@ -0,0 +1,94 @@ +--- +name: frontend +description: 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/` (not `utils`). 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-react` for icons. +- **Comments mix Vietnamese and English.** Preserve existing language in nearby code. +- **`localStorage.user`** is the single source of truth for the logged-in user. The shape is parsed JSON `{ id, userName, email, ... }`. Use `isJsonString` from `untils/helper` before parsing. +- **`apiUrl` pattern:** every file that calls REST does `const apiUrl = import.meta.env.VITE_BACKEND_URL;` at the top, then `axios.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` 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: +1. Always `socket.off("event", handler)` in the effect cleanup with the same handler reference. +2. Don't subscribe to events that `App.tsx` already listens to — `App.tsx`'s handler will fire too and you'll get double updates. Filter inside your handler instead (e.g. by `stationId`/`lineId`). +3. Re-subscribe when the relevant ID prop changes — include `stationId`/`lineId` in the dependency array. + +## Adding a new socket round-trip (FE side) + +Pattern, copy-pasted from `ModalLineHistory.tsx`: + +```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 `` 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 higher `zIndex` (use `1000000` to stay above any portaled content). +- **Close-guarding:** when adding a new inner modal opened from `ModalTerminal`, add its `open` flag to the close-guard around line `560` of `ModalTerminal.tsx` so Escape on the inner modal doesn't bubble up and close the terminal too: + ```ts + if (openSelectIos || openLineHistory || /* your flag */ || ...) return; + ``` +- **Don't roll a custom backdrop.** The previous version of `ModalLineHistory.tsx` used a fixed-position `
` overlay — that's been replaced with ``. 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 `untils` typo. +- Don't add a new socket connection. +- Don't bypass `updateValueLineStation` to mutate `stations` directly when a line update arrives. +- Don't add a per-message render — batch via `lineBuffersRef` if frequency is high. +- Don't mock UI/UX changes only with type-checks; for any visible change, run `npm run dev` and walk through the affected flow in the browser before reporting done. diff --git a/BACKEND/.gitignore b/BACKEND/.gitignore index b174380..a6a3572 100644 --- a/BACKEND/.gitignore +++ b/BACKEND/.gitignore @@ -20,6 +20,7 @@ yarn-error.log .fleet .idea .vscode +.claudeignore # Platform specific .DS_Store diff --git a/BACKEND/providers/socket_io_provider.ts b/BACKEND/providers/socket_io_provider.ts index 11ebbd5..ac70af9 100644 --- a/BACKEND/providers/socket_io_provider.ts +++ b/BACKEND/providers/socket_io_provider.ts @@ -578,6 +578,16 @@ export class WebSocketIo { await this.clearLineBeforeConnect(stationId, lineClear) }) + socket.on('get_line_history', async (data) => { + const stationId = Number(data?.stationId) + const lineId = Number(data?.lineId) + if (!stationId || !lineId) { + return io.to(socket.id).emit('line_history', { stationId, lineId, history: [] }) + } + const history = await this.getHistory(stationId, lineId) + io.to(socket.id).emit('line_history', { stationId, lineId, history }) + }) + socket.on('get_list_history', async (data) => { const { stationIds = [] } = data diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..56755f9 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,90 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Repo layout + +This is a monorepo with two independent Node packages: + +- `BACKEND/` — AdonisJS 6 (TypeScript, ESM) HTTP API + Socket.IO server. Talks to MySQL via Lucid ORM and uses Redis for shared/persisted state. Connects to physical lab equipment (Cisco-style consoles, APC PDUs, network switches) over raw TCP/Telnet. +- `FRONTEND/` — Vite + React 19 + Mantine 8 SPA. Authenticates locally and connects to the backend's REST API and Socket.IO server. + +There is **no workspace tool** — each side has its own `package.json`, `node_modules`, lockfile, and ESLint/TS config. Run npm commands from inside the relevant directory. + +## Common commands + +### Backend (`cd BACKEND`) +- `npm run dev` — starts AdonisJS via `node ace serve --hmr` (HTTP + Socket.IO providers boot together). +- `npm run build` — `node ace build`. +- `npm start` — `node bin/server.js` (production entry; the Socket.IO provider's `ready()` only boots the WS server when the entry is `server.js`). +- `npm test` — `node ace test` (Japa). Suites are configured in `adonisrc.ts`: `unit` (`tests/unit/**/*.spec.ts`) and `functional` (`tests/functional/**/*.spec.ts`). +- `npm run lint` / `npm run format` / `npm run typecheck`. +- `node ace migration:run` — apply MySQL migrations from `database/migrations/`. +- `node ace db:seed` — run seeders in `database/seeders/` (e.g. `prompt_ai_seeder.ts`). +- Run a single test: `node ace test --files "tests/unit/foo.spec.ts"` (Japa filter; `--tags` and `--match` also work). + +### Frontend (`cd FRONTEND`) +- `npm run dev` — Vite dev server (default port 5173). +- `npm run build` — `tsc -b && vite build`. +- `npm run lint` — flat-config ESLint. +- `npm run preview` — preview the production build. + +### Setup / environment +- Both sides use `.env` (copy from `.env.example`). Backend reads `SOCKET_PORT` (default 8989), `FRONTEND_URL` (CORS origin), MySQL/Redis creds, Zulip stream/topic, `LINK_WIKI`, `TIME_ZONE`. Frontend reads `VITE_BACKEND_URL` and `VITE_SOCKET_SERVER`. +- A local Redis server is required (the README documents `apt install redis-server` for Linux hosts). + +## Architecture — the parts that span files + +### Backend: HTTP API is thin; the real engine is the Socket.IO provider + +`BACKEND/providers/socket_io_provider.ts` is registered in `adonisrc.ts` and **only boots its WebSocket server when the process entry is `server.js`** (so `ace` commands like migrations don't open the socket port). It exposes a static `SocketIoProvider.io` for the rest of the app. + +Inside `WebSocketIo.boot()` it creates a separate `http.createServer()` listening on `SOCKET_PORT` (independent of the AdonisJS HTTP port) and registers a single big `connection` handler with ~30 socket events (`connect_lines`, `write_command_line_from_web`, `run_scenario`, `open_cli`/`close_cli`, `control_apc`, `control_switch`, `run_all_dpelp`, `load_ios_router`, etc.). When extending behavior, the pattern is: add a new `socket.on(...)` block and route it through `handleLineOperation` / `handleStationOperation`, which auto-reconnect the underlying TCP session if it has dropped. + +The provider keeps in-memory maps that ARE the live system state: +- `lineMap: Map` +- `stationMap: Map` +- `apcsControl: Map` +- `switchControl: Map` +- `userConnecting: Map` + +Every 10s `saveState()` serializes a slimmed `lineMap` (status forced to `disconnected`, output truncated to last 5000 chars, CLI ownership cleared) into Redis under key `socket_state`. On boot, `restoreState()` rehydrates it. Per-line history is stored in Redis sorted sets keyed `station:{stationId}:line:{lineId}:history`. + +`setTimeoutConnect(lineId, conn, 8h)` arms an idle-disconnect interval per connection; `keepConnectAPC` / `keepConnectStation` arm the inverse — periodic `\r\n` / ENTER keep-alives. Keep these paired when adding new connection types or sessions silently die / outlive their usefulness. + +### Backend: `app/services/` is a hierarchy of TCP session classes + +Each class wraps a `net.Socket` to a piece of lab hardware and exposes a `config` object that the Socket.IO layer mutates and broadcasts: +- `station_connection.ts` — top-level Cisco station console (used to issue `clear line N`, `show line`, etc.). +- `line_connection.ts` — individual serial line on a station; the heart of test orchestration. Owns scenario execution (`runScript`), DPELP flow, IOS/license loading (`loadIosRouter`, `loadIosSwitch`, `loadLicenseRouter`, `loadLicenseSwitch`), physical port testing (`PhysicalPortTest` from `physical_test_service.ts`), and AI log classification (uses `PromptAi` model + axios calls). +- `apc_connection.ts` — APC PDU outlet control (on/off/restart/reconnect, `navigateToOutlets`). +- `switch_connection.ts` — managed switch port + PoE control. + +When a line emits data, `line_connection.ts` parses it through TextFSM-style templates (`app/ultils/templates/`) and `helper.ts` parsers, and emits Socket.IO events (`line_output`, `data_textfsm`, `running_scenario`, `feature_tested`, `summary_tested`, `test_port_physical`, etc.) that the frontend subscribes to in `App.tsx`'s big `useEffect`. + +### Backend: routes and AdonisJS subpath imports + +`start/routes.ts` defines REST endpoints grouped by resource (`api/stations`, `api/lines`, `api/logs`, `api/users`, `api/models`, `api/scenarios`, `api/auth`, `api/ticket`, `api/brands`, `api/categories`, `api/config-ram`, `api/keywords`, `api/prompt-ai`, `atc/health-check`, `api/ios`, `api/license`). Controllers live under `app/controllers/`, models under `app/models/`. + +The project relies heavily on AdonisJS subpath imports declared in `package.json#imports` — always use these, not relative paths: +- `#controllers/*`, `#models/*`, `#services/*`, `#middleware/*`, `#start/*`, `#config/*`, `#exceptions/*`, `#validators/*`, `#providers/*`, `#policies/*`, `#abilities/*`, `#database/*`, `#tests/*`, `#mails/*`, `#listeners/*`, `#events/*`. + +Note: `socket_io_provider.ts` itself uses **relative** paths to reach into `app/` (e.g. `'../app/services/line_connection.js'`) because it lives outside `app/`. Match the convention of the file you're editing. + +### Frontend: single-page app driven by Socket.IO events + +- `src/main.tsx` → `src/App.tsx` is the entire mounted tree. `Main` (default export) wraps `App` in `MantineProvider` + `SocketProvider` and gates on `localStorage.user`; if absent, it renders `PageLogin` instead. +- `src/context/SocketContext.tsx` creates a single `socket.io-client` instance authed with `{ userId, userName }` and provides it via `useSocket()`. URL comes from `VITE_SOCKET_SERVER` (default `http://localhost:8989/`). +- `App.tsx` keeps the canonical `stations: TStation[]` state. Most socket events arrive as line-level deltas and are funneled through `updateValueLineStation(lineId, updates, stationId)`, which fans out to `selectedLine` / `selectedLines` in lockstep. `line_output` is special: its text is buffered in `lineBuffersRef` (a `Map`) and flushed every ~50ms via `flushBuffers` to keep terminal updates from causing per-character React renders. +- Large log payloads arrive as `response_content_log` chunks (`{ fileId, chunkIndex, totalChunks, chunk }`) and are reassembled into a single string before being shown. + +UI is built with Mantine 8 (`@mantine/core`, `@mantine/dates`, `@mantine/form`, `@mantine/hooks`, `@mantine/notifications`), `@dnd-kit/*` for the draggable station tabs (`DragTabs.tsx`), `@tabler/icons-react`, and `xterm` (+ `@xterm/addon-fit`) for the terminal modal. + +## Conventions worth knowing before editing + +- **Folder name typos are intentional** (or at least entrenched): backend uses `app/ultils/` (helpers, types, templates) and frontend uses `src/untils/`. Imports across the codebase reference these names; do not rename them as a "cleanup". +- **Two TCP-session lifecycles to remember**: idle-timeout (`setTimeoutConnect`, default 8h) and keep-alive (`keepConnectAPC` 40s, `keepConnectStation` 120s). When you add a new long-lived connection, wire both — otherwise it'll either disconnect silently or hold a slot forever. +- **State that survives restarts lives in Redis**, not memory: `socket_state` (full `lineMap` snapshot) and `station:{id}:line:{id}:history` sorted sets. If you add per-line state that should persist across restarts, extend `saveState` / `restoreState`. +- **Hot-reload boundaries** (`hotHook.boundaries` in `BACKEND/package.json`) are limited to controllers and middleware. Editing a service or provider file may require a full restart for changes to apply. +- **Comments and string literals frequently mix Vietnamese and English** — preserve existing language when modifying nearby code. +- **Auth is custom + minimal**: routes under `api/auth/login` and `api/auth/register` are public; `start/kernel.ts` registers an `auth` named middleware via `@adonisjs/auth`, but most routes in `start/routes.ts` are not currently behind it. The frontend gates on `localStorage.user` only. diff --git a/FRONTEND/.gitignore b/FRONTEND/.gitignore index ef57cd0..c46a70d 100644 --- a/FRONTEND/.gitignore +++ b/FRONTEND/.gitignore @@ -28,3 +28,4 @@ dist-ssr .env.local .env.production.local .env.development.local +.claudeignore \ No newline at end of file diff --git a/FRONTEND/src/components/CardLine.tsx b/FRONTEND/src/components/CardLine.tsx index 680e242..c313b5a 100644 --- a/FRONTEND/src/components/CardLine.tsx +++ b/FRONTEND/src/components/CardLine.tsx @@ -50,6 +50,10 @@ const CardLine = ({ const [valueBaud, setValueBaud] = useState(""); const [focusTerminal, setFocusTerminal] = useState(false); const [isShowIssue, setIsShowIssue] = useState(false); + const [contentIssue, setContentIssue] = useState<{ + label: string; + color: string; + }>({ label: "", color: "red" }); // useEffect(() => { // if ( @@ -141,17 +145,55 @@ const CardLine = ({ setIsShowIssue(false); return; } - const data = Array.isArray(line?.latestScenario?.detectAI?.issue) - ? "- " + - line?.latestScenario?.detectAI?.issue - .filter((i: string) => i.trim()) - .join("\n- ") - : ""; - if (data && !data.includes("No issues detected")) setIsShowIssue(true); - else setIsShowIssue(false); + setIsShowIssue(true); + const data = line?.latestScenario?.detectAI?.issue; + const result = detectResultStatus(data); + if (result) { + setContentIssue(result); + } } else setIsShowIssue(false); }, [line?.latestScenario]); + function detectResultStatus(lines: string[]) { + const text = lines.join("\n"); + + const match = text.match( + /^RESULT:\s*(PASS WITH WARNING|PASS|FAIL|INSUFFICIENT DATA)/im, + ); + + if (!match) { + return null; + } + + const status = match[1]; + + switch (status) { + case "PASS": + return { + label: "PASS", + color: "green", + }; + + case "PASS WITH WARNING": + return { + label: "PASS WITH WARNING", + color: "yellow", + }; + + case "FAIL": + return { + label: "FAIL", + color: "red", + }; + + case "INSUFFICIENT DATA": + return { + label: "INSUFFICIENT DATA", + color: "gray", + }; + } + } + return ( - Issue + {contentIssue.label || "Issue"} ) : ( "" @@ -530,11 +572,11 @@ const CardLine = ({ selectedLines={[line]} isDisable={isDisabled} dataDPELP={scenarios?.find( - (el) => el.title.toUpperCase() === "DPELP" + (el) => el.title.toUpperCase() === "DPELP", )} onClick={() => { const dpelp = scenarios?.find( - (el) => el.title.toUpperCase() === "DPELP" + (el) => el.title.toUpperCase() === "DPELP", ); if (dpelp?.isReboot || dpelp?.is_reboot) { socket?.emit("control_apc", { diff --git a/FRONTEND/src/components/Modal/ModalLineHistory.tsx b/FRONTEND/src/components/Modal/ModalLineHistory.tsx new file mode 100644 index 0000000..89e78b8 --- /dev/null +++ b/FRONTEND/src/components/Modal/ModalLineHistory.tsx @@ -0,0 +1,181 @@ +import { useEffect, useState } from "react"; +import { + Box, + CloseButton, + Flex, + Loader, + ScrollArea, + Table, + Text, +} from "@mantine/core"; +import moment from "moment"; +import type { Socket } from "socket.io-client"; +import classes from "../Component.module.css"; + +interface LineHistoryItem { + id: number; + number: number; + stationId: number; + pid: string; + vid: string; + sn: string; + scenario: string; + timestamp: number; +} + +interface ModalLineHistoryProps { + opened: boolean; + onClose: () => void; + socket: Socket | null; + stationId?: number; + lineId?: number; + lineNumber?: number; + stationName?: string; +} + +const ModalLineHistory = ({ + opened, + onClose, + socket, + stationId, + lineId, + lineNumber, + stationName, +}: ModalLineHistoryProps) => { + const [history, setHistory] = useState([]); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (!socket || !opened) return; + + const handleResponse = (data: { + stationId: number; + lineId: number; + history: LineHistoryItem[]; + }) => { + if (data?.stationId !== stationId || data?.lineId !== lineId) return; + setHistory(Array.isArray(data?.history) ? data.history : []); + setLoading(false); + }; + + socket.on("line_history", handleResponse); + + return () => { + socket.off("line_history", handleResponse); + }; + }, [socket, opened, stationId, lineId]); + + useEffect(() => { + if (!socket || !opened || !stationId || !lineId) return; + setLoading(true); + setHistory([]); + socket.emit("get_line_history", { stationId, lineId }); + }, [socket, opened, stationId, lineId]); + + if (!opened) return null; + + const sorted = [...history].sort((a, b) => b.timestamp - a.timestamp); + + return ( +
{ + e.stopPropagation(); + onClose(); + }} + > +
e.stopPropagation()} + > + + + 🕘 Line history + {lineNumber ? ` — Line ${lineNumber}` : ""} + {stationName ? ` (${stationName})` : ""} + + + + + + {loading ? ( + + + + ) : sorted.length === 0 ? ( + + No history data available + + ) : ( + + + + + PID + VID + SN + Scenario + Time + + + + {sorted.map((item, i) => ( + + + {item.pid || "-"} + + {item.vid || "-"} + {item.sn || "-"} + {item.scenario || "-"} + + {item.timestamp + ? moment(item.timestamp).format("DD/MM/YYYY HH:mm:ss") + : "-"} + + + ))} + +
+
+ )} +
+
+
+ ); +}; + +export default ModalLineHistory; diff --git a/FRONTEND/src/components/Modal/ModalTerminal.tsx b/FRONTEND/src/components/Modal/ModalTerminal.tsx index 0b78e91..0a0f464 100644 --- a/FRONTEND/src/components/Modal/ModalTerminal.tsx +++ b/FRONTEND/src/components/Modal/ModalTerminal.tsx @@ -54,6 +54,7 @@ import ModalSelectIOS from "./ModalSelectIOS"; import ModalSelectLicense from "./ModalSelectLicense"; import ModalRunScenario from "./ModalRunScenario"; import DrawerScenario from "./ModalScenario"; +import ModalLineHistory from "./ModalLineHistory"; import AutoProgress from "../Components/AutoProgress"; import { bodyDPELP, convertFromKilobytesString } from "../../untils/helper"; const apiUrl = import.meta.env.VITE_BACKEND_URL; @@ -123,6 +124,7 @@ const ModalTerminal = ({ useState(false); const [openSelectIos, setOpenSelectIos] = useState(false); const [openSelectLicense, setOpenSelectLicense] = useState(false); + const [openLineHistory, setOpenLineHistory] = useState(false); const [openScenarioModal, setOpenScenarioModal] = useState(false); const [openDrawerScenario, setOpenDrawerScenario] = useState(false); const [isPhysicalTest, setIsPhysicalTest] = useState(false); @@ -1564,6 +1566,18 @@ const ModalTerminal = ({ > Send Break +