Compare commits

..

No commits in common. "main" and "new-form-summary" have entirely different histories.

30 changed files with 1111 additions and 2506 deletions

View File

@ -1,140 +0,0 @@
---
description: AI review một PR/commit Gitea theo thứ tự Security → Logic → Performance → Consistency → Simplicity
argument-hint: <gitea-pr-or-commit-url>
allowed-tools: Bash, Read, Grep, Glob
---
# /ai-review
Đầu vào (`$ARGUMENTS`) là **một link Gitea**, hai dạng:
- Pull request: `https://<gitea-host>/<owner>/<repo>/pulls/<index>` (chấp nhận cả `/pull/<index>`).
- Commit: `https://<gitea-host>/<owner>/<repo>/commit/<sha>` (chấp nhận cả `/commits/<sha>`).
Nếu thiếu link, **dừng** và yêu cầu cung cấp.
Gọi API bằng `curl -s -u "andrew.ng@apactech.io:andrew.ng@123" -H "Accept: application/json" --fail-with-body "<url>"`. **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 <sha>` / `git diff <base>..<head>` để 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 — <KIND> #<REF>
## 1. Security — <status>
- <file:line><finding>
...
## 2. Logic & Edge Cases — <status>
- <file:line><finding>
...
## 3. Performance — <status>
- <file:line><finding>
...
## 4. Simplicity — <status>
- <file:line><đề xuất rút gọn>
...
## Tổng kết
- Đánh giá chung: <12 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.

View File

@ -1,87 +0,0 @@
---
description: Đọc task Jira + PR/commit Gitea, sinh comment ghi nhận công việc đã làm theo format ngày
argument-hint: <jira-task-url> <gitea-pr-or-commit-url>
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://<site>.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://<gitea-host>/<owner>/<repo>/pulls/<index>` (cũng chấp nhận `/pull/<index>`).
- Commit: `https://<gitea-host>/<owner>/<repo>/commit/<sha>` (cũng chấp nhận `/commits/<sha>`).
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`.
Khi gọi API Gitea bằng `curl`, dùng `-u "andrew.ng@apactech.io:andrew.ng@123"``--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" "<url>"`. 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
- <Việc 1 đã làm>
- <Việc 2 đã làm>
- ...
```
### 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.

View File

@ -1,128 +0,0 @@
---
description: Sinh smoke test checklist (Happy / Empty / Error / Responsive / Data Edge) từ PR/commit Gitea
argument-hint: <gitea-pr-or-commit-url>
allowed-tools: Bash, Read, Grep, Glob
---
# /smoke-test-checklist
Đầu vào (`$ARGUMENTS`) là **một link Gitea**:
- Pull request: `https://<gitea-host>/<owner>/<repo>/pulls/<index>` (chấp nhận cả `/pull/<index>`).
- Commit: `https://<gitea-host>/<owner>/<repo>/commit/<sha>` (chấp nhận cả `/commits/<sha>`).
Nếu thiếu link, **dừng** và yêu cầu cung cấp.
Gọi API bằng `curl -s -u "andrew.ng@apactech.io:andrew.ng@123" -H "Accept: application/json" --fail-with-body "<url>"`. **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 <sha>` / `git diff <base>..<head>` 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 — <KIND> #<REF>
1. Happy Path
- [ ] <bước 1 + kỳ vọng>
- [ ] <bước 2 + kỳ vọng>
...
2. Empty State
- [ ] <case 1>
...
3. Error Case
- [ ] <case 1>
...
4. Data Edge Case
- [ ] <case 1>
...
```
### 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 — <lý do>`, không tự bịa.
- Tổng số item khuyến nghị: 38 / 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.

View File

@ -13,22 +13,22 @@ DOMAIN_NAME=http://localhost
SOCKET_PORT=8989
FRONTEND_URL=http://localhost:5173
BACKEND_URL=http://localhost:3333
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GOOGLE_CLIENT_ID=532287737140-2e3kb67raaac56u2uohnqveg6gt7vga9.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=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
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USERNAME=
SMTP_PASSWORD=
PAYPAL_MODE=sandbox
PAYPAL_BASE_URL=https://api-m.sandbox.paypal.com
PAYPAL_CLIENT_ID=
PAYPAL_CLIENT_SECRET=
PAYPAL_CLIENT_ID=ASV19JCbp2rUaaFLtfJ7TQtLFvaIFKMrze6kiK4LJYjcCRPjFWFjcAzFueofJKKzQ0iJTGle4qPUGwex
PAYPAL_CLIENT_SECRET=EDprE7q7sdsY2Lk869AnRI_mIc5VPtDLMfK4bsVlGd6Qswe4T2_By9anIi9mEKe-bNHosW9J2N_urTaH
PAYPAL_CURRENCY=USD
SEND_ZULIP=1
ZULIP_REALM=
ZULIP_USERNAME=
ZULIP_API_KEY=
ZULIP_REALM="https://zulip.ipsupply.com.au"
ZULIP_USERNAME="networktool-bot@zulip.ipsupply.com.au"
ZULIP_API_KEY="0jMAmOuhfLvBqKJikv5oAkyNM4RIEoAM"
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
REDIS_PASSWORD=

1
BACKEND/.gitignore vendored
View File

@ -29,4 +29,3 @@ storage/system_logs
storage/ios
storage/i
storage/license
storage/report_sn

View File

@ -32,7 +32,7 @@ export default class AuthController {
const user = await User.query().where('user_name', userName).first()
if (!user) {
const remoteUrl = process.env.ERP_URL_AUTH || 'https://stage.nswteam.net'
const remoteUrl = process.env.ERP_URL || 'https://stage.nswteam.net'
const remoteResp = await axios.post(remoteUrl + '/api/login', {
userEmail: userName,
password: password,

View File

@ -1,44 +1,13 @@
import type { HttpContext } from '@adonisjs/core/http'
import axios from 'axios'
import fs from 'fs'
import path from 'path'
const linkWiki =
process.env.LINK_WIKI || 'https://logs.danielvu.com/api/wiki/page/insert?title=Dev_test'
const remoteUrl = process.env.ERP_URL || 'https://stage.nswteam.net'
export default class HealCheckController {
private saveErrorLog(error: any) {
try {
const logsDir = path.join(process.cwd(), 'storage', 'health-check')
// Tạo thư mục nếu không tồn tại
if (!fs.existsSync(logsDir)) {
fs.mkdirSync(logsDir, { recursive: true })
}
const timestamp = new Date().toISOString()
const fileName = `healthcheck-error-${new Date().toISOString().split('T')[0]}.log`
const filePath = path.join(logsDir, fileName)
const errorContent = `
=== ERROR LOG ===
Time: ${timestamp}
Message: ${error?.message || 'Unknown error'}
Status: ${error?.response?.status || 'N/A'}
URL: ${error?.response?.config?.url || 'N/A'}
Error Details: ${JSON.stringify(error?.response?.data || error, null, 2)}
---
`
fs.appendFileSync(filePath, errorContent)
console.log(`Error logged to: ${filePath}`)
} catch (logError) {
console.error('Failed to save error log:', logError)
}
}
// GET /health-check
async check({ }: HttpContext) {
async check({}: HttpContext) {
try {
const header = {
Authorization: 'Bearer ' + process.env.ERP_TOKEN,
@ -52,21 +21,21 @@ Error Details: ${JSON.stringify(error?.response?.data || error, null, 2)}
status: true,
message: 'Checking api update note SN success',
}
const responseDataSN = await axios.get(
remoteUrl + '/api/stock-model-serial/get-list-for-test-log',
const responseDataSN = await axios.post(
remoteUrl + '/api/transferGetData',
{
params: {
filter: {
where: {
_q: 'FOC1425Z3RN',
},
urlAPI: '/api/stock-model-serial/get-list-for-test-log',
filter: {
where: {
_q: 'FOC1425Z3RN',
},
orgId: ['5fadc798f070e4b64b53ac9c', '5fadc7b0f070e4b64b53ac9d'],
},
orgId: ['5fadc798f070e4b64b53ac9c', '5fadc7b0f070e4b64b53ac9d'],
},
{
headers: header,
}
);
)
if (!responseDataSN?.data?.data || responseDataSN?.data?.data?.length === 0) {
dataCheckNote = {
name: 'update-note-sn',
@ -78,15 +47,18 @@ Error Details: ${JSON.stringify(error?.response?.data || error, null, 2)}
// console.log(payload)
const resSN = await axios.post(
remoteUrl + '/api/stock-model-serial/data-save-for-test-log',
remoteUrl + '/api/transferPostData',
{
id: dataSN?.id,
serialNumberA: dataSN?.serialNumberA,
productModelId: dataSN?.productModelId,
orgId: dataSN?.orgId,
condition: dataSN?.condition,
testNotes: dataSN?.testNotes,
healthCheck: true,
urlAPI: '/api/stock-model-serial/data-save-for-test-log',
data: {
id: dataSN?.id,
serialNumberA: dataSN?.serialNumberA,
productModelId: dataSN?.productModelId,
orgId: dataSN?.orgId,
condition: dataSN?.condition,
testNotes: dataSN?.testNotes,
healthCheck: true,
},
},
{
headers: header,
@ -111,9 +83,6 @@ Error Details: ${JSON.stringify(error?.response?.data || error, null, 2)}
],
}
} catch (error: any) {
// Lưu log lỗi
this.saveErrorLog(error)
return {
code: 200,
data: [

View File

@ -104,57 +104,4 @@ export default class LogsController {
})
}
}
async listSystemLogFiles({ request, response }: HttpContext) {
try {
const filename = request.input('filename', '')
const fromDate = request.input('from_date', '')
const toDate = request.input('to_date', '')
const systemLogsDir = path.join(DIRNAME, '..', '..', 'storage', 'system_logs')
// Nếu thư mục không tồn tại, trả về danh sách rỗng
if (!fs.existsSync(systemLogsDir)) {
return response.ok({
status: true,
data: [],
})
}
let files = fs.readdirSync(systemLogsDir)
// Lọc theo tên file nếu có
if (filename) {
files = files.filter((f) => f.includes(filename))
}
// Lọc theo khoảng thời gian nếu có
if (fromDate || toDate) {
const fromTime = fromDate ? new Date(fromDate).getTime() : 0
// Cộng thêm 24h để bao gồm cả ngày cuối cùng (end of day 23:59:59)
const toTime = toDate ? new Date(toDate).getTime() + 24 * 60 * 60 * 1000 : Date.now()
files = files.filter((f) => {
const filePath = path.join(systemLogsDir, f)
const stat = fs.statSync(filePath)
const fileTime = stat.mtime.getTime()
return fileTime >= fromTime && fileTime < toTime
})
}
// Lấy 100 tên file mới nhất (cuối danh sách)
const result = files.slice(-100)
return response.ok({
status: true,
data: result,
})
} catch (error) {
return response.internalServerError({
status: false,
message: 'Failed to list system log files',
error: error.message,
})
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -29,7 +29,7 @@ type InputData = {
lineNumber: number
inventory: any
latestScenario?: {
detectAI?: string | string[]
detectAI?: DetectAI
}
data?: any[]
}
@ -216,17 +216,16 @@ export function mapToLineFormat(input: InputData) {
const license =
dataLicense?.textfsm && Array.isArray(dataLicense.textfsm)
? dataLicense.textfsm
?.filter((el: any) => el.LICENSE_TYPE === 'Permanent')
.map((v: any) => v.FEATURE)
?.filter((el: any) => el.LICENSE_TYPE === 'Permanent')
.map((v: any) => v.FEATURE)
: ''
// // Mode (DPEL / DPELP)
// const dataPlatform = input.data?.find((el) => el.command?.trim() === 'show platform')
// const mode = dataPlatform && !dataPlatform.output?.includes('Incomplete') ? 'DPELP' : 'DPEL'
const detectAI: string | string[] | undefined = input.latestScenario?.detectAI
// Issues
const issues = Array.isArray(detectAI) ? detectAI : typeof detectAI === 'string' && detectAI?.includes('[') ? [...detectAI.matchAll(/"((?:\\.|[^"\\])*)"/g)]
.map(m => m[1]) : []
const issues = Array.isArray(input.latestScenario?.detectAI) ? input.latestScenario?.detectAI : []
// Issues
const summary = ''
@ -682,34 +681,21 @@ export async function updateNoteToERP(sn: string, note: string) {
const header = {
Authorization: 'Bearer ' + process.env.ERP_TOKEN,
}
// const responseDataSN = await axios.post(
// 'https://stage.nswteam.net/api/transferGetData',
// {
// urlAPI: '/api/stock-model-serial/get-list-for-test-log',
// filter: {
// where: {
// _q: sn,
// },
// },
// },
// {
// headers: header,
// }
// )
const responseDataSN = await axios.get(
remoteUrl + '/api/stock-model-serial/get-list-for-test-log',
const responseDataSN = await axios.post(
remoteUrl + '/api/transferGetData',
{
params: {
filter: {
where: {
_q: sn,
},
}
urlAPI: '/api/stock-model-serial/get-list-for-test-log',
filter: {
where: {
_q: sn,
},
},
orgId: ['5fadc798f070e4b64b53ac9c', '5fadc7b0f070e4b64b53ac9d'],
},
{
headers: header,
}
);
)
// console.log('updateNoteToERP', responseDataSN?.data?.data)
if (!responseDataSN?.data?.data || responseDataSN?.data?.data?.length === 0) {
@ -731,15 +717,16 @@ export async function updateNoteToERP(sn: string, note: string) {
testNotes: note + (dataSN?.testNotes || ''),
}
// console.log(payload)
await axios.post(remoteUrl + '/api/stock-model-serial/data-save-for-test-log', payload, {
headers: header,
})
// await axios.post("https://stage.nswteam.net/api/transferPostData", {
// urlAPI: '/api/stock-model-serial/data-save-for-test-log',
// data: payload
// }, {
// headers: header,
// })
await axios.post(
remoteUrl + '/api/transferPostData',
{
urlAPI: '/api/stock-model-serial/data-save-for-test-log',
data: payload,
},
{
headers: header,
}
)
} catch (error) {
console.log('updateNoteToERP', error)
}
@ -1450,49 +1437,3 @@ export function canInputCommand(buffer: string): boolean {
return false
}
export async function getIncomingInfoBySN(sn: string) {
try {
if (!sn) return
const remoteUrl = process.env.ERP_URL || 'https://stage.nswteam.net'
const header = {
Authorization: 'Bearer ' + process.env.ERP_TOKEN,
}
// const responseDataSN = await axios.post(
// 'https://stage.nswteam.net/api/transferGetData',
// {
// urlAPI: '/api/package-po/get-incoming-by-sn',
// filter: {
// where: {
// serialNumber: sn,
// },
// },
// },
// {
// headers: header,
// }
// )
const responseDataSN = await axios.get(
remoteUrl + '/api/package-po/get-incoming-by-sn',
{
params: {
filter: {
where: {
serialNumber: sn,
},
},
},
headers: header,
}
);
if (!responseDataSN?.data?.data) {
return
}
return responseDataSN?.data?.data
} catch (error) {
console.log('getIncomingInfoBySN', error)
}
}

View File

@ -36,9 +36,6 @@ const parseLog = (data: string) => {
// Update current record with matched fields
Object.keys(currentRecord).forEach((key) => {
if (item && item[key] !== undefined) {
if (key === "pid") {
item[key] = item[key].replace(/[()]/g, '')
}
currentRecord[key] = item[key].trim()
}
})

View File

@ -53,22 +53,22 @@ type StationAction = (
export default class SocketIoProvider {
private static _io: CustomServer
constructor(protected app: ApplicationService) { }
constructor(protected app: ApplicationService) {}
/**
* Register bindings to the container
*/
register() { }
register() {}
/**
* The container bindings have booted
*/
async boot() { }
async boot() {}
/**
* The application has been booted
*/
async start() { }
async start() {}
/**
* The process has been started
@ -83,7 +83,7 @@ export default class SocketIoProvider {
/**
* Preparing to shutdown the app
*/
async shutdown() { }
async shutdown() {}
public static get io() {
return this._io
@ -100,7 +100,7 @@ export class WebSocketIo {
lineConnecting: number[] = [] // key = lineId
intervalKeepConnect: { [key: string]: NodeJS.Timeout } = {}
constructor(protected app: ApplicationService) { }
constructor(protected app: ApplicationService) {}
async boot() {
const SOCKET_IO_PORT = env.get('SOCKET_PORT') || 8989
@ -336,7 +336,7 @@ export class WebSocketIo {
socket.on('get_content_log', async (data) => {
try {
const { line, socketId } = data
const filePath = `storage/system_logs/${line.systemLogUrl}`
const filePath = line.systemLogUrl
if (fs.existsSync(filePath)) {
// Get file stats
const stats = fs.statSync(filePath)
@ -1439,11 +1439,6 @@ export class WebSocketIo {
return html
}
shortenResult(text: string) {
const match = text.match(/RESULT:.*?(?=SUMMARY:)/s);
return match ? match[0].trim().replace(/,\s*$/, '') : text;
}
/**
* Generates a Zulip-compatible Markdown table string from the results array.
* Uses <br> to force line breaks within the License cell.
@ -1452,8 +1447,8 @@ export class WebSocketIo {
*/
generateZulipMessage(results: any[]) {
let msg = ``
msg += `| Line | PID | SN | MAC | IOS | License | Issues |\n`
msg += `| ---- | ---- | ---- | ---- | ---- | ---- | ---- |\n`
msg += `| Line | PID | SN | MAC | IOS | License | Summary | Issues |\n`
msg += `| ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- |\n`
for (const item of results) {
if (!item) continue
@ -1469,13 +1464,9 @@ export class WebSocketIo {
// Format issues
const issuesMd = item.issues?.length
? item.issues.map((i: string) => `${i.replace('|', '')}`).join(' --')
? item.issues.map((i: string) => `${i}`).join(' --')
: '- No issues detected.'
const issue = item.issues?.length ? item.issues.join("\n") : "- No issues detected."
const shortenedIssue = this.shortenResult(issue).split("\n")
const issuesMdShort = shortenedIssue.map((i: string) => `${i.replace('|', '')}`).join(' --')
msg +=
`| ${item.line || ''}` +
` | ${item.pid || ''} ${item.vid ? ` (${item.vid})` : ''}` +
@ -1483,7 +1474,8 @@ export class WebSocketIo {
` | ${item.mac || ''}` +
` | ${item.ios || ''}` +
` | ${licenseMd}` +
` | ${issuesMdShort}` +
` | ${item.summary || ''}` +
` | ${issuesMd}` +
` |\n`
}

View File

@ -32,7 +32,6 @@ router
router.get('list', '#controllers/logs_controller.list')
router.post('viewLog', '#controllers/logs_controller.viewLog')
router.post('downloadLog', '#controllers/logs_controller.downloadLog')
router.get('listSystemLogFiles', '#controllers/logs_controller.listSystemLogFiles')
})
.prefix('api/logs')

View File

@ -2,6 +2,6 @@
"extends": "@adonisjs/tsconfig/tsconfig.app.json",
"compilerOptions": {
"rootDir": "./",
"outDir": "./build",
},
}
"outDir": "./build"
}
}

View File

@ -17,7 +17,3 @@ button:focus {
overflow-y: scroll !important;
overscroll-behavior: contain;
}
.modalNoScroll > .mantine-Modal-inner > .mantine-Modal-content {
overflow-y: hidden !important;
}

View File

@ -405,7 +405,6 @@ function App() {
data: data.data,
inventory: data.inventory,
latestScenario: data.latestScenario,
loadingNote: false,
},
data?.stationId,
);
@ -455,7 +454,6 @@ function App() {
runningPhysical: data?.physical || false,
ports: data?.ports || [],
listPortsPhysical: [],
isPassword: data?.password || false,
},
data?.stationId,
);
@ -495,9 +493,7 @@ function App() {
if (
valueLine &&
openModalTerminal &&
selectedLine?.id === valueLine?.id &&
(!valueLine?.listPortsPhysical ||
valueLine?.listPortsPhysical?.length === 0)
selectedLine?.id === valueLine?.id
)
setLinesConfirmSkipPort((pre) => [
...pre,
@ -551,14 +547,6 @@ function App() {
}
});
socket?.on("loading_note", (data) => {
updateValueLineStation(
data?.lineId,
{ loadingNote: true },
data?.stationId,
);
});
// ✅ cleanup on unmount or when socket changes
return () => {
socket.off("init");
@ -579,7 +567,6 @@ function App() {
socket.off("test_port_physical");
socket.off("feature_tested");
socket.off("summary_tested");
socket.off("loading_note");
};
}, [socket, stations, selectedLine]);

View File

@ -155,11 +155,10 @@ const CardLine = ({
}, [line?.latestScenario]);
function detectResultStatus(lines: string[]) {
if (!lines || lines.length === 0 || typeof lines === "string") return null;
const text = lines.join("\n");
const match = text.match(
/RESULT:\s*(PASS WITH WARNING|PASS|FAIL|INSUFFICIENT DATA)/im,
/^RESULT:\s*(PASS WITH WARNING|PASS|FAIL|INSUFFICIENT DATA)/im,
);
if (!match) {
@ -506,21 +505,7 @@ const CardLine = ({
paddingTop: "2px",
}}
>
{line?.isPassword ? (
<Text
style={{
border: "1px solid #ccc",
borderRadius: "16px",
paddingLeft: "4px",
paddingRight: "4px",
backgroundColor: "red",
}}
fz={"9px"}
c={"white"}
>
{"Password"}
</Text>
) : isShowIssue ? (
{isShowIssue ? (
<Text
style={{
border: "1px solid #ccc",

View File

@ -159,8 +159,7 @@
.scenarioCard {
border: 2px solid transparent;
background:
linear-gradient(white, white) padding-box,
background: linear-gradient(white, white) padding-box,
linear-gradient(145deg, #e0e0e0, #f5f5f5) border-box;
}
@ -169,18 +168,3 @@
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.specRow {
padding: 9px 0;
border-bottom: 1px dashed #e9ecef;
}
.connStatus {
padding: 8px 0;
border-radius: 8px;
width: 300px;
}
.blink {
animation: fade 1.2s ease-in-out infinite;
}

View File

@ -1,32 +0,0 @@
import { Box, Flex, Text } from "@mantine/core";
interface GroupButtonProps {
title: string;
children: React.ReactNode;
}
export default function GroupButtonTerminal({
title,
children,
}: GroupButtonProps) {
return (
<Box
p="xs"
style={{
border: "1px solid var(--mantine-color-gray-3)",
borderRadius: "8px",
backgroundColor: "var(--mantine-color-gray-0)",
}}
>
<Flex justify="space-between" align="center" gap="md">
<Text fz={12} c="dimmed">
{title}
</Text>
<Flex gap="sm" wrap="wrap">
{children}
</Flex>
</Flex>
</Box>
);
}

View File

@ -1,41 +0,0 @@
import { Box, Center, Loader } from "@mantine/core";
import React from "react";
interface LoaderOverlayProps {
isLoading: boolean;
children: React.ReactNode;
size?: "xs" | "sm" | "md" | "lg" | "xl";
}
const LoaderOverlay: React.FC<LoaderOverlayProps> = ({
isLoading,
children,
size = "md",
}) => {
return (
<Box style={{ position: "relative" }}>
{children}
{isLoading && (
<Box
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: "rgba(255, 255, 255, 0.7)",
display: "flex",
justifyContent: "center",
alignItems: "center",
borderRadius: "inherit",
zIndex: 10,
}}
>
<Loader size={size} />
</Box>
)}
</Box>
);
};
export default LoaderOverlay;

View File

@ -12,7 +12,7 @@ import {
Loader,
} from "@mantine/core";
import { DateInput } from "@mantine/dates";
import { useEffect, useState, useRef } from "react";
import { useEffect, useState } from "react";
import type { ISystemLog } from "../../untils/types";
import {
IconDownload,
@ -24,7 +24,6 @@ import classes from "../Component.module.css";
import moment from "moment";
import type { Socket } from "socket.io-client";
import ModalLog from "../Modal/ModalLog";
import axios from "axios";
function DrawerLogs({
socket,
@ -42,73 +41,45 @@ function DrawerLogs({
const [opened, { open, close }] = useDisclosure(false);
const [systemLogs, setSystemLogs] = useState<ISystemLog[]>([]);
const [isDownloadLog, setIsDownloadLog] = useState(false);
const [loading, setLoading] = useState(false);
// const [testLogContent, setTestLogContent] = useState("");
const [loading, setLoading] = useState(true);
const [downloadName, setDownloadName] = useState("");
const [searchFileName, setSearchFileName] = useState("");
const [fromDate, setFromDate] = useState<Date | null>(null);
const [toDate, setToDate] = useState<Date | null>(null);
const [filteredLogs, setFilteredLogs] = useState<ISystemLog[]>([]);
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null);
const apiUrl = import.meta.env.VITE_BACKEND_URL;
const fetchSystemLogFiles = async (
filename: string,
fromDateVal: Date | null,
toDateVal: Date | null,
) => {
try {
setLoading(true);
const params: Record<string, string> = {};
if (filename) {
params.filename = filename;
}
if (fromDateVal) {
params.from_date = moment(fromDateVal).format("YYYY-MM-DD");
}
if (toDateVal) {
params.to_date = moment(toDateVal).format("YYYY-MM-DD");
}
const response = await axios.get(`${apiUrl}api/logs/listSystemLogFiles`, {
params,
});
if (response.data && response.data.data) {
const list: ISystemLog[] = response.data.data.map((file: string) => {
const filename = file.replace(/^.*[\\/]/, "");
const createAt = filename.match(/\d{8}/);
return {
fileName:
file.split("/")[3] ||
file.split("/")[2] ||
file.split("/")[1] ||
file.split("/")[0],
createdAt: createAt ? createAt[0] : "N/A",
path: file,
};
});
setSystemLogs(
list.sort(
(a: ISystemLog, b: ISystemLog) =>
parseInt(b.createdAt) - parseInt(a.createdAt),
),
);
}
setLoading(false);
} catch (error) {
console.error("Failed to fetch system log files:", error);
setLoading(false);
}
};
useEffect(() => {
if (opened) {
fetchSystemLogFiles("", null, null);
socket?.emit("get_list_logs");
}
}, [opened]);
useEffect(() => {
socket?.on("list_logs", (files: string[]) => {
const list: ISystemLog[] = files.map((file) => {
const filename = file.replace(/^.*[\\/]/, "");
const createAt = filename.match(/\d{8}/);
return {
fileName:
file.split("/")[3] || file.split("/")[2] || file.split("/")[1],
createdAt: createAt ? createAt[0] : "N/A",
path: file,
};
});
setTimeout(() => {
setLoading(false);
}, 1000);
setSystemLogs(
list.sort(
(a: ISystemLog, b: ISystemLog) =>
parseInt(b.createdAt) - parseInt(a.createdAt)
)
);
});
}, [socket]);
useEffect(() => {
if (isDownloadLog && testLogContent && downloadName) {
const blob = new Blob([testLogContent], { type: "text/plain" });
@ -127,28 +98,40 @@ function DrawerLogs({
}, [testLogContent]);
useEffect(() => {
if (!opened) return;
// Clear previous debounce timer
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
// Chuẩn bị trước các giá trị search/date để tránh tính lại trong filter cho từng phần tử
const trimmedSearch = searchFileName.trim().toLowerCase();
const hasSearch = trimmedSearch.length > 0;
const fromMoment = fromDate ? moment(fromDate).startOf("day") : null;
const toMoment = toDate ? moment(toDate).endOf("day") : null;
// Set new debounce timer for API call
debounceTimerRef.current = setTimeout(() => {
fetchSystemLogFiles(searchFileName, fromDate, toDate);
const delayDebounceFn = setTimeout(() => {
// Nếu không có filter nào, tránh filter tốn công, gán thẳng
if (!hasSearch && !fromMoment && !toMoment) {
setFilteredLogs(systemLogs);
return;
}
const next = systemLogs.filter((log) => {
if (hasSearch && !log.fileName.toLowerCase().includes(trimmedSearch)) {
return false;
}
const logDate = moment(log.createdAt, "YYYYMMDD");
if (fromMoment && !logDate.isSameOrAfter(fromMoment)) {
return false;
}
if (toMoment && !logDate.isSameOrBefore(toMoment)) {
return false;
}
return true;
});
setFilteredLogs(next);
}, 500);
return () => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
};
}, [searchFileName, fromDate, toDate]);
useEffect(() => {
// API already returns filtered results, so just update filteredLogs
setFilteredLogs(systemLogs);
}, [systemLogs]);
return () => clearTimeout(delayDebounceFn);
}, [searchFileName, fromDate, toDate, systemLogs]);
return (
<>
@ -306,7 +289,7 @@ function DrawerLogs({
setDownloadName(
element.path.split("/")[3] ||
element.path.split("/")[2] ||
element.path.split("/")[1],
element.path.split("/")[1]
);
}}
width={20}

View File

@ -85,15 +85,15 @@ const StationSetting = ({
form.setFieldValue("is_active", dataStation?.is_active);
form.setFieldValue(
"switch_control_port",
dataStation.switch_control_port,
dataStation.switch_control_port
);
form.setFieldValue(
"switch_control_username",
dataStation.switch_control_username,
dataStation.switch_control_username
);
form.setFieldValue(
"switch_control_password",
dataStation.switch_control_password,
dataStation.switch_control_password
);
const dataLine = dataStation.lines.map((value) => ({
@ -116,7 +116,7 @@ const StationSetting = ({
const lastLine = lines[lines.length - 1];
if (lastLine?.lineNumber || lastLine?.port)
setLines((pre) => [...pre, lineInit]);
} else setLines([lineInit]);
}
}, [lines]);
useEffect(() => {
@ -138,8 +138,8 @@ const StationSetting = ({
onChange={(e) =>
setLines((pre) =>
pre.map((value, i) =>
i === index ? { ...value, lineNumber: Number(e!) } : value,
),
i === index ? { ...value, lineNumber: Number(e!) } : value
)
)
}
/>
@ -150,8 +150,8 @@ const StationSetting = ({
onChange={(e) =>
setLines((pre) =>
pre.map((value, i) =>
i === index ? { ...value, port: Number(e!) } : value,
),
i === index ? { ...value, port: Number(e!) } : value
)
)
}
/>
@ -162,8 +162,8 @@ const StationSetting = ({
onChange={(e) =>
setLines((pre) =>
pre.map((value, i) =>
i === index ? { ...value, lineClear: Number(e!) } : value,
),
i === index ? { ...value, lineClear: Number(e!) } : value
)
)
}
/>
@ -178,8 +178,8 @@ const StationSetting = ({
onChange={(e) =>
setLines((pre) =>
pre.map((value, i) =>
i === index ? { ...value, apc_name: e! } : value,
),
i === index ? { ...value, apc_name: e! } : value
)
)
}
/>
@ -190,8 +190,8 @@ const StationSetting = ({
onChange={(e) =>
setLines((pre) =>
pre.map((value, i) =>
i === index ? { ...value, outlet: Number(e!) } : value,
),
i === index ? { ...value, outlet: Number(e!) } : value
)
)
}
/>
@ -204,8 +204,8 @@ const StationSetting = ({
pre.map((value, i) =>
i === index
? { ...value, interface: e.target.value }
: value,
),
: value
)
)
}
/>
@ -286,16 +286,16 @@ const StationSetting = ({
dataStationLines?.find((value) => value?.id === el.id)
? {
...dataStationLines?.find(
(value: TLine) => value?.id === el.id,
(value: TLine) => value?.id === el.id
),
...el,
}
: el,
: el
),
}
: el,
: el
)
: [...pre, station],
: [...pre, station]
);
if (isEdit) {
lineUpdate.forEach((el) => {
@ -345,7 +345,7 @@ const StationSetting = ({
const listStations = stations.filter((el) => el.id !== dataStation?.id);
setStations(listStations);
setActiveTab(
listStations.length ? listStations[0]?.id.toString() : "0",
listStations.length ? listStations[0]?.id.toString() : "0"
);
notifications.show({
title: "Success",
@ -672,7 +672,7 @@ const StationSetting = ({
onChange={(e) =>
form.setFieldValue(
"switch_control_port",
parseInt(e.toString()),
parseInt(e.toString())
)
}
/>
@ -692,7 +692,7 @@ const StationSetting = ({
onChange={(e) =>
form.setFieldValue(
"switch_control_username",
e.target.value,
e.target.value
)
}
/>
@ -704,7 +704,7 @@ const StationSetting = ({
onChange={(e) =>
form.setFieldValue(
"switch_control_password",
e.target.value,
e.target.value
)
}
/>

View File

@ -7,8 +7,6 @@ import {
TextInput,
Button,
ScrollArea,
Radio,
Group,
} from "@mantine/core";
import type { TLine, TStation } from "../../untils/types";
import type { Socket } from "socket.io-client";
@ -23,7 +21,6 @@ interface Props {
interface PropsLines {
id: number | undefined;
note: string;
reasonType?: string;
pid?: string;
sn?: string;
vid?: string;
@ -51,13 +48,10 @@ export default function ModalConfirmSkipTestPort({
pid: line?.inventory?.pid,
sn: line?.inventory?.sn,
vid: line?.inventory?.vid,
reasonType: prev?.find((el) => el.id === line.id)
? prev?.find((el) => el.id === line.id)?.reasonType || ""
: "",
note: prev?.find((el) => el.id === line.id)
? prev?.find((el) => el.id === line.id)?.note || ""
: "",
})),
}))
);
}
}, [listLines]);
@ -105,80 +99,39 @@ export default function ModalConfirmSkipTestPort({
</Text>
</Flex>
<Box>
<Group mt="8px" mb="4px">
<Radio
label="No PoE"
value="no_poe"
checked={line.reasonType === "no_poe"}
onChange={() =>
setDataLines(
dataLines.map((el) =>
el.id === line.id
? {
...el,
reasonType: "no_poe",
note: "",
isError: false,
}
: el,
),
<TextInput
required
placeholder="Enter the reason for skip test ports"
mt="4px"
mb="4px"
value={line.note}
onChange={(e) =>
setDataLines(
dataLines.map((el) =>
el.id === line.id
? {
...el,
note: e.target.value,
isError: false,
}
: el
)
}
/>
<Radio
label="Other"
value="other"
checked={line.reasonType === "other"}
onChange={() =>
setDataLines(
dataLines.map((el) =>
el.id === line.id
? {
...el,
reasonType: "other",
isError: false,
}
: el,
),
)
}
/>
</Group>
{line.reasonType === "other" && (
<TextInput
required
placeholder="Enter the reason for skip test ports"
mt="4px"
mb="4px"
value={line.note}
onChange={(e) =>
setDataLines(
dataLines.map((el) =>
el.id === line.id
? {
...el,
note: e.target.value,
isError: false,
}
: el,
),
)
}
error={
line.isError
? "Please enter the reason for skip test ports"
: ""
}
/>
)}
)
}
error={
line.isError
? "Please enter the reason for skip test ports"
: ""
}
/>
</Box>
<Flex justify={"end"}>
<Button
disabled={isDisabled || !line.reasonType}
disabled={isDisabled}
color={"green"}
size="xs"
onClick={() => {
if (line.reasonType === "other" && !line.note?.trim()) {
if (!line.note)
setDataLines(
dataLines.map((el) =>
el.id === line.id
@ -186,15 +139,14 @@ export default function ModalConfirmSkipTestPort({
...el,
isError: true,
}
: el,
),
: el
)
);
} else {
else {
socket?.emit("end_run_physical_test", {
lineId: line?.id,
stationId: Number(station?.id),
reasonSkipPhysical:
line.reasonType === "no_poe" ? "No PoE" : line?.note,
reasonSkipPhysical: line?.note,
});
setDataLines(dataLines.filter((el) => el.id !== line.id));
setListLines(listLines.filter((el) => el.id !== line.id));

View File

@ -1,6 +1,5 @@
import { useEffect, useState } from "react";
import {
ActionIcon,
Box,
CloseButton,
Flex,
@ -12,9 +11,6 @@ import {
import moment from "moment";
import type { Socket } from "socket.io-client";
import classes from "../Component.module.css";
import { IconFileText, IconListCheck } from "@tabler/icons-react";
import ModalPortPhysicalTest from "./ModalPortPhysicalTest";
import ModalLog from "./ModalLog";
interface LineHistoryItem {
id: number;
@ -24,13 +20,6 @@ interface LineHistoryItem {
vid: string;
sn: string;
scenario: string;
output?: string;
portPhysical?: [
{
name: string;
tested: boolean;
},
];
timestamp: number;
}
@ -55,10 +44,6 @@ const ModalLineHistory = ({
}: ModalLineHistoryProps) => {
const [history, setHistory] = useState<LineHistoryItem[]>([]);
const [loading, setLoading] = useState(false);
const [openPortPhysical, setOpenPortPhysical] = useState(false);
const [selectedHistory, setSelectedHistory] =
useState<LineHistoryItem | null>(null);
const [openLog, setOpenLog] = useState(false);
useEffect(() => {
if (!socket || !opened) return;
@ -92,159 +77,104 @@ const ModalLineHistory = ({
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={{
position: "fixed",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: "rgba(0,0,0,0.6)",
zIndex: 100000,
background: "white",
borderRadius: "12px",
width: "70%",
maxWidth: "1000px",
maxHeight: "80vh",
display: "flex",
alignItems: "center",
justifyContent: "center",
backdropFilter: "blur(3px)",
}}
onClick={(e) => {
e.stopPropagation();
onClose();
flexDirection: "column",
boxShadow: "0 20px 60px rgba(0,0,0,0.3)",
overflow: "hidden",
}}
onClick={(e) => e.stopPropagation()}
>
<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 }}
>
<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>
<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>Log</Table.Th>
<Table.Th>Time</Table.Th>
<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.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>
<Flex align="center" gap="xs">
{item.output ? (
<ActionIcon
variant="outline"
onClick={() => {
setSelectedHistory(item);
setOpenLog(true);
}}
>
<IconFileText size={16} />
</ActionIcon>
) : (
""
)}
{item.portPhysical ? (
<ActionIcon
variant="outline"
onClick={() => {
setSelectedHistory(item);
setOpenPortPhysical(true);
}}
>
<IconListCheck size={16} />
</ActionIcon>
) : (
""
)}
</Flex>
</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>
))}
</Table.Tbody>
</Table>
</ScrollArea>
)}
</Box>
</div>
<ModalPortPhysicalTest
opened={openPortPhysical}
onClose={() => {
setSelectedHistory(null);
setOpenPortPhysical(false);
}}
selectedHistory={selectedHistory}
lineNumber={lineNumber}
stationName={stationName}
/>
<ModalLog
opened={openLog}
onClose={() => {
setSelectedHistory(null);
setOpenLog(false);
}}
testLogContent={selectedHistory?.output || ""}
isShowShortLog={true}
portPhysical={selectedHistory?.portPhysical}
/>
</>
</div>
);
};

View File

@ -1,48 +1,16 @@
import { Button, Flex, Modal, Text } from "@mantine/core";
import { Modal, Text } from "@mantine/core";
import classes from "../Component.module.css";
import {
convertTimestampToDate,
createShortLog,
printLogWeb,
} from "../../untils/helper";
import { useEffect, useState } from "react";
import { convertTimestampToDate } from "../../untils/helper";
const ModalLog = ({
opened,
onClose,
testLogContent,
isShowShortLog = false,
portPhysical,
}: {
opened: boolean;
onClose: () => void;
testLogContent: string;
isShowShortLog?: boolean;
portPhysical?: {
name: string;
tested: boolean;
}[];
}) => {
const [valueLog, setValueLog] = useState(testLogContent);
const [isShort, setIsShort] = useState(false);
const [valueTestedPorts, setValueTestedPorts] = useState<string>("");
useEffect(() => {
setValueLog(testLogContent);
}, [testLogContent]);
useEffect(() => {
if(portPhysical && Array.isArray(portPhysical)) {
const portPoE = portPhysical.filter(port => !port.name.includes("SFP"))
const poeTested = portPhysical.filter(port => port.tested && !port.name.includes("SFP"))
const portSFP = portPhysical.filter(port => port.name.includes("SFP"))
const sfpTested = portPhysical.filter(port => port.tested && port.name.includes("SFP"))
const value = `POE Tested: ${poeTested.length}/${portPoE.length}\nSFP Tested: ${sfpTested.length}/${portSFP.length}`;
setValueTestedPorts(value);
setValueLog(testLogContent + "\n\n" + value);
}
}, [portPhysical]);
const addTooltipsToHighlights = () => {
const highlights = document.querySelectorAll(".highlight");
highlights.forEach((highlight) => {
@ -67,7 +35,7 @@ const ModalLog = ({
return `<span style="background-color: ${
prefix.includes("start") ? colorStart : colorEnd
}" title="${date}">${prefix}${timestamp}${suffix}</span>`;
},
}
)
// Highlight full ---User---
.replace(/^-------([^-\n]+)-------$/gm, (match) => {
@ -83,7 +51,6 @@ const ModalLog = ({
onClose={() => {
onClose();
}}
zIndex={100001}
title={
<Text fz={"lg"} fw={"bolder"}>
Log Content
@ -92,7 +59,7 @@ const ModalLog = ({
size="90%"
styles={{
content: {
height: isShowShortLog ? "90vh" : "85vh",
height: "85vh",
display: "flex",
flexDirection: "column",
},
@ -104,50 +71,13 @@ const ModalLog = ({
>
<div
dangerouslySetInnerHTML={{
__html: highlightSystemLog(valueLog),
__html: highlightSystemLog(testLogContent),
}}
className={`${classes.viewLog} ${classes.logLight}`}
ref={(el) => {
if (el) addTooltipsToHighlights();
}}
></div>
{isShowShortLog ? (
<Flex justify="flex-end" mt="md" gap={"md"}>
{!isShort ? (
<Button
variant="outline"
onClick={() => {
setValueLog(createShortLog(testLogContent) + "\n\n" + valueTestedPorts);
setIsShort(true);
}}
color="green"
>
Short
</Button>
) : (
<Button
variant="outline"
onClick={() => {
setValueLog(testLogContent + "\n\n" + valueTestedPorts);
setIsShort(false);
}}
color="green"
>
Original
</Button>
)}
<Button
variant="outline"
onClick={() => printLogWeb(valueLog)}
color="blue"
>
Print
</Button>
</Flex>
) : (
""
)}
</Modal>
);
};

View File

@ -1,131 +0,0 @@
import {
ActionIcon,
Box,
Flex,
Modal,
ScrollArea,
Table,
Text,
} from "@mantine/core";
import { IconCheck, IconX } from "@tabler/icons-react";
interface LineHistoryItem {
id: number;
number: number;
stationId: number;
pid: string;
vid: string;
sn: string;
scenario: string;
output?: string;
portPhysical?: [
{
name: string;
tested: boolean;
},
];
timestamp: number;
}
interface ModalPortPhysicalTestProps {
opened: boolean;
onClose: () => void;
selectedHistory: LineHistoryItem | null;
lineNumber?: number;
stationName?: string;
}
const ModalPortPhysicalTest = ({
opened,
onClose,
selectedHistory,
lineNumber,
stationName,
}: ModalPortPhysicalTestProps) => {
return (
<Modal
opened={opened}
onClose={onClose}
title={
<Flex align="center" gap="sm">
<Text fw={700} size="lg">
🔌 Port Physical Test Status
{lineNumber ? ` — Line ${lineNumber}` : ""}
{stationName ? ` (${stationName})` : ""}
</Text>
</Flex>
}
size="lg"
style={{ position: "absolute", left: 0 }}
centered
zIndex={100001}
>
<Flex mb="md" gap={"sm"}>
<Text fw={"bold"}>PID: {selectedHistory?.pid}</Text>
<Text fw={"bold"}>{selectedHistory?.vid}</Text>
<Text fw={"bold"}>SN: {selectedHistory?.sn}</Text>
</Flex>
<Box>
{selectedHistory?.portPhysical &&
selectedHistory.portPhysical.length > 0 ? (
<ScrollArea h="75vh">
<Table striped highlightOnHover withTableBorder>
<Table.Thead>
<Table.Tr>
<Table.Th>Port Name</Table.Th>
<Table.Th>Status</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{selectedHistory?.portPhysical?.map((port, index) => (
<Table.Tr key={index}>
<Table.Td style={{ fontWeight: 600 }}>{port.name}</Table.Td>
<Table.Td>
<Flex align="center" gap="xs">
{port.tested ? (
<>
<ActionIcon
variant="filled"
color="green"
size="sm"
disabled
>
<IconCheck size={16} />
</ActionIcon>
<Text size="sm" c="green" fw={500}>
Tested
</Text>
</>
) : (
<>
<ActionIcon
variant="filled"
color="red"
size="sm"
disabled
>
<IconX size={16} />
</ActionIcon>
<Text size="sm" c="red" fw={500}>
Not Tested
</Text>
</>
)}
</Flex>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</ScrollArea>
) : (
<Flex justify="center" align="center" h="20vh">
<Text c="dimmed">No port data available</Text>
</Flex>
)}
</Box>
</Modal>
);
};
export default ModalPortPhysicalTest;

File diff suppressed because it is too large Load Diff

View File

@ -113,11 +113,11 @@ const TerminalCLI: React.FC<TerminalCLIProps> = ({
// Handle Ctrl+V (Paste)
if (e.ctrlKey && e.key.toLowerCase() === "v") return false;
// Handle Esc
// if (e.key === "Escape") return false;
if (e.key === "Escape") return false;
// Handle Enter
// if (e.key === "ArrowUp") handleArrowUp();
return true; // allow all other keys through
},
}
);
const handleContextMenu = async (e: MouseEvent) => {
@ -147,11 +147,11 @@ const TerminalCLI: React.FC<TerminalCLIProps> = ({
"[CLEAR_TERMINAL_SCROLL_BACK]",
Array(miniSize ? 20 : 70)
.fill("\r\n")
.join(""),
.join("")
)
.replaceAll(
"[CONNECT_TO_SERVER_TFTP_FAIL]",
"\x1b[41;37m CONNECT TO SERVER TFTP FAIL \x1b[0m\n",
"\x1b[41;37m CONNECT TO SERVER TFTP FAIL \x1b[0m\n"
);
terminal.current?.write(valueContent);
terminal.current?.scrollToBottom();
@ -182,11 +182,11 @@ const TerminalCLI: React.FC<TerminalCLIProps> = ({
"[CLEAR_TERMINAL_SCROLL_BACK]",
Array(miniSize ? 20 : 70)
.fill("\r\n")
.join(""),
.join("")
)
.replaceAll(
"[CONNECT_TO_SERVER_TFTP_FAIL]",
"\x1b[41;37m CONNECT TO SERVER TFTP FAIL \x1b[0m\n",
"\x1b[41;37m CONNECT TO SERVER TFTP FAIL \x1b[0m\n"
);
terminal.current?.write(valueContent);
setIsInit(true);
@ -295,7 +295,6 @@ const TerminalCLI: React.FC<TerminalCLIProps> = ({
backgroundColor: "black",
paddingBottom: customStyle.paddingBottom ?? "10px",
maxHeight: customStyle.maxHeight ?? "70vh",
borderRadius: "8px",
}}
>
<div

View File

@ -43,7 +43,7 @@ export const convertTimestampToDate = (timestamp: number) => {
*/
export const useDebounce = <T extends (...args: any[]) => void>(
callback: T,
delay: number,
delay: number
) => {
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
@ -57,7 +57,7 @@ export const useDebounce = <T extends (...args: any[]) => void>(
callback(...args);
}, delay);
},
[callback, delay],
[callback, delay]
);
return debouncedFn;
@ -199,7 +199,7 @@ export const bodyDPELP = [
export function convertFromKilobytesString(
input: string,
decimals = 0,
decimals = 0
): string {
if (!input) return "0 KB";
@ -242,99 +242,3 @@ export function convertFromKilobytesString(
return `${displayValue.toFixed(decimals)} ${units[unitIndex]}`;
}
/**
* Hàm tách rút gọn log Cisco (show inv, show version, show license)
* @param {string} rawLog - Chuỗi log thô ban đu (chứa toàn bộ kết quả terminal)
* @returns {string} Chuỗi log đã đưc rút gọn theo đnh dạng yêu cầu
*/
export function createShortLog(rawLog: string): string {
const shortLog: string[] = [];
// 1. Tách TOÀN BỘ show inventory bằng vòng lặp Regex
// Sử dụng cờ /gi để quét toàn bộ file tìm các khối NAME + PID kế tiếp
const invRegex = /(NAME:\s*"[^"]*",\s*DESCR:\s*"[^"]*"\s*PID:\s*[^,]+,\s*VID:\s*[^,]*,\s*SN:[^\r\n]*)/gi;
const invMatches = rawLog.match(invRegex);
if (invMatches && invMatches.length > 0) {
// Gom tất cả các module tìm thấy và trim() sạch sẽ khoảng trắng dư thừa
const allModules = invMatches.map(module => module.trim()).join('\n\n');
shortLog.push(allModules);
}
// 2. Tách show version (Cập nhật để bắt được cả Cisco/cisco và các dòng ISR/Catalyst khác nhau)
// - Group 1: System image file
// - Nhảy qua đoạn rác (Cryptographic/License info rườm rà)
// - Group 2: Bắt đầu từ chữ Cisco (bất kể hoa thường) + Mã máy + "processor" cho đến hết Config register
const verRegex = /(System image file is[^\r\n]+)[\s\S]*?((?:[Cc]isco)\s+\S+.*?(?:processor|bytes of memory)[\s\S]*?Configuration register is 0x[0-9a-fA-F]+)/i;
const verMatch = rawLog.match(verRegex);
if (verMatch) {
// verMatch[1] là dòng System image
// verMatch[2] là đoạn từ "cisco ISR..." hoặc "Cisco CISCO..." đến hết
shortLog.push(`\n${verMatch[1].trim()}\n${verMatch[2].trim()}`);
}
// 3. Tách show license
// Hỗ trợ cả định dạng cũ (Index 1 Feature) và định dạng Suite mới trên IOS-XE
const licRegex = /(Index\s+1\s+Feature:[\s\S]*?)(?=\r?\n[a-zA-Z0-9\-\_]+[#>]|$)/i;
const licMatch = rawLog.match(licRegex);
if (licMatch && licMatch[1]) {
shortLog.push(`\n${licMatch[1].trim()}`);
}
return shortLog.join('\n');
}
/**
* Hàm in log ra máy in vật trong môi trường Web (React/Vue/Vanilla)
* @param shortLog - Chuỗi log đã đưc rút gọn
*/
export function printLogWeb(shortLog: string): void {
if (!shortLog.trim()) {
console.warn("Không có dữ liệu để in.");
return;
}
// Mở một cửa sổ ẩn để in
const printWindow = window.open("", "_blank", "width=800,height=600");
if (!printWindow) {
console.error("Trình duyệt đã chặn popup. Vui lòng cho phép popup để in.");
return;
}
// Ghi nội dung HTML với font monospace (giống terminal)
printWindow.document.write(`
<!DOCTYPE html>
<html>
<head>
<title>Cisco Short Log</title>
<style>
body {
font-family: 'Courier New', Courier, monospace;
font-size: 12px;
padding: 20px;
color: #000;
}
pre {
white-space: pre-wrap; /* Tự động xuống dòng nếu quá dài */
word-wrap: break-word;
}
@media print {
@page { margin: 1cm; }
body { padding: 0; }
}
</style>
</head>
<body>
<pre>${shortLog}</pre>
</body>
</html>
`);
printWindow.document.close();
printWindow.focus();
// Gọi lệnh in và đóng cửa sổ sau khi in xong (hoặc hủy)
printWindow.print();
printWindow.onafterprint = () => printWindow.close();
}

View File

@ -111,8 +111,6 @@ export type TLine = {
isReady?: boolean;
isSkipPhysical?: boolean;
reasonSkipPhysical?: string;
loadingNote?: boolean;
isPassword?: boolean;
};
export type TUser = {