Add dashboard and fix some little UX

This commit is contained in:
Do Manh Phuong 2026-04-01 16:46:33 +07:00
parent f1f477f2b2
commit cf2b17d49d
26 changed files with 1433 additions and 1511 deletions

View File

@ -1,341 +0,0 @@
# TanStack Query Hooks Documentation
Tất cả các API đã được tách riêng thành TanStack Query hooks trong folder `src/hooks/queries/`.
## Cấu trúc
```
src/hooks/queries/
├── index.ts # Export tất cả hooks
├── useAuthQueries.ts # Auth hooks
├── useAppVersionQueries.ts # App/Software hooks
├── useDeviceCommQueries.ts # Device communication hooks
└── useCommandQueries.ts # Command hooks
```
## Cách Sử Dụng
### 1. Auth Queries (Xác thực)
#### Đăng nhập
```tsx
import { useLogin } from '@/hooks/queries'
function LoginPage() {
const loginMutation = useLogin()
const handleLogin = async () => {
try {
await loginMutation.mutateAsync({
username: 'user',
password: 'password'
})
// Tự động lưu token vào localStorage
} catch (error) {
console.error(error)
}
}
return (
<button
onClick={handleLogin}
disabled={loginMutation.isPending}
>
{loginMutation.isPending ? 'Đang đăng nhập...' : 'Đăng nhập'}
</button>
)
}
```
#### Đăng xuất
```tsx
import { useLogout } from '@/hooks/queries'
function LogoutButton() {
const logoutMutation = useLogout()
return (
<button onClick={() => logoutMutation.mutate()}>
Đăng xuất
</button>
)
}
```
#### Kiểm tra phiên
```tsx
import { usePing } from '@/hooks/queries'
function CheckSession() {
const { data, isLoading } = usePing(token, true)
if (isLoading) return <div>Checking...</div>
return <div>Session: {data?.message}</div>
}
```
#### Thay đổi mật khẩu
```tsx
import { useChangePassword } from '@/hooks/queries'
function ChangePasswordForm() {
const changePasswordMutation = useChangePassword()
const handleSubmit = async () => {
await changePasswordMutation.mutateAsync({
currentPassword: 'old',
newPassword: 'new'
})
}
return <button onClick={handleSubmit}>Thay đổi</button>
}
```
### 2. App Version Queries (Phần mềm/Agent)
#### Lấy danh sách agent
```tsx
import { useGetAgentVersion } from '@/hooks/queries'
function AgentList() {
const { data: agents, isLoading } = useGetAgentVersion()
if (isLoading) return <div>Loading...</div>
return <div>{agents?.length} agents</div>
}
```
#### Lấy danh sách phần mềm
```tsx
import { useGetSoftwareList } from '@/hooks/queries'
function SoftwareList() {
const { data: software, isLoading } = useGetSoftwareList()
return software?.map(item => <div key={item.id}>{item.name}</div>)
}
```
#### Upload file
```tsx
import { useUploadSoftware } from '@/hooks/queries'
function UploadForm() {
const uploadMutation = useUploadSoftware()
const handleUpload = async (file: File) => {
const formData = new FormData()
formData.append('file', file)
await uploadMutation.mutateAsync({
formData,
onUploadProgress: (event) => {
const percent = (event.loaded / event.total) * 100
console.log(`Upload: ${percent}%`)
}
})
}
return <input type="file" onChange={(e) => e.target.files && handleUpload(e.target.files[0])} />
}
```
#### Quản lý blacklist
```tsx
import {
useGetBlacklist,
useAddBlacklist,
useDeleteBlacklist
} from '@/hooks/queries'
function BlacklistManager() {
const { data: blacklist } = useGetBlacklist()
const addMutation = useAddBlacklist()
const deleteMutation = useDeleteBlacklist()
const handleAdd = async () => {
await addMutation.mutateAsync({ appId: 1 })
}
const handleDelete = async (appId: number) => {
await deleteMutation.mutateAsync(appId)
}
return (
<>
{blacklist?.map(item => (
<div key={item.id}>
{item.name}
<button onClick={() => handleDelete(item.id)}>Delete</button>
</div>
))}
<button onClick={handleAdd}>Add</button>
</>
)
}
```
### 3. Device Communication Queries
#### Lấy danh sách phòng
```tsx
import { useGetRoomList } from '@/hooks/queries'
function RoomSelector() {
const { data: rooms } = useGetRoomList()
return (
<select>
{rooms?.map(room => (
<option key={room.id} value={room.id}>{room.name}</option>
))}
</select>
)
}
```
#### Lấy thiết bị trong phòng
```tsx
import { useGetDeviceFromRoom } from '@/hooks/queries'
function DeviceList({ roomName }: { roomName: string }) {
const { data: devices, isLoading } = useGetDeviceFromRoom(roomName, true)
if (isLoading) return <div>Loading devices...</div>
return devices?.map(device => (
<div key={device.id}>{device.name}</div>
))
}
```
#### Gửi lệnh
```tsx
import { useSendCommand } from '@/hooks/queries'
function CommandForm() {
const sendMutation = useSendCommand()
const handleSend = async () => {
await sendMutation.mutateAsync({
roomName: 'Room A',
data: { command: 'dir' }
})
}
return <button onClick={handleSend}>Gửi lệnh</button>
}
```
#### Cài đặt phần mềm
```tsx
import { useInstallMsi } from '@/hooks/queries'
function InstallSoftware() {
const installMutation = useInstallMsi()
const handleInstall = async () => {
await installMutation.mutateAsync({
roomName: 'Room A',
data: { msiFileId: 1 }
})
}
return <button onClick={handleInstall}>Cài đặt</button>
}
```
### 4. Command Queries
#### Lấy danh sách lệnh
```tsx
import { useGetCommandList } from '@/hooks/queries'
function CommandList() {
const { data: commands } = useGetCommandList()
return commands?.map(cmd => <div key={cmd.id}>{cmd.name}</div>)
}
```
#### Thêm lệnh
```tsx
import { useAddCommand } from '@/hooks/queries'
function AddCommandForm() {
const addMutation = useAddCommand()
const handleAdd = async () => {
await addMutation.mutateAsync({
name: 'My Command',
command: 'echo hello'
})
}
return <button onClick={handleAdd}>Add Command</button>
}
```
## Lợi ích
1. **Automatic Caching** - TanStack Query tự động cache dữ liệu
2. **Background Refetching** - Cập nhật dữ liệu trong background
3. **Stale Time Management** - Kiểm soát thời gian dữ liệu còn "fresh"
4. **Automatic Invalidation** - Tự động update dữ liệu sau mutations
5. **Deduplication** - Gộp các request giống nhau
6. **Error Handling** - Xử lý lỗi tập trung
7. **Loading States** - Tracking loading/pending/error states
## Advanced Usage
### Dependent Queries
```tsx
function DeviceDetails({ deviceId }: { deviceId: number }) {
const { data: device } = useGetDeviceFromRoom(deviceId, true)
// Chỉ fetch khi có device
const { data: status } = useGetClientFolderStatus(
device?.roomName,
!!device
)
return <div>{status?.path}</div>
}
```
### Prefetching
```tsx
import { useQueryClient } from '@tanstack/react-query'
import { useGetSoftwareList } from '@/hooks/queries'
function PrefetchOnHover() {
const queryClient = useQueryClient()
const handleMouseEnter = () => {
queryClient.prefetchQuery({
queryKey: ['app-version', 'software'],
queryFn: () => useGetSoftwareList
})
}
return <div onMouseEnter={handleMouseEnter}>Hover me</div>
}
```
## Migration từ cách cũ
**Trước:**
```tsx
const { data } = useQueryData({
queryKey: ["software-version"],
url: API_ENDPOINTS.APP_VERSION.GET_SOFTWARE,
})
```
**Sau:**
```tsx
const { data } = useGetSoftwareList()
```
Đơn giản hơn, type-safe hơn, và dễ bảo trì hơn!

View File

@ -1,669 +0,0 @@
# 📚 MeshCentral Remote Desktop trong iframe - Documentation đầy đủ
## 🎯 Tổng quan
Tài liệu này mô tả chi tiết việc implement chức năng **Remote Desktop** sử dụng **MeshCentral** được nhúng trong **iframe** của ứng dụng web, với **backend proxy** để giải quyết vấn đề third-party cookies.
---
## 🐛 Vấn đề ban đầu
### Vấn đề 1: Third-party Cookies Blocking
Khi nhúng MeshCentral vào iframe:
```
┌─────────────────────────────────────┐
│ Frontend App (localhost:3000) │
│ ┌───────────────────────────────┐ │
│ │ <iframe> │ │
│ │ MeshCentral │ │
│ │ (my-mesh-test.com) │ │
│ │ │ │
│ │ X Cookies BLOCKED │ │ ← Third-party context
│ └───────────────────────────────┘ │
└─────────────────────────────────────┘
```
**Nguyên nhân:**
- Browser modern (Chrome, Firefox, Edge) block third-party cookies trong iframe
- MeshCentral (`https://my-mesh-test.com`) khác domain với app (`http://localhost:3000`)
- Cookies `xid`, `xid.sig` không được set → Authentication fail
- `commander.ashx`, `control.ashx` không load được
### Vấn đề 2: WebSocket Cross-Origin
MeshCentral client trong iframe tự động tạo WebSocket URL dựa trên `window.location`:
```javascript
var url = window.location.protocol.replace('http', 'ws')
+ '//' + window.location.host + '/control.ashx';
// Result: ws://localhost:3000/control.ashx (Frontend - WRONG!)
// Should be: wss://my-mesh-test.com/control.ashx (MeshCentral server)
```
---
## ✅ Giải pháp: Backend Proxy
### Ý tưởng
```
Frontend iframe (localhost:3000)
↓ (same-origin request)
Backend Proxy (localhost:5218)
↓ (authenticated request)
MeshCentral Server (my-mesh-test.com)
```
**Lợi ích:**
- ✅ iframe → backend: same-origin → cookies first-party
- ✅ Backend inject authentication headers tự động
- ✅ WebSocket connections được proxy bidirectionally
- ✅ Không cần config MeshCentral server
---
## 📁 Architecture & Implementation
### 1. Backend - Proxy Controllers
#### 1.1 HTTP Proxy Controller
**File:** `MeshCentralProxyController.cs`
**Location:** `f:\TTMT.ComputerManagement\TTMT.CompManageWeb\Controllers\APIs\`
**Route:** `/api/meshcentral/proxy/**`
**Chức năng:**
- Proxy tất cả HTTP requests (GET, POST, PUT, DELETE)
- Inject `x-meshauth` header tự động
- Forward requests đến MeshCentral server
- Stream response về client
**Key endpoints:**
```csharp
[Route("api/meshcentral/proxy")]
[ApiController]
public class MeshCentralProxyController : ControllerBase
{
[HttpGet("{**path}")]
[HttpPost("{**path}")]
[HttpPut("{**path}")]
[HttpDelete("{**path}")]
public async Task<IActionResult> ProxyRequest(string path)
{
// Build target URL
var targetUrl = $"{_options.ServerUrl}/{path}{Request.QueryString}";
// Inject authentication
var authHeader = BuildMeshAuthHeader(_options.Username, _options.Password);
requestMessage.Headers.TryAddWithoutValidation("x-meshauth", authHeader);
// Forward and stream response
await responseStream.CopyToAsync(Response.Body);
}
[HttpGet("meshrelay.ashx")]
public async Task ProxyMeshRelayWebSocket()
{
// WebSocket proxy cho desktop/terminal/files relay
}
}
```
#### 1.2 WebSocket Proxy Controller
**File:** `MeshCentralWebSocketProxyController.cs`
**Location:** `f:\TTMT.ComputerManagement\TTMT.CompManageWeb\Controllers\APIs\`
**Routes:** Root level endpoints
**Chức năng:**
- Proxy WebSocket connections từ MeshCentral client
- Handle `/control.ashx`, `/commander.ashx`, `/mesh.ashx`
- Bidirectional message relay
**Key endpoints:**
```csharp
[ApiController]
public class MeshCentralWebSocketProxyController : ControllerBase
{
[HttpGet("/control.ashx")]
public async Task ProxyControlWebSocket()
{
// Main control channel
}
[HttpGet("/commander.ashx")]
public async Task ProxyCommanderWebSocket()
{
// Command channel
}
[HttpGet("/mesh.ashx")]
public async Task ProxyMeshWebSocket()
{
// Mesh relay channel
}
}
```
**WebSocket relay logic:**
```csharp
private async Task RelayWebSocket(WebSocket source, WebSocket destination, string direction)
{
var buffer = new byte[1024 * 16]; // 16KB buffer
while (source.State == WebSocketState.Open && destination.State == WebSocketState.Open)
{
var result = await source.ReceiveAsync(buffer);
await destination.SendAsync(buffer, result.MessageType, result.EndOfMessage);
}
}
```
### 2. Backend Configuration
#### 2.1 Program.cs Changes
**File:** `f:\TTMT.ComputerManagement\TTMT.CompManageWeb\Program.cs`
**Changes:**
1. **HttpClient Factory:**
```csharp
builder.Services.AddHttpClient("MeshCentralProxy")
.ConfigurePrimaryHttpMessageHandler(() =>
{
var handler = new HttpClientHandler
{
AllowAutoRedirect = false,
UseCookies = false,
};
if (meshOptions?.AllowInvalidTlsCertificate == true)
{
handler.ServerCertificateCustomValidationCallback = (_, _, _, _) => true;
}
return handler;
});
```
2. **WebSocket Support:**
```csharp
app.UseWebSockets();
```
#### 2.2 appsettings.json
**File:** `f:\TTMT.ComputerManagement\TTMT.CompManageWeb\appsettings.json`
**Configuration:**
### 3. Frontend - Remote Control Component
#### 3.1 Component Structure
**File:** `f:\TTMT.ManageWebGUI\src\routes\_auth\remote-control\index.tsx`
**Features:**
- Input field cho nodeID
- Connect button
- Modal với iframe embedded
- Fullscreen support
- Close button
**Key code:**
```typescript
const connectMutation = useMutation({
mutationFn: async (nodeIdValue: string) => {
// Call API để lấy URL
const response = await getRemoteDesktopUrl(nodeIdValue);
return response;
},
onSuccess: (data) => {
// Transform URL to 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}`;
setProxyUrl(proxyUrlFull);
setShowRemote(true);
}
});
```
**iframe render:**
```tsx
<iframe
id="mesh-iframe"
title="Remote Desktop"
src={proxyUrl}
className="h-[calc(90vh-44px)] w-full border-0"
allowFullScreen
allow="clipboard-read; clipboard-write; camera; microphone"
/>
```
#### 3.2 API Service
**File:** `f:\TTMT.ManageWebGUI\src\services\remote-control.service.ts`
```typescript
export async function getRemoteDesktopUrl(nodeId: string): Promise<RemoteDesktopResponse> {
const response = await axios.get<RemoteDesktopResponse>(
API_ENDPOINTS.MESH_CENTRAL.GET_REMOTE_DESKTOP(nodeId.trim())
);
return response.data;
}
```
#### 3.3 Configuration
**File:** `f:\TTMT.ManageWebGUI\.env`
```env
VITE_API_URL_DEV=http://localhost:5218/api
```
**File:** `f:\TTMT.ManageWebGUI\src\config\api.ts`
```typescript
export const BASE_URL = isDev
? import.meta.env.VITE_API_URL_DEV
: "/api";
export const API_ENDPOINTS = {
MESH_CENTRAL: {
GET_REMOTE_DESKTOP: (deviceId: string) =>
`${BASE_URL}/MeshCentral/devices/${encodeURIComponent(deviceId)}/remote-desktop`,
},
};
```
---
## 🔄 Flow hoàn chỉnh
### Step-by-step flow:
```
1. User nhập nodeID và click Connect
2. Frontend call API: GET /api/meshcentral/devices/{nodeId}/remote-desktop
3. Backend tạo temporary token (expire 5 phút)
4. Backend return URL: https://my-mesh-test.com/login?user=~t:xxx&pass=yyy&...
5. Frontend transform URL thành proxy URL:
http://localhost:5218/api/meshcentral/proxy/login?user=~t:xxx&pass=yyy&...
6. Frontend render iframe với proxy URL
7. iframe load → Browser request đến proxy endpoint (same-origin ✅)
8. Backend proxy forward request đến MeshCentral server
- Inject x-meshauth header
- Add Origin header
9. MeshCentral validate token → Set cookies → Return login page
10. Backend proxy return response → iframe
11. MeshCentral client trong iframe khởi động
12. Client tạo WebSocket connections:
- ws://localhost:5218/control.ashx
- ws://localhost:5218/api/meshcentral/proxy/meshrelay.ashx
13. Backend WebSocket proxy controllers:
- Accept client WebSocket
- Connect đến MeshCentral server WebSocket
- Protocol conversion: https → wss
- Inject x-meshauth header
- Bidirectional relay messages
14. Remote desktop session established ✅
- Desktop tab: Screen streaming
- Terminal tab: Shell access
- Files tab: File management
- All features working!
```
---
## 🛠️ Technical Details
### 1. Authentication Flow
**Token Generation:**
```csharp
// Backend tạo temporary token
var response = await SendAuthorizedCommandAsync(new JsonObject
{
["action"] = "createLoginToken",
["name"] = "RemoteSession",
["expire"] = 5, // 5 minutes
["responseid"] = myResponseId
});
string tUser = response["tokenUser"]?.GetValue<string>(); // ~t:xxx
string tPass = response["tokenPass"]?.GetValue<string>(); // yyy
```
**URL Construction:**
```csharp
var remoteUrl = $"{baseUrl}/login?user={encUser}&pass={encPass}&node={fullNodeId}&viewmode=11&hide=31&ts={cacheBuster}";
```
**x-meshauth Header:**
```csharp
private static string BuildMeshAuthHeader(string username, string password)
{
var userBytes = Encoding.UTF8.GetBytes(username);
var passBytes = Encoding.UTF8.GetBytes(password);
var userPart = Convert.ToBase64String(userBytes);
var passPart = Convert.ToBase64String(passBytes);
return $"{userPart},{passPart}";
}
```
### 2. WebSocket Protocol Conversion
**Issue:** MeshCentral ServerUrl là `https://` nhưng WebSocket cần `wss://`
**Solution:**
```csharp
var baseUrl = _options.ServerUrl
.Replace("https://", "wss://")
.Replace("http://", "ws://");
```
### 3. Proxy Endpoints Summary
| Client Request | Proxy Endpoint | MeshCentral Target | Purpose |
|----------------|----------------|-------------------|---------|
| HTTP | `/api/meshcentral/proxy/login?...` | `https://mesh/login?...` | Login page |
| HTTP | `/api/meshcentral/proxy/**` | `https://mesh/**` | Static resources |
| WS | `/control.ashx` | `wss://mesh/control.ashx` | Main control channel |
| WS | `/commander.ashx` | `wss://mesh/commander.ashx` | Command channel |
| WS | `/mesh.ashx` | `wss://mesh/mesh.ashx` | Mesh relay |
| WS | `/api/meshcentral/proxy/meshrelay.ashx` | `wss://mesh/meshrelay.ashx` | Desktop/Terminal/Files |
### 4. Buffer Sizes & Performance
**HTTP Proxy:**
- Stream-based: `responseStream.CopyToAsync(Response.Body)`
- No buffering → Low memory usage
**WebSocket Relay:**
- Buffer: 16KB (`byte[1024 * 16]`)
- Bidirectional: 2 tasks (client→server, server→client)
- Non-blocking: `async/await`
**Performance:**
- HTTP latency: +10-30ms (proxy overhead)
- WebSocket latency: +5-15ms (relay overhead)
- Throughput: ~100-200 Mbps (depends on network)
---
## 🧪 Testing Guide
### 1. Setup
**Backend:**
```bash
cd f:\TTMT.ComputerManagement\TTMT.CompManageWeb
dotnet run
```
**Frontend:**
```bash
cd f:\TTMT.ManageWebGUI
npm run dev
```
### 2. Test Remote Desktop
1. Mở browser → `http://localhost:3000`
2. Navigate đến "Điều khiển trực tiếp"
3. Nhập nodeID: `node//xxxxx`
4. Click **Connect**
5. Modal xuất hiện với iframe
6. MeshCentral UI load
7. Click **Desktop** tab
8. Remote screen hiển thị ✅
### 3. Verify Logs
**Backend logs should show:**
```
[MeshProxy] Proxying meshrelay WebSocket to: wss://my-mesh-test.com/meshrelay.ashx?...
[MeshProxy] meshrelay WebSocket connected, starting bidirectional relay
[MeshWSProxy] Proxying WebSocket to: wss://my-mesh-test.com/control.ashx
[MeshWSProxy] WebSocket connected for control.ashx, starting relay
```
**Browser DevTools (F12) → Network → WS:**
- `control.ashx`: Status 101 ✅
- `meshrelay.ashx`: Status 101 ✅
- Messages flowing (green arrows)
### 4. Test Features
**Desktop:**
- ✅ Screen streaming
- ✅ Mouse control
- ✅ Keyboard input
- ✅ Clipboard sync
**Terminal:**
- ✅ Command execution
- ✅ Interactive shell
- ✅ Output streaming
**Files:**
- ✅ File browser
- ✅ Upload/Download
- ✅ Delete/Rename
---
## 🐛 Troubleshooting
### Issue 1: 404 Not Found trong iframe
**Symptoms:** iframe hiển thị trang 404
**Cause:** iframe src dùng relative URL (`/api/...`) → resolve to frontend port
**Solution:** Sử dụng `BASE_URL` để có full URL
```typescript
const baseWithoutApi = BASE_URL.replace('/api', '');
const proxyUrlFull = `${baseWithoutApi}/api/meshcentral/proxy/${cleanPath}`;
```
### Issue 2: WebSocket "Unable to connect"
**Symptoms:** Error "Unable to connect web socket, click to reconnect"
**Possible causes:**
1. **Backend proxy controller chưa load**
- Solution: Restart backend
2. **WebSocket endpoint not found**
- Solution: Check endpoint exists (`control.ashx`, `meshrelay.ashx`)
3. **Protocol mismatch** (`https://` vs `wss://`)
- Solution: Convert protocol in proxy controller
### Issue 3: Authentication Failed
**Symptoms:** Login loop hoặc "Authentication failed"
**Check:**
1. `appsettings.json` → MeshCentral credentials correct?
2. Backend logs → `x-meshauth` header being injected?
3. MeshCentral server → Username/Password valid?
### Issue 4: 502 Bad Gateway
**Symptoms:** Backend returns 502
**Cause:** Backend cannot connect to MeshCentral server
**Check:**
1. MeshCentral ServerUrl correct?
2. Network/firewall blocking?
3. MeshCentral server running?
---
## 📊 Performance & Scalability
### Metrics
**Single remote session:**
- Memory: ~50-100 MB (backend + websockets)
- CPU: ~5-10% (1 core)
- Network: ~1-5 Mbps (depends on screen resolution)
**Multiple sessions:**
- Linear scaling (each session independent)
- Recommended: Max 50 concurrent sessions per backend instance
- For more: Deploy multiple backend instances with load balancer
### Optimization Tips
1. **HTTP Proxy:**
- Enable compression in MeshCentral
- Use CDN for static assets
2. **WebSocket Relay:**
- Increase buffer size for high-bandwidth scenarios
- Use dedicated thread pool for relay tasks
3. **Caching:**
- Cache static resources (images, scripts)
- Set appropriate cache headers
---
## 🔒 Security Considerations
### 1. Authentication
**Current:**
- ✅ Backend stores credentials (not exposed to client)
- ✅ Temporary tokens (5 min expiration)
- ✅ x-meshauth header injected by backend
**Recommendations:**
- Add JWT authentication for proxy endpoints
- Rate limiting on connect endpoint
- Audit logging cho remote sessions
### 2. Network Security
**Current:**
- ✅ HTTPS between client-backend (production)
- ✅ WSS (WebSocket Secure) to MeshCentral
- ✅ CORS configured
**Recommendations:**
- Restrict CORS to specific origins (production)
- Use certificate pinning for MeshCentral connection
- Implement connection timeout policies
### 3. Data Protection
**Current:**
- ✅ No credentials stored in client
- ✅ Tokens expire after 5 minutes
- ✅ WebSocket messages not logged
**Recommendations:**
- Encrypt sensitive data in transit
- Implement session timeout
- Add PII data masking in logs
---
## 📈 Future Improvements
### 1. Multi-tenancy
- [ ] Support multiple MeshCentral servers
- [ ] Per-user MeshCentral credentials
- [ ] Organization-level access control
### 2. Features
- [ ] Session recording/playback
- [ ] File transfer progress indicator
- [ ] Clipboard history
- [ ] Multi-monitor support
### 3. Performance
- [ ] WebRTC for lower latency
- [ ] H.264 video encoding
- [ ] Adaptive quality based on bandwidth
### 4. Monitoring
- [ ] Prometheus metrics export
- [ ] Session duration tracking
- [ ] Error rate monitoring
- [ ] Performance dashboards
---
## 📚 References
### MeshCentral Documentation
- Official site: https://meshcentral.com
- GitHub: https://github.com/Ylianst/MeshCentral
- API docs: https://meshcentral.com/apidoc
### Related Technologies
- ASP.NET Core WebSockets: https://docs.microsoft.com/en-us/aspnet/core/fundamentals/websockets
- React + TypeScript: https://react.dev
- Vite: https://vitejs.dev
- shadcn/ui: https://ui.shadcn.com
---
## ✅ Summary
Bạn đã successfully implement:
1. ✅ **Backend HTTP Proxy** cho tất cả MeshCentral requests
2. ✅ **Backend WebSocket Proxy** cho control/relay channels
3. ✅ **Frontend iframe component** với proxy integration
4. ✅ **Authentication flow** với temporary tokens
5. ✅ **Protocol conversion** (HTTP→WS, HTTPS→WSS)
6. ✅ **Full feature support** (Desktop, Terminal, Files)
**Result:**
- Remote desktop hoạt động 100% trong iframe
- Cookies không bị block (same-origin via proxy)
- Tất cả MeshCentral features available
- Clean, maintainable code structure
🎉 **Chúc mừng!** Implementation hoàn chỉnh và production-ready!

View File

@ -1,138 +0,0 @@
# ✅ MeshCentral Remote Desktop - Hoàn thành!
## 🎯 Đã implement
**MeshCentral Remote Desktop** nhúng trong **iframe** với **backend proxy** để giải quyết third-party cookies blocking.
---
## 📁 Files đã thay đổi/tạo mới
### Backend (C#)
1. ✅ **`MeshCentralProxyController.cs`** (NEW)
- HTTP proxy: `/api/meshcentral/proxy/**`
- WebSocket proxy: `/api/meshcentral/proxy/meshrelay.ashx`
2. ✅ **`MeshCentralWebSocketProxyController.cs`** (NEW)
- WebSocket proxy: `/control.ashx`, `/commander.ashx`, `/mesh.ashx`
3. ✅ **`Program.cs`** (MODIFIED)
- HttpClient factory config
- WebSocket middleware enabled
### Frontend (React + TypeScript)
4. ✅ **`remote-control/index.tsx`** (MODIFIED)
- iframe component với proxy URL
- Fullscreen support
- Clean UI (removed popup option)
5. ✅ **`switch.tsx`** (NEW)
- shadcn/ui Switch component (đã add)
---
## 🔧 Cách hoạt động
```
User nhập nodeID → Click Connect
Frontend call API → Backend tạo token
Backend return URL → Frontend transform to proxy URL
iframe load → Same-origin request to proxy ✅
Backend proxy → Forward to MeshCentral
WebSocket connections → Proxy bidirectionally
Remote Desktop work! 🎉
```
---
## 🚀 Cách sử dụng
### 1. Start Backend
```bash
cd f:\TTMT.ComputerManagement\TTMT.CompManageWeb
dotnet run
```
### 2. Start Frontend
```bash
cd f:\TTMT.ManageWebGUI
npm run dev
```
### 3. Test Remote
1. Mở `http://localhost:3000`
2. Navigate → "Điều khiển trực tiếp"
3. Nhập nodeID: `node//xxxxx`
4. Click **Connect**
5. Modal xuất hiện → Remote desktop load!
---
## ✅ Features
- 🖥️ **Remote Desktop** - Screen streaming
- 💻 **Terminal** - Interactive shell
- 📁 **Files** - File manager
- 📋 **Clipboard** - Copy/paste sync
- 🎛️ **All MeshCentral features** work!
---
## 📊 Endpoints Summary
| Endpoint | Type | Purpose |
|----------|------|---------|
| `/api/meshcentral/proxy/**` | HTTP | Proxy all HTTP requests |
| `/api/meshcentral/proxy/meshrelay.ashx` | WebSocket | Desktop/Terminal/Files |
| `/control.ashx` | WebSocket | Main control channel |
| `/commander.ashx` | WebSocket | Command channel |
| `/mesh.ashx` | WebSocket | Mesh relay |
---
## 🐛 Common Issues
### 404 Not Found
- **Fix:** Restart backend để load controllers mới
### WebSocket Error
- **Fix:** Check protocol conversion (HTTPS → WSS)
### Authentication Failed
- **Fix:** Verify credentials trong `appsettings.json`
---
## 📚 Documentation
**Chi tiết đầy đủ:** Xem file `COMPLETE_IMPLEMENTATION_GUIDE.md`
Bao gồm:
- Architecture details
- Flow diagrams
- Code explanations
- Troubleshooting guide
- Performance tips
- Security considerations
---
## 🎉 Kết quả
✅ iframe remote desktop **hoạt động 100%**
✅ Cookies **không bị block** (same-origin via proxy)
✅ Tất cả features **available**
✅ Code **clean & maintainable**
**Production-ready**!
---
**Chúc mừng! Implementation hoàn chỉnh!** 🚀

View File

@ -1,302 +0,0 @@
# MeshCentral Remote Desktop - Implementation Summary
## 🎯 Overview
Đã implement **MeshCentral Remote Desktop** nhúng trong **iframe** với **backend proxy** để giải quyết vấn đề third-party cookies blocking.
---
## 📁 Files Changed
### Backend (C#)
**Location:** `f:\TTMT.ComputerManagement\TTMT.CompManageWeb\Controllers\APIs\`
1. ✅ **`MeshCentralProxyController.cs`** (NEW - 230 lines)
- HTTP proxy cho `/api/meshcentral/proxy/**`
- WebSocket proxy cho `meshrelay.ashx`
- Tự động inject `x-meshauth` header
2. ✅ **`MeshCentralWebSocketProxyController.cs`** (NEW - 180 lines)
- WebSocket proxy cho `/control.ashx`, `/commander.ashx`, `/mesh.ashx`
- Bidirectional message relay
- Protocol conversion (HTTPS → WSS)
3. ✅ **`Program.cs`** (MODIFIED)
- HttpClient factory configuration
- WebSocket middleware enabled (`app.UseWebSockets()`)
### Frontend (React + TypeScript)
**Location:** `f:\TTMT.ManageWebGUI\src\routes\_auth\remote-control\`
4. ✅ **`index.tsx`** (MODIFIED - cleaned up)
- iframe component với proxy URL
- Fullscreen support
- Removed popup option (chỉ giữ iframe)
---
## 🔧 How It Works
### Flow Diagram
```
User nhập nodeID → Click Connect
Frontend call API
GET /api/meshcentral/devices/{nodeId}/remote-desktop
Backend tạo temporary token (expire 5 phút)
MeshCentral.createLoginToken()
Backend return URL
https://my-mesh-test.com/login?user=~t:xxx&pass=yyy&node=...
Frontend transform to proxy URL
http://localhost:5218/api/meshcentral/proxy/login?user=~t:xxx&pass=yyy&...
iframe render với proxy URL (same-origin ✅)
Backend HTTP Proxy
- Accept request
- Inject x-meshauth header
- Forward to MeshCentral
MeshCentral validate token → Set cookies → Return page
iframe load MeshCentral client
Client create WebSocket connections:
- ws://localhost:5218/control.ashx
- ws://localhost:5218/api/meshcentral/proxy/meshrelay.ashx
Backend WebSocket Proxy
- Accept client WebSocket
- Convert protocol (HTTPS → WSS)
- Connect to MeshCentral server
- Inject x-meshauth header
- Bidirectional relay messages
✅ Remote Desktop Session Established!
```
---
## 🚀 Usage
### 1. Start Backend
```bash
cd f:\TTMT.ComputerManagement\TTMT.CompManageWeb
dotnet run
```
Backend runs on: `http://localhost:5218`
### 2. Start Frontend
```bash
cd f:\TTMT.ManageWebGUI
npm run dev
```
Frontend runs on: `http://localhost:3000`
### 3. Test Remote Desktop
1. Mở browser → `http://localhost:3000`
2. Navigate → "Điều khiển trực tiếp"
3. Nhập nodeID: `node//xxxxx` (replace với nodeID thật)
4. Click **Connect**
5. Modal xuất hiện với iframe
6. 🎉 Remote desktop load và hoạt động!
---
## ✅ Features Available
- 🖥️ **Remote Desktop** - Screen streaming với mouse/keyboard control
- 💻 **Terminal** - Interactive shell session
- 📁 **Files** - File browser, upload/download
- 📋 **Clipboard** - Sync clipboard giữa local và remote
- 🎛️ **All MeshCentral features** đều work!
---
## 📊 Proxy Endpoints
### HTTP Proxy
| Frontend Request | Backend Forward To | Purpose |
|-----------------|-------------------|---------|
| `http://localhost:5218/api/meshcentral/proxy/login?...` | `https://my-mesh-test.com/login?...` | Login page |
| `http://localhost:5218/api/meshcentral/proxy/**` | `https://my-mesh-test.com/**` | All resources |
### WebSocket Proxy
| Frontend Connect | Backend Forward To | Purpose |
|-----------------|-------------------|---------|
| `ws://localhost:5218/control.ashx` | `wss://my-mesh-test.com/control.ashx` | Main control channel |
| `ws://localhost:5218/commander.ashx` | `wss://my-mesh-test.com/commander.ashx` | Command channel |
| `ws://localhost:5218/mesh.ashx` | `wss://my-mesh-test.com/mesh.ashx` | Mesh relay |
| `ws://localhost:5218/api/meshcentral/proxy/meshrelay.ashx` | `wss://my-mesh-test.com/meshrelay.ashx` | Desktop/Terminal/Files relay |
---
## 🔑 Configuration
### Backend - appsettings.json
```json
{
"MeshCentral": {
"ServerUrl": "https://my-mesh-test.com",
"Username": "~t:khXUGsHAPKvs3oLs",
"Password": "r4Ks7OUX40K5PLZh4jZO",
"LoginTokenKey": "e5ffe284c480581056188cabb28bebc2647f44a3...",
"AllowInvalidTlsCertificate": true
}
}
```
### Frontend - .env
```env
VITE_API_URL_DEV=http://localhost:5218/api
```
---
## 🐛 Troubleshooting
### Issue 1: 404 Not Found trong iframe
**Symptom:** iframe hiển thị trang 404
**Cause:** Backend chưa restart sau khi thêm proxy controllers
**Solution:**
```bash
# Stop backend (Ctrl+C)
cd f:\TTMT.ComputerManagement\TTMT.CompManageWeb
dotnet run
```
### Issue 2: "Unable to connect web socket"
**Symptom:** Error message trong iframe
**Possible Causes:**
1. Backend proxy controller chưa load → Restart backend
2. WebSocket endpoint not found → Check controllers exist
3. Protocol mismatch (HTTPS vs WSS) → Already fixed in code
**Solution:**
- Restart backend
- Check backend logs cho `[MeshProxy]` hoặc `[MeshWSProxy]`
### Issue 3: Authentication Failed
**Symptom:** Login loop hoặc error
**Check:**
1. `appsettings.json` → MeshCentral credentials correct?
2. Backend logs → `x-meshauth` header được inject?
3. MeshCentral server online và credentials valid?
### Issue 4: 502 Bad Gateway
**Symptom:** Backend returns 502
**Cause:** Backend không connect được đến MeshCentral server
**Check:**
1. MeshCentral ServerUrl correct trong appsettings.json?
2. Network/firewall blocking connection?
3. MeshCentral server đang chạy?
---
## 📝 Verify Logs
### Expected Backend Logs (khi connect)
```
[MeshProxy] Proxying meshrelay WebSocket to: wss://my-mesh-test.com/meshrelay.ashx?...
[MeshProxy] meshrelay WebSocket connected, starting bidirectional relay
[MeshWSProxy] Proxying WebSocket to: wss://my-mesh-test.com/control.ashx
[MeshWSProxy] WebSocket connected for control.ashx, starting relay
```
### Browser DevTools (F12 → Network → WS tab)
Expected WebSocket connections:
- ✅ `control.ashx` - Status: 101 Switching Protocols
- ✅ `meshrelay.ashx` - Status: 101 Switching Protocols
- ✅ Messages flowing (green arrows in Chrome DevTools)
---
## 🔒 Security Notes
### Authentication
- ✅ Backend stores credentials (not exposed to client)
- ✅ Temporary tokens expire after 5 minutes
- ✅ `x-meshauth` header injected by backend automatically
- ⚠️ Consider adding JWT authentication for proxy endpoints in production
### Network
- ✅ HTTPS between client-backend (production)
- ✅ WSS (WebSocket Secure) to MeshCentral
- ✅ CORS configured (currently AllowAll)
- ⚠️ Restrict CORS to specific origins in production
---
## 📈 Performance
### Single Session
- Memory: ~50-100 MB (backend + websockets)
- CPU: ~5-10% (1 core)
- Network: ~1-5 Mbps (depends on screen resolution)
### Multiple Sessions
- Linear scaling (each session independent)
- Recommended: Max 50 concurrent sessions per backend instance
- For more: Deploy multiple backend instances with load balancer
---
## 🎉 Result
✅ **iframe remote desktop hoạt động 100%**
**Cookies không bị block** (same-origin via proxy)
✅ **Tất cả MeshCentral features available**
✅ **Code clean & maintainable**
✅ **Production-ready!**
---
## 📚 Additional Documentation
Chi tiết đầy đủ về implementation có trong session workspace:
**Location:** `C:\Users\psydu\.copilot\session-state\c87806ca-6b49-41de-8573-1504efb7be1f\`
- `COMPLETE_IMPLEMENTATION_GUIDE.md` (18KB) - Architecture, flow, technical details
- `SUMMARY.md` (3KB) - Quick reference
- `FIX_*.md` - Troubleshooting guides từng issue cụ thể
---
**Chúc mừng! Implementation hoàn chỉnh!** 🚀
---
_Last updated: 2026-03-28_
_Version: 1.0_

View File

@ -3,7 +3,13 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="/public/computer-956.svg" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Be+Vietnam+Pro:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
<link rel="icon" href="/computer-956.svg" />
<meta name="theme-color" content="#000000" /> <meta name="theme-color" content="#000000" />
<meta <meta
name="description" name="description"

419
package-lock.json generated
View File

@ -37,6 +37,7 @@
"radix-ui": "^1.4.3", "radix-ui": "^1.4.3",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"recharts": "^3.8.1",
"shadcn": "^2.9.3", "shadcn": "^2.9.3",
"sidebar": "^1.0.0", "sidebar": "^1.0.0",
"sonner": "^2.0.7", "sonner": "^2.0.7",
@ -2855,6 +2856,40 @@
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@reduxjs/toolkit": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
"integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@standard-schema/utils": "^0.3.0",
"immer": "^11.0.0",
"redux": "^5.0.1",
"redux-thunk": "^3.1.0",
"reselect": "^5.1.0"
},
"peerDependencies": {
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-redux": {
"optional": true
}
}
},
"node_modules/@reduxjs/toolkit/node_modules/immer": {
"version": "11.1.4",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz",
"integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/@rolldown/pluginutils": { "node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.27", "version": "1.0.0-beta.27",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
@ -3162,6 +3197,16 @@
"win32" "win32"
] ]
}, },
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="
},
"node_modules/@tailwindcss/node": { "node_modules/@tailwindcss/node": {
"version": "4.1.11", "version": "4.1.11",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.11.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.11.tgz",
@ -3918,6 +3963,60 @@
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==" "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="
}, },
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="
},
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-path": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="
},
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"dependencies": {
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-shape": {
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/@types/d3-time": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="
},
"node_modules/@types/deep-eql": { "node_modules/@types/deep-eql": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
@ -3970,12 +4069,16 @@
"resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz",
"integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==" "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA=="
}, },
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="
},
"node_modules/@vitejs/plugin-basic-ssl": { "node_modules/@vitejs/plugin-basic-ssl": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-2.3.0.tgz", "resolved": "https://registry.npmjs.org/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-2.3.0.tgz",
"integrity": "sha512-bdyo8rB3NnQbikdMpHaML9Z1OZPBu6fFOBo+OtxsBlvMJtysWskmBcnbIDhUqgC8tcxNv/a+BcV5U+2nQMm1OQ==", "integrity": "sha512-bdyo8rB3NnQbikdMpHaML9Z1OZPBu6fFOBo+OtxsBlvMJtysWskmBcnbIDhUqgC8tcxNv/a+BcV5U+2nQMm1OQ==",
"dev": true, "dev": true,
"license": "MIT",
"engines": { "engines": {
"node": "^18.0.0 || ^20.0.0 || >=22.0.0" "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
}, },
@ -4409,9 +4512,9 @@
} }
}, },
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "2.0.2", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==",
"dependencies": { "dependencies": {
"balanced-match": "^1.0.0" "balanced-match": "^1.0.0"
} }
@ -4887,6 +4990,116 @@
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="
}, },
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"engines": {
"node": ">=12"
}
},
"node_modules/data-uri-to-buffer": { "node_modules/data-uri-to-buffer": {
"version": "4.0.1", "version": "4.0.1",
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
@ -4941,6 +5154,11 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/decimal.js-light": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="
},
"node_modules/decode-formdata": { "node_modules/decode-formdata": {
"version": "0.9.0", "version": "0.9.0",
"resolved": "https://registry.npmjs.org/decode-formdata/-/decode-formdata-0.9.0.tgz", "resolved": "https://registry.npmjs.org/decode-formdata/-/decode-formdata-0.9.0.tgz",
@ -5016,9 +5234,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/devalue": { "node_modules/devalue": {
"version": "5.6.3", "version": "5.6.4",
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.3.tgz", "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.4.tgz",
"integrity": "sha512-nc7XjUU/2Lb+SvEFVGcWLiKkzfw8+qHI7zn8WYXKkLMgfGSHbgCEaR6bJpev8Cm6Rmrb19Gfd/tZvGqx9is3wg==" "integrity": "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA=="
}, },
"node_modules/diff": { "node_modules/diff": {
"version": "8.0.3", "version": "8.0.3",
@ -5152,6 +5370,15 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/es-toolkit": {
"version": "1.45.1",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz",
"integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==",
"workspaces": [
"docs",
"benchmarks"
]
},
"node_modules/esbuild": { "node_modules/esbuild": {
"version": "0.25.8", "version": "0.25.8",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.8.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.8.tgz",
@ -5235,6 +5462,11 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/eventemitter3": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="
},
"node_modules/eventsource": { "node_modules/eventsource": {
"version": "3.0.7", "version": "3.0.7",
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
@ -5329,11 +5561,11 @@
} }
}, },
"node_modules/express-rate-limit": { "node_modules/express-rate-limit": {
"version": "8.2.1", "version": "8.3.1",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz", "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.1.tgz",
"integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==", "integrity": "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==",
"dependencies": { "dependencies": {
"ip-address": "10.0.1" "ip-address": "10.1.0"
}, },
"engines": { "engines": {
"node": ">= 16" "node": ">= 16"
@ -5743,9 +5975,9 @@
"integrity": "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==" "integrity": "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ=="
}, },
"node_modules/hono": { "node_modules/hono": {
"version": "4.12.4", "version": "4.12.9",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.4.tgz", "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.9.tgz",
"integrity": "sha512-ooiZW1Xy8rQ4oELQ++otI2T9DsKpV0M6c6cO6JGx4RTfav9poFFLlet9UMXHZnoM1yG0HWGlQLswBGX3RZmHtg==", "integrity": "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA==",
"engines": { "engines": {
"node": ">=16.9.0" "node": ">=16.9.0"
} }
@ -5850,6 +6082,15 @@
} }
] ]
}, },
"node_modules/immer": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/import-fresh": { "node_modules/import-fresh": {
"version": "3.3.1", "version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@ -5870,10 +6111,18 @@
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
}, },
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"engines": {
"node": ">=12"
}
},
"node_modules/ip-address": { "node_modules/ip-address": {
"version": "10.0.1", "version": "10.1.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
"integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
"engines": { "engines": {
"node": ">= 12" "node": ">= 12"
} }
@ -6929,9 +7178,9 @@
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="
}, },
"node_modules/picomatch": { "node_modules/picomatch": {
"version": "2.3.1", "version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"engines": { "engines": {
"node": ">=8.6" "node": ">=8.6"
}, },
@ -7249,9 +7498,30 @@
"version": "17.0.2", "version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/react-redux": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"@types/react": "^18.2.25 || ^19",
"react": "^18.0 || ^19",
"redux": "^5.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"redux": {
"optional": true
}
}
},
"node_modules/react-refresh": { "node_modules/react-refresh": {
"version": "0.17.0", "version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@ -7378,6 +7648,48 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/recharts": {
"version": "3.8.1",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz",
"integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==",
"workspaces": [
"www"
],
"dependencies": {
"@reduxjs/toolkit": "^1.9.0 || 2.x.x",
"clsx": "^2.1.1",
"decimal.js-light": "^2.5.1",
"es-toolkit": "^1.39.3",
"eventemitter3": "^5.0.1",
"immer": "^10.1.1",
"react-redux": "8.x.x || 9.x.x",
"reselect": "5.1.1",
"tiny-invariant": "^1.3.3",
"use-sync-external-store": "^1.2.2",
"victory-vendor": "^37.0.2"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="
},
"node_modules/redux-thunk": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
"peerDependencies": {
"redux": "^5.0.0"
}
},
"node_modules/require-directory": { "node_modules/require-directory": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
@ -7399,6 +7711,11 @@
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==" "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ=="
}, },
"node_modules/reselect": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="
},
"node_modules/resolve-from": { "node_modules/resolve-from": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
@ -7520,9 +7837,9 @@
} }
}, },
"node_modules/router/node_modules/path-to-regexp": { "node_modules/router/node_modules/path-to-regexp": {
"version": "8.3.0", "version": "8.4.0",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.0.tgz",
"integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", "integrity": "sha512-PuseHIvAnz3bjrM2rGJtSgo1zjgxapTLZ7x2pjhzWwlp4SJQgK3f3iZIQwkpEnBaKz6seKBADpM4B4ySkuYypg==",
"funding": { "funding": {
"type": "opencollective", "type": "opencollective",
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
@ -8073,9 +8390,9 @@
} }
}, },
"node_modules/tar": { "node_modules/tar": {
"version": "7.5.9", "version": "7.5.13",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.9.tgz", "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.13.tgz",
"integrity": "sha512-BTLcK0xsDh2+PUe9F6c2TlRp4zOOBMTkoQHQIWSIzI0R7KG46uEwq4OPk2W7bZcprBMsuaeFsqwYr7pjh6CuHg==", "integrity": "sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==",
"dependencies": { "dependencies": {
"@isaacs/fs-minipass": "^4.0.0", "@isaacs/fs-minipass": "^4.0.0",
"chownr": "^3.0.0", "chownr": "^3.0.0",
@ -8150,10 +8467,9 @@
} }
}, },
"node_modules/tinyglobby/node_modules/picomatch": { "node_modules/tinyglobby/node_modules/picomatch": {
"version": "4.0.3", "version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"license": "MIT",
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@ -8385,9 +8701,9 @@
} }
}, },
"node_modules/unplugin/node_modules/picomatch": { "node_modules/unplugin/node_modules/picomatch": {
"version": "4.0.3", "version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@ -8509,6 +8825,27 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/victory-vendor": {
"version": "37.3.6",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
"dependencies": {
"@types/d3-array": "^3.0.3",
"@types/d3-ease": "^3.0.0",
"@types/d3-interpolate": "^3.0.1",
"@types/d3-scale": "^4.0.2",
"@types/d3-shape": "^3.1.0",
"@types/d3-time": "^3.0.0",
"@types/d3-timer": "^3.0.0",
"d3-array": "^3.1.6",
"d3-ease": "^3.0.1",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-shape": "^3.1.0",
"d3-time": "^3.0.0",
"d3-timer": "^3.0.1"
}
},
"node_modules/vite": { "node_modules/vite": {
"version": "6.4.1", "version": "6.4.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
@ -8620,10 +8957,9 @@
} }
}, },
"node_modules/vite/node_modules/picomatch": { "node_modules/vite/node_modules/picomatch": {
"version": "4.0.3", "version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"license": "MIT",
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@ -8705,11 +9041,10 @@
} }
}, },
"node_modules/vitest/node_modules/picomatch": { "node_modules/vitest/node_modules/picomatch": {
"version": "4.0.3", "version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true, "dev": true,
"license": "MIT",
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },

View File

@ -41,6 +41,7 @@
"radix-ui": "^1.4.3", "radix-ui": "^1.4.3",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"recharts": "^3.8.1",
"shadcn": "^2.9.3", "shadcn": "^2.9.3",
"sidebar": "^1.0.0", "sidebar": "^1.0.0",
"sonner": "^2.0.7", "sonner": "^2.0.7",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

View File

@ -125,6 +125,15 @@ export function DeviceSearchDialog({
onClose(); onClose();
}; };
const parseDeviceId = (id: string) => {
const match = /^P(.+?)M(\d+)$/i.exec(id.trim());
if (!match) return null;
return {
room: match[1].trim(),
index: Number(match[2]),
};
};
return ( return (
<Dialog open={open} onOpenChange={handleClose}> <Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-none w-[95vw] max-h-[90vh]"> <DialogContent className="max-w-none w-[95vw] max-h-[90vh]">
@ -156,11 +165,31 @@ export function DeviceSearchDialog({
const isExpanded = expandedRoom === room.name; const isExpanded = expandedRoom === room.name;
const isLoading = loadingRoom === room.name; const isLoading = loadingRoom === room.name;
const devices = roomDevices[room.name] || []; const devices = roomDevices[room.name] || [];
const sortedDevices = [...devices].sort((a, b) => {
const aId = String(a.id);
const bId = String(b.id);
const parsedA = parseDeviceId(aId);
const parsedB = parseDeviceId(bId);
if (parsedA && parsedB) {
const roomCompare = parsedA.room.localeCompare(parsedB.room, undefined, {
numeric: true,
sensitivity: "base",
});
if (roomCompare !== 0) return roomCompare;
return parsedA.index - parsedB.index;
}
return aId.localeCompare(bId, undefined, {
numeric: true,
sensitivity: "base",
});
});
const allSelected = const allSelected =
devices.length > 0 && sortedDevices.length > 0 &&
devices.every((d) => selected.includes(d.id)); sortedDevices.every((d) => selected.includes(d.id));
const someSelected = devices.some((d) => selected.includes(d.id)); const someSelected = sortedDevices.some((d) => selected.includes(d.id));
const selectedCount = devices.filter((d) => const selectedCount = sortedDevices.filter((d) =>
selected.includes(d.id) selected.includes(d.id)
).length; ).length;
@ -219,7 +248,7 @@ export function DeviceSearchDialog({
</div> </div>
{/* Device table - collapsible */} {/* Device table - collapsible */}
{isExpanded && devices.length > 0 && ( {isExpanded && sortedDevices.length > 0 && (
<div className="border-t bg-muted/20 overflow-x-auto"> <div className="border-t bg-muted/20 overflow-x-auto">
<table className="w-full text-xs"> <table className="w-full text-xs">
<thead className="bg-muted/50 border-b sticky top-0"> <thead className="bg-muted/50 border-b sticky top-0">
@ -243,7 +272,7 @@ export function DeviceSearchDialog({
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{devices.map((device) => ( {sortedDevices.map((device) => (
<tr <tr
key={device.id} key={device.id}
className="border-b last:border-b-0 hover:bg-muted/50" className="border-b last:border-b-0 hover:bg-muted/50"

View File

@ -0,0 +1,98 @@
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/components/ui/card";
import type { DeviceOverviewResponse } from "@/types/dashboard";
import { ResponsiveContainer, PieChart, Pie, Cell, Tooltip, Legend } from "recharts";
export function DeviceOverviewCard({
data,
isLoading = false,
}: {
data?: DeviceOverviewResponse | null;
isLoading?: boolean;
}) {
const pieData = [
{ name: "Online", value: data?.onlineDevices ?? 0 },
{ name: "Offline", value: data?.offlineDevices ?? 0 },
];
const COLORS = ["#22c55e", "#ef4444"];
return (
<Card className="bg-white/80 backdrop-blur border-muted/60 shadow-sm">
<CardHeader>
<CardTitle>Tổng quan thiết bị</CardTitle>
<CardDescription>Trạng thái chung các thiết bị offline gần đây</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-3">
<div className="p-3 rounded-lg bg-muted/30 border border-muted/40">
<div className="text-xs text-muted-foreground">Tổng thiết bị</div>
<div className="text-2xl font-bold">{data?.totalDevices ?? 0}</div>
</div>
<div className="p-3 rounded-lg bg-muted/30 border border-muted/40">
<div className="text-xs text-muted-foreground">Agent chưa đưc cập nhật</div>
<div className="text-2xl font-bold">{data?.devicesWithOutdatedVersion ?? 0}</div>
</div>
<div className="p-3 rounded-lg bg-muted/30 border border-muted/40">
<div className="text-xs text-muted-foreground">Online</div>
<div className="text-2xl font-bold">{data?.onlineDevices ?? 0}</div>
</div>
<div className="p-3 rounded-lg bg-muted/30 border border-muted/40">
<div className="text-xs text-muted-foreground">Offline</div>
<div className="text-2xl font-bold">{data?.offlineDevices ?? 0}</div>
</div>
</div>
<div className="mt-4 grid grid-cols-1 lg:grid-cols-2 gap-4">
<div>
<div className="text-sm font-medium mb-2">Tỉ lệ Online / Offline</div>
<div className="h-40">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={pieData}
dataKey="value"
nameKey="name"
innerRadius={30}
outerRadius={60}
label
>
{pieData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<Tooltip />
<Legend />
</PieChart>
</ResponsiveContainer>
</div>
</div>
<div>
<div className="text-sm font-medium">Thiết bị offline gần đây</div>
<div className="mt-2 max-h-40 overflow-auto divide-y divide-muted/40">
{data?.recentOfflineDevices && data.recentOfflineDevices.length > 0 ? (
data.recentOfflineDevices.map((d) => (
<div key={d.deviceId} className="flex items-center justify-between py-2">
<div>
<div className="font-medium">{d.deviceId}</div>
<div className="text-xs text-muted-foreground">{d.room ?? "-"}</div>
</div>
<div className="text-xs text-muted-foreground">
{d.lastSeen ? new Date(d.lastSeen).toLocaleString() : "-"}
</div>
</div>
))
) : (
<div className="text-sm text-muted-foreground">Không thiết bị offline gần đây</div>
)}
</div>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,74 @@
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import type { RoomManagementResponse, RoomHealthStatus } from "@/types/dashboard";
import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip } from "recharts";
function statusBadge(status?: string) {
if (!status) return <Badge>Unknown</Badge>;
if (status === "InSession") return <Badge className="bg-green-100 text-green-700">Đang sử dụng</Badge>;
if (status === "NotInUse") return <Badge className="bg-red-100 text-red-700">Không sử dụng</Badge>;
return <Badge className="bg-yellow-100 text-yellow-700"> thể lớp học</Badge>;
}
export function RoomManagementCard({
data,
isLoading = false,
}: {
data?: RoomManagementResponse | null;
isLoading?: boolean;
}) {
const chartData = (data?.rooms ?? []).map((r) => ({ room: r.roomName, health: r.healthPercentage ?? 0 }));
return (
<Card className="bg-white/80 backdrop-blur border-muted/60 shadow-sm">
<CardHeader>
<CardTitle>Quản phòng</CardTitle>
<CardDescription>Thông tin tổng quan phòng cần chú ý</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div>
<div className="text-xs text-muted-foreground">Tổng phòng</div>
<div className="text-2xl font-bold">{data?.totalRooms ?? 0}</div>
</div>
</div>
<div className="mt-4">
<div className="text-sm font-medium mb-2">Tỉ lệ thiết bị online</div>
<div className="h-48">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={chartData} layout="vertical">
<CartesianGrid strokeDasharray="3 3" />
<XAxis type="number" domain={[0, 100]} />
<YAxis dataKey="room" type="category" width={110} />
<Tooltip />
<Bar dataKey="health" fill="#0ea5e9" radius={[4, 4, 4, 4]} />
</BarChart>
</ResponsiveContainer>
</div>
<div className="mt-4">
<div className="text-sm font-medium">Phòng cần chú ý</div>
<div className="mt-2 space-y-2">
{data?.roomsNeedAttention && data.roomsNeedAttention.length > 0 ? (
data.roomsNeedAttention.map((r: RoomHealthStatus) => (
<div key={r.roomName} className="flex items-center justify-between">
<div>
<div className="font-medium">{r.roomName}</div>
<div className="text-xs text-muted-foreground">{r.totalDevices} thiết bị</div>
</div>
<div className="flex items-center gap-3">
<div className="text-sm font-medium">{r.healthPercentage?.toFixed(1) ?? "-"}%</div>
{statusBadge(r.healthStatus)}
</div>
</div>
))
) : (
<div className="text-sm text-muted-foreground">Không phòng cần chú ý</div>
)}
</div>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,88 @@
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import type { SoftwareDistributionResponse } from "@/types/dashboard";
import { ResponsiveContainer, PieChart, Pie, Cell, Tooltip, Legend } from "recharts";
export function SoftwareDistributionCard({
data,
isLoading = false,
}: {
data?: SoftwareDistributionResponse | null;
isLoading?: boolean;
}) {
void isLoading;
const distData = [
{ name: "Success", value: data?.successfulInstallations ?? 0 },
{ name: "Failed", value: data?.failedInstallations ?? 0 },
{ name: "Pending", value: data?.pendingInstallations ?? 0 },
];
const COLORS = ["#10b981", "#ef4444", "#f59e0b"];
return (
<Card className="bg-white/80 backdrop-blur border-muted/60 shadow-sm">
<CardHeader>
<CardTitle>Phân phối phần mềm</CardTitle>
<CardDescription>Thống cài đt lỗi phổ biến</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="space-y-3">
<div>
<div className="text-xs text-muted-foreground">Tổng log</div>
<div className="text-2xl font-bold">{data?.totalInstallations ?? 0}</div>
</div>
<div className="grid grid-cols-3 gap-2">
<div className="p-3 rounded-lg bg-muted/30 border border-muted/40 text-center">
<div className="text-xs text-muted-foreground">Thành công</div>
<div className="text-2xl font-bold">{data?.successfulInstallations ?? 0}</div>
</div>
<div className="p-3 rounded-lg bg-muted/30 border border-muted/40 text-center">
<div className="text-xs text-muted-foreground">Thất bại</div>
<div className="text-2xl font-bold">{data?.failedInstallations ?? 0}</div>
</div>
<div className="p-3 rounded-lg bg-muted/30 border border-muted/40 text-center">
<div className="text-xs text-muted-foreground">Đang chờ</div>
<div className="text-2xl font-bold">{data?.pendingInstallations ?? 0}</div>
</div>
</div>
<div className="mt-4">
<div className="text-sm font-medium">Top lỗi</div>
<div className="mt-2 space-y-2">
{data?.topFailedSoftware && data.topFailedSoftware.length > 0 ? (
data.topFailedSoftware.map((t) => (
<div key={t.fileName} className="flex items-center justify-between">
<div className="truncate max-w-[180px]">{t.fileName}</div>
<Badge className="bg-red-100 text-red-700">{t.failCount}</Badge>
</div>
))
) : (
<div className="text-sm text-muted-foreground">Không lỗi phổ biến</div>
)}
</div>
</div>
</div>
<div>
<div className="text-sm font-medium mb-2">Tỉ lệ trạng thái cài đt</div>
<div className="h-40">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie data={distData} dataKey="value" nameKey="name" innerRadius={30} outerRadius={60} label>
{distData.map((_, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<Tooltip />
<Legend />
</PieChart>
</ResponsiveContainer>
</div>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@ -4,7 +4,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import type { LoginResquest } from "@/types/auth"; import type { LoginResquest } from "@/types/auth";
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import { login } from "@/services/auth.service"; import { buildSsoLoginUrl, login } from "@/services/auth.service";
import { useState } from "react"; import { useState } from "react";
import { useNavigate, useRouter } from "@tanstack/react-router"; import { useNavigate, useRouter } from "@tanstack/react-router";
import { Route } from "@/routes/(auth)/login"; import { Route } from "@/routes/(auth)/login";
@ -44,6 +44,14 @@ export function LoginForm({ className }: React.ComponentProps<"form">) {
} }
}); });
const handleSsoLogin = () => {
const returnUrl = new URL("/sso/callback", window.location.origin);
if (search.redirect) {
returnUrl.searchParams.set("redirect", search.redirect);
}
window.location.assign(buildSsoLoginUrl(returnUrl.toString()));
};
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => { const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault();
setErrorMessage(null); setErrorMessage(null);
@ -53,10 +61,10 @@ export function LoginForm({ className }: React.ComponentProps<"form">) {
return ( return (
<div className={cn("flex flex-col gap-6", className)}> <div className={cn("flex flex-col gap-6", className)}>
<Card> <Card>
<CardHeader className="text-center flex flex-col items-center"> <CardHeader className="text-center">
<CardTitle className="text-xl flex items-center gap-3"> <CardTitle className="text-2xl font-semibold tracking-tight flex items-center justify-center gap-3">
<img src="/soict_logo.png" alt="logo" className="size-20" /> <img src="/soict_logo.png" alt="SOICT logo" className="h-7 w-auto object-contain" />
<p> Computer Management</p> <span>Computer Management</span>
</CardTitle> </CardTitle>
<CardDescription>Hệ thống quản phòng máy thực hành</CardDescription> <CardDescription>Hệ thống quản phòng máy thực hành</CardDescription>
</CardHeader> </CardHeader>
@ -103,6 +111,16 @@ export function LoginForm({ className }: React.ComponentProps<"form">) {
Đăng nhập Đăng nhập
</Button> </Button>
)} )}
<div className="text-center text-sm text-muted-foreground">Hoặc</div>
<Button type="button" variant="outline" className="w-full gap-2" onClick={handleSsoLogin}>
<svg viewBox="0 0 24 24" aria-hidden="true" className="h-4 w-4">
<rect x="1" y="1" width="10" height="10" fill="#F25022" />
<rect x="13" y="1" width="10" height="10" fill="#7FBA00" />
<rect x="1" y="13" width="10" height="10" fill="#00A4EF" />
<rect x="13" y="13" width="10" height="10" fill="#FFB900" />
</svg>
Đăng nhập với Microsoft
</Button>
</div> </div>
</form> </form>
</CardContent> </CardContent>

View File

@ -11,6 +11,8 @@ export const BASE_MESH_URL = isDev
export const API_ENDPOINTS = { export const API_ENDPOINTS = {
AUTH: { AUTH: {
LOGIN: `${BASE_URL}/login`, LOGIN: `${BASE_URL}/login`,
SSO_LOGIN: `${BASE_URL}/auth/sso/login`,
SSO_EXCHANGE: `${BASE_URL}/auth/sso/exchange`,
LOGOUT: `${BASE_URL}/logout`, LOGOUT: `${BASE_URL}/logout`,
CHANGE_PASSWORD: `${BASE_URL}/auth/change-password`, CHANGE_PASSWORD: `${BASE_URL}/auth/change-password`,
CHANGE_PASSWORD_ADMIN: `${BASE_URL}/auth/admin/change-password`, CHANGE_PASSWORD_ADMIN: `${BASE_URL}/auth/admin/change-password`,
@ -90,11 +92,15 @@ export const API_ENDPOINTS = {
GET_REMOTE_DESKTOP: (deviceId: string) => GET_REMOTE_DESKTOP: (deviceId: string) =>
`${BASE_URL}/MeshCentral/devices/${encodeURIComponent(deviceId)}/remote-desktop`, `${BASE_URL}/MeshCentral/devices/${encodeURIComponent(deviceId)}/remote-desktop`,
}, },
DASHBOARD: DASHBOARD: {
{ GET_SUMMARY: `${BASE_URL}/dashboard/summary`,
GET_GENERAL: `${BASE_URL}/dashboard/general`,
} GET_ROOM_USAGE: `${BASE_URL}/dashboard/usage/rooms`,
, GET_DEVICE_OVERVIEW: `${BASE_URL}/dashboard/devices/overview`,
GET_DEVICES_BY_ROOM: `${BASE_URL}/dashboard/devices/by-room`,
GET_ROOMS: `${BASE_URL}/dashboard/rooms`,
GET_SOFTWARE: `${BASE_URL}/dashboard/software`,
},
AUDIT: { AUDIT: {
GET_AUDITS: `${BASE_URL}/Audit/audits`, GET_AUDITS: `${BASE_URL}/Audit/audits`,
} }

View File

@ -7,6 +7,9 @@ export * from "./useAppVersionQueries";
// Device Communication Queries // Device Communication Queries
export * from "./useDeviceCommQueries"; export * from "./useDeviceCommQueries";
// Dashboard Queries
export * from "./useDashboardQueries";
// Command Queries // Command Queries
export * from "./useCommandQueries"; export * from "./useCommandQueries";

View File

@ -113,3 +113,12 @@ export function useCreateAccount() {
}, },
}); });
} }
/**
* Hook đ đi one-time code SSO lấy payload đăng nhập
*/
export function useExchangeSsoCode() {
return useMutation<LoginResponse, any, string>({
mutationFn: (code) => authService.exchangeSsoCode(code),
});
}

View File

@ -0,0 +1,85 @@
import { useQuery } from "@tanstack/react-query";
import * as dashboardService from "@/services/dashboard.service";
import type {
DashboardSummaryResponse,
DashboardGeneralInfo,
DeviceOverviewResponse,
DeviceStatusByRoom,
RoomManagementResponse,
RoomUsageResponse,
SoftwareDistributionResponse,
} from "@/types/dashboard";
const DASHBOARD_QUERY_KEYS = {
all: ["dashboard"] as const,
summary: () => [...DASHBOARD_QUERY_KEYS.all, "summary"] as const,
general: () => [...DASHBOARD_QUERY_KEYS.all, "general"] as const,
roomUsage: () => [...DASHBOARD_QUERY_KEYS.all, "usage", "rooms"] as const,
deviceOverview: () => [...DASHBOARD_QUERY_KEYS.all, "devices", "overview"] as const,
devicesByRoom: () => [...DASHBOARD_QUERY_KEYS.all, "devices", "by-room"] as const,
rooms: () => [...DASHBOARD_QUERY_KEYS.all, "rooms"] as const,
software: () => [...DASHBOARD_QUERY_KEYS.all, "software"] as const,
};
export function useGetDashboardSummary(enabled = true) {
return useQuery<DashboardSummaryResponse>({
queryKey: DASHBOARD_QUERY_KEYS.summary(),
queryFn: () => dashboardService.getDashboardSummary(),
enabled,
staleTime: 60 * 1000,
});
}
export function useGetDashboardGeneralInfo(enabled = true) {
return useQuery<DashboardGeneralInfo>({
queryKey: DASHBOARD_QUERY_KEYS.general(),
queryFn: () => dashboardService.getDashboardGeneralInfo(),
enabled,
staleTime: 60 * 1000,
});
}
export function useGetRoomUsage(enabled = true) {
return useQuery<RoomUsageResponse>({
queryKey: DASHBOARD_QUERY_KEYS.roomUsage(),
queryFn: () => dashboardService.getRoomUsage(),
enabled,
staleTime: 5 * 60 * 1000,
});
}
export function useGetDeviceOverview(enabled = true) {
return useQuery<DeviceOverviewResponse>({
queryKey: DASHBOARD_QUERY_KEYS.deviceOverview(),
queryFn: () => dashboardService.getDeviceOverview(),
enabled,
staleTime: 30 * 1000,
});
}
export function useGetDeviceStatusByRoom(enabled = true) {
return useQuery<DeviceStatusByRoom[]>({
queryKey: DASHBOARD_QUERY_KEYS.devicesByRoom(),
queryFn: () => dashboardService.getDeviceStatusByRoom(),
enabled,
staleTime: 5 * 60 * 1000,
});
}
export function useGetRoomManagement(enabled = true) {
return useQuery<RoomManagementResponse>({
queryKey: DASHBOARD_QUERY_KEYS.rooms(),
queryFn: () => dashboardService.getRoomManagement(),
enabled,
staleTime: 5 * 60 * 1000,
});
}
export function useGetSoftwareDistribution(enabled = true) {
return useQuery<SoftwareDistributionResponse>({
queryKey: DASHBOARD_QUERY_KEYS.software(),
queryFn: () => dashboardService.getSoftwareDistribution(),
enabled,
staleTime: 60 * 1000,
});
}

View File

@ -116,5 +116,10 @@
} }
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground;
font-family: "Be Vietnam Pro", "Segoe UI", sans-serif;
} }
} }
.dashboard-scope {
font-family: "Be Vietnam Pro", "Segoe UI", sans-serif;
}

View File

@ -28,6 +28,7 @@ import { Route as AuthRoomsRoomNameIndexRouteImport } from './routes/_auth/rooms
import { Route as AuthRoleCreateIndexRouteImport } from './routes/_auth/role/create/index' import { Route as AuthRoleCreateIndexRouteImport } from './routes/_auth/role/create/index'
import { Route as AuthProfileChangePasswordIndexRouteImport } from './routes/_auth/profile/change-password/index' import { Route as AuthProfileChangePasswordIndexRouteImport } from './routes/_auth/profile/change-password/index'
import { Route as AuthProfileUserNameIndexRouteImport } from './routes/_auth/profile/$userName/index' import { Route as AuthProfileUserNameIndexRouteImport } from './routes/_auth/profile/$userName/index'
import { Route as authSsoCallbackIndexRouteImport } from './routes/(auth)/sso/callback/index'
import { Route as AuthUserRoleRoleIdIndexRouteImport } from './routes/_auth/user/role/$roleId/index' import { Route as AuthUserRoleRoleIdIndexRouteImport } from './routes/_auth/user/role/$roleId/index'
import { Route as AuthUserChangePasswordUserNameIndexRouteImport } from './routes/_auth/user/change-password/$userName/index' import { Route as AuthUserChangePasswordUserNameIndexRouteImport } from './routes/_auth/user/change-password/$userName/index'
import { Route as AuthRoomsRoomNameFolderStatusIndexRouteImport } from './routes/_auth/rooms/$roomName/folder-status/index' import { Route as AuthRoomsRoomNameFolderStatusIndexRouteImport } from './routes/_auth/rooms/$roomName/folder-status/index'
@ -130,6 +131,11 @@ const AuthProfileUserNameIndexRoute =
path: '/profile/$userName/', path: '/profile/$userName/',
getParentRoute: () => AuthRoute, getParentRoute: () => AuthRoute,
} as any) } as any)
const authSsoCallbackIndexRoute = authSsoCallbackIndexRouteImport.update({
id: '/(auth)/sso/callback/',
path: '/sso/callback/',
getParentRoute: () => rootRouteImport,
} as any)
const AuthUserRoleRoleIdIndexRoute = AuthUserRoleRoleIdIndexRouteImport.update({ const AuthUserRoleRoleIdIndexRoute = AuthUserRoleRoleIdIndexRouteImport.update({
id: '/user/role/$roleId/', id: '/user/role/$roleId/',
path: '/user/role/$roleId/', path: '/user/role/$roleId/',
@ -173,6 +179,7 @@ export interface FileRoutesByFullPath {
'/role': typeof AuthRoleIndexRoute '/role': typeof AuthRoleIndexRoute
'/rooms': typeof AuthRoomsIndexRoute '/rooms': typeof AuthRoomsIndexRoute
'/user': typeof AuthUserIndexRoute '/user': typeof AuthUserIndexRoute
'/sso/callback': typeof authSsoCallbackIndexRoute
'/profile/$userName': typeof AuthProfileUserNameIndexRoute '/profile/$userName': typeof AuthProfileUserNameIndexRoute
'/profile/change-password': typeof AuthProfileChangePasswordIndexRoute '/profile/change-password': typeof AuthProfileChangePasswordIndexRoute
'/role/create': typeof AuthRoleCreateIndexRoute '/role/create': typeof AuthRoleCreateIndexRoute
@ -198,6 +205,7 @@ export interface FileRoutesByTo {
'/role': typeof AuthRoleIndexRoute '/role': typeof AuthRoleIndexRoute
'/rooms': typeof AuthRoomsIndexRoute '/rooms': typeof AuthRoomsIndexRoute
'/user': typeof AuthUserIndexRoute '/user': typeof AuthUserIndexRoute
'/sso/callback': typeof authSsoCallbackIndexRoute
'/profile/$userName': typeof AuthProfileUserNameIndexRoute '/profile/$userName': typeof AuthProfileUserNameIndexRoute
'/profile/change-password': typeof AuthProfileChangePasswordIndexRoute '/profile/change-password': typeof AuthProfileChangePasswordIndexRoute
'/role/create': typeof AuthRoleCreateIndexRoute '/role/create': typeof AuthRoleCreateIndexRoute
@ -225,6 +233,7 @@ export interface FileRoutesById {
'/_auth/role/': typeof AuthRoleIndexRoute '/_auth/role/': typeof AuthRoleIndexRoute
'/_auth/rooms/': typeof AuthRoomsIndexRoute '/_auth/rooms/': typeof AuthRoomsIndexRoute
'/_auth/user/': typeof AuthUserIndexRoute '/_auth/user/': typeof AuthUserIndexRoute
'/(auth)/sso/callback/': typeof authSsoCallbackIndexRoute
'/_auth/profile/$userName/': typeof AuthProfileUserNameIndexRoute '/_auth/profile/$userName/': typeof AuthProfileUserNameIndexRoute
'/_auth/profile/change-password/': typeof AuthProfileChangePasswordIndexRoute '/_auth/profile/change-password/': typeof AuthProfileChangePasswordIndexRoute
'/_auth/role/create/': typeof AuthRoleCreateIndexRoute '/_auth/role/create/': typeof AuthRoleCreateIndexRoute
@ -252,6 +261,7 @@ export interface FileRouteTypes {
| '/role' | '/role'
| '/rooms' | '/rooms'
| '/user' | '/user'
| '/sso/callback'
| '/profile/$userName' | '/profile/$userName'
| '/profile/change-password' | '/profile/change-password'
| '/role/create' | '/role/create'
@ -277,6 +287,7 @@ export interface FileRouteTypes {
| '/role' | '/role'
| '/rooms' | '/rooms'
| '/user' | '/user'
| '/sso/callback'
| '/profile/$userName' | '/profile/$userName'
| '/profile/change-password' | '/profile/change-password'
| '/role/create' | '/role/create'
@ -303,6 +314,7 @@ export interface FileRouteTypes {
| '/_auth/role/' | '/_auth/role/'
| '/_auth/rooms/' | '/_auth/rooms/'
| '/_auth/user/' | '/_auth/user/'
| '/(auth)/sso/callback/'
| '/_auth/profile/$userName/' | '/_auth/profile/$userName/'
| '/_auth/profile/change-password/' | '/_auth/profile/change-password/'
| '/_auth/role/create/' | '/_auth/role/create/'
@ -319,6 +331,7 @@ export interface RootRouteChildren {
IndexRoute: typeof IndexRoute IndexRoute: typeof IndexRoute
AuthRoute: typeof AuthRouteWithChildren AuthRoute: typeof AuthRouteWithChildren
authLoginIndexRoute: typeof authLoginIndexRoute authLoginIndexRoute: typeof authLoginIndexRoute
authSsoCallbackIndexRoute: typeof authSsoCallbackIndexRoute
} }
declare module '@tanstack/react-router' { declare module '@tanstack/react-router' {
@ -456,6 +469,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthProfileUserNameIndexRouteImport preLoaderRoute: typeof AuthProfileUserNameIndexRouteImport
parentRoute: typeof AuthRoute parentRoute: typeof AuthRoute
} }
'/(auth)/sso/callback/': {
id: '/(auth)/sso/callback/'
path: '/sso/callback'
fullPath: '/sso/callback'
preLoaderRoute: typeof authSsoCallbackIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/_auth/user/role/$roleId/': { '/_auth/user/role/$roleId/': {
id: '/_auth/user/role/$roleId/' id: '/_auth/user/role/$roleId/'
path: '/user/role/$roleId' path: '/user/role/$roleId'
@ -550,6 +570,7 @@ const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute, IndexRoute: IndexRoute,
AuthRoute: AuthRouteWithChildren, AuthRoute: AuthRouteWithChildren,
authLoginIndexRoute: authLoginIndexRoute, authLoginIndexRoute: authLoginIndexRoute,
authSsoCallbackIndexRoute: authSsoCallbackIndexRoute,
} }
export const routeTree = rootRouteImport export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren) ._addFileChildren(rootRouteChildren)

View File

@ -0,0 +1,81 @@
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
import { useEffect, useState } from "react";
import { useExchangeSsoCode } from "@/hooks/queries";
import { useAuth } from "@/hooks/useAuth";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { LoaderCircle } from "lucide-react";
export const Route = createFileRoute("/(auth)/sso/callback/")({
component: SsoCallbackPage,
});
function SsoCallbackPage() {
const auth = useAuth();
const navigate = useNavigate();
const exchangeMutation = useExchangeSsoCode();
const search = Route.useSearch() as { code?: string; redirect?: string };
const [errorMessage, setErrorMessage] = useState<string | null>(null);
useEffect(() => {
if (!search.code) {
setErrorMessage("SSO code is missing.");
return;
}
setErrorMessage(null);
exchangeMutation.mutate(search.code, {
onSuccess: async (data) => {
if (!data.token) {
setErrorMessage("SSO response missing token.");
return;
}
localStorage.setItem("token", data.token);
localStorage.setItem("username", data.username || "");
localStorage.setItem("name", data.name || "");
localStorage.setItem("acs", (data.access ?? "").toString());
localStorage.setItem("role", data.role?.roleName || "");
localStorage.setItem("priority", String(data.role?.priority ?? "-1"));
localStorage.setItem("computersmanagement.auth.user", data.username || "");
localStorage.setItem("accesscontrol.auth.user", data.username || "");
auth.setAuthenticated(true);
auth.login(data.username || "");
await navigate({ to: search.redirect || "/dashboard" });
},
onError: () => {
setErrorMessage("SSO exchange failed.");
},
});
}, [auth, exchangeMutation, 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">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<CardTitle className="text-xl">Đang xác thực SSO</CardTitle>
<CardDescription>Vui lòng đi trong giây lát.</CardDescription>
</CardHeader>
<CardContent className="flex flex-col items-center gap-4">
{exchangeMutation.isPending && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<LoaderCircle className="w-4 h-4 animate-spin" />
Đang trao đi đăng nhập
</div>
)}
{errorMessage && (
<div className="text-destructive text-sm text-center">{errorMessage}</div>
)}
{errorMessage && (
<Link to="/login" className="w-full">
<Button className="w-full">Quay lại đăng nhập</Button>
</Link>
)}
</CardContent>
</Card>
</div>
);
}

View File

@ -1,15 +1,75 @@
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute } from '@tanstack/react-router'
import { DashboardTemplate } from '@/template/dashboard-template'
import {
useGetDashboardSummary,
useGetDashboardGeneralInfo,
useGetDeviceOverview,
useGetDeviceStatusByRoom,
useGetRoomUsage,
useGetRoomManagement,
useGetSoftwareDistribution,
} from '@/hooks/queries/useDashboardQueries'
export const Route = createFileRoute('/_auth/dashboard/')({ export const Route = createFileRoute('/_auth/dashboard/')({
component: RouteComponent, component: RouteComponent,
head: () => ({ meta: [{ title: 'Dashboard' }] }), head: () => ({ meta: [{ title: 'Dashboard' }] }),
loader: async ({ context }) => { loader: async ({ context }) => {
context.breadcrumbs = [ context.breadcrumbs = [
{ title: "Dashboard", path: "/_auth/dashboard/" }, { title: "Dashboard", path: "#" },
]; ];
}, },
}) })
function RouteComponent() { function RouteComponent() {
return <div>Hello "/(auth)/dashboard/"!</div> const summaryQuery = useGetDashboardSummary();
const generalQuery = useGetDashboardGeneralInfo();
const deviceOverviewQuery = useGetDeviceOverview();
const devicesByRoomQuery = useGetDeviceStatusByRoom();
const roomUsageQuery = useGetRoomUsage();
const roomsQuery = useGetRoomManagement();
const softwareQuery = useGetSoftwareDistribution();
const isLoading =
summaryQuery.isLoading ||
generalQuery.isLoading ||
deviceOverviewQuery.isLoading ||
devicesByRoomQuery.isLoading ||
roomUsageQuery.isLoading ||
roomsQuery.isLoading ||
softwareQuery.isLoading;
const isFetching =
summaryQuery.isFetching ||
generalQuery.isFetching ||
deviceOverviewQuery.isFetching ||
devicesByRoomQuery.isFetching ||
roomUsageQuery.isFetching ||
roomsQuery.isFetching ||
softwareQuery.isFetching;
const handleRefresh = async () => {
await Promise.allSettled([
summaryQuery.refetch(),
generalQuery.refetch(),
deviceOverviewQuery.refetch(),
devicesByRoomQuery.refetch(),
roomUsageQuery.refetch(),
roomsQuery.refetch(),
softwareQuery.refetch(),
]);
};
return (
<DashboardTemplate
generalInfo={generalQuery.data ?? summaryQuery.data?.generalInfo}
deviceOverview={deviceOverviewQuery.data ?? summaryQuery.data?.deviceOverview}
roomManagement={roomsQuery.data ?? summaryQuery.data?.roomManagement}
roomUsage={roomUsageQuery.data ?? summaryQuery.data?.roomUsage}
softwareDistribution={softwareQuery.data ?? summaryQuery.data?.softwareDistribution}
devicesByRoom={devicesByRoomQuery.data}
isLoading={isLoading}
isFetching={isFetching}
onRefresh={handleRefresh}
/>
);
} }

View File

@ -15,6 +15,28 @@ export async function login(credentials: LoginResquest): Promise<LoginResponse>
return response.data; return response.data;
} }
/**
* Build SSO login URL
* @param returnUrl - FE callback url
*/
export function buildSsoLoginUrl(returnUrl: string): string {
const base = API_ENDPOINTS.AUTH.SSO_LOGIN;
const encoded = encodeURIComponent(returnUrl);
return `${base}?returnUrl=${encoded}`;
}
/**
* Exchange one-time code for login payload
* @param code - one-time code
*/
export async function exchangeSsoCode(code: string): Promise<LoginResponse> {
const response = await axios.post<LoginResponse>(
API_ENDPOINTS.AUTH.SSO_EXCHANGE,
{ code }
);
return response.data;
}
/** /**
* Đăng xuất * Đăng xuất
*/ */

View File

@ -0,0 +1,46 @@
import axios from "@/config/axios";
import { API_ENDPOINTS } from "@/config/api";
import type {
DashboardSummaryResponse,
DashboardGeneralInfo,
DeviceOverviewResponse,
DeviceStatusByRoom,
RoomManagementResponse,
RoomUsageResponse,
SoftwareDistributionResponse,
} from "@/types/dashboard";
export async function getDashboardSummary(): Promise<DashboardSummaryResponse> {
const response = await axios.get<DashboardSummaryResponse>(API_ENDPOINTS.DASHBOARD.GET_SUMMARY);
return response.data;
}
export async function getDashboardGeneralInfo(): Promise<DashboardGeneralInfo> {
const response = await axios.get<DashboardGeneralInfo>(API_ENDPOINTS.DASHBOARD.GET_GENERAL);
return response.data;
}
export async function getRoomUsage(): Promise<RoomUsageResponse> {
const response = await axios.get<RoomUsageResponse>(API_ENDPOINTS.DASHBOARD.GET_ROOM_USAGE);
return response.data;
}
export async function getDeviceOverview(): Promise<DeviceOverviewResponse> {
const response = await axios.get<DeviceOverviewResponse>(API_ENDPOINTS.DASHBOARD.GET_DEVICE_OVERVIEW);
return response.data;
}
export async function getDeviceStatusByRoom(): Promise<DeviceStatusByRoom[]> {
const response = await axios.get<DeviceStatusByRoom[]>(API_ENDPOINTS.DASHBOARD.GET_DEVICES_BY_ROOM);
return response.data;
}
export async function getRoomManagement(): Promise<RoomManagementResponse> {
const response = await axios.get<RoomManagementResponse>(API_ENDPOINTS.DASHBOARD.GET_ROOMS);
return response.data;
}
export async function getSoftwareDistribution(): Promise<SoftwareDistributionResponse> {
const response = await axios.get<SoftwareDistributionResponse>(API_ENDPOINTS.DASHBOARD.GET_SOFTWARE);
return response.data;
}

View File

@ -19,5 +19,8 @@ export * as roleService from "./role.service";
// Mesh Central API Services // Mesh Central API Services
export * as meshCentralService from "./meshcentral.service"; export * as meshCentralService from "./meshcentral.service";
// Dashboard API Services
export * as dashboardService from "./dashboard.service";
// Remote Control API Services // Remote Control API Services
export * as remoteControlService from "./remote-control.service"; export * as remoteControlService from "./remote-control.service";

View File

@ -0,0 +1,277 @@
import { useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { DeviceOverviewCard } from "@/components/cards/device-overview-card";
import { RoomManagementCard } from "@/components/cards/room-management-card";
import { SoftwareDistributionCard } from "@/components/cards/software-distribution-card";
import { RefreshCw } from "lucide-react";
import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import type {
DashboardGeneralInfo,
DeviceOverviewResponse,
RoomManagementResponse,
RoomUsageResponse,
SoftwareDistributionResponse,
DeviceStatusByRoom,
} from "@/types/dashboard";
interface DashboardTemplateProps {
generalInfo?: DashboardGeneralInfo | null;
deviceOverview?: DeviceOverviewResponse | null;
roomManagement?: RoomManagementResponse | null;
roomUsage?: RoomUsageResponse | null;
softwareDistribution?: SoftwareDistributionResponse | null;
devicesByRoom?: DeviceStatusByRoom[] | null;
isLoading?: boolean;
isFetching?: boolean;
onRefresh?: () => void | Promise<void>;
}
export function DashboardTemplate({
generalInfo,
deviceOverview,
roomManagement,
roomUsage,
softwareDistribution,
devicesByRoom,
isLoading = false,
isFetching = false,
onRefresh,
}: DashboardTemplateProps) {
const [showAllRooms, setShowAllRooms] = useState(false);
const [usageRange, setUsageRange] = useState<"weekly" | "monthly">("weekly");
const totalDevices = generalInfo?.totalDevices ?? deviceOverview?.totalDevices ?? 0;
const onlineDevices = generalInfo?.onlineDevices ?? deviceOverview?.onlineDevices ?? 0;
const offlineDevices = generalInfo?.offlineDevices ?? deviceOverview?.offlineDevices ?? 0;
const totalRooms = generalInfo?.totalRooms ?? roomManagement?.totalRooms ?? 0;
const roomsNeedAttentionCount = roomManagement?.roomsNeedAttention?.length ?? 0;
const onlineRate = totalDevices > 0 ? Math.round((onlineDevices / totalDevices) * 100) : 0;
const stats = [
{
label: "Tổng thiết bị",
value: totalDevices,
note: `Online ${onlineRate}%`,
},
{
label: "Đang online",
value: onlineDevices,
note: "Thiết bị kết nối",
},
{
label: "Đang offline",
value: offlineDevices,
note: "Cần kiểm tra",
},
{
label: "Tổng phòng",
value: totalRooms,
note: `${roomsNeedAttentionCount} phòng cần chú ý`,
},
];
const getRoomPercent = (room: DeviceStatusByRoom) => {
if (typeof room.onlinePercentage === "number") return room.onlinePercentage;
if (!room.totalDevices) return 0;
return (room.onlineDevices / room.totalDevices) * 100;
};
const sortedRooms = useMemo(() => {
return (devicesByRoom ?? [])
.slice()
.sort((a, b) => getRoomPercent(a) - getRoomPercent(b));
}, [devicesByRoom]);
const roomSnapshot = showAllRooms ? sortedRooms : sortedRooms.slice(0, 6);
const usageSeries = useMemo(() => {
const usageRooms = roomUsage?.rooms ?? [];
const usageKey = usageRange === "weekly" ? "weekly" : "monthly";
const aggregated = new Map<string, number>();
for (const room of usageRooms) {
for (const point of room[usageKey]) {
aggregated.set(
point.date,
(aggregated.get(point.date) ?? 0) + point.onlineDevices
);
}
}
return Array.from(aggregated.entries())
.map(([date, value]) => ({
date,
value,
label: new Date(date).toLocaleDateString("vi-VN", {
day: "2-digit",
month: "2-digit",
}),
}))
.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
}, [roomUsage, usageRange]);
return (
<div className="dashboard-scope relative w-full px-6 py-8">
<div className="pointer-events-none absolute inset-0 -z-10 bg-gradient-to-br from-slate-50 via-white to-emerald-50" />
<div className="pointer-events-none absolute -top-20 right-10 -z-10 h-72 w-72 rounded-full bg-emerald-200/40 blur-3xl" />
<div className="pointer-events-none absolute top-32 -left-24 -z-10 h-72 w-72 rounded-full bg-sky-200/40 blur-3xl" />
<div className="pointer-events-none absolute bottom-0 right-1/3 -z-10 h-64 w-64 rounded-full bg-amber-100/40 blur-3xl" />
<div className="mx-auto flex w-full max-w-7xl flex-col gap-6">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between animate-in fade-in slide-in-from-bottom-4 duration-500">
<div className="space-y-2">
<h1 className="text-3xl font-semibold tracking-tight">Bảng điều khiển</h1>
<p className="text-sm text-muted-foreground">
Tổng hợp sức khỏe thiết bị, phòng phân phối phần mềm trong thời gian thực.
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline" className="border-amber-200 bg-amber-50 text-amber-700">
Cần chú ý: {roomsNeedAttentionCount} phòng
</Badge>
<Button onClick={() => onRefresh?.()} disabled={isFetching} className="gap-2">
<RefreshCw className={isFetching ? "h-4 w-4 animate-spin" : "h-4 w-4"} />
{isFetching ? "Đang làm mới" : "Làm mới"}
</Button>
</div>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4 animate-in fade-in slide-in-from-bottom-4 duration-500">
{stats.map((stat) => (
<div
key={stat.label}
className="rounded-2xl border border-muted/60 bg-white/80 p-4 shadow-sm backdrop-blur"
>
<div className="text-xs uppercase tracking-wide text-muted-foreground">{stat.label}</div>
<div className="mt-2 flex items-end justify-between">
<div className="text-3xl font-semibold">{stat.value}</div>
</div>
<div className="mt-1 text-xs text-muted-foreground">{stat.note}</div>
</div>
))}
</div>
<div className="grid grid-cols-1 gap-6 xl:grid-cols-3 animate-in fade-in slide-in-from-bottom-6 duration-700">
<div className="flex flex-col gap-6 xl:col-span-2">
<DeviceOverviewCard data={deviceOverview ?? null} isLoading={!!isLoading} />
<Card className="bg-white/80 backdrop-blur border-muted/60 shadow-sm">
<CardHeader>
<CardTitle>Tần suất sử dụng</CardTitle>
<CardDescription>Tổng thiết bị online theo ngày</CardDescription>
<CardAction>
<div className="flex items-center gap-2">
<Button
type="button"
size="sm"
variant={usageRange === "weekly" ? "default" : "outline"}
onClick={() => setUsageRange("weekly")}
>
7 ngay
</Button>
<Button
type="button"
size="sm"
variant={usageRange === "monthly" ? "default" : "outline"}
onClick={() => setUsageRange("monthly")}
>
30 ngay
</Button>
</div>
</CardAction>
</CardHeader>
<CardContent>
{usageSeries.length > 0 ? (
<div className="h-56">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={usageSeries} margin={{ top: 10, right: 20, left: 0, bottom: 0 }}>
<defs>
<linearGradient id="usageGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#0ea5e9" stopOpacity={0.4} />
<stop offset="95%" stopColor="#0ea5e9" stopOpacity={0.05} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="label" tick={{ fontSize: 12 }} />
<YAxis tick={{ fontSize: 12 }} />
<Tooltip
formatter={(value) => [value ?? 0, "Online"]}
labelFormatter={(label) => `Ngày ${label}`}
/>
<Area
type="monotone"
dataKey="value"
stroke="#0ea5e9"
fill="url(#usageGradient)"
strokeWidth={2}
/>
</AreaChart>
</ResponsiveContainer>
</div>
) : (
<div className="text-sm text-muted-foreground">Chưa dữ liệu sử dụng</div>
)}
</CardContent>
</Card>
<RoomManagementCard data={roomManagement ?? null} isLoading={!!isLoading} />
</div>
<div className="flex flex-col gap-6">
<SoftwareDistributionCard data={softwareDistribution ?? null} isLoading={!!isLoading} />
<Card className="bg-white/80 backdrop-blur border-muted/60 shadow-sm">
<CardHeader>
<CardTitle>Tình trạng theo phòng</CardTitle>
<CardDescription>
Online / Tổng thiết bị từng phòng ({sortedRooms.length} phòng)
</CardDescription>
{sortedRooms.length > 6 && (
<CardAction>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setShowAllRooms((value) => !value)}
>
{showAllRooms ? "Thu gọn" : "Xem tất cả"}
</Button>
</CardAction>
)}
</CardHeader>
<CardContent className={showAllRooms ? "space-y-4 max-h-[480px] overflow-auto pr-2" : "space-y-4"}>
{roomSnapshot.length > 0 ? (
roomSnapshot.map((room) => {
const percentRaw = getRoomPercent(room);
const percent = Math.max(0, Math.min(100, Math.round(percentRaw)));
return (
<div key={room.roomName} className="space-y-2">
<div className="flex items-center justify-between text-sm">
<div className="font-medium">{room.roomName}</div>
<div className="text-xs text-muted-foreground">
{room.onlineDevices}/{room.totalDevices}
</div>
</div>
<div className="flex items-center gap-3">
<div className="h-2 flex-1 rounded-full bg-muted/40">
<div
className="h-full rounded-full bg-emerald-500"
style={{ width: `${percent}%` }}
/>
</div>
<div className="w-10 text-right text-xs font-medium">{percent}%</div>
</div>
</div>
);
})
) : (
<div className="text-sm text-muted-foreground">Chưa dữ liệu phòng</div>
)}
</CardContent>
</Card>
</div>
</div>
</div>
</div>
);
}

105
src/types/dashboard.ts Normal file
View File

@ -0,0 +1,105 @@
export interface RecentOfflineDevice {
deviceId: string;
room?: string | null;
lastSeen?: string | null;
}
export interface DeviceOverviewResponse {
totalDevices: number;
onlineDevices: number;
offlineDevices: number;
onlinePercentage?: number;
devicesWithOutdatedVersion: number;
recentOfflineDevices: RecentOfflineDevice[];
}
export interface DeviceStatusByRoom {
roomName: string;
totalDevices: number;
onlineDevices: number;
offlineDevices: number;
onlinePercentage?: number;
}
export interface DashboardGeneralInfo {
totalDevices: number;
totalRooms: number;
onlineDevices: number;
offlineDevices: number;
}
export interface RoomUsagePoint {
date: string;
onlineDevices: number;
}
export interface RoomUsageItem {
roomName: string;
weekly: RoomUsagePoint[];
monthly: RoomUsagePoint[];
weeklyTotalOnlineDevices: number;
monthlyTotalOnlineDevices: number;
}
export interface RoomUsageResponse {
weekFrom: string;
weekTo: string;
monthFrom: string;
monthTo: string;
rooms: RoomUsageItem[];
}
export interface RoomHealthStatus {
roomName: string;
totalDevices: number;
onlineDevices: number;
offlineDevices: number;
healthPercentage?: number;
healthStatus?: string;
}
export interface RoomManagementResponse {
totalRooms: number;
rooms: RoomHealthStatus[];
roomsNeedAttention: RoomHealthStatus[];
}
export interface AgentVersionStats {
latestVersion: string;
devicesWithLatestVersion: number;
devicesWithOldVersion: number;
updateCoverage?: number;
}
export interface TopFailedSoftware {
fileName: string;
failCount: number;
}
export interface RecentInstallActivity {
deviceId: string;
fileName: string;
status: string;
timestamp: string;
message?: string | null;
}
export interface SoftwareDistributionResponse {
totalInstallations: number;
successfulInstallations: number;
failedInstallations: number;
pendingInstallations: number;
successRate?: number;
agentVersionStats: AgentVersionStats;
topFailedSoftware: TopFailedSoftware[];
recentActivities: RecentInstallActivity[];
}
export interface DashboardSummaryResponse {
generalInfo: DashboardGeneralInfo;
roomUsage: RoomUsageResponse;
deviceOverview: DeviceOverviewResponse;
roomManagement: RoomManagementResponse;
softwareDistribution: SoftwareDistributionResponse;
generatedAt: string;
}