Update view history for line, UI
This commit is contained in:
parent
7fea517efa
commit
5a79a0448e
|
|
@ -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<lineId, LineConnection>`
|
||||||
|
- `stationMap: Map<stationId, StationConnection>`
|
||||||
|
- `apcsControl: Map<apcIp, APCController>`
|
||||||
|
- `switchControl: Map<switchIp, SwitchController>`
|
||||||
|
- `userConnecting: Map<userId, ...>`
|
||||||
|
|
||||||
|
`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 <name>` then `node ace make:model <Name>`. 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.
|
||||||
|
|
@ -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<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:
|
||||||
|
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 `<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 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 `<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 `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.
|
||||||
|
|
@ -20,6 +20,7 @@ yarn-error.log
|
||||||
.fleet
|
.fleet
|
||||||
.idea
|
.idea
|
||||||
.vscode
|
.vscode
|
||||||
|
.claudeignore
|
||||||
|
|
||||||
# Platform specific
|
# Platform specific
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
|
||||||
|
|
@ -578,6 +578,16 @@ export class WebSocketIo {
|
||||||
await this.clearLineBeforeConnect(stationId, lineClear)
|
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) => {
|
socket.on('get_list_history', async (data) => {
|
||||||
const { stationIds = [] } = data
|
const { stationIds = [] } = data
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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<lineId, LineConnection>`
|
||||||
|
- `stationMap: Map<stationId, StationConnection>`
|
||||||
|
- `apcsControl: Map<apcIp, APCController>`
|
||||||
|
- `switchControl: Map<switchIp, SwitchController>`
|
||||||
|
- `userConnecting: Map<userId, ...>`
|
||||||
|
|
||||||
|
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<lineId, string>`) 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.
|
||||||
|
|
@ -28,3 +28,4 @@ dist-ssr
|
||||||
.env.local
|
.env.local
|
||||||
.env.production.local
|
.env.production.local
|
||||||
.env.development.local
|
.env.development.local
|
||||||
|
.claudeignore
|
||||||
|
|
@ -50,6 +50,10 @@ const CardLine = ({
|
||||||
const [valueBaud, setValueBaud] = useState<string>("");
|
const [valueBaud, setValueBaud] = useState<string>("");
|
||||||
const [focusTerminal, setFocusTerminal] = useState<boolean>(false);
|
const [focusTerminal, setFocusTerminal] = useState<boolean>(false);
|
||||||
const [isShowIssue, setIsShowIssue] = useState<boolean>(false);
|
const [isShowIssue, setIsShowIssue] = useState<boolean>(false);
|
||||||
|
const [contentIssue, setContentIssue] = useState<{
|
||||||
|
label: string;
|
||||||
|
color: string;
|
||||||
|
}>({ label: "", color: "red" });
|
||||||
|
|
||||||
// useEffect(() => {
|
// useEffect(() => {
|
||||||
// if (
|
// if (
|
||||||
|
|
@ -141,17 +145,55 @@ const CardLine = ({
|
||||||
setIsShowIssue(false);
|
setIsShowIssue(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const data = Array.isArray(line?.latestScenario?.detectAI?.issue)
|
setIsShowIssue(true);
|
||||||
? "- " +
|
const data = line?.latestScenario?.detectAI?.issue;
|
||||||
line?.latestScenario?.detectAI?.issue
|
const result = detectResultStatus(data);
|
||||||
.filter((i: string) => i.trim())
|
if (result) {
|
||||||
.join("\n- ")
|
setContentIssue(result);
|
||||||
: "";
|
}
|
||||||
if (data && !data.includes("No issues detected")) setIsShowIssue(true);
|
|
||||||
else setIsShowIssue(false);
|
|
||||||
} else setIsShowIssue(false);
|
} else setIsShowIssue(false);
|
||||||
}, [line?.latestScenario]);
|
}, [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 (
|
return (
|
||||||
<Card
|
<Card
|
||||||
key={line.id}
|
key={line.id}
|
||||||
|
|
@ -216,7 +258,7 @@ const CardLine = ({
|
||||||
navigator.clipboard.writeText(
|
navigator.clipboard.writeText(
|
||||||
`PID: ${line?.inventory?.pid || ""} | SN: ${
|
`PID: ${line?.inventory?.pid || ""} | SN: ${
|
||||||
line?.inventory?.sn || ""
|
line?.inventory?.sn || ""
|
||||||
}`
|
}`,
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
className={classes.buttonCopy}
|
className={classes.buttonCopy}
|
||||||
|
|
@ -255,7 +297,7 @@ const CardLine = ({
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (!line?.inventory?.pid) return;
|
if (!line?.inventory?.pid) return;
|
||||||
navigator.clipboard.writeText(
|
navigator.clipboard.writeText(
|
||||||
line?.inventory?.pid || ""
|
line?.inventory?.pid || "",
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
className={`${classes.info_line} ${classes.buttonCopy}`}
|
className={`${classes.info_line} ${classes.buttonCopy}`}
|
||||||
|
|
@ -305,7 +347,7 @@ const CardLine = ({
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (!line?.inventory?.sn) return;
|
if (!line?.inventory?.sn) return;
|
||||||
navigator.clipboard.writeText(
|
navigator.clipboard.writeText(
|
||||||
line?.inventory?.sn || ""
|
line?.inventory?.sn || "",
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
className={`${classes.info_line} ${classes.buttonCopy}`}
|
className={`${classes.info_line} ${classes.buttonCopy}`}
|
||||||
|
|
@ -470,12 +512,12 @@ const CardLine = ({
|
||||||
borderRadius: "16px",
|
borderRadius: "16px",
|
||||||
paddingLeft: "4px",
|
paddingLeft: "4px",
|
||||||
paddingRight: "4px",
|
paddingRight: "4px",
|
||||||
backgroundColor: "#ff2b2b",
|
backgroundColor: contentIssue.color,
|
||||||
}}
|
}}
|
||||||
fz={"9px"}
|
fz={"9px"}
|
||||||
c={"white"}
|
c={contentIssue.color === "green" ? "white" : "black"}
|
||||||
>
|
>
|
||||||
Issue
|
{contentIssue.label || "Issue"}
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
""
|
""
|
||||||
|
|
@ -530,11 +572,11 @@ const CardLine = ({
|
||||||
selectedLines={[line]}
|
selectedLines={[line]}
|
||||||
isDisable={isDisabled}
|
isDisable={isDisabled}
|
||||||
dataDPELP={scenarios?.find(
|
dataDPELP={scenarios?.find(
|
||||||
(el) => el.title.toUpperCase() === "DPELP"
|
(el) => el.title.toUpperCase() === "DPELP",
|
||||||
)}
|
)}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const dpelp = scenarios?.find(
|
const dpelp = scenarios?.find(
|
||||||
(el) => el.title.toUpperCase() === "DPELP"
|
(el) => el.title.toUpperCase() === "DPELP",
|
||||||
);
|
);
|
||||||
if (dpelp?.isReboot || dpelp?.is_reboot) {
|
if (dpelp?.isReboot || dpelp?.is_reboot) {
|
||||||
socket?.emit("control_apc", {
|
socket?.emit("control_apc", {
|
||||||
|
|
|
||||||
|
|
@ -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<LineHistoryItem[]>([]);
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
backgroundColor: "rgba(0,0,0,0.6)",
|
||||||
|
zIndex: 100000,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
backdropFilter: "blur(3px)",
|
||||||
|
}}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "white",
|
||||||
|
borderRadius: "12px",
|
||||||
|
width: "70%",
|
||||||
|
maxWidth: "1000px",
|
||||||
|
maxHeight: "80vh",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
boxShadow: "0 20px 60px rgba(0,0,0,0.3)",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<Flex
|
||||||
|
justify="space-between"
|
||||||
|
align="center"
|
||||||
|
p="lg"
|
||||||
|
style={{ borderBottom: "1px solid #e9ecef", flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
<Text fw={700} size="lg">
|
||||||
|
🕘 Line history
|
||||||
|
{lineNumber ? ` — Line ${lineNumber}` : ""}
|
||||||
|
{stationName ? ` (${stationName})` : ""}
|
||||||
|
</Text>
|
||||||
|
<CloseButton size="lg" onClick={onClose} />
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
<Box p="md" style={{ flex: 1, overflow: "hidden" }}>
|
||||||
|
{loading ? (
|
||||||
|
<Flex justify="center" align="center" h="40vh">
|
||||||
|
<Loader />
|
||||||
|
</Flex>
|
||||||
|
) : sorted.length === 0 ? (
|
||||||
|
<Flex justify="center" align="center" h="40vh">
|
||||||
|
<Text c="dimmed">No history data available</Text>
|
||||||
|
</Flex>
|
||||||
|
) : (
|
||||||
|
<ScrollArea h="60vh" className={classes.hideScrollBar}>
|
||||||
|
<Table striped highlightOnHover withTableBorder>
|
||||||
|
<Table.Thead
|
||||||
|
style={{
|
||||||
|
position: "sticky",
|
||||||
|
top: 0,
|
||||||
|
background: "#f1f3f5",
|
||||||
|
zIndex: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>PID</Table.Th>
|
||||||
|
<Table.Th>VID</Table.Th>
|
||||||
|
<Table.Th>SN</Table.Th>
|
||||||
|
<Table.Th>Scenario</Table.Th>
|
||||||
|
<Table.Th>Time</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{sorted.map((item, i) => (
|
||||||
|
<Table.Tr key={`${item.timestamp}-${item.sn || ""}-${i}`}>
|
||||||
|
<Table.Td style={{ fontWeight: 600 }}>
|
||||||
|
{item.pid || "-"}
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>{item.vid || "-"}</Table.Td>
|
||||||
|
<Table.Td>{item.sn || "-"}</Table.Td>
|
||||||
|
<Table.Td>{item.scenario || "-"}</Table.Td>
|
||||||
|
<Table.Td c="dimmed" style={{ fontSize: "12px" }}>
|
||||||
|
{item.timestamp
|
||||||
|
? moment(item.timestamp).format("DD/MM/YYYY HH:mm:ss")
|
||||||
|
: "-"}
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</ScrollArea>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ModalLineHistory;
|
||||||
|
|
@ -54,6 +54,7 @@ import ModalSelectIOS from "./ModalSelectIOS";
|
||||||
import ModalSelectLicense from "./ModalSelectLicense";
|
import ModalSelectLicense from "./ModalSelectLicense";
|
||||||
import ModalRunScenario from "./ModalRunScenario";
|
import ModalRunScenario from "./ModalRunScenario";
|
||||||
import DrawerScenario from "./ModalScenario";
|
import DrawerScenario from "./ModalScenario";
|
||||||
|
import ModalLineHistory from "./ModalLineHistory";
|
||||||
import AutoProgress from "../Components/AutoProgress";
|
import AutoProgress from "../Components/AutoProgress";
|
||||||
import { bodyDPELP, convertFromKilobytesString } from "../../untils/helper";
|
import { bodyDPELP, convertFromKilobytesString } from "../../untils/helper";
|
||||||
const apiUrl = import.meta.env.VITE_BACKEND_URL;
|
const apiUrl = import.meta.env.VITE_BACKEND_URL;
|
||||||
|
|
@ -123,6 +124,7 @@ const ModalTerminal = ({
|
||||||
useState<boolean>(false);
|
useState<boolean>(false);
|
||||||
const [openSelectIos, setOpenSelectIos] = useState<boolean>(false);
|
const [openSelectIos, setOpenSelectIos] = useState<boolean>(false);
|
||||||
const [openSelectLicense, setOpenSelectLicense] = useState<boolean>(false);
|
const [openSelectLicense, setOpenSelectLicense] = useState<boolean>(false);
|
||||||
|
const [openLineHistory, setOpenLineHistory] = useState<boolean>(false);
|
||||||
const [openScenarioModal, setOpenScenarioModal] = useState<boolean>(false);
|
const [openScenarioModal, setOpenScenarioModal] = useState<boolean>(false);
|
||||||
const [openDrawerScenario, setOpenDrawerScenario] = useState<boolean>(false);
|
const [openDrawerScenario, setOpenDrawerScenario] = useState<boolean>(false);
|
||||||
const [isPhysicalTest, setIsPhysicalTest] = useState<boolean>(false);
|
const [isPhysicalTest, setIsPhysicalTest] = useState<boolean>(false);
|
||||||
|
|
@ -1564,6 +1566,18 @@ const ModalTerminal = ({
|
||||||
>
|
>
|
||||||
Send Break
|
Send Break
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
fw={400}
|
||||||
|
disabled={isDisable}
|
||||||
|
variant="outline"
|
||||||
|
color="cyan"
|
||||||
|
size="xs"
|
||||||
|
onClick={() => {
|
||||||
|
setOpenLineHistory(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
View History
|
||||||
|
</Button>
|
||||||
<Menu trigger="hover" withArrow shadow="md" position="top">
|
<Menu trigger="hover" withArrow shadow="md" position="top">
|
||||||
<Menu.Target>
|
<Menu.Target>
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -1860,6 +1874,16 @@ const ModalTerminal = ({
|
||||||
listBrands={listBrands}
|
listBrands={listBrands}
|
||||||
listCategories={listCategories}
|
listCategories={listCategories}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ModalLineHistory
|
||||||
|
opened={openLineHistory}
|
||||||
|
onClose={() => setOpenLineHistory(false)}
|
||||||
|
socket={socket}
|
||||||
|
stationId={stationItem?.id}
|
||||||
|
lineId={line?.id}
|
||||||
|
lineNumber={line?.lineNumber || line?.line_number}
|
||||||
|
stationName={stationItem?.name}
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue