Compare commits
No commits in common. "main" and "new-form-summary" have entirely different histories.
main
...
new-form-s
|
|
@ -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: <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.
|
||||
|
|
@ -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"` 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" "<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.
|
||||
|
|
@ -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ị: 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.
|
||||
|
|
@ -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=
|
||||
|
|
@ -29,4 +29,3 @@ storage/system_logs
|
|||
storage/ios
|
||||
storage/i
|
||||
storage/license
|
||||
storage/report_sn
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,6 @@
|
|||
"extends": "@adonisjs/tsconfig/tsconfig.app.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./",
|
||||
"outDir": "./build",
|
||||
},
|
||||
}
|
||||
"outDir": "./build"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,3 @@ button:focus {
|
|||
overflow-y: scroll !important;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.modalNoScroll > .mantine-Modal-inner > .mantine-Modal-content {
|
||||
overflow-y: hidden !important;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 và 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 lý 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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -111,8 +111,6 @@ export type TLine = {
|
|||
isReady?: boolean;
|
||||
isSkipPhysical?: boolean;
|
||||
reasonSkipPhysical?: string;
|
||||
loadingNote?: boolean;
|
||||
isPassword?: boolean;
|
||||
};
|
||||
|
||||
export type TUser = {
|
||||
|
|
|
|||
Loading…
Reference in New Issue