Compare commits

..

No commits in common. "main" and "newRemoteUI" have entirely different histories.

29 changed files with 395 additions and 1887 deletions

View File

@ -1,28 +0,0 @@
name: CI
on:
push:
pull_request:
jobs:
build-test:
runs-on: docker
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 22.14.0
cache: npm
- name: Install deps
run: npm ci
- name: Test
run: npm run test -- --passWithNoTests
- name: Build
run: npm run build

View File

@ -1,45 +0,0 @@
name: Deploy Staging (Docker)
on:
push:
branches: [main]
jobs:
deploy:
runs-on: docker
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Generate build env
env:
VITE_API_MESH: ${{ secrets.VITE_API_MESH }}
VITE_ENABLE_MESH_DIRECT_AFTER_PROXY: ${{ secrets.VITE_ENABLE_MESH_DIRECT_AFTER_PROXY }}
run: |
cat > .env.production <<EOF
VITE_API_MESH=$VITE_API_MESH
VITE_ENABLE_MESH_DIRECT_AFTER_PROXY=$VITE_ENABLE_MESH_DIRECT_AFTER_PROXY
EOF
- name: Build Image
run: |
IMAGE="${{ secrets.IMAGE_NAME }}"
if [ -z "$IMAGE" ]; then IMAGE="ttmt-frontend"; fi
IMAGE=$(echo "$IMAGE" | tr '[:upper:]' '[:lower:]')
docker build --build-arg NODE_VERSION=22.14.0 -t $IMAGE:staging-${{ github.sha }} .
- name: Deploy local (docker compose)
run: |
IMAGE="${{ secrets.IMAGE_NAME }}"
if [ -z "$IMAGE" ]; then IMAGE="ttmt-frontend"; fi
IMAGE=$(echo "$IMAGE" | tr '[:upper:]' '[:lower:]')
COMPOSE_FILE=/home/compmanage/docker-compose.yml
OVERRIDE_FILE=/tmp/ttmt-frontend.override.yml
cat > $OVERRIDE_FILE <<EOF
services:
frontend:
image: $IMAGE:staging-${{ github.sha }}
EOF
docker rm -f ttmt-frontend || true
docker compose -f $COMPOSE_FILE -f $OVERRIDE_FILE up -d --no-deps --force-recreate frontend
rm -f $OVERRIDE_FILE

1
.gitignore vendored
View File

@ -8,4 +8,3 @@ count.txt
.nitro
.tanstack
.vscode/
plans/

View File

@ -1,93 +0,0 @@
# Frontend Guide - Microsoft SSO (Entra ID)
## 1) Muc tieu
Tai lieu nay danh cho frontend de tich hop dang nhap Microsoft SSO voi backend TTMT.CompManageWeb.
## 2) Redirect URI da dang ky
Redirect URI cho Microsoft app:
https://comp.soict.io/api/auth/sso/callback
Luu y:
- Redirect URI trong Azure App Registration phai giong 100% (scheme, domain, path).
- Sai ky tu hoac sai slash cuoi co the gay loi redirect_uri mismatch.
## 3) Endpoint frontend can goi
Backend route SSO Microsoft (legacy route):
- GET /api/auth/sso/login?returnUrl={FRONTEND_RETURN_URL}
- POST /api/auth/sso/exchange
Route alias cung ho tro:
- GET /api/sso/login?returnUrl={FRONTEND_RETURN_URL}
- POST /api/sso/exchange
## 4) Login flow cho frontend
1. User bam nut "Login with Microsoft".
2. Frontend redirect browser den:
/api/auth/sso/login?returnUrl={FRONTEND_RETURN_URL}
3. Backend redirect sang Microsoft login page.
4. Sau khi user xac thuc thanh cong, Microsoft goi ve redirect URI:
https://comp.soict.io/api/auth/sso/callback
5. Backend tao one-time code va redirect user ve FRONTEND_RETURN_URL kem query:
?code={ONE_TIME_CODE}
6. Frontend doc code trong URL, goi API exchange de doi code lay JWT noi bo.
7. Frontend luu token va thong tin user, dieu huong vao app.
## 5) API exchange chi tiet
Method: POST
Path: /api/auth/sso/exchange
Content-Type: application/json
Request body:
{
"code": "<one-time-code>"
}
Success response (HTTP 200):
{
"token": "<jwt>",
"name": "Nguyen Van A",
"username": "user@hust.edu.vn",
"access": [1, 2, 3],
"role": {
"roleName": "Pending",
"priority": 99
}
}
## 6) Error handling goi y cho frontend
- 400 BadRequest: code thieu hoac token khong hop le.
- 401 Unauthorized: code het han / da dung / khong hop le.
- 404 NotFound: user khong ton tai sau khi exchange.
One-time code co han su dung ngan (khoang 2 phut) va chi dung 1 lan.
Neu exchange that bai, can yeu cau user login lai.
## 7) Mau frontend pseudo-code
const returnUrl = window.location.origin + "/sso/complete";
window.location.href = `/api/auth/sso/login?returnUrl=${encodeURIComponent(returnUrl)}`;
// tai /sso/complete
const code = new URLSearchParams(window.location.search).get("code");
if (code) {
const res = await fetch("/api/auth/sso/exchange", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ code })
});
if (!res.ok) {
// hien thi thong bao loi va cho user thu lai
} else {
const data = await res.json();
// save data.token, data.username, data.role, data.access
}
}
## 8) Checklist truoc khi UAT
- Redirect URI trong Azure dung: https://comp.soict.io/api/auth/sso/callback
- Frontend returnUrl la URL FE hop le (vi du: https://comp.soict.io/sso/complete)
- Frontend parse duoc query code
- Frontend goi exchange ngay sau khi nhan code
- Frontend xu ly day du HTTP 400/401/404

View File

@ -3,208 +3,91 @@
# server 127.0.0.1:8080;
# server 172.18.10.8:8080;
# }
server {
listen 80;
server_name comp.soict.io;
server_name comp.soict.io;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
# root /usr/share/nginx/html;
# index index.html index.htm;
return 301 https://$host$request_uri;
}
}
server {
server{
listen 443 ssl;
server_name comp.soict.io;
ssl_certificate /etc/letsencrypt/live/comp.soict.io/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/comp.soict.io/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
# MeshCentral proxied flow can set sizable auth cookies.
client_header_buffer_size 16k;
large_client_header_buffers 8 32k;
# Required when proxy_pass uses variables.
# In Docker, 127.0.0.11 is the embedded DNS resolver.
resolver 127.0.0.11 valid=30s ipv6=off;
resolver_timeout 5s;
set $backend_server ttmt-web:8080;
# Internal MeshCentral hop to avoid upstream TLS handshake instability.
set $meshserver meshcentral:8082;
# Public host MeshCentral expects in Host header.
set $meshhost soict-overleaf.tailc51e09.ts.net:8443;
set $backend_server 172.18.10.8:8080;
root /usr/share/nginx/html;
# Default file to serve for directory requests
index index.html index.htm;
# MeshCentral auth entrypoint. If iframe/browser lands on /login due to
# redirect, keep it on MeshCentral instead of frontend routing.
location = /login {
proxy_pass http://$meshserver;
proxy_http_version 1.1;
proxy_set_header Host $meshhost;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_cookie_path / "/; HTTPOnly; Secure; SameSite=None";
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_buffering off;
}
# MeshCentral may redirect to "/" with remote params after login.
# Detect those requests and proxy them to MeshCentral instead of SPA.
location = / {
if ($arg_node != "") {
rewrite ^ /__mesh_root_proxy__ last;
}
if ($arg_viewmode != "") {
rewrite ^ /__mesh_root_proxy__ last;
}
if ($arg_gotonode != "") {
rewrite ^ /__mesh_root_proxy__ last;
}
try_files $uri $uri/ /index.html;
}
location = /__mesh_root_proxy__ {
proxy_pass http://$meshserver;
proxy_http_version 1.1;
proxy_set_header Host $meshhost;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_cookie_path / "/; HTTPOnly; Secure; SameSite=None";
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_buffering off;
}
location / {
# Try to serve the requested file directly ($uri)
# If it's a directory, try serving the index file ($uri/)
# If neither exists, fall back to serving /index.html
try_files $uri $uri/ /index.html;
}
# Optional: Add cache control headers for static assets for better performance
location ~* \.(?:css|js|jpg|jpeg|gif|png|ico|svg|webp)$ {
expires 1y;
add_header Cache-Control "public";
access_log off;
access_log off; # Optional: Don't log accesses for static files
}
location /api/ {
proxy_pass http://$backend_server;
client_max_body_size 900M;
# Cho phép upload file lớn (vd: 200MB)
client_max_body_size 200M;
# Truyền thẳng stream sang backend
proxy_request_buffering off;
# Tăng timeout khi upload
proxy_read_timeout 300s;
proxy_connect_timeout 300s;
proxy_send_timeout 300s;
# CORS headers - Comment vi da xu ly o backend C#
# add_header 'Access-Control-Allow-Origin' '*' always;
# add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
# add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization' always;
if ($request_method = OPTIONS) {
return 204;
}
}
location /api/Sse/events {
proxy_pass http://$backend_server;
proxy_pass http://$backend_server/api/Sse/events;
proxy_http_version 1.1;
# cần thiết cho SSE
proxy_set_header Connection '';
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 1h;
}
# MeshCentral client builds WebSocket URL from current location,
# e.g. wss://comp.soict.io/control.ashx.
location ~ ^/(control|meshrelay|commander|mesh)\.ashx$ {
proxy_pass http://$meshserver;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $meshhost;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_buffering off;
}
location = /api/meshcentral/proxy {
return 301 /api/meshcentral/proxy/;
}
location ~ ^/api/meshcentral/proxy/(.*)$ {
# Forward directly to MeshCentral via Tailscale HTTPS so MeshCentral receives
# requests over TLS and generates correct absolute URLs based on $meshhost.
proxy_pass https://soict-overleaf.tailc51e09.ts.net:8443/$1$is_args$args;
proxy_ssl_verify off;
proxy_ssl_server_name on;
location /mesh-proxy/ {
proxy_pass https://202.191.59.59/;
proxy_cookie_path / "/; HTTPOnly; Secure; SameSite=None";
proxy_set_header Host $meshhost;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
# Keep browser navigation under /api/meshcentral/proxy/*.
proxy_redirect ~^https?://[^/]+(/.*)$ /api/meshcentral/proxy$1;
proxy_redirect ~^(/.*)$ /api/meshcentral/proxy$1;
# Cấu hình WebSocket cho commander.ashx
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_buffering off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
# FE production currently builds mesh proxy path as /meshapi/api/meshcentral/proxy/...
location = /meshapi/api/meshcentral/proxy {
return 301 /meshapi/api/meshcentral/proxy/;
}
location ^~ /meshapi/api/meshcentral/proxy/ {
# Legacy frontend path -> backend MeshCentralProxyController
rewrite ^/meshapi/api/meshcentral/proxy/(.*)$ /$1 break;
proxy_pass http://$backend_server;
proxy_cookie_path / "/; HTTPOnly; Secure; SameSite=None";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port 443;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_buffering off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
}

154
package-lock.json generated
View File

@ -26,7 +26,6 @@
"@tanstack/react-router": "^1.121.2",
"@tanstack/react-router-devtools": "^1.121.2",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.13.26",
"@tanstack/router-plugin": "^1.121.2",
"@tanstack/zod-form-adapter": "^0.42.1",
"axios": "^1.11.0",
@ -1120,9 +1119,9 @@
"license": "MIT"
},
"node_modules/@hono/node-server": {
"version": "1.19.14",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz",
"integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==",
"version": "1.19.10",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.10.tgz",
"integrity": "sha512-hZ7nOssGqRgyV3FVVQdfi+U4q02uB23bpnYpdvNXkYTRRyWx84b7yf1ans+dnJ/7h41sGL3CeQTfO+ZGxuO+Iw==",
"engines": {
"node": ">=18.14.1"
},
@ -3621,22 +3620,6 @@
"react-dom": ">=16.8"
}
},
"node_modules/@tanstack/react-virtual": {
"version": "3.13.26",
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.26.tgz",
"integrity": "sha512-DosdgjOxCLahkn0o+ilmZYwEjo1glfMGuRT/j3PQ18yr5XqA8N/BCaL9IJ3B5TRl+nnzyK2IOFgAILwzN3a9xQ==",
"dependencies": {
"@tanstack/virtual-core": "3.16.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tanstack/router-core": {
"version": "1.129.8",
"resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.129.8.tgz",
@ -3801,15 +3784,6 @@
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/virtual-core": {
"version": "3.16.0",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.16.0.tgz",
"integrity": "sha512-Er2N7q3WOiH6y2JLxsxNX+u2/sLqSsL0bxFgDjuiPiA7vKhZRm+IzcS17vRee3GNXr64UsesA5CAp9yTiIYw9A==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/virtual-file-routes": {
"version": "1.129.7",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-file-routes/-/virtual-file-routes-1.129.7.tgz",
@ -4434,37 +4408,13 @@
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
},
"node_modules/axios": {
"version": "1.16.1",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz",
"integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==",
"version": "1.13.6",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz",
"integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==",
"dependencies": {
"follow-redirects": "^1.16.0",
"follow-redirects": "^1.15.11",
"form-data": "^4.0.5",
"https-proxy-agent": "^5.0.1",
"proxy-from-env": "^2.1.0"
}
},
"node_modules/axios/node_modules/agent-base": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
"dependencies": {
"debug": "4"
},
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/axios/node_modules/https-proxy-agent": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
"dependencies": {
"agent-base": "6",
"debug": "4"
},
"engines": {
"node": ">= 6"
"proxy-from-env": "^1.1.0"
}
},
"node_modules/babel-dead-code-elimination": {
@ -5284,9 +5234,9 @@
"license": "MIT"
},
"node_modules/devalue": {
"version": "5.8.1",
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz",
"integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw=="
"version": "5.6.4",
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.4.tgz",
"integrity": "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA=="
},
"node_modules/diff": {
"version": "8.0.3",
@ -5611,11 +5561,11 @@
}
},
"node_modules/express-rate-limit": {
"version": "8.5.2",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz",
"integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==",
"version": "8.3.1",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.1.tgz",
"integrity": "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==",
"dependencies": {
"ip-address": "^10.2.0"
"ip-address": "10.1.0"
},
"engines": {
"node": ">= 16"
@ -5648,9 +5598,9 @@
}
},
"node_modules/fast-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz",
"integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==",
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
"integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
"funding": [
{
"type": "github",
@ -5724,9 +5674,9 @@
}
},
"node_modules/follow-redirects": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"funding": [
{
"type": "individual",
@ -6025,9 +5975,9 @@
"integrity": "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ=="
},
"node_modules/hono": {
"version": "4.12.23",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.23.tgz",
"integrity": "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==",
"version": "4.12.9",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.9.tgz",
"integrity": "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA==",
"engines": {
"node": ">=16.9.0"
}
@ -6170,9 +6120,9 @@
}
},
"node_modules/ip-address": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
"engines": {
"node": ">= 12"
}
@ -6924,15 +6874,16 @@
}
},
"node_modules/nanoid": {
"version": "3.3.12",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
@ -7246,9 +7197,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.15",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
"funding": [
{
"type": "opencollective",
@ -7263,8 +7214,9 @@
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.12",
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
@ -7347,12 +7299,9 @@
}
},
"node_modules/proxy-from-env": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
"engines": {
"node": ">=10"
}
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="
},
"node_modules/psl": {
"version": "1.15.0",
@ -7375,9 +7324,9 @@
}
},
"node_modules/qs": {
"version": "6.15.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
"integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz",
"integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==",
"dependencies": {
"side-channel": "^1.1.0"
},
@ -8857,9 +8806,9 @@
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
},
"node_modules/uuid": {
"version": "13.0.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.2.tgz",
"integrity": "sha512-vzi9uRZ926x4XV73S/4qQaTwPXM2JBj6/6lI/byHH1jOpCzb0zDbfytgA9LcN/hzb2l7WQSQnxITOVx5un/wGw==",
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
"integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
@ -8898,9 +8847,9 @@
}
},
"node_modules/vite": {
"version": "6.4.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz",
"integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
"version": "6.4.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",
@ -9252,10 +9201,11 @@
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="
},
"node_modules/ws": {
"version": "8.21.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
"version": "8.18.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},

View File

@ -30,7 +30,6 @@
"@tanstack/react-router": "^1.121.2",
"@tanstack/react-router-devtools": "^1.121.2",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.13.26",
"@tanstack/router-plugin": "^1.121.2",
"@tanstack/zod-form-adapter": "^0.42.1",
"axios": "^1.11.0",

View File

@ -1,226 +0,0 @@
import { useMemo, useState } from "react";
import { AlertTriangle, CheckCircle2, Power, PowerOff, RotateCcw, ShieldBan, XCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { useMachineNumber } from "@/hooks/useMachineNumber";
import { useExecuteSensitiveCommand, useGetSensitiveCommands } from "@/hooks/queries/useCommandQueries";
import { CommandType } from "@/types/command-registry";
import { toast } from "sonner";
interface DeviceActionBarProps {
roomName: string;
selectedDevices: any[];
onClearSelection: () => void;
}
const ACTIONS = [
{
type: CommandType.RESTART,
label: "Khởi động lại",
icon: Power,
variant: "outline" as const,
},
{
type: CommandType.SHUTDOWN,
label: "Tắt máy",
icon: PowerOff,
variant: "destructive" as const,
},
{
type: CommandType.TASKKILL,
label: "Kết thúc tác vụ",
icon: XCircle,
variant: "outline" as const,
},
{
type: CommandType.BLOCK,
label: "Chặn",
icon: ShieldBan,
variant: "outline" as const,
},
{
type: CommandType.RESET,
label: "Reset",
icon: RotateCcw,
variant: "destructive" as const,
},
];
const DANGER_TYPES = new Set<CommandType>([CommandType.SHUTDOWN, CommandType.RESET]);
export function DeviceActionBar({
roomName,
selectedDevices,
onClearSelection,
}: DeviceActionBarProps) {
const [confirmOpen, setConfirmOpen] = useState(false);
const [activeType, setActiveType] = useState<CommandType | null>(null);
const [password, setPassword] = useState("");
const [isExecuting, setIsExecuting] = useState(false);
const getMachineNumber = useMachineNumber();
const { data: sensitiveCommands = [] } = useGetSensitiveCommands();
const executeSensitiveMutation = useExecuteSensitiveCommand();
const commandsByType = useMemo(() => {
return (Object.values(CommandType) as Array<number | string>)
.filter((value) => typeof value === "number")
.reduce((acc: Record<number, any[]>, type) => {
acc[type as number] = (sensitiveCommands || []).filter(
(command: any) => Number(command.command) === Number(type)
);
return acc;
}, {} as Record<number, any[]>);
}, [sensitiveCommands]);
const selectedCount = selectedDevices.length;
const activeCommand = activeType ? commandsByType[activeType]?.[0] : null;
const buildDeviceLabel = (device: any) => {
const number = getMachineNumber(device?.id || "");
const ipAddress = device?.networkInfos?.[0]?.ipAddress;
if (number > 0) {
return `#${number}${ipAddress ? ` (${ipAddress})` : ""}`;
}
return `${device?.id ?? ""}${ipAddress ? ` (${ipAddress})` : ""}`;
};
const openConfirm = (type: CommandType) => {
if (!commandsByType[type]?.length) {
toast.error("Chưa có lệnh phù hợp cho thao tác này.");
return;
}
setActiveType(type);
setConfirmOpen(true);
};
const handleClose = () => {
if (isExecuting) return;
setConfirmOpen(false);
setActiveType(null);
setPassword("");
};
const handleConfirm = async () => {
if (!activeCommand || !activeType) return;
if (!password.trim()) {
toast.error("Vui lòng nhập mật khẩu xác nhận.");
return;
}
setIsExecuting(true);
try {
await executeSensitiveMutation.mutateAsync({
roomName,
command: activeCommand.commandName,
password,
});
toast.success(`Đã gửi lệnh: ${activeCommand.commandName}`);
handleClose();
onClearSelection();
} catch (error) {
console.error("Execute command error:", error);
toast.error("Lỗi khi gửi lệnh!");
} finally {
setIsExecuting(false);
}
};
if (selectedCount === 0) return null;
return (
<>
<div className="sticky bottom-4 z-30">
<div className="flex flex-col gap-3 rounded-xl border bg-background/95 px-4 py-3 shadow-lg backdrop-blur sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-2 text-sm font-semibold">
<CheckCircle2 className="h-4 w-4 text-emerald-600" />
Đã chọn {selectedCount} thiết bị
</div>
<div className="flex flex-wrap items-center gap-2">
{ACTIONS.map((action) => {
const Icon = action.icon;
const isDisabled = !commandsByType[action.type]?.length;
return (
<Button
key={action.type}
variant={action.variant}
size="sm"
disabled={isDisabled}
onClick={() => openConfirm(action.type)}
>
<Icon className="h-4 w-4" />
{action.label}
</Button>
);
})}
<Button variant="ghost" size="sm" onClick={onClearSelection}>
Bỏ chọn
</Button>
</div>
</div>
</div>
<Dialog open={confirmOpen} onOpenChange={handleClose}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-orange-600" />
Xác nhận thực thi lệnh
</DialogTitle>
<DialogDescription className="text-left space-y-3">
<p>
Bạn chắc chắn muốn thực thi lệnh{" "}
<strong>{activeCommand?.commandName ?? ""}</strong>?
</p>
{DANGER_TYPES.has(activeType ?? CommandType.RESTART) && (
<p className="text-sm text-destructive">
Hành đng này không thể hoàn tác.
</p>
)}
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="text-sm font-medium">Thiết bị đưc chọn</div>
<ScrollArea className="max-h-40 rounded-lg border p-2">
<div className="space-y-1 text-sm">
{selectedDevices.map((device) => (
<div key={device.id} className="text-muted-foreground">
{buildDeviceLabel(device)}
</div>
))}
</div>
</ScrollArea>
<div>
<label className="mb-1 block text-sm font-medium">Mật khẩu</label>
<Input
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder="Nhập mật khẩu để xác nhận"
/>
</div>
</div>
<DialogFooter className="flex gap-2 sm:gap-3">
<Button variant="outline" onClick={handleClose} disabled={isExecuting}>
Hủy
</Button>
<Button
variant={DANGER_TYPES.has(activeType ?? CommandType.RESTART) ? "destructive" : "default"}
onClick={handleConfirm}
disabled={isExecuting}
>
{isExecuting ? "Đang gửi..." : "Xác nhận"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@ -205,7 +205,7 @@ export function CommandActionButtons({ roomName, selectedDevices = [] }: Command
return (
<>
<div className="flex gap-2 flex-wrap items-center">
<div className="flex gap-2 flex-nowrap overflow-x-auto items-center whitespace-nowrap">
{Object.values(CommandType)
.filter((value) => typeof value === "number")
.map((commandType) => renderCommandButton(commandType as CommandType))}

View File

@ -1,29 +1,25 @@
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Badge } from "@/components/ui/badge";
import { Monitor, Wifi, WifiOff, Loader2, Maximize2, X } from "lucide-react";
import { useState, type MouseEvent } from "react";
import { useState } from "react";
import { cn } from "@/lib/utils";
import { FolderStatusPopover } from "../folder-status-popover";
import { useGetClientFolderStatusForDevice } from "@/hooks/queries";
import type { ClientFolderStatus } from "@/types/folder";
import { Button } from "@/components/ui/button";
import { getRemoteDesktopUrl } from "@/services/remote-control.service";
import { buildMeshProxyUrl } from "@/config/api";
import { BASE_URL } from "@/config/api";
import { toast } from "sonner";
export function ComputerCard({
device,
position,
folderStatus,
isCheckingFolder,
isSelected,
onSelect,
}: {
device: any | undefined;
position: number;
folderStatus?: ClientFolderStatus;
isCheckingFolder?: boolean;
isSelected?: boolean;
onSelect?: (event: MouseEvent<HTMLElement>) => void;
}) {
const [isConnecting, setIsConnecting] = useState(false);
const [showRemote, setShowRemote] = useState(false);
@ -31,16 +27,12 @@ export function ComputerCard({
if (!device) {
return (
<div className="flex flex-col items-stretch rounded-lg border border-dashed border-muted-foreground/20 overflow-hidden w-[88px]">
<div className="flex items-center justify-between px-1.5 py-1 bg-muted/30">
<span className="text-[11px] font-bold text-muted-foreground/50 leading-none">
{position}
</span>
</div>
<div className="flex flex-col items-center justify-center py-2 gap-0.5">
<Monitor className="h-5 w-5 text-muted-foreground/20" />
<span className="text-[10px] text-muted-foreground/40">Trống</span>
<div className="relative flex flex-col items-center justify-center w-24 h-24 rounded-lg border-2 border-dashed border-muted-foreground/30 bg-muted/20">
<div className="absolute -top-2 -left-2 w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold bg-muted text-muted-foreground">
{position}
</div>
<Monitor className="h-8 w-8 mb-1 text-muted-foreground/40" />
<span className="text-xs text-muted-foreground">Trống</span>
</div>
);
}
@ -60,7 +52,11 @@ export function ComputerCard({
const response = await getRemoteDesktopUrl(device.id);
const originalUrl = new URL(response.url);
const pathAndQuery = originalUrl.pathname + originalUrl.search;
const proxyUrlFull = buildMeshProxyUrl(pathAndQuery);
const cleanPath = pathAndQuery.startsWith("/")
? pathAndQuery.substring(1)
: pathAndQuery;
const baseWithoutApi = BASE_URL.replace("/api", "");
const proxyUrlFull = `${baseWithoutApi}/api/meshcentral/proxy/${cleanPath}`;
setProxyUrl(proxyUrlFull);
setShowRemote(true);
@ -224,68 +220,53 @@ export function ComputerCard({
<Popover>
<PopoverTrigger asChild>
<div
onClick={onSelect}
className={cn(
"flex flex-col items-stretch w-[88px] rounded-lg border-2 overflow-hidden transition-all hover:shadow-md hover:-translate-y-0.5 cursor-pointer select-none",
"relative flex flex-col items-center justify-center w-24 h-24 rounded-lg border-2 transition-all hover:scale-105 cursor-pointer",
isOffline
? "border-red-400 bg-white hover:border-red-500"
: "border-emerald-400 bg-white hover:border-emerald-500",
isSelected && "ring-2 ring-primary ring-offset-1 ring-offset-background"
? "bg-red-50 border-red-300 hover:border-red-400 hover:shadow-lg"
: "bg-green-50 border-green-300 hover:border-green-400 hover:shadow-lg"
)}
>
{/* Top bar: position + folder status */}
<div
className={cn(
"flex items-center justify-between px-1.5 py-1",
isOffline ? "bg-red-500" : "bg-emerald-500"
"absolute -top-2 -left-2 w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold",
isOffline ? "bg-red-500 text-white" : "bg-green-500 text-white"
)}
>
<span
className="text-[11px] font-bold text-white leading-none"
style={{ textShadow: "0 1px 2px rgba(0,0,0,0.5)" }}
>
{position}
</span>
{!isOffline && (
<div
onClick={(e) => e.stopPropagation()}
className="[&_button]:p-0 [&_button]:rounded [&_button]:hover:bg-emerald-400 [&_svg]:h-3.5 [&_svg]:w-3.5 [&_svg]:text-white"
>
<FolderStatusPopover
deviceId={device.networkInfos?.[0]?.macAddress || device.id}
status={folderStatus}
isLoading={isCheckingFolder}
/>
</div>
)}
{position}
</div>
{/* Body */}
<div className="flex flex-col items-center justify-center gap-0.5 py-2 px-1">
<Monitor
className={cn(
"h-5 w-5",
isOffline ? "text-red-300" : "text-emerald-400"
{/* Folder Status Icon */}
{device && !isOffline && (
<div className="absolute -top-2 -right-2">
<FolderStatusPopover
deviceId={device.networkInfos?.[0]?.macAddress || device.id}
status={folderStatus}
isLoading={isCheckingFolder}
/>
</div>
)}
<Monitor className={cn("h-6 w-6 mb-1", isOffline ? "text-red-600" : "text-green-600")} />
{firstNetworkInfo?.ipAddress && (
<div className="text-[10px] font-mono text-center mb-1 px-1 truncate w-full">
{firstNetworkInfo.ipAddress}
{agentVersion && (
<div className="text-[10px] font-mono text-center mb-1 px-1 truncate w-full">
v{agentVersion}
</div>
)}
/>
{firstNetworkInfo?.ipAddress && (
<div className="text-[9px] font-mono text-center leading-tight w-full truncate text-muted-foreground px-0.5">
{firstNetworkInfo.ipAddress}
</div>
)}
{agentVersion && (
<div className="text-[9px] font-mono text-center text-muted-foreground/60 leading-tight">
v{agentVersion}
</div>
)}
<div
</div>
)}
<div className="flex items-center gap-1">
<span
className={cn(
"text-[10px] font-semibold leading-none mt-0.5",
isOffline ? "text-red-500" : "text-emerald-600"
"text-xs font-medium",
isOffline ? "text-red-700" : "text-green-700"
)}
>
{isOffline ? "Off" : "On"}
</div>
</span>
</div>
</div>
</PopoverTrigger>

View File

@ -4,7 +4,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import type { LoginResquest } from "@/types/auth";
import { useMutation } from "@tanstack/react-query";
import { buildGoogleOAuthLoginUrl, buildMicrosoftSsoLoginUrl, login } from "@/services/auth.service";
import { buildGoogleOAuthLoginUrl, login } from "@/services/auth.service";
import { useState } from "react";
import { useNavigate, useRouter } from "@tanstack/react-router";
import { Route } from "@/routes/(auth)/login";
@ -46,22 +46,12 @@ export function LoginForm({ className }: React.ComponentProps<"form">) {
const handleGoogleLogin = () => {
const returnUrl = new URL("/oauth/callback", window.location.origin);
returnUrl.searchParams.set("provider", "google");
if (search.redirect) {
returnUrl.searchParams.set("redirect", search.redirect);
}
window.location.assign(buildGoogleOAuthLoginUrl(returnUrl.toString()));
};
const handleMicrosoftLogin = () => {
const returnUrl = new URL("/oauth/callback", window.location.origin);
returnUrl.searchParams.set("provider", "azuread");
if (search.redirect) {
returnUrl.searchParams.set("redirect", search.redirect);
}
window.location.assign(buildMicrosoftSsoLoginUrl(returnUrl.toString()));
};
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setErrorMessage(null);
@ -143,15 +133,6 @@ export function LoginForm({ className }: React.ComponentProps<"form">) {
</svg>
Đăng nhập với Google
</Button>
<Button type="button" variant="outline" className="w-full gap-2" onClick={handleMicrosoftLogin}>
<svg viewBox="0 0 24 24" aria-hidden="true" className="h-4 w-4">
<rect x="2" y="2" width="9" height="9" fill="#F35325" />
<rect x="13" y="2" width="9" height="9" fill="#81BC06" />
<rect x="2" y="13" width="9" height="9" fill="#05A6F0" />
<rect x="13" y="13" width="9" height="9" fill="#FFBA08" />
</svg>
Đăng nhập với Microsoft
</Button>
</div>
</form>
</CardContent>

View File

@ -18,7 +18,7 @@ export function UploadVersionForm({ onSubmit, closeDialog }: UploadVersionFormPr
const [isDone, setIsDone] = useState(false);
// Match server allowed extensions
const ALLOWED_EXTENSIONS = [".exe", ".apk", ".conf", ".json", ".xml", ".setting", ".lnk", ".url", ".seb", ".ps1", ".zip"];
const ALLOWED_EXTENSIONS = [".exe", ".apk", ".conf", ".json", ".xml", ".setting", ".lnk", ".url", ".seb", ".ps1"];
const isFileValid = (file: File) => {
const fileName = file.name.toLowerCase();
return ALLOWED_EXTENSIONS.some((ext) => fileName.endsWith(ext));

View File

@ -1,122 +0,0 @@
import { useMemo, type MouseEvent } from "react";
import { cn } from "@/lib/utils";
import { useMachineNumber } from "@/hooks/useMachineNumber";
interface DeviceGridCompactProps {
devices: any[];
selectedIds?: string[];
onSelectDevice?: (
deviceId: string,
index: number,
event: MouseEvent<HTMLElement>
) => void;
}
export function DeviceGridCompact({
devices,
selectedIds = [],
onSelectDevice,
}: DeviceGridCompactProps) {
const getMachineNumber = useMachineNumber();
const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds]);
const items = useMemo(() => {
return [...devices]
.map((device, index) => ({
device,
index,
number: getMachineNumber(device?.id || ""),
}))
.sort((a, b) => {
const aNumber = a.number > 0 ? a.number : Number.MAX_SAFE_INTEGER;
const bNumber = b.number > 0 ? b.number : Number.MAX_SAFE_INTEGER;
if (aNumber !== bNumber) return aNumber - bNumber;
return a.index - b.index;
});
}, [devices, getMachineNumber]);
return (
<div className="grid grid-cols-[repeat(auto-fill,minmax(76px,1fr))] gap-2">
{items.map((item, index) => {
const device = item.device;
const position = item.number > 0 ? item.number : item.index + 1;
const ipAddress = device?.networkInfos?.[0]?.ipAddress;
const version = device?.version;
const titleParts = [`#${position}`];
if (ipAddress) titleParts.push(`IP: ${ipAddress}`);
if (version) titleParts.push(`v${version}`);
const isOffline = device?.isOffline;
const isSelected = selectedSet.has(device?.id);
// last 2 octets of IP for compact display
const shortIp = ipAddress
? ipAddress.split(".").slice(-2).join(".")
: null;
return (
<button
key={device?.id || `device-${item.index}`}
type="button"
title={titleParts.join(" | ")}
onClick={(event) => {
if (!device?.id) return;
onSelectDevice?.(device.id, index, event);
}}
className={cn(
"flex flex-col items-stretch rounded-lg border-2 overflow-hidden transition-all hover:shadow-md hover:-translate-y-0.5 cursor-pointer",
isOffline
? "border-red-400 bg-white hover:border-red-500"
: "border-emerald-400 bg-white hover:border-emerald-500",
isSelected &&
"ring-2 ring-primary ring-offset-1 ring-offset-background"
)}
>
{/* Top color bar with position number */}
<div
className={cn(
"flex items-center justify-between px-1.5 py-1",
isOffline ? "bg-red-500" : "bg-emerald-500"
)}
>
<span
className="text-[11px] font-bold text-white leading-none"
style={{ textShadow: "0 1px 2px rgba(0,0,0,0.5)" }}
>
{position}
</span>
<span
className={cn(
"h-1.5 w-1.5 rounded-full border border-white/40",
isOffline ? "bg-red-200" : "bg-emerald-200"
)}
/>
</div>
{/* Body */}
<div className="flex flex-col items-center justify-center gap-0.5 py-1.5 px-1">
{shortIp ? (
<span
className="font-mono text-muted-foreground truncate w-full text-center leading-tight"
style={{ fontSize: "9px" }}
>
{shortIp}
</span>
) : (
<span className="text-[9px] text-muted-foreground/50"></span>
)}
<span
className={cn(
"text-[10px] font-semibold leading-none",
isOffline ? "text-red-500" : "text-emerald-600"
)}
>
{isOffline ? "Off" : "On"}
</span>
</div>
</button>
);
})}
</div>
);
}

View File

@ -1,4 +1,3 @@
import { useMemo, type MouseEvent } from "react";
import { Monitor, DoorOpen } from "lucide-react";
import { ComputerCard } from "../cards/computer-card";
import { useMachineNumber } from "../../hooks/useMachineNumber";
@ -8,146 +7,68 @@ export function DeviceGrid({
devices,
folderStatuses,
isCheckingFolder,
totalSeats,
selectedIds = [],
onSelectDevice,
}: {
devices: any[];
folderStatuses?: Map<string, ClientFolderStatus>;
isCheckingFolder?: boolean;
totalSeats?: number;
selectedIds?: string[];
onSelectDevice?: (
deviceId: string,
index: number,
event: MouseEvent<HTMLElement>
) => void;
}) {
const getMachineNumber = useMachineNumber();
const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds]);
const parsedDevices = devices
.map((device, index) => ({
device,
index,
number: getMachineNumber(device.id || ""),
}))
.sort((a, b) => {
const aNumber = a.number > 0 ? a.number : Number.MAX_SAFE_INTEGER;
const bNumber = b.number > 0 ? b.number : Number.MAX_SAFE_INTEGER;
const deviceMap = new Map<number, any>();
if (aNumber !== bNumber) {
return aNumber - bNumber;
}
devices.forEach((device) => {
const number = getMachineNumber(device.id || "");
if (number > 0 && number <= 40) deviceMap.set(number, device);
});
return a.index - b.index;
});
const orderedDevices = parsedDevices.map((item, orderIndex) => ({
...item,
orderIndex,
}));
const seatCount =
typeof totalSeats === "number" && totalSeats > 0 ? totalSeats : orderedDevices.length;
const rightCapacity = Math.ceil(seatCount / 2);
const inRangeCount = orderedDevices.filter(
(item) => item.number > 0 && item.number <= seatCount
).length;
const useThresholdSplit =
seatCount > 0 && inRangeCount >= Math.ceil(orderedDevices.length * 0.6);
let rightDevices = orderedDevices;
let leftDevices: typeof orderedDevices = [];
if (useThresholdSplit) {
rightDevices = orderedDevices.filter(
(item) => item.number > 0 && item.number <= rightCapacity
);
leftDevices = orderedDevices.filter((item) => item.number > rightCapacity);
const unassigned = orderedDevices.filter(
(item) => item.number <= 0 || item.number > seatCount
);
leftDevices = [...leftDevices, ...unassigned];
} else {
const splitIndex = Math.ceil(orderedDevices.length / 2);
rightDevices = orderedDevices.slice(0, splitIndex);
leftDevices = orderedDevices.slice(splitIndex);
}
const renderDevice = (item: (typeof orderedDevices)[number]) => {
const device = item.device;
const position = item.number > 0 ? item.number : item.index + 1;
const macAddress = device?.networkInfos?.[0]?.macAddress || device?.id;
const folderStatus = folderStatuses?.get(macAddress);
const isSelected = device?.id ? selectedSet.has(device.id) : false;
return (
<ComputerCard
key={device?.id || `device-${item.index}`}
device={device}
position={position}
folderStatus={folderStatus}
isCheckingFolder={isCheckingFolder}
isSelected={isSelected}
onSelect={(event) => {
if (!device?.id) return;
onSelectDevice?.(device.id, item.orderIndex, event);
}}
/>
);
};
const columnsPerSide = 4;
const chunkRows = <T,>(items: T[], size: number) => {
const rows: T[][] = [];
for (let i = 0; i < items.length; i += size) {
rows.push(items.slice(i, i + size));
}
return rows;
};
const leftRows = chunkRows(leftDevices, columnsPerSide);
const rightRows = chunkRows(rightDevices, columnsPerSide);
const totalRows = Math.max(leftRows.length, rightRows.length);
const renderPlaceholder = (key: string) => (
<div key={key} className="w-24 h-24 shrink-0" aria-hidden="true" />
);
const totalRows = 5;
const renderRow = (rowIndex: number) => {
const leftRow = leftRows[rowIndex] ?? [];
const rightRow = rightRows[rowIndex] ?? [];
const leftFill = Math.max(0, columnsPerSide - leftRow.length);
const rightFill = Math.max(0, columnsPerSide - rightRow.length);
// Cả 2 panel đều mirror: số nhỏ nhất sát divider, tăng ra ngoài
// Right: [8,7,6,5,4,3,2,1 | divider] Left: [divider | 9,10,11,12,13,14,15,16]
// Nhìn từ bàn GV (phải) sang trái: 1,2,3,4,... liên tục
const rightRowReversed = [...rightRow].reverse();
const leftRowReversed = [...leftRow].reverse();
// Đảo ngược: 21-40 sang trái, 1-20 sang phải
const leftStart = 21 + (totalRows - 1 - rowIndex) * 4;
const rightStart = (totalRows - 1 - rowIndex) * 4 + 1;
return (
<div key={`row-${rowIndex}`} className="flex items-center justify-center gap-3">
{/* Left panel: số lớn sát divider, giảm ra ngoài trái */}
<div className="flex items-center gap-3">
{Array.from({ length: leftFill }).map((_, i) =>
renderPlaceholder(`left-pad-${rowIndex}-${i}`)
)}
{leftRowReversed.map(renderDevice)}
<div key={rowIndex} className="flex items-center justify-center gap-3">
{/* Bên trái (2140) */}
{Array.from({ length: 4 }).map((_, i) => {
const pos = leftStart + (3 - i);
const device = deviceMap.get(pos);
const macAddress = device?.networkInfos?.[0]?.macAddress || device?.id;
const folderStatus = folderStatuses?.get(macAddress);
return (
<ComputerCard
key={pos}
device={device}
position={pos}
folderStatus={folderStatus}
isCheckingFolder={isCheckingFolder}
/>
);
})}
{/* Đường chia giữa */}
<div className="w-32 flex items-center justify-center">
<div className="h-px w-full bg-border border-t-2 border-dashed" />
</div>
<div className="w-10 flex items-center justify-center">
<div className="h-10 w-px bg-border border-l-2 border-dashed" />
</div>
{/* Bên phải (120) */}
{Array.from({ length: 4 }).map((_, i) => {
const pos = rightStart + (3 - i);
const device = deviceMap.get(pos);
const macAddress = device?.networkInfos?.[0]?.macAddress || device?.id;
const folderStatus = folderStatuses?.get(macAddress);
{/* Right panel: số 1 sát divider, tăng ra ngoài phải */}
<div className="flex items-center gap-3">
{rightRowReversed.map(renderDevice)}
{Array.from({ length: rightFill }).map((_, i) =>
renderPlaceholder(`right-pad-${rowIndex}-${i}`)
)}
</div>
return (
<ComputerCard
key={pos}
device={device}
position={pos}
folderStatus={folderStatus}
isCheckingFolder={isCheckingFolder}
/>
);
})}
</div>
);
};
@ -155,7 +76,7 @@ export function DeviceGrid({
return (
<div className="px-0.5 py-8 space-y-6">
<div className="space-y-4">
{Array.from({ length: totalRows }).map((_, i) => renderRow(totalRows - 1 - i))}
{Array.from({ length: totalRows }).map((_, i) => renderRow(i))}
</div>
<div className="flex items-center justify-between px-4 pb-6 border-b-2 border-dashed">
<div className="flex items-center gap-3 px-6 py-4 bg-muted rounded-lg border-2 border-border">

View File

@ -1,89 +0,0 @@
import { useMemo } from "react";
import type { Room } from "@/types/room";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
interface RoomListPanelProps {
rooms: Room[];
activeRoomName?: string;
onSelectRoom: (roomName: string) => void;
}
const formatDeviceCount = (value: number) => {
if (!Number.isFinite(value)) return "0";
if (value < 1000) return String(value);
const k = value / 1000;
const needsDecimal = value < 10000 && value % 1000 !== 0;
const formatted = k.toFixed(needsDecimal ? 1 : 0).replace(/\.0$/, "");
return `${formatted}k`;
};
export function RoomListPanel({
rooms,
activeRoomName,
onSelectRoom,
}: RoomListPanelProps) {
const sortedRooms = useMemo(() => {
return [...rooms].sort((a, b) => {
const nameA = String(a?.name ?? "");
const nameB = String(b?.name ?? "");
return nameA.localeCompare(nameB, "vi", {
numeric: true,
sensitivity: "base",
});
});
}, [rooms]);
return (
<div className="w-[200px] shrink-0 overflow-hidden rounded-xl border bg-background shadow-sm">
<div className="flex items-center justify-between border-b bg-muted/30 px-3 py-2">
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Danh sách phòng
</span>
<Badge variant="secondary" className="text-[10px]">
{rooms.length}
</Badge>
</div>
<ScrollArea className="h-[calc(100vh-240px)] p-2">
<div className="space-y-1.5">
{sortedRooms.length === 0 && (
<div className="px-2 py-6 text-center text-xs text-muted-foreground">
Chưa phòng
</div>
)}
{sortedRooms.map((room) => {
const isActive = room.name === activeRoomName;
const hasOffline = room.numberOfOfflineDevices > 0;
return (
<button
key={room.name}
type="button"
onClick={() => onSelectRoom(room.name)}
className={cn(
"w-full flex items-center gap-2 rounded-lg border px-2.5 py-2 text-left text-sm transition-colors",
isActive
? "border-border/60 bg-background text-foreground shadow-sm"
: "border-transparent text-muted-foreground hover:bg-muted/40"
)}
>
<span
className={cn(
"h-2 w-2 rounded-full",
hasOffline ? "bg-red-500" : "bg-green-500"
)}
/>
<span className="flex-1 truncate font-medium text-foreground">
{room.name}
</span>
<Badge variant="secondary" className="text-[10px]">
{formatDeviceCount(room.numberOfDevices)}
</Badge>
</button>
);
})}
</div>
</ScrollArea>
</div>
);
}

View File

@ -5,8 +5,6 @@ import {
useReactTable,
type ColumnDef,
} from "@tanstack/react-table";
import { useMemo, useRef, type MouseEvent } from "react";
import { useVirtualizer, type VirtualItem } from "@tanstack/react-virtual";
import {
Table,
TableBody,
@ -18,7 +16,6 @@ import {
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Wifi, WifiOff, Clock, MapPin, Monitor, ChevronLeft, ChevronRight } from "lucide-react";
import { useMachineNumber } from "@/hooks/useMachineNumber";
import { FolderStatusPopover } from "../folder-status-popover";
@ -26,13 +23,6 @@ import { FolderStatusPopover } from "../folder-status-popover";
interface DeviceTableProps {
devices: any[];
isCheckingFolder?: boolean;
selectedIds?: string[];
onToggleDevice?: (
deviceId: string,
index: number,
event: MouseEvent<HTMLElement>
) => void;
onToggleAll?: (checked: boolean) => void;
}
/**
@ -41,46 +31,10 @@ interface DeviceTableProps {
export function DeviceTable({
devices,
isCheckingFolder,
selectedIds = [],
onToggleDevice,
onToggleAll,
}: DeviceTableProps) {
const getMachineNumber = useMachineNumber();
const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds]);
const allSelected = devices.length > 0 && devices.every((d) => selectedSet.has(d.id));
const someSelected = devices.some((d) => selectedSet.has(d.id));
const selectionEnabled = Boolean(onToggleDevice || onToggleAll);
const selectionColumn: ColumnDef<any> = {
id: "select",
header: () => (
<div className="flex items-center justify-center">
<Checkbox
checked={allSelected ? true : someSelected ? "indeterminate" : false}
onCheckedChange={(value) => onToggleAll?.(value === true)}
aria-label="Chọn tất cả"
/>
</div>
),
cell: ({ row }) => {
const device = row.original;
return (
<div className="flex items-center justify-center">
<Checkbox
checked={selectedSet.has(device.id)}
onClick={(event) => {
event.stopPropagation();
onToggleDevice?.(device.id, row.index, event);
}}
aria-label="Chọn thiết bị"
/>
</div>
);
},
};
const columns: ColumnDef<any>[] = [
...(selectionEnabled ? [selectionColumn] : []),
{
header: "STT",
cell: ({ row }) => {
@ -154,11 +108,11 @@ export function DeviceTable({
key={idx}
className="flex items-center gap-2 text-sm font-mono px-2 py-1 rounded bg-muted/30"
>
<span className="text-primary">*</span>
<span className="text-primary"></span>
<code className="bg-background px-2 py-0.5 rounded">
{info.macAddress ?? "-"}
</code>
<span className="text-muted-foreground">-&gt;</span>
<span className="text-muted-foreground"></span>
<code className="bg-background px-2 py-0.5 rounded">
{info.ipAddress ?? "-"}
</code>
@ -217,24 +171,8 @@ export function DeviceTable({
initialState: { pagination: { pageSize: 16 } },
});
const parentRef = useRef<HTMLDivElement>(null);
const rows = table.getRowModel().rows;
const rowVirtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 72,
overscan: 8,
});
const virtualRows = rowVirtualizer.getVirtualItems() as VirtualItem[];
const paddingTop = virtualRows.length > 0 ? virtualRows[0].start : 0;
const paddingBottom =
virtualRows.length > 0
? rowVirtualizer.getTotalSize() - virtualRows[virtualRows.length - 1].end
: 0;
const columnCount = table.getVisibleLeafColumns().length;
return (
<div ref={parentRef} className="max-h-[600px] overflow-y-auto">
<div className="max-h-[600px] overflow-y-auto">
<Table>
<TableHeader className="sticky top-0 bg-background z-10">
{table.getHeaderGroups().map((headerGroup) => (
@ -248,40 +186,15 @@ export function DeviceTable({
))}
</TableHeader>
<TableBody>
{paddingTop > 0 && (
<TableRow>
<TableCell
colSpan={columnCount}
className="p-0"
style={{ height: `${paddingTop}px` }}
/>
{table.getRowModel().rows.map((row) => (
<TableRow key={row.id} className="hover:bg-muted/50 transition-colors">
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id} className="py-4">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
)}
{virtualRows.map((virtualRow) => {
const row = rows[virtualRow.index];
return (
<TableRow
key={row.id}
className="hover:bg-muted/50 transition-colors"
style={{ height: `${virtualRow.size}px` }}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id} className="py-4">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
);
})}
{paddingBottom > 0 && (
<TableRow>
<TableCell
colSpan={columnCount}
className="p-0"
style={{ height: `${paddingBottom}px` }}
/>
</TableRow>
)}
))}
</TableBody>
</Table>

View File

@ -1,4 +1,5 @@
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"
@ -6,23 +7,50 @@ function ScrollArea({
className,
children,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<div
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative overflow-auto", className)}
className={cn("relative", className)}
{...props}
>
{children}
</div>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return <div className={className} {...props} />
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="bg-border relative flex-1 rounded-full"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
)
}
export { ScrollArea, ScrollBar }

View File

@ -1,39 +1,12 @@
const isDev = import.meta.env.MODE === "development";
const trimTrailingSlash = (value: string) => value.replace(/\/+$/, "");
export const BASE_URL = isDev
? import.meta.env.VITE_API_URL_DEV
: "/api";
export const BASE_MESH_URL = isDev
? (import.meta.env.VITE_API_MESH || import.meta.env.VITE_API_MESH_DEV || "")
: (import.meta.env.VITE_API_MESH || "");
export const buildMeshProxyUrl = (meshPathAndQuery: string) => {
const cleanPath = meshPathAndQuery.startsWith("/")
? meshPathAndQuery.substring(1)
: meshPathAndQuery;
const proxyPath = `/api/meshcentral/proxy/${cleanPath}`;
// If an explicit mesh host is configured, always use it.
// This allows forcing proxy URLs to https://<IP>:<port>/api/meshcentral/proxy/...
if (BASE_MESH_URL && BASE_MESH_URL.startsWith("http")) {
return `${trimTrailingSlash(BASE_MESH_URL)}${proxyPath}`;
}
// In development, BASE_URL is usually absolute (e.g. http://localhost:5218/api).
// Build an absolute proxy URL to backend so iframe requests do not hit Vite dev server.
if (BASE_URL.startsWith("http")) {
const apiBase = trimTrailingSlash(BASE_URL);
const backendOrigin = apiBase.endsWith("/api")
? apiBase.slice(0, -4)
: apiBase;
return `${backendOrigin}${proxyPath}`;
}
return proxyPath;
};
? import.meta.env.VITE_API_MESH_DEV
: "/meshapi";
export const API_ENDPOINTS = {
AUTH: {

View File

@ -2,7 +2,6 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import * as deviceCommService from "@/services/device-comm.service";
import type { DeviceHealthCheck } from "@/types/device";
import type { ClientFolderStatus } from "@/types/folder";
import type { Room } from "@/types/room";
const DEVICE_COMM_QUERY_KEYS = {
all: ["device-comm"] as const,
@ -30,7 +29,7 @@ export function useGetAllDevices(enabled = true) {
* Hook đ lấy danh sách phòng
*/
export function useGetRoomList(enabled = true) {
return useQuery<Room[]>({
return useQuery({
queryKey: DEVICE_COMM_QUERY_KEYS.roomList(),
queryFn: () => deviceCommService.getRoomList(),
enabled,

View File

@ -5,7 +5,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
import { Button } from "@/components/ui/button";
import { LoaderCircle } from "lucide-react";
import axios from "axios";
import { exchangeCodeByProvider } from "@/services/auth.service";
import { exchangeOAuthCode } from "@/services/auth.service";
import type { LoginResponse } from "@/types/auth";
const inFlightExchanges = new Map<string, Promise<LoginResponse>>();
@ -18,21 +18,18 @@ export const Route = createFileRoute("/(auth)/oauth/callback/")({
function OAuthCallbackPage() {
const auth = useAuth();
const navigate = useNavigate();
const search = Route.useSearch() as { code?: string; redirect?: string; provider?: string };
const search = Route.useSearch() as { code?: string; redirect?: string };
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [isExchanging, setIsExchanging] = useState(false);
useEffect(() => {
const code = search.code;
const provider = (search.provider || "").toLowerCase();
if (!code) {
setErrorMessage("OAuth code is missing.");
return;
}
const exchangeId = `${provider || "auto"}:${code}`;
if (consumedCodes.has(exchangeId)) {
if (consumedCodes.has(code)) {
setErrorMessage("Mã đăng nhập đã được sử dụng. Vui lòng đăng nhập lại.");
return;
}
@ -41,10 +38,10 @@ function OAuthCallbackPage() {
setIsExchanging(true);
let cancelled = false;
let exchangePromise = inFlightExchanges.get(exchangeId);
let exchangePromise = inFlightExchanges.get(code);
if (!exchangePromise) {
exchangePromise = exchangeCodeByProvider(code, provider);
inFlightExchanges.set(exchangeId, exchangePromise);
exchangePromise = exchangeOAuthCode(code);
inFlightExchanges.set(code, exchangePromise);
}
exchangePromise
@ -56,7 +53,7 @@ function OAuthCallbackPage() {
return;
}
consumedCodes.add(exchangeId);
consumedCodes.add(code);
localStorage.setItem("token", data.token);
localStorage.setItem("username", data.username || "");
@ -77,14 +74,14 @@ function OAuthCallbackPage() {
if (cancelled) return;
if (axios.isAxiosError(error) && error.response?.status === 401) {
consumedCodes.add(exchangeId);
consumedCodes.add(code);
setErrorMessage("Mã đăng nhập đã hết hạn hoặc đã được sử dụng. Vui lòng đăng nhập lại.");
return;
}
setErrorMessage("OAuth exchange failed.");
})
.finally(() => {
inFlightExchanges.delete(exchangeId);
inFlightExchanges.delete(code);
if (!cancelled) {
setIsExchanging(false);
}
@ -93,7 +90,7 @@ function OAuthCallbackPage() {
return () => {
cancelled = true;
};
}, [auth, navigate, search.code, search.provider, search.redirect]);
}, [auth, navigate, search.code, search.redirect]);
return (
<div className="flex items-center justify-center min-h-screen bg-gradient-to-br from-background to-muted/20">

View File

@ -1,9 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import { useState } from "react";
import {
CommandSubmitTemplate,
type SendCommandOptions,
} from "@/template/command-submit-template";
import { CommandSubmitTemplate } from "@/template/command-submit-template";
import { CommandRegistryForm, type CommandRegistryFormData } from "@/components/forms/command-registry-form";
import {
useGetCommandList,
@ -70,17 +67,6 @@ function CommandPage() {
const deleteCommandMutation = useDeleteCommand();
const sendCommandMutation = useSendCommand();
const formInitialData = selectedCommand
? {
commandName: selectedCommand.commandName,
commandType: selectedCommand.commandType,
description: selectedCommand.description,
commandContent: selectedCommand.commandContent,
qos: selectedCommand.qoS,
isRetained: selectedCommand.isRetained,
}
: undefined;
// Columns for command table
const columns: ColumnDef<CommandRegistry>[] = [
{
@ -239,10 +225,7 @@ function CommandPage() {
};
// Handle execute commands from list
const handleExecuteSelected = async (
targets: string[],
options?: SendCommandOptions
) => {
const handleExecuteSelected = async (targets: string[]) => {
if (!table) {
toast.error("Không thể lấy thông tin bảng!");
return;
@ -262,8 +245,6 @@ function CommandPage() {
Command: row.original.commandContent,
QoS: row.original.qoS,
IsRetained: row.original.isRetained,
TtlMinutes: options?.ttlMinutes,
SendTime: options?.sendTime,
};
await sendCommandMutation.mutateAsync({
@ -282,11 +263,7 @@ function CommandPage() {
};
// Handle execute custom command
const handleExecuteCustom = async (
targets: string[],
commandData: ShellCommandData,
options?: SendCommandOptions
) => {
const handleExecuteCustom = async (targets: string[], commandData: ShellCommandData) => {
try {
for (const target of targets) {
// API expects PascalCase directly
@ -294,8 +271,6 @@ function CommandPage() {
Command: commandData.command,
QoS: commandData.qos,
IsRetained: commandData.isRetained,
TtlMinutes: options?.ttlMinutes,
SendTime: options?.sendTime,
};
await sendCommandMutation.mutateAsync({
roomName: target,
@ -328,7 +303,7 @@ function CommandPage() {
<CommandRegistryForm
onSubmit={handleFormSubmit}
closeDialog={() => setIsDialogOpen(false)}
initialData={formInitialData}
initialData={selectedCommand || undefined}
title={selectedCommand ? "Sửa Lệnh" : "Thêm Lệnh Mới"}
/>
}

View File

@ -6,6 +6,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { getRemoteDesktopUrl } from "@/services/remote-control.service";
import { BASE_URL } from "@/config/api";
export const Route = createFileRoute("/_auth/remote-control/")({
@ -34,8 +35,15 @@ function RemoteControlPage() {
onSuccess: (data) => {
setErrorMessage(null);
console.log("[RemoteControl] URL:", data.url);
setProxyUrl(data.url);
// Chuyển URL MeshCentral thành proxy URL
const originalUrl = new URL(data.url);
const pathAndQuery = originalUrl.pathname + originalUrl.search;
const cleanPath = pathAndQuery.startsWith('/') ? pathAndQuery.substring(1) : pathAndQuery;
const baseWithoutApi = BASE_URL.replace('/api', '');
const proxyUrlFull = `${baseWithoutApi}/api/meshcentral/proxy/${cleanPath}`;
console.log("[RemoteControl] Proxy URL:", proxyUrlFull);
setProxyUrl(proxyUrlFull);
setShowRemote(true);
},
onError: (error: any) => {

View File

@ -5,7 +5,7 @@ import {
} from "@tanstack/react-router";
import { useMemo } from "react";
import { useGetClientFolderStatus } from "@/hooks/queries";
import type { ClientFolderStatus, ExtraFile, MissingFile } from "@/types/folder";
import type { ClientFolderStatus } from "@/types/folder";
import FolderStatusTemplate from "@/template/folder-status-template";
import {
createColumnHelper,
@ -36,46 +36,8 @@ function RouteComponent() {
roomName as string,
);
const sortedFolderStatusList = useMemo(() => {
return [...(folderStatusList ?? [])].sort((a, b) => {
const aRoom = (a as ClientFolderStatus & { roomName?: string }).roomName;
const bRoom = (b as ClientFolderStatus & { roomName?: string }).roomName;
if (aRoom || bRoom) {
return (aRoom ?? "").localeCompare(bRoom ?? "", "vi", {
numeric: true,
sensitivity: "base",
});
}
return (a.deviceId ?? "").localeCompare(b.deviceId ?? "", "vi", {
numeric: true,
sensitivity: "base",
});
});
}, [folderStatusList]);
const columnHelper = createColumnHelper<ClientFolderStatus>();
const renderFileList = (files?: MissingFile[] | ExtraFile[]) => {
if (!files || files.length === 0) {
return <span className="text-muted-foreground">-</span>;
}
return (
<div className="max-h-32 overflow-auto space-y-1">
{files.map((file) => (
<div key={`${file.folderPath}/${file.fileName}`} className="text-xs">
<div className="font-mono break-all">{file.fileName}</div>
<div className="text-muted-foreground break-all">
{file.folderPath}
</div>
</div>
))}
</div>
);
};
const columns = useMemo(
() => [
columnHelper.accessor("deviceId", {
@ -84,13 +46,14 @@ function RouteComponent() {
}),
columnHelper.display({
id: "missing",
header: "File thiếu",
cell: (info) => renderFileList(info.row.original.missingFiles),
header: "Số lượng file thiếu",
cell: (info) =>
(info.row.original.missingFiles?.length ?? 0).toString(),
}),
columnHelper.display({
id: "extra",
header: "File thừa",
cell: (info) => renderFileList(info.row.original.extraFiles),
header: "Số lượng file thừa",
cell: (info) => (info.row.original.extraFiles?.length ?? 0).toString(),
}),
columnHelper.display({
id: "current",
@ -117,7 +80,7 @@ function RouteComponent() {
);
const table = useReactTable({
data: sortedFolderStatusList,
data: folderStatusList ?? [],
columns,
getCoreRowModel: getCoreRowModel(),
});
@ -125,7 +88,7 @@ function RouteComponent() {
return (
<FolderStatusTemplate
roomName={roomName as string}
data={sortedFolderStatusList}
data={folderStatusList}
isLoading={isLoading}
onBack={() =>
navigate({ to: "/rooms/$roomName/", params: { roomName } } as any)

View File

@ -1,23 +1,14 @@
import { createFileRoute, useParams, useNavigate } from "@tanstack/react-router";
import { useEffect, useMemo, useState, type MouseEvent } from "react";
import { useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { LayoutGrid, TableIcon, Monitor, FolderCheck } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useGetDeviceFromRoom, useGetRoomList } from "@/hooks/queries";
import { useGetDeviceFromRoom } from "@/hooks/queries";
import { useDeviceEvents } from "@/hooks/useDeviceEvents";
import { DeviceGrid } from "@/components/grids/device-grid";
import { DeviceGridCompact } from "@/components/grids/device-grid-compact";
import { DeviceTable } from "@/components/tables/device-table";
import { useMachineNumber } from "@/hooks/useMachineNumber";
import { CommandActionButtons } from "@/components/buttons/command-action-buttons";
import { DeviceActionBar } from "@/components/bars/device-action-bar";
export const Route = createFileRoute("/_auth/rooms/$roomName/")({
head: ({ params }) => ({
@ -34,174 +25,69 @@ export const Route = createFileRoute("/_auth/rooms/$roomName/")({
function RoomDetailPage() {
const { roomName } = useParams({ from: "/_auth/rooms/$roomName/" });
const [viewMode, setViewMode] = useState<"grid" | "table" | "map">("map");
const [statusFilter, setStatusFilter] = useState<"all" | "on" | "off">("all");
const [searchInput, setSearchInput] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState("");
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [lastSelectedIndex, setLastSelectedIndex] = useState<number | null>(null);
const [viewMode, setViewMode] = useState<"grid" | "table">("grid");
// SSE real-time updates
useDeviceEvents(roomName);
// Folder status from SS
const { data: devices = [] } = useGetDeviceFromRoom(roomName);
const { data: roomData = [] } = useGetRoomList();
const parseMachineNumber = useMachineNumber();
const navigate = useNavigate();
const sortedDevices = useMemo(() => {
return [...devices].sort((a, b) => {
return parseMachineNumber(a.id) - parseMachineNumber(b.id);
});
}, [devices, parseMachineNumber]);
const currentRoom = roomData.find((room) => room.name === roomName);
const totalSeats = currentRoom?.numberOfDevices;
const deviceCount = sortedDevices.length;
const offlineCount = sortedDevices.filter((device) => device.isOffline).length;
const onlineCount = Math.max(0, deviceCount - offlineCount);
const mapDisabled = deviceCount > 200;
const forceTable = deviceCount > 2000;
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedSearch(searchInput.trim());
}, 300);
return () => clearTimeout(handler);
}, [searchInput]);
useEffect(() => {
setSelectedIds([]);
setLastSelectedIndex(null);
}, [roomName]);
useEffect(() => {
if (forceTable && viewMode !== "table") {
setViewMode("table");
}
}, [forceTable, viewMode]);
useEffect(() => {
if (mapDisabled && viewMode === "map") {
setViewMode("grid");
}
}, [mapDisabled, viewMode]);
const filteredDevices = useMemo(() => {
const query = debouncedSearch.toLowerCase();
return sortedDevices.filter((device) => {
if (statusFilter === "on" && device.isOffline) return false;
if (statusFilter === "off" && !device.isOffline) return false;
if (!query) return true;
const ipAddress = device.networkInfos?.[0]?.ipAddress ?? "";
const macAddress = device.networkInfos?.[0]?.macAddress ?? "";
const id = device.id ?? "";
const haystack = `${id} ${ipAddress} ${macAddress}`.toLowerCase();
return haystack.includes(query);
});
}, [sortedDevices, statusFilter, debouncedSearch]);
const filteredDeviceIds = useMemo(
() => filteredDevices.map((device) => device.id),
[filteredDevices]
);
const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds]);
const selectedDevices = useMemo(
() => devices.filter((device) => selectedSet.has(device.id)),
[devices, selectedSet]
);
useEffect(() => {
setSelectedIds((prev) => prev.filter((id) => devices.some((d) => d.id === id)));
}, [devices]);
useEffect(() => {
if (lastSelectedIndex !== null && lastSelectedIndex >= filteredDeviceIds.length) {
setLastSelectedIndex(null);
}
}, [filteredDeviceIds.length, lastSelectedIndex]);
const handleSelectDevice = (
deviceId: string,
index: number,
event: MouseEvent<HTMLElement>
) => {
const isShift = event.shiftKey;
setSelectedIds((prev) => {
const next = new Set(prev);
if (isShift && lastSelectedIndex !== null) {
const start = Math.min(lastSelectedIndex, index);
const end = Math.max(lastSelectedIndex, index);
const rangeIds = filteredDeviceIds.slice(start, end + 1);
rangeIds.forEach((id) => next.add(id));
return Array.from(next);
}
if (next.has(deviceId)) {
next.delete(deviceId);
} else {
next.add(deviceId);
}
return Array.from(next);
});
setLastSelectedIndex(index);
};
const handleToggleAll = (checked: boolean) => {
setSelectedIds(checked ? filteredDeviceIds : []);
setLastSelectedIndex(null);
};
const handleClearSelection = () => {
setSelectedIds([]);
setLastSelectedIndex(null);
};
const chipOptions = [
{ key: "all" as const, label: `Tất cả (${deviceCount})` },
{ key: "on" as const, label: `On (${onlineCount})` },
{ key: "off" as const, label: `Off (${offlineCount})` },
];
const sortedDevices = [...devices].sort((a, b) => {
return parseMachineNumber(a.id) - parseMachineNumber(b.id);
});
return (
<div className="w-full px-6">
<div className="space-y-6">
<Card className="shadow-sm">
<CardHeader className="bg-muted/50 space-y-3 pb-3">
{/* Row 1: Title + stats */}
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
<div className="flex items-center gap-2 shrink-0">
<Monitor className="h-5 w-5" />
<CardTitle>Phòng {roomName}</CardTitle>
</div>
<div className="w-full px-6 space-y-6">
<Card className="shadow-sm">
<CardHeader className="bg-muted/50 space-y-4">
{/* Hàng 1: Thông tin phòng và controls */}
<div className="flex items-center justify-between w-full gap-4">
<div className="flex items-center gap-2">
<Monitor className="h-5 w-5" />
<CardTitle>Danh sách thiết bị phòng {roomName}</CardTitle>
</div>
<div className="flex items-center gap-1.5">
<Badge variant="outline" className="text-[11px] text-emerald-700 border-emerald-200 bg-emerald-50">
On {onlineCount}
</Badge>
<Badge variant="outline" className="text-[11px] text-red-600 border-red-200 bg-red-50">
Off {offlineCount}
</Badge>
<Badge variant="secondary" className="text-[11px]">
Tổng {deviceCount}
</Badge>
</div>
</div>
<div className="flex items-center gap-2 bg-background rounded-lg p-1 border shrink-0">
<Button
variant={viewMode === "grid" ? "default" : "ghost"}
size="sm"
onClick={() => setViewMode("grid")}
className="flex items-center gap-2"
>
<LayoutGrid className="h-4 w-4" />
đ
</Button>
<Button
variant={viewMode === "table" ? "default" : "ghost"}
size="sm"
onClick={() => setViewMode("table")}
className="flex items-center gap-2"
>
<TableIcon className="h-4 w-4" />
Bảng
</Button>
</div>
</div>
{/* Row 2: Command buttons + folder button cùng hàng */}
{/* Hàng 2: Thực thi lệnh */}
<div className="flex items-center justify-between w-full gap-4">
<div className="flex items-center gap-2 text-sm font-semibold">
Thực thi lệnh
</div>
<div className="flex items-center gap-3 justify-end">
{/* Command Action Buttons */}
{devices.length > 0 && (
<div className="flex flex-wrap items-center gap-2">
<>
<CommandActionButtons roomName={roomName} />
<div className="h-5 w-px bg-border shrink-0" />
<div className="h-8 w-px bg-border" />
<Button
onClick={() =>
navigate({
@ -216,158 +102,33 @@ function RoomDetailPage() {
<FolderCheck className="h-4 w-4" />
Kiểm tra thư mục Setup
</Button>
</div>
</>
)}
</div>
</div>
</CardHeader>
{/* Row 3: View toggle + search + filter */}
<div className="flex flex-wrap items-center gap-2">
{/* View mode */}
<div className="flex items-center gap-1 rounded-lg border bg-background p-0.5">
<Button
variant={viewMode === "grid" ? "default" : "ghost"}
size="sm"
onClick={() => setViewMode("grid")}
className="h-7 gap-1.5 px-2.5 text-xs"
disabled={forceTable}
>
<LayoutGrid className="h-3.5 w-3.5" />
Lưới
</Button>
<Button
variant={viewMode === "table" ? "default" : "ghost"}
size="sm"
onClick={() => setViewMode("table")}
className="h-7 gap-1.5 px-2.5 text-xs"
>
<TableIcon className="h-3.5 w-3.5" />
Bảng
</Button>
{mapDisabled || forceTable ? (
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
variant="ghost"
size="sm"
className="h-7 gap-1.5 px-2.5 text-xs"
disabled
>
<Monitor className="h-3.5 w-3.5" />
đ
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
đ chỉ hỗ trợ phòng &lt;= 200 thiết bị.
</TooltipContent>
</Tooltip>
) : (
<Button
variant={viewMode === "map" ? "default" : "ghost"}
size="sm"
onClick={() => setViewMode("map")}
className="h-7 gap-1.5 px-2.5 text-xs"
>
<Monitor className="h-3.5 w-3.5" />
đ
</Button>
)}
</div>
{/* Search */}
<Input
value={searchInput}
onChange={(event) => setSearchInput(event.target.value)}
placeholder="Tìm theo số máy, IP hoặc mã thiết bị"
className="h-8 w-56 shrink-0"
/>
{/* Status filter */}
<div className="flex items-center gap-1 ml-auto">
{chipOptions.map((chip) => (
<Button
key={chip.key}
variant={statusFilter === chip.key ? "default" : "outline"}
size="sm"
className="h-8"
onClick={() => setStatusFilter(chip.key)}
>
{chip.label}
</Button>
))}
</div>
</div>
</CardHeader>
<CardContent className="p-0">
{devices.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12">
<Monitor className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold mb-2">Không thiết bị</h3>
<p className="text-muted-foreground text-center max-w-sm">
Phòng này chưa thiết bị nào đưc kết nối.
</p>
</div>
) : (
<div className="space-y-4 p-4">
{forceTable && (
<div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-900">
Phòng này {deviceCount} thiết bị. Chỉ hiển thị chế đ
Bảng đ đm bảo hiệu năng.
</div>
)}
{!forceTable && viewMode === "grid" && deviceCount > 500 && (
<div className="rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-700">
Phòng này nhiều thiết bị. Bạn thể chuyển sang chế đ
Bảng đ thao tác nhanh hơn.
</div>
)}
{filteredDevices.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12">
<Monitor className="h-10 w-10 text-muted-foreground mb-3" />
<h3 className="text-base font-semibold mb-1">
Không thiết bị phù hợp
</h3>
<p className="text-muted-foreground text-center max-w-sm">
Hãy thử thay đi từ khóa hoặc bộ lọc trạng thái.
</p>
</div>
) : forceTable || viewMode === "table" ? (
<DeviceTable
devices={filteredDevices}
selectedIds={selectedIds}
onToggleDevice={handleSelectDevice}
onToggleAll={handleToggleAll}
/>
) : viewMode === "map" ? (
<DeviceGrid
devices={filteredDevices}
totalSeats={totalSeats}
selectedIds={selectedIds}
onSelectDevice={handleSelectDevice}
/>
) : (
<DeviceGridCompact
devices={filteredDevices}
selectedIds={selectedIds}
onSelectDevice={handleSelectDevice}
/>
)}
</div>
)}
</CardContent>
</Card>
<DeviceActionBar
roomName={roomName}
selectedDevices={selectedDevices}
onClearSelection={handleClearSelection}
/>
</div>
<CardContent className="p-0">
{devices.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12">
<Monitor className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold mb-2">Không thiết bị</h3>
<p className="text-muted-foreground text-center max-w-sm">
Phòng này chưa thiết bị nào đưc kết nối.
</p>
</div>
) : viewMode === "grid" ? (
<DeviceGrid
devices={sortedDevices}
/>
) : (
<DeviceTable
devices={sortedDevices}
/>
)}
</CardContent>
</Card>
</div>
);
}

View File

@ -35,16 +35,6 @@ export function buildGoogleOAuthLoginUrl(returnUrl: string): string {
return buildOAuthLoginUrl("google", returnUrl);
}
/**
* Build Microsoft SSO login URL (legacy endpoint)
* @param returnUrl - FE callback url
*/
export function buildMicrosoftSsoLoginUrl(returnUrl: string): string {
const base = API_ENDPOINTS.AUTH.SSO_LOGIN;
const encoded = encodeURIComponent(returnUrl);
return `${base}?returnUrl=${encoded}`;
}
/**
* Exchange one-time OAuth code for login payload
* @param code - one-time code
@ -85,27 +75,6 @@ export async function exchangeSsoCode(code: string): Promise<LoginResponse> {
return exchangeOAuthCode(code);
}
/**
* Exchange one-time code by provider without breaking existing flows.
* - azuread/microsoft: force legacy SSO exchange endpoint
* - default: use OAuth exchange flow
*/
export async function exchangeCodeByProvider(
code: string,
provider?: string
): Promise<LoginResponse> {
const providerKey = (provider || "").toLowerCase();
if (providerKey === "microsoft" || providerKey === "azuread") {
const response = await rawAxios.post<LoginResponse>(
API_ENDPOINTS.AUTH.SSO_EXCHANGE,
{ code }
);
return response.data;
}
return exchangeOAuthCode(code);
}
/**
* Đăng xuất
*/

View File

@ -1,7 +1,6 @@
import axios from "@/config/axios";
import { API_ENDPOINTS } from "@/config/api";
import type { DeviceHealthCheck } from "@/types/device";
import type { Room } from "@/types/room";
/**
* Lấy tất cả thiết bị trong hệ thống
@ -14,8 +13,8 @@ export async function getAllDevices(): Promise<any[]> {
/**
* Lấy danh sách phòng
*/
export async function getRoomList(): Promise<Room[]> {
const response = await axios.get<Room[]>(API_ENDPOINTS.DEVICE_COMM.GET_ROOM_LIST);
export async function getRoomList(): Promise<any[]> {
const response = await axios.get<any[]>(API_ENDPOINTS.DEVICE_COMM.GET_ROOM_LIST);
return response.data;
}

View File

@ -14,7 +14,7 @@ import { RequestUpdateMenu } from "@/components/menu/request-update-menu";
import { DeleteMenu } from "@/components/menu/delete-menu";
import { Button } from "@/components/ui/button";
import type { AxiosProgressEvent } from "axios";
import { useMemo, useState } from "react";
import { useState } from "react";
import { SelectDialog } from "@/components/dialogs/select-dialog";
import { DeviceSearchDialog } from "@/components/bars/device-searchbar";
import { UploadVersionForm } from "@/components/forms/upload-file-form";
@ -80,22 +80,6 @@ export function AppManagerTemplate<TData>({
const [dialogOpen, setDialogOpen] = useState(false);
const [dialogType, setDialogType] = useState<"room" | "device" | "download-room" | "download-device" | null>(null);
const sortedData = useMemo(() => {
const firstItem = data?.[0] as { fileName?: string } | undefined;
if (!firstItem || typeof firstItem.fileName !== "string") {
return data;
}
return [...data].sort((a, b) => {
const aName = (a as { fileName?: string }).fileName ?? "";
const bName = (b as { fileName?: string }).fileName ?? "";
return aName.localeCompare(bName, "vi", {
numeric: true,
sensitivity: "base",
});
});
}, [data]);
const openRoomDialog = () => {
if (rooms.length > 0 && onUpdate) {
setDialogType("room");
@ -165,7 +149,7 @@ export function AppManagerTemplate<TData>({
<CardContent>
<VersionTable
data={sortedData}
data={data}
isLoading={isLoading}
columns={columns}
onTableInit={onTableInit}

View File

@ -11,13 +11,9 @@ import { Plus, CommandIcon, Zap, Building2 } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import type { ColumnDef } from "@tanstack/react-table";
import { VersionTable } from "@/components/tables/version-table";
import {
@ -32,11 +28,6 @@ import { getDeviceFromRoom } from "@/services/device-comm.service";
import type { Room } from "@/types/room";
import { toast } from "sonner";
export interface SendCommandOptions {
ttlMinutes?: number;
sendTime?: Date;
}
interface CommandSubmitTemplateProps<T extends { id: number }> {
title: string;
description: string;
@ -60,15 +51,8 @@ interface CommandSubmitTemplateProps<T extends { id: number }> {
onTableInit?: (table: any) => void;
// Execute
onExecuteSelected?: (
targets: string[],
options?: SendCommandOptions
) => void | Promise<void>;
onExecuteCustom?: (
targets: string[],
commandData: ShellCommandData,
options?: SendCommandOptions
) => void | Promise<void>;
onExecuteSelected?: (targets: string[]) => void;
onExecuteCustom?: (targets: string[], commandData: ShellCommandData) => void;
isExecuting?: boolean;
// Execution scope
@ -129,158 +113,17 @@ export function CommandSubmitTemplate<T extends { id: number }>({
const [customCommand, setCustomCommand] = useState("");
const [customQoS, setCustomQoS] = useState<0 | 1 | 2>(0);
const [customRetained, setCustomRetained] = useState(false);
const [table, setTable] = useState<any>();
const [dialogOpen2, setDialogOpen2] = useState(false);
const [dialogType, setDialogType] = useState<
"room" | "device" | "room-custom" | "device-custom" | null
>(null);
const [confirmOpen, setConfirmOpen] = useState(false);
const [ttlMinutesInput, setTtlMinutesInput] = useState("");
const [sendTimeInput, setSendTimeInput] = useState("");
const [confirmError, setConfirmError] = useState<string | null>(null);
const [pendingAction, setPendingAction] = useState<
| { type: "selected"; targets: string[] }
| { type: "custom"; targets: string[]; commandData: ShellCommandData }
| null
>(null);
const handleTableInit = (t: any) => {
setTable(t);
onTableInit?.(t);
};
const closeTargetDialog = () => {
setDialogOpen2(false);
setDialogType(null);
};
const resetConfirmState = () => {
setConfirmOpen(false);
setPendingAction(null);
setTtlMinutesInput("");
setSendTimeInput("");
setConfirmError(null);
};
const formatLocalSendTime = (date: Date) => {
const pad2 = (value: number) => String(value).padStart(2, "0");
const hh = pad2(date.getHours());
const mm = pad2(date.getMinutes());
const ss = pad2(date.getSeconds());
const dd = pad2(date.getDate());
const MM = pad2(date.getMonth() + 1);
const yy = pad2(date.getFullYear() % 100);
return `${hh}:${mm}:${ss} ${dd}/${MM}/${yy}`;
};
const openConfirmForSelected = (targets: string[]) => {
setPendingAction({ type: "selected", targets });
setSendTimeInput(formatLocalSendTime(new Date()));
setConfirmOpen(true);
setConfirmError(null);
};
const openConfirmForCustom = (
targets: string[],
commandData: ShellCommandData
) => {
setPendingAction({ type: "custom", targets, commandData });
setSendTimeInput(formatLocalSendTime(new Date()));
setConfirmOpen(true);
setConfirmError(null);
};
const parseSendOptions = (): {
options?: SendCommandOptions;
error?: string;
} => {
const options: SendCommandOptions = {};
const ttlTrimmed = ttlMinutesInput.trim();
if (ttlTrimmed) {
const parsedTtl = Number(ttlTrimmed);
if (!Number.isInteger(parsedTtl) || parsedTtl < 0) {
return { error: "TtlMinutes phải là số nguyên >= 0." };
}
options.ttlMinutes = parsedTtl;
}
const sendTrimmed = sendTimeInput.trim();
if (sendTrimmed) {
const match =
/^(\d{2}):(\d{2}):(\d{2})\s+(\d{2})\/(\d{2})\/(\d{2})$/.exec(
sendTrimmed
);
if (!match) {
return { error: "SendTime không đúng định dạng HH:MM:SS DD/MM/YY." };
}
const [, hh, mm, ss, dd, MM, yy] = match;
const hour = Number(hh);
const minute = Number(mm);
const second = Number(ss);
const day = Number(dd);
const month = Number(MM);
const year = 2000 + Number(yy);
if (
hour > 23 ||
minute > 59 ||
second > 59 ||
month < 1 ||
month > 12 ||
day < 1 ||
day > 31
) {
return { error: "SendTime không hợp lệ." };
}
const date = new Date(year, month - 1, day, hour, minute, second);
if (
date.getFullYear() !== year ||
date.getMonth() !== month - 1 ||
date.getDate() !== day ||
date.getHours() !== hour ||
date.getMinutes() !== minute ||
date.getSeconds() !== second
) {
return { error: "SendTime không hợp lệ." };
}
options.sendTime = date;
}
return { options };
};
const handleConfirmSend = async () => {
if (!pendingAction) return;
const { options, error } = parseSendOptions();
if (error) {
setConfirmError(error);
toast.error(error);
return;
}
try {
if (pendingAction.type === "selected") {
await onExecuteSelected?.(pendingAction.targets, options);
} else {
await onExecuteCustom?.(
pendingAction.targets,
pendingAction.commandData,
options
);
setCustomCommand("");
setCustomQoS(0);
setCustomRetained(false);
}
} catch (e) {
console.error("Confirm send error:", e);
} finally {
resetConfirmState();
setTimeout(() => window.location.reload(), 500);
}
};
const openRoomDialog = () => {
if (rooms.length > 0 && onExecuteSelected) {
setDialogType("room");
@ -295,6 +138,21 @@ export function CommandSubmitTemplate<T extends { id: number }>({
}
};
const handleExecuteSelected = () => {
if (!table) {
toast.error("Không thể lấy thông tin bảng!");
return;
}
const selectedRows = table.getSelectedRowModel().rows;
if (selectedRows.length === 0) {
toast.error("Vui lòng chọn ít nhất một mục để thực thi!");
return;
}
onExecuteSelected?.([]);
};
const handleExecuteAll = () => {
if (!onExecuteSelected) return;
try {
@ -302,7 +160,7 @@ export function CommandSubmitTemplate<T extends { id: number }>({
typeof room === "string" ? room : room.name,
);
const allTargets = [...roomNames, ...devices];
openConfirmForSelected(allTargets);
onExecuteSelected(allTargets);
} catch (e) {
console.error("Execute error:", e);
}
@ -320,7 +178,14 @@ export function CommandSubmitTemplate<T extends { id: number }>({
isRetained: customRetained,
};
openConfirmForCustom(targets, shellCommandData);
try {
await onExecuteCustom?.(targets, shellCommandData);
setCustomCommand("");
setCustomQoS(0);
setCustomRetained(false);
} catch (e) {
console.error("Execute custom command error:", e);
}
};
const handleExecuteCustomAll = () => {
@ -478,7 +343,9 @@ export function CommandSubmitTemplate<T extends { id: number }>({
<SelectDialog
open={dialogOpen2}
onClose={() => {
closeTargetDialog();
setDialogOpen2(false);
setDialogType(null);
setTimeout(() => window.location.reload(), 500);
}}
title="Chọn phòng"
description="Chọn các phòng để thực thi lệnh"
@ -487,11 +354,13 @@ export function CommandSubmitTemplate<T extends { id: number }>({
onConfirm={async (selectedItems) => {
if (!onExecuteSelected) return;
try {
openConfirmForSelected(selectedItems);
await onExecuteSelected(selectedItems);
} catch (e) {
console.error("Execute error:", e);
} finally {
closeTargetDialog();
setDialogOpen2(false);
setDialogType(null);
setTimeout(() => window.location.reload(), 500);
}
}}
/>
@ -502,21 +371,27 @@ export function CommandSubmitTemplate<T extends { id: number }>({
<DeviceSearchDialog
open={dialogOpen2 && dialogType === "device"}
onClose={() => {
closeTargetDialog();
setDialogOpen2(false);
setDialogType(null);
setTimeout(() => window.location.reload(), 500);
}}
rooms={rooms}
fetchDevices={getDeviceFromRoom}
onSelect={async (deviceIds) => {
if (!onExecuteSelected) {
closeTargetDialog();
setDialogOpen2(false);
setDialogType(null);
setTimeout(() => window.location.reload(), 500);
return;
}
try {
openConfirmForSelected(deviceIds);
await onExecuteSelected(deviceIds);
} catch (e) {
console.error("Execute error:", e);
} finally {
closeTargetDialog();
setDialogOpen2(false);
setDialogType(null);
setTimeout(() => window.location.reload(), 500);
}
}}
/>
@ -527,7 +402,9 @@ export function CommandSubmitTemplate<T extends { id: number }>({
<SelectDialog
open={dialogOpen2}
onClose={() => {
closeTargetDialog();
setDialogOpen2(false);
setDialogType(null);
setTimeout(() => window.location.reload(), 500);
}}
title="Chọn phòng"
description="Chọn các phòng để thực thi lệnh tùy chỉnh"
@ -539,7 +416,9 @@ export function CommandSubmitTemplate<T extends { id: number }>({
} catch (e) {
console.error("Execute error:", e);
} finally {
closeTargetDialog();
setDialogOpen2(false);
setDialogType(null);
setTimeout(() => window.location.reload(), 500);
}
}}
/>
@ -550,7 +429,9 @@ export function CommandSubmitTemplate<T extends { id: number }>({
<DeviceSearchDialog
open={dialogOpen2 && dialogType === "device-custom"}
onClose={() => {
closeTargetDialog();
setDialogOpen2(false);
setDialogType(null);
setTimeout(() => window.location.reload(), 500);
}}
rooms={rooms}
fetchDevices={getDeviceFromRoom}
@ -560,67 +441,14 @@ export function CommandSubmitTemplate<T extends { id: number }>({
} catch (e) {
console.error("Execute error:", e);
} finally {
closeTargetDialog();
setDialogOpen2(false);
setDialogType(null);
setTimeout(() => window.location.reload(), 500);
}
}}
/>
)}
{/* Dialog xác nhận gửi lệnh */}
<Dialog
open={confirmOpen}
onOpenChange={(open) => {
if (!open) resetConfirmState();
}}
>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Xác nhận gửi lệnh</DialogTitle>
<DialogDescription>
Vui lòng xác nhận nhập thông tin bổ sung trước khi gửi.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="ttl-minutes">TtlMinutes (phút)</Label>
<Input
id="ttl-minutes"
type="number"
min={0}
placeholder="VD: 60"
value={ttlMinutesInput}
onChange={(e) => setTtlMinutesInput(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="send-time">SendTime</Label>
<Input
id="send-time"
placeholder="HH:MM:SS DD/MM/YY (VD: 14:30:00 25/05/26)"
value={sendTimeInput}
onChange={(e) => setSendTimeInput(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Đ trống nếu muốn gửi ngay.
</p>
</div>
{confirmError && (
<p className="text-sm text-red-600">{confirmError}</p>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={resetConfirmState}>
Hủy
</Button>
<Button onClick={handleConfirmSend}>Xác nhận gửi</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Dialog for add/edit */}
{formContent && (
<Dialog open={dialogOpen} onOpenChange={onDialogOpen}>

View File

@ -4,7 +4,7 @@ export type UserProfile = {
name: string;
role: string;
roleId: number;
accessRooms: string[];
accessRooms: number[];
createdAt?: string | null;
createdBy?: string | null;
updatedAt?: string | null;