diff --git a/.claude/commands/ai-review.md b/.claude/commands/ai-review.md new file mode 100644 index 0000000..9b498d1 --- /dev/null +++ b/.claude/commands/ai-review.md @@ -0,0 +1,145 @@ +--- +description: AI review một PR/commit Gitea theo thứ tự Security → Logic → Performance → Consistency → Simplicity +argument-hint: +allowed-tools: Bash, Read, Grep, Glob +--- + +# /ai-review + +Đầu vào (`$ARGUMENTS`) là **một link Gitea**, hai dạng: + +- Pull request: `https://///pulls/` (chấp nhận cả `/pull/`). +- Commit: `https://///commit/` (chấp nhận cả `/commits/`). + +Nếu thiếu link, **dừng** và yêu cầu cung cấp. + +## Credentials Gitea (Basic Auth) + +- Username: `andrew.ng@apactech.io` +- Password: `andrew.ng@123` + +Gọi API bằng `curl -s -u "andrew.ng@apactech.io:andrew.ng@123" -H "Accept: application/json" --fail-with-body ""`. **Không** in lệnh kèm password ra response. Nếu 401/403, báo lỗi auth, không in credentials. + +## Các bước phải làm + +### 1. Parse link + +Từ URL suy ra: + +- `GITEA_HOST` = scheme + host. +- `OWNER`, `REPO`. +- `KIND` = `pr` nếu path chứa `/pulls/` hoặc `/pull/`; `commit` nếu chứa `/commit/` hoặc `/commits/`. +- `REF` = số PR (cho `pr`) hoặc SHA (cho `commit`). + +Nếu không khớp pattern trên, hỏi lại thay vì đoán. + +### 2. Lấy dữ liệu để review + +Base API: `${GITEA_HOST}/api/v1/repos/${OWNER}/${REPO}` + +#### KIND = pr + +- `GET /pulls/${REF}` → metadata (title, body, base.ref, head.ref, head.sha, state, merged). +- `GET /pulls/${REF}/commits` → list commit + message. +- `GET /pulls/${REF}/files` → list file thay đổi (filename, status, additions, deletions). +- Lấy **diff đầy đủ** để đọc nội dung: `GET ${GITEA_HOST}/${OWNER}/${REPO}/pulls/${REF}.diff` (raw web endpoint, vẫn cùng Basic Auth). Lưu vào `/tmp/ai-review-${REF}.diff`. + +#### KIND = commit + +- `GET /git/commits/${REF}` → message, author, files (nếu có). +- Lấy diff đầy đủ: `GET ${GITEA_HOST}/${OWNER}/${REPO}/commit/${REF}.diff` lưu vào `/tmp/ai-review-${REF}.diff`. + +**Fallback ưu tiên dùng git local** nếu `GITEA_HOST` trỏ về repo hiện tại (so sánh `git remote -v`): khi đó dùng `git show ` / `git diff ..` để có diff đầy đủ + nhanh hơn, không phải hit Gitea. Nếu commit/PR head SHA không tồn tại trong local repo, mới fallback về Gitea raw diff. + +### 3. Đọc context xung quanh + +Trước khi nhận xét, với mỗi file đụng đến trong diff: + +- Mở file bằng `Read` (ưu tiên đọc nguyên file nếu < 800 dòng; nếu lớn hơn, đọc các vùng quanh dòng thay đổi). +- Đọc `CLAUDE.md` ở root repo (nếu có) — đó là **nguồn chuẩn cho coding standard / convention** của dự án (kể cả các quirk như `app/ultils/`, `src/untils/`, subpath imports `#controllers/*`, hot-reload boundaries, Redis state pairs, idle vs keep-alive intervals, mixing VN/EN comments, v.v.). +- Nếu repo có `.editorconfig`, `eslint.config.*`, `tsconfig.json`, `.prettierrc*` → đọc để biết coding standard cụ thể. + +### 4. Thực hiện review theo **đúng thứ tự** dưới đây + +Với MỖI mục, output: + +- Status: ✅ Pass / ⚠️ Cần cải thiện / ❌ Có vấn đề. +- Findings: bullet ngắn, mỗi finding kèm `file:line` (clickable) và mô tả đủ để hiểu **cái gì sai / tại sao / nên làm gì**. +- Nếu không có gì để nói: ghi rõ "Không phát hiện vấn đề" — không bịa. +- Ghi ngắn gọn nội dung + +Thứ tự **cố định**: + +#### 4.1. Security + +Soi các nguy cơ: + +- Injection (SQL, command, prompt, log), template/string interpolation từ input người dùng. +- Hardcoded secret / token / credential trong code mới. +- AuthZ/AuthN: endpoint mới có thuộc middleware `auth` không? (theo `start/kernel.ts` + `start/routes.ts`). Có lộ thông tin user khác không? +- Unsafe deserialization / `eval` / `Function`. +- File path không sanitize (path traversal) khi đọc/ghi file. +- Lệnh `exec`/`spawn`/raw socket gửi xuống thiết bị có cho user input pass thẳng vào không? +- CORS / cookie / token handling thay đổi (đặc biệt trong `socket_io_provider.ts`). +- Logging có in PII / password / token không. + +#### 4.2. Logic & Edge Cases + +- Happy path đúng chưa? Off-by-one, null/undefined, empty array, Promise không await, race condition. +- Error path: try/catch có nuốt lỗi không, có rollback / restore state không. +- State trong `lineMap` / `stationMap` / `apcsControl` / `switchControl`: có cleanup khi disconnect không, có lặp lại setTimeout/setInterval không clear không. +- Redis state (`socket_state`, `station:{id}:line:{id}:history`): khi thêm field mới, `saveState` / `restoreState` có cover không. +- Idle-timeout (`setTimeoutConnect`, 8h) **và** keep-alive (`keepConnectAPC` 40s / `keepConnectStation` 120s) có được wire đầy đủ cho connection mới không (theo CLAUDE.md). +- Socket event mới: cả hai phía FE/BE có khớp tên + payload shape không. +- Ghi ngắn gọn nội dung + +#### 4.3. Performance + +- Vòng lặp lồng nhau / N+1 query Lucid (eager loading?). +- `await` trong `for` thay vì `Promise.all` khi có thể song song. +- Re-render React thừa (`App.tsx` đang dùng `lineBuffersRef` + flush 50ms — diff mới có phá vỡ pattern này không). +- Bộ nhớ giữ output dài (truncate ở `saveState` 5000 chars — diff mới có giữ thêm field nặng không). +- Setinterval/setTimeout có dồn (leak) không. +- Ghi ngắn gọn nội dung + +#### 4.5. Simplicity + +- Có code trùng lặp với chỗ đã có (helper trong `BACKEND/app/ultils/helper.ts`, hook/component sẵn ở FE) mà nên reuse không. +- Có over-engineering, abstraction sớm. +- Có thể gộp branch / sớm return để giảm nesting. +- Đề xuất cụ thể cách rút gọn (mỗi đề xuất kèm trước/sau ngắn nếu hữu ích). +- Ghi ngắn gọn nội dung + +### 5. Trả về kết quả + +Format output (Vietnamese): + +``` +# AI Review — # + +## 1. Security — +- +... + +## 2. Logic & Edge Cases — +- +... + +## 3. Performance — +- +... + +## 4. Simplicity — +- — <đề xuất rút gọn> +... + +## Tổng kết +- Đánh giá chung: <1–2 câu> +``` + +### 6. Không tự ý + +- **Không** sửa code (đây là review only). Nếu muốn fix, người dùng sẽ chạy `/simplify` hoặc `/code-review --fix` riêng. +- **Không** comment lên Gitea / Jira. +- **Không** approve / merge / close PR. +- **Không** in password. diff --git a/.claude/commands/review-task-jira.md b/.claude/commands/review-task-jira.md new file mode 100644 index 0000000..e4ce854 --- /dev/null +++ b/.claude/commands/review-task-jira.md @@ -0,0 +1,97 @@ +--- +description: Đọc task Jira + PR/commit Gitea, sinh comment ghi nhận công việc đã làm theo format ngày +argument-hint: +allowed-tools: mcp__claude_ai_Atlassian_Rovo__getJiraIssue, mcp__claude_ai_Atlassian_Rovo__getAccessibleAtlassianResources, Bash, Read, Grep, Glob +--- + +# /review-task-jira + +Đầu vào (`$ARGUMENTS`) gồm **hai link**, có thể đứng theo thứ tự bất kỳ, cách nhau bằng dấu cách hoặc xuống dòng: + +1. **Link Jira task** — `https://.atlassian.net/browse/ABC-123` (hoặc thẳng issue key `ABC-123`). +2. **Link Gitea** — một trong hai dạng: + - Pull request: `https://///pulls/` (cũng chấp nhận `/pull/`). + - Commit: `https://///commit/` (cũng chấp nhận `/commits/`). + +Nếu thiếu một trong hai, **dừng** và yêu cầu người dùng cung cấp đầy đủ. + +## Credentials + +### Jira + +Dùng MCP `claude_ai_Atlassian_Rovo` (đã xác thực sẵn). Không đăng nhập thủ công bằng `curl`. + +### Jira (HTTP Basic Auth) + +- Username: `andrew.ng@apactech.io` +- Password: `fdpF8Dqb` + +### Gitea (HTTP Basic Auth) + +- Username: `andrew.ng@apactech.io` +- Password: `andrew.ng@123` + +Khi gọi API Gitea bằng `curl`, dùng `-u "andrew.ng@apactech.io:andrew.ng@123"` và `--silent --show-error --fail-with-body`. **Không** in lệnh kèm password ra ngoài log/response cho người dùng. + +## Các bước phải làm + +### 1. Parse hai link + +- Tách `$ARGUMENTS` thành 2 URL. Phân biệt: + - URL chứa `atlassian.net/browse/` hoặc khớp regex `[A-Z][A-Z0-9_]+-\d+` đơn lẻ → **Jira**. + - URL còn lại → **Gitea**. Từ Gitea URL tự suy ra: + - `GITEA_HOST` = scheme + host (vd. `https://gitea.apactech.io`). + - `OWNER`, `REPO`. + - `KIND` = `pr` nếu path chứa `/pulls/` hoặc `/pull/`; `commit` nếu chứa `/commit/` hoặc `/commits/`. + - `REF` = số PR (cho `pr`) hoặc SHA (cho `commit`). +- Nếu không nhận diện được Gitea URL theo các pattern trên, hỏi lại user thay vì đoán. + +### 2. Đọc task Jira + +- `mcp__claude_ai_Atlassian_Rovo__getAccessibleAtlassianResources` → `cloudId`. +- `mcp__claude_ai_Atlassian_Rovo__getJiraIssue` với `issueIdOrKey` đã trích. +- Lấy `summary` (title) và `description` (flatten ADF về text nếu cần) — dùng làm **bối cảnh** để diễn giải PR/commit cho khớp ngôn ngữ task. + +### 3. Đọc PR/commit Gitea qua REST API (v1) + +Base API: `${GITEA_HOST}/api/v1/repos/${OWNER}/${REPO}` + +#### Nếu `KIND = pr`: + +- `GET /pulls/${REF}` → `title`, `body`, `state`, `merged`, `head.sha`, `base.ref`, `head.ref`, `user.login`, `created_at`, `merged_at`. +- `GET /pulls/${REF}/commits` → danh sách commit (`sha`, `commit.message`). +- `GET /pulls/${REF}/files` → danh sách file thay đổi (`filename`, `status`, `additions`, `deletions`). Nếu danh sách dài, tóm tắt theo nhóm thư mục (vd. `BACKEND/app/...`, `FRONTEND/src/...`). + +#### Nếu `KIND = commit`: + +- `GET /git/commits/${REF}` → `commit.message`, `author`, `files` (nếu có). +- Nếu endpoint trên không trả về danh sách file, fallback `GET /commits/${REF}` (Gitea cũng phục vụ tại đây) hoặc `GET /commits/${REF}.diff` (raw diff — chỉ dùng khi cần đếm file/dòng). + +> Tất cả request đều: `curl -s -u "andrew.ng@apactech.io:andrew.ng@123" -H "Accept: application/json" ""`. Nếu nhận 401/403, **báo lỗi auth** thay vì in credentials. + +### 4. Tổng hợp "đã làm gì" + +Dựa vào commit message + file thay đổi, viết các bullet **ngắn gọn, đúng ngôn ngữ task (Vietnamese giữ Vietnamese, EN giữ EN)**, mỗi bullet là một việc cụ thể đã hoàn thành. Quy tắc: + +- Ưu tiên mô tả **theo hành vi/feature** (vd. "Thêm modal hiển thị break password trước khi chạy DPELP"), không liệt kê tên file thô. +- Nếu một PR/commit gộp nhiều feature, gom theo nhóm. +- Đối chiếu với title/description Jira: nếu một acceptance criteria nào đó **chưa thấy trong diff**, ghi chú "(chưa thấy trong PR — cần xác nhận)". +- Không bịa thêm việc ngoài diff. + +### 5. Trả về đúng format dưới đây + +Ngày = hôm nay theo `TIME_ZONE` trong `BACKEND/.env` (project đang chạy), format `DD/MM/YYYY`. Output thuần văn bản (không bọc `code block`), sẵn sàng paste vào ô comment Jira: + +``` +Ngày DD/MM/YYYY + - + - + - ... +``` + +### 6. Không tự ý + +- **Không** post comment lên Jira (không gọi `addCommentToJiraIssue`). Chỉ in nội dung ra để user copy. +- **Không** transition issue, không edit Jira fields. +- **Không** push/merge gì lên Gitea. +- **Không** in password ra response. diff --git a/.claude/commands/smoke-test-checklist.md b/.claude/commands/smoke-test-checklist.md new file mode 100644 index 0000000..7736cfb --- /dev/null +++ b/.claude/commands/smoke-test-checklist.md @@ -0,0 +1,133 @@ +--- +description: Sinh smoke test checklist (Happy / Empty / Error / Responsive / Data Edge) từ PR/commit Gitea +argument-hint: +allowed-tools: Bash, Read, Grep, Glob +--- + +# /smoke-test-checklist + +Đầu vào (`$ARGUMENTS`) là **một link Gitea**: + +- Pull request: `https://///pulls/` (chấp nhận cả `/pull/`). +- Commit: `https://///commit/` (chấp nhận cả `/commits/`). + +Nếu thiếu link, **dừng** và yêu cầu cung cấp. + +## Credentials Gitea (Basic Auth) + +- Username: `andrew.ng@apactech.io` +- Password: `andrew.ng@123` + +Gọi API bằng `curl -s -u "andrew.ng@apactech.io:andrew.ng@123" -H "Accept: application/json" --fail-with-body ""`. **Không** in lệnh kèm password ra response. Nếu 401/403, báo lỗi auth, không in credentials. + +## Các bước phải làm + +### 1. Parse link + +- `GITEA_HOST` = scheme + host. `OWNER`, `REPO`. +- `KIND` = `pr` (path chứa `/pulls/` hoặc `/pull/`) | `commit` (path chứa `/commit/` hoặc `/commits/`). +- `REF` = số PR (cho `pr`) hoặc SHA (cho `commit`). + +Nếu không khớp pattern → hỏi lại thay vì đoán. + +### 2. Lấy diff để hiểu phạm vi thay đổi + +Base API: `${GITEA_HOST}/api/v1/repos/${OWNER}/${REPO}` + +#### KIND = pr + +- `GET /pulls/${REF}` → `title`, `body`, `base.ref`, `head.ref`, `head.sha`, `state`, `merged`. +- `GET /pulls/${REF}/files` → list file thay đổi. +- Raw diff: `GET ${GITEA_HOST}/${OWNER}/${REPO}/pulls/${REF}.diff` → lưu `/tmp/smoke-${REF}.diff`. + +#### KIND = commit + +- `GET /git/commits/${REF}` → `message`, `files`. +- Raw diff: `GET ${GITEA_HOST}/${OWNER}/${REPO}/commit/${REF}.diff` → lưu `/tmp/smoke-${REF}.diff`. + +**Ưu tiên git local** nếu `git remote -v` trỏ về cùng `GITEA_HOST/${OWNER}/${REPO}`: dùng `git show ` / `git diff ..` thay vì hit Gitea. + +### 3. Phân loại thay đổi + +Đọc diff + (nếu cần) mở file gốc bằng `Read` để xác định **bề mặt cần test**: + +- **UI / FE**: file dưới `FRONTEND/src/` (components, modals, pages, routing, `App.tsx`, terminal). +- **API / BE**: file dưới `BACKEND/app/controllers/`, routes (`start/routes.ts`), models, migrations. +- **Socket event**: handler mới/sửa trong `BACKEND/providers/socket_io_provider.ts` hoặc handler trong `FRONTEND/src/App.tsx`'s big `useEffect` / `SocketContext`. +- **Device interaction**: thay đổi trong `BACKEND/app/services/{line,station,apc,switch}_connection.ts` (gửi command xuống thiết bị, scenario, DPELP, physical test, IOS/license load). +- **Persisted state**: thay đổi `saveState`/`restoreState`, key Redis `socket_state` hoặc `station:{id}:line:{id}:history`, migration MySQL. + +Ghi nhớ phân loại để generate checklist phù hợp (vd. không thêm "Mobile / Responsive" cho thay đổi thuần BE). + +### 4. Sinh checklist + +**5 nhóm bắt buộc** (đúng thứ tự, đúng tên): + +#### Happy Path + +Kịch bản chính của tính năng vừa thêm/sửa, đi từ A→Z với input hợp lệ. Mỗi mục mô tả **hành động cụ thể + kỳ vọng quan sát được**. + +#### Empty State + +- Danh sách rỗng, chưa có dữ liệu. +- User chưa chọn line/station nào. +- Modal mở khi không có context (chưa connect, chưa run scenario, v.v.). +- Trang load lần đầu chưa có `localStorage.user`. + +#### Error Case + +- API trả 4xx/5xx. +- Socket disconnect giữa chừng (mất line/station session → cần auto-reconnect theo `handleLineOperation`). +- TCP session timeout / `setTimeoutConnect` chạm 8h. +- Device không phản hồi command (vd. APC, switch). +- Input invalid (tên rỗng, ký tự đặc biệt, số âm). +- Lỗi từ Lucid model (unique constraint, FK). + +#### Data Edge Case + +- Chuỗi rất dài (output line, log, scenario name → buffer truncate ở `saveState` 5000 chars). +- Ký tự Unicode / emoji / VN dấu trong tên station, scenario, comment. +- Số rất lớn / âm / 0 cho port number, line number, timeout. +- Concurrent: 2 user thao tác cùng 1 line/station (CLI ownership trong `userConnecting`). +- Race với keep-alive (`keepConnectAPC` 40s / `keepConnectStation` 120s) đúng lúc user gửi command. +- Restart backend → state restore đúng từ Redis (`restoreState`). + +### 5. Trả về kết quả + +Format markdown (Vietnamese), checkbox GitHub-style `- [ ]`: + +``` +# Smoke Test Checklist — # + +1. Happy Path +- [ ] +- [ ] +... + +2. Empty State +- [ ] +... + +3. Error Case +- [ ] +... + +4. Data Edge Case +- [ ] +... +``` + +### 6. Nguyên tắc viết checklist + +- Mỗi item bắt đầu bằng **động từ hành động** (Mở, Nhấn, Gửi, Disconnect…) + **kỳ vọng quan sát được**, không viết chung chung "test feature X". +- Bám sát diff: chỉ liệt kê case **liên quan trực tiếp** thay đổi này. Không generate checklist generic cho cả app. +- Đặt item theo thứ tự dễ thực hiện (UI flow trước, edge sau). +- Nếu một nhóm không có case nào áp dụng → ghi rõ `N/A — `, không tự bịa. +- Tổng số item khuyến nghị: 3–8 / nhóm. Nếu nhiều hơn, gom case tương đương. + +### 7. Không tự ý + +- **Không** tự chạy test, không khởi động app (nếu user muốn, họ chạy `/run` hoặc `/verify` riêng). +- **Không** comment lên Gitea / Jira. +- **Không** sửa code. +- **Không** in password. 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/.gitignore b/.gitignore new file mode 100644 index 0000000..5c16e0b --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +# Secret cục bộ — chứa token xác thực, KHÔNG commit. +config.local.js 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/app.js b/app.js index ffeaaa9..1f3cd01 100644 --- a/app.js +++ b/app.js @@ -423,6 +423,10 @@ async function callApi(region, id, btn) { const modelData = await fetchModel(product.model); if (!modelData) { setStatus(`Model "${product.model}" không tồn tại trong ERP`, "error"); + // Quan trọng: nhả trạng thái loading/disable trước khi return, + // nếu không nút AU/US và cả dòng sẽ kẹt spinner vĩnh viễn. + setBtnLoading(btn, false); + setRowBusy(rowEl, false); return; } diff --git a/config.js b/config.js index a8f2531..a692234 100644 --- a/config.js +++ b/config.js @@ -29,12 +29,14 @@ window.APP_CONFIG = { }, // Header gửi kèm mỗi request. + // Token xác thực KHÔNG để trong file này (tránh commit secret vào repo). + // Nó được nạp từ config.local.js -> window.APP_SECRETS.authorization + // (xem config.local.example.js). config.local.js đã được .gitignore. HEADERS: { "accept": "application/json, text/plain, */*", "content-type": "application/json", - // Token xác thực — thay bằng token còn hiệu lực của bạn. "authorization": - "Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJodHRwczovL2ludC5pcHN1cHBseS5jb20uYXUvYXBpL2xvZ2luIiwiaWF0IjoxNzc1MDI4MDg2LCJleHAiOjMyODg3NTYwODYsIm5iZiI6MTc3NTAyODA4NiwianRpIjoiTkx0b09iTmI5ZzhkNnJDdiIsInN1YiI6MSwicHJ2IjoiYzhlZTFmYzg5ZTc3NWVjNGM3Mzg2NjdlNWJlMTdhNTkwYjZkNDBmYyJ9.bUK9fOLPR9b6ADNkT5Uj1nyudbo-zaM2lwnN1WTYHzE", + (window.APP_SECRETS && window.APP_SECRETS.authorization) || "", }, // Link sản phẩm eBay theo khu vực (ghép với listingId trả về). diff --git a/config.local.example.js b/config.local.example.js new file mode 100644 index 0000000..9bcd71b --- /dev/null +++ b/config.local.example.js @@ -0,0 +1,13 @@ +/** + * config.local.example.js — file mẫu (CÓ commit). + * + * Copy thành config.local.js (KHÔNG commit) rồi điền token thật: + * + * cp config.local.example.js config.local.js + * + * config.local.js đã được .gitignore và phải nạp TRƯỚC config.js trong index.html. + */ +window.APP_SECRETS = { + // Bearer token xác thực tới int.ipsupply.com.au + authorization: "Bearer ", +}; diff --git a/index.html b/index.html index 541e673..e49b8c4 100644 --- a/index.html +++ b/index.html @@ -66,6 +66,8 @@
+ +