Compare commits

..

9 Commits

56 changed files with 3113 additions and 1709 deletions

View File

@ -20,6 +20,6 @@ COPY --from=development /app/dist /usr/share/nginx/html
COPY nginx/nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
EXPOSE 80 443
ENTRYPOINT [ "nginx", "-g", "daemon off;" ]

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_

74
Users-API.md Normal file
View File

@ -0,0 +1,74 @@
# User API
Tai lieu mo ta cac endpoint cap nhat role va thong tin nguoi dung.
----------------------------------------
## 1) Cap nhat thong tin nguoi dung
- PUT /api/User/{id}
- Permission: EDIT_USER_ROLE
### Request
```json
{
"name": "Nguyen Van A",
"userName": "nguyenvana",
"accessRooms": [1, 2, 3]
}
```
### Response (200)
```json
{
"success": true,
"message": "User updated successfully",
"data": {
"userId": 12,
"userName": "nguyenvana",
"name": "Nguyen Van A",
"roleId": 3,
"accessRooms": [1, 2, 3],
"updatedAt": "2026-04-03T10:20:30Z",
"updatedBy": "admin"
}
}
```
### Ghi chu
- Neu khong truyen `accessRooms` thi giu nguyen danh sach phong.
- Neu truyen `accessRooms` = [] thi xoa tat ca phong.
- Neu `userName` bi trung hoac khong hop le thi tra ve 400.
----------------------------------------
## 2) Cap nhat role nguoi dung
- PUT /api/User/{id}/role
- Permission: EDIT_USER_ROLE
### Request
```json
{
"roleId": 2
}
```
### Response (200)
```json
{
"success": true,
"message": "User role updated",
"data": {
"userId": 12,
"userName": "nguyenvana",
"roleId": 2,
"roleName": "Manager",
"updatedAt": "2026-04-03T10:20:30Z",
"updatedBy": "admin"
}
}
```
### Ghi chu
- Chi System Admin moi duoc phep cap nhat role System Admin.
----------------------------------------

View File

@ -3,7 +3,13 @@
<head>
<meta charset="UTF-8" />
<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="description"

View File

@ -1,10 +1,21 @@
upstream backend {
server 100.66.170.15:8080;
server 127.0.0.1:8080;
server 172.18.10.8:8080;
}
# upstream backend {
# server 100.66.170.15:8080;
# server 127.0.0.1:8080;
# server 172.18.10.8:8080;
# }
server {
listen 80;
return 301 https://$host$request_uri; # Redirect HTTP sang HTTPS
}
server{
listen 443 ssl;
server_name comp.soict.io;
ssl_certificate /etc/nginx/ssl/nginx-selfsigned.crt;
ssl_certificate_key /etc/nginx/ssl/nginx-selfsigned.key;
set $backend_server 172.18.10.8:8080;
root /usr/share/nginx/html;
# Default file to serve for directory requests
@ -25,7 +36,7 @@ server {
}
location /api/ {
proxy_pass http://100.66.170.15:8080;
proxy_pass http://$backend_server;
# Cho phép upload file lớn (vd: 200MB)
client_max_body_size 200M;
@ -38,17 +49,17 @@ server {
proxy_connect_timeout 300s;
proxy_send_timeout 300s;
# CORS headers
add_header 'Access-Control-Allow-Origin' '*' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization' always;
# CORS headers - Comment vi da xu ly o backend C#
# add_header 'Access-Control-Allow-Origin' '*' always;
# add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
# add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization' always;
if ($request_method = OPTIONS) {
return 204;
}
}
location /api/Sse/events {
proxy_pass http://backend/api/Sse/events;
proxy_pass http://$backend_server/api/Sse/events;
proxy_http_version 1.1;
# cần thiết cho SSE

419
package-lock.json generated
View File

@ -37,6 +37,7 @@
"radix-ui": "^1.4.3",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"recharts": "^3.8.1",
"shadcn": "^2.9.3",
"sidebar": "^1.0.0",
"sonner": "^2.0.7",
@ -2855,6 +2856,40 @@
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
"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": {
"version": "1.0.0-beta.27",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
@ -3162,6 +3197,16 @@
"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": {
"version": "4.1.11",
"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",
"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": {
"version": "4.0.2",
"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",
"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": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-2.3.0.tgz",
"integrity": "sha512-bdyo8rB3NnQbikdMpHaML9Z1OZPBu6fFOBo+OtxsBlvMJtysWskmBcnbIDhUqgC8tcxNv/a+BcV5U+2nQMm1OQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
},
@ -4409,9 +4512,9 @@
}
},
"node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz",
"integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==",
"dependencies": {
"balanced-match": "^1.0.0"
}
@ -4887,6 +4990,116 @@
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"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": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
@ -4941,6 +5154,11 @@
"dev": true,
"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": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/decode-formdata/-/decode-formdata-0.9.0.tgz",
@ -5016,9 +5234,9 @@
"license": "MIT"
},
"node_modules/devalue": {
"version": "5.6.3",
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.3.tgz",
"integrity": "sha512-nc7XjUU/2Lb+SvEFVGcWLiKkzfw8+qHI7zn8WYXKkLMgfGSHbgCEaR6bJpev8Cm6Rmrb19Gfd/tZvGqx9is3wg=="
"version": "5.6.4",
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.4.tgz",
"integrity": "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA=="
},
"node_modules/diff": {
"version": "8.0.3",
@ -5152,6 +5370,15 @@
"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": {
"version": "0.25.8",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.8.tgz",
@ -5235,6 +5462,11 @@
"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": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
@ -5329,11 +5561,11 @@
}
},
"node_modules/express-rate-limit": {
"version": "8.2.1",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz",
"integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==",
"version": "8.3.1",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.1.tgz",
"integrity": "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==",
"dependencies": {
"ip-address": "10.0.1"
"ip-address": "10.1.0"
},
"engines": {
"node": ">= 16"
@ -5743,9 +5975,9 @@
"integrity": "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ=="
},
"node_modules/hono": {
"version": "4.12.4",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.4.tgz",
"integrity": "sha512-ooiZW1Xy8rQ4oELQ++otI2T9DsKpV0M6c6cO6JGx4RTfav9poFFLlet9UMXHZnoM1yG0HWGlQLswBGX3RZmHtg==",
"version": "4.12.9",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.9.tgz",
"integrity": "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA==",
"engines": {
"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": {
"version": "3.3.1",
"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",
"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": {
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz",
"integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==",
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
"engines": {
"node": ">= 12"
}
@ -6929,9 +7178,9 @@
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="
},
"node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"engines": {
"node": ">=8.6"
},
@ -7249,9 +7498,30 @@
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true,
"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": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@ -7378,6 +7648,48 @@
"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": {
"version": "2.1.1",
"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",
"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": {
"version": "4.0.0",
"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": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz",
"integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==",
"version": "8.4.0",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.0.tgz",
"integrity": "sha512-PuseHIvAnz3bjrM2rGJtSgo1zjgxapTLZ7x2pjhzWwlp4SJQgK3f3iZIQwkpEnBaKz6seKBADpM4B4ySkuYypg==",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
@ -8073,9 +8390,9 @@
}
},
"node_modules/tar": {
"version": "7.5.9",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.9.tgz",
"integrity": "sha512-BTLcK0xsDh2+PUe9F6c2TlRp4zOOBMTkoQHQIWSIzI0R7KG46uEwq4OPk2W7bZcprBMsuaeFsqwYr7pjh6CuHg==",
"version": "7.5.13",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.13.tgz",
"integrity": "sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==",
"dependencies": {
"@isaacs/fs-minipass": "^4.0.0",
"chownr": "^3.0.0",
@ -8150,10 +8467,9 @@
}
},
"node_modules/tinyglobby/node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"engines": {
"node": ">=12"
},
@ -8385,9 +8701,9 @@
}
},
"node_modules/unplugin/node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"engines": {
"node": ">=12"
},
@ -8509,6 +8825,27 @@
"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": {
"version": "6.4.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
@ -8620,10 +8957,9 @@
}
},
"node_modules/vite/node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"engines": {
"node": ">=12"
},
@ -8705,11 +9041,10 @@
}
},
"node_modules/vitest/node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},

View File

@ -41,6 +41,7 @@
"radix-ui": "^1.4.3",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"recharts": "^3.8.1",
"shadcn": "^2.9.3",
"sidebar": "^1.0.0",
"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();
};
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 (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-none w-[95vw] max-h-[90vh]">
@ -156,11 +165,31 @@ export function DeviceSearchDialog({
const isExpanded = expandedRoom === room.name;
const isLoading = loadingRoom === 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 =
devices.length > 0 &&
devices.every((d) => selected.includes(d.id));
const someSelected = devices.some((d) => selected.includes(d.id));
const selectedCount = devices.filter((d) =>
sortedDevices.length > 0 &&
sortedDevices.every((d) => selected.includes(d.id));
const someSelected = sortedDevices.some((d) => selected.includes(d.id));
const selectedCount = sortedDevices.filter((d) =>
selected.includes(d.id)
).length;
@ -219,7 +248,7 @@ export function DeviceSearchDialog({
</div>
{/* Device table - collapsible */}
{isExpanded && devices.length > 0 && (
{isExpanded && sortedDevices.length > 0 && (
<div className="border-t bg-muted/20 overflow-x-auto">
<table className="w-full text-xs">
<thead className="bg-muted/50 border-b sticky top-0">
@ -243,7 +272,7 @@ export function DeviceSearchDialog({
</tr>
</thead>
<tbody>
{devices.map((device) => (
{sortedDevices.map((device) => (
<tr
key={device.id}
className="border-b last:border-b-0 hover:bg-muted/50"

View File

@ -109,7 +109,7 @@ export function CommandActionButtons({ roomName, selectedDevices = [] }: Command
// All rendered commands are sourced from sensitiveCommands — send via sensitive mutation
await executeSensitiveMutation.mutateAsync({
roomName,
command: confirmDialog.command.commandContent,
command: confirmDialog.command.commandName,
password: sensitivePassword,
});

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 các phòng đang không sử dụng</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 không dùng</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

@ -0,0 +1,23 @@
import type { Version } from "@/types/file";
import type { ColumnDef } from "@tanstack/react-table";
export const agentColumns: ColumnDef<Version>[] = [
{ accessorKey: "version", header: "Phiên bản" },
{ accessorKey: "fileName", header: "Tên file" },
{
accessorKey: "updatedAt",
header: "Thời gian cập nhật",
cell: ({ getValue }) =>
getValue()
? new Date(getValue() as string).toLocaleString("vi-VN")
: "N/A",
},
{
accessorKey: "requestUpdateAt",
header: "Thời gian yêu cầu cập nhật",
cell: ({ getValue }) =>
getValue()
? new Date(getValue() as string).toLocaleString("vi-VN")
: "N/A",
},
];

View File

@ -0,0 +1,70 @@
// components/columns/apps-column.tsx
import type { Version } from "@/types/file";
import type { ColumnDef } from "@tanstack/react-table";
import { Check, X } from "lucide-react";
// Không gọi hook ở đây — nhận isPending từ ngoài truyền vào
export function createAppsColumns(isPending: boolean): ColumnDef<Version>[] {
return [
{ accessorKey: "version", header: "Phiên bản" },
{ accessorKey: "fileName", header: "Tên file" },
{
accessorKey: "updatedAt",
header: () => (
<div className="whitespace-normal max-w-xs">Thời gian cập nhật</div>
),
cell: ({ getValue }) =>
getValue()
? new Date(getValue() as string).toLocaleString("vi-VN")
: "N/A",
},
{
accessorKey: "requestUpdateAt",
header: () => (
<div className="whitespace-normal max-w-xs">
Thời gian yêu cầu cài đt/tải xuống
</div>
),
cell: ({ getValue }) =>
getValue()
? new Date(getValue() as string).toLocaleString("vi-VN")
: "N/A",
},
{
id: "required",
header: () => (
<div className="whitespace-normal max-w-xs">Đã thêm vào danh sách</div>
),
cell: ({ row }) => {
const isRequired = row.original.isRequired;
return isRequired ? (
<div className="flex items-center gap-1">
<Check className="h-4 w-4 text-green-600" />
<span className="text-sm text-green-600"></span>
</div>
) : (
<div className="flex items-center gap-1">
<X className="h-4 w-4 text-gray-400" />
<span className="text-sm text-gray-400">Không</span>
</div>
);
},
enableSorting: false,
enableHiding: false,
},
{
id: "select",
header: () => <div className="whitespace-normal max-w-xs">Chọn</div>,
cell: ({ row }) => (
<input
type="checkbox"
checked={row.getIsSelected?.() ?? false}
onChange={row.getToggleSelectedHandler?.()}
disabled={isPending} // ← nhận từ tham số, không gọi hook
/>
),
enableSorting: false,
enableHiding: false,
},
];
}

View File

@ -0,0 +1,98 @@
import { type ColumnDef } from "@tanstack/react-table";
import { Badge } from "@/components/ui/badge";
import type { Audits } from "@/types/audit";
export const auditColumns: ColumnDef<Audits>[] = [
{
header: "Thời gian",
accessorKey: "dateTime",
cell: ({ getValue }) => {
const v = getValue() as string;
const d = v ? new Date(v) : null;
return d ? (
<div className="text-sm whitespace-nowrap">
<div className="font-medium">{d.toLocaleDateString("vi-VN")}</div>
<div className="text-muted-foreground text-xs">
{d.toLocaleTimeString("vi-VN")}
</div>
</div>
) : (
<span className="text-muted-foreground"></span>
);
},
},
{
header: "User",
accessorKey: "username",
cell: ({ getValue }) => (
<span className="font-medium text-sm whitespace-nowrap">
{getValue() as string}
</span>
),
},
{
header: "Loại",
accessorKey: "apiCall",
cell: ({ getValue }) => {
const v = (getValue() as string) ?? "";
if (!v) return <span className="text-muted-foreground"></span>;
return (
<code className="text-xs bg-muted px-1.5 py-0.5 rounded whitespace-nowrap">
{v}
</code>
);
},
},
{
header: "Hành động",
accessorKey: "action",
cell: ({ getValue }) => (
<code className="text-xs bg-muted px-1.5 py-0.5 rounded whitespace-nowrap">
{getValue() as string}
</code>
),
},
{
header: "URL",
accessorKey: "url",
cell: ({ getValue }) => (
<code className="text-xs text-muted-foreground max-w-[180px] truncate block">
{(getValue() as string) ?? "—"}
</code>
),
},
{
header: "Kết quả",
accessorKey: "isSuccess",
cell: ({ getValue }) => {
const v = getValue();
if (v == null) return <span className="text-muted-foreground"></span>;
return v ? (
<Badge variant="outline" className="text-green-600 border-green-600 whitespace-nowrap">
Thành công
</Badge>
) : (
<Badge variant="outline" className="text-red-600 border-red-600 whitespace-nowrap">
Thất bại
</Badge>
);
},
},
{
header: "Nội dung request",
accessorKey: "requestPayload",
cell: ({ getValue }) => {
const v = getValue() as string;
if (!v) return <span className="text-muted-foreground"></span>;
let preview = v;
try {
preview = JSON.stringify(JSON.parse(v));
} catch {}
return (
<span className="text-xs text-muted-foreground max-w-[200px] truncate block">
{preview}
</span>
);
},
},
];

View File

@ -0,0 +1,180 @@
import { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Separator } from "@/components/ui/separator";
import type { Audits } from "@/types/audit";
function JsonDisplay({ value }: { value: string | null | undefined }) {
if (!value) return <span className="text-muted-foreground"></span>;
try {
return (
<pre className="text-xs bg-muted/60 p-2.5 rounded-md overflow-auto whitespace-pre-wrap break-all leading-relaxed max-h-48 font-mono">
{JSON.stringify(JSON.parse(value), null, 2)}
</pre>
);
} catch {
return <span className="text-xs break-all font-mono">{value}</span>;
}
}
interface AuditDetailDialogProps {
audit: Audits | null;
open: boolean;
onClose: () => void;
}
export function AuditDetailDialog({
audit,
open,
onClose,
}: AuditDetailDialogProps) {
if (!audit) return null;
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-2xl w-full max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
Chi tiết audit
<span className="text-muted-foreground font-normal text-sm">
#{audit.id}
</span>
</DialogTitle>
</DialogHeader>
<Separator />
<div className="grid grid-cols-2 gap-x-6 gap-y-3 pt-1">
<div className="space-y-0.5">
<p className="text-xs text-muted-foreground uppercase tracking-wide">
Thời gian
</p>
<p className="text-sm font-medium">
{audit.dateTime
? new Date(audit.dateTime).toLocaleString("vi-VN")
: "—"}
</p>
</div>
<div className="space-y-0.5">
<p className="text-xs text-muted-foreground uppercase tracking-wide">
User
</p>
<p className="text-sm font-medium">{audit.username}</p>
</div>
<div className="space-y-0.5">
<p className="text-xs text-muted-foreground uppercase tracking-wide">
API Call
</p>
<code className="text-xs bg-muted px-1.5 py-0.5 rounded">
{audit.apiCall ?? "—"}
</code>
</div>
<div className="space-y-0.5">
<p className="text-xs text-muted-foreground uppercase tracking-wide">
Kết quả
</p>
<div>
{audit.isSuccess == null ? (
<span className="text-muted-foreground text-sm"></span>
) : audit.isSuccess ? (
<Badge
variant="outline"
className="text-green-600 border-green-600"
>
Thành công
</Badge>
) : (
<Badge
variant="outline"
className="text-red-600 border-red-600"
>
Thất bại
</Badge>
)}
</div>
</div>
<div className="space-y-0.5">
<p className="text-xs text-muted-foreground uppercase tracking-wide">
Hành đng
</p>
<code className="text-xs bg-muted px-1.5 py-0.5 rounded">
{audit.action}
</code>
</div>
<div className="space-y-0.5">
<p className="text-xs text-muted-foreground uppercase tracking-wide">
URL
</p>
<code className="text-xs text-muted-foreground break-all">
{audit.url ?? "—"}
</code>
</div>
<div className="space-y-0.5">
<p className="text-xs text-muted-foreground uppercase tracking-wide">
Bảng
</p>
<p className="text-sm">{audit.tableName ?? "—"}</p>
</div>
<div className="space-y-0.5">
<p className="text-xs text-muted-foreground uppercase tracking-wide">
Entity ID
</p>
<p className="text-sm">{audit.entityId ?? "—"}</p>
</div>
<div className="col-span-2 space-y-0.5">
<p className="text-xs text-muted-foreground uppercase tracking-wide">
Lỗi
</p>
<p className="text-sm text-red-600">{audit.errorMessage ?? "—"}</p>
</div>
</div>
<Separator />
<div className="space-y-4">
<div className="space-y-1.5">
<p className="text-xs text-muted-foreground uppercase tracking-wide">
Nội dung request
</p>
<JsonDisplay value={audit.requestPayload} />
</div>
<div className="space-y-1.5">
<p className="text-xs text-muted-foreground uppercase tracking-wide">
Giá trị
</p>
<JsonDisplay value={audit.oldValues} />
</div>
<div className="space-y-1.5">
<p className="text-xs text-muted-foreground uppercase tracking-wide">
Giá trị mới
</p>
<JsonDisplay value={audit.newValues} />
</div>
<div className="space-y-1.5">
<p className="text-xs text-muted-foreground uppercase tracking-wide">
Kết quả
</p>
<p className="text-sm">{audit.isSuccess == null ? "—" : audit.isSuccess ? "Thành công" : "Thất bại"}</p>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@ -2,7 +2,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/u
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import { useState, useMemo } from "react";
import { useEffect, useMemo, useState } from "react";
export interface SelectItem {
label: string;
@ -16,6 +16,7 @@ interface SelectDialogProps {
description?: string;
icon?: React.ReactNode;
items: SelectItem[];
selectedValues?: string[];
onConfirm: (values: string[]) => Promise<void> | void;
}
@ -26,11 +27,18 @@ export function SelectDialog({
description,
icon,
items,
selectedValues,
onConfirm,
}: SelectDialogProps) {
const [selected, setSelected] = useState<string[]>([]);
const [search, setSearch] = useState("");
useEffect(() => {
if (!open) return;
if (!selectedValues) return;
setSelected(selectedValues);
}, [open, selectedValues]);
const filteredItems = useMemo(() => {
return items.filter((item) =>
item.label.toLowerCase().includes(search.toLowerCase())

View File

@ -0,0 +1,73 @@
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
interface AuditFilterBarProps {
username: string | null;
action: string | null;
from: string | null;
to: string | null;
isLoading: boolean;
isFetching: boolean;
onUsernameChange: (v: string | null) => void;
onActionChange: (v: string | null) => void;
onFromChange: (v: string | null) => void;
onToChange: (v: string | null) => void;
onSearch: () => void;
onReset: () => void;
}
export function AuditFilterBar({
username,
action,
from,
to,
isLoading,
isFetching,
onUsernameChange,
onActionChange,
onFromChange,
onToChange,
onSearch,
onReset,
}: AuditFilterBarProps) {
return (
<div className="flex gap-2 mb-4 flex-wrap items-end">
<Input
className="w-36"
placeholder="Username"
value={username ?? ""}
onChange={(e) => onUsernameChange(e.target.value || null)}
/>
<Input
className="w-44"
placeholder="Hành động..."
value={action ?? ""}
onChange={(e) => onActionChange(e.target.value || null)}
/>
<Input
className="w-36"
type="date"
value={from ?? ""}
onChange={(e) => onFromChange(e.target.value || null)}
/>
<Input
className="w-36"
type="date"
value={to ?? ""}
onChange={(e) => onToChange(e.target.value || null)}
/>
<div className="flex gap-2">
<Button onClick={onSearch} disabled={isFetching || isLoading} size="sm">
Tìm
</Button>
<Button variant="outline" onClick={onReset} size="sm">
Reset
</Button>
</div>
</div>
);
}

View File

@ -4,7 +4,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import type { LoginResquest } from "@/types/auth";
import { useMutation } from "@tanstack/react-query";
import { login } from "@/services/auth.service";
import { buildSsoLoginUrl, login } from "@/services/auth.service";
import { useState } from "react";
import { useNavigate, useRouter } from "@tanstack/react-router";
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>) => {
e.preventDefault();
setErrorMessage(null);
@ -53,10 +61,10 @@ export function LoginForm({ className }: React.ComponentProps<"form">) {
return (
<div className={cn("flex flex-col gap-6", className)}>
<Card>
<CardHeader className="text-center flex flex-col items-center">
<CardTitle className="text-xl flex items-center gap-3">
<img src="/soict_logo.png" alt="logo" className="size-20" />
<p> Computer Management</p>
<CardHeader className="text-center">
<CardTitle className="text-2xl font-semibold tracking-tight flex items-center justify-center gap-3">
<img src="/soict_logo.png" alt="SOICT logo" className="h-7 w-auto object-contain" />
<span>Computer Management</span>
</CardTitle>
<CardDescription>Hệ thống quản phòng máy thực hành</CardDescription>
</CardHeader>
@ -103,6 +111,16 @@ export function LoginForm({ className }: React.ComponentProps<"form">) {
Đăng nhập
</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>
</form>
</CardContent>

View File

@ -11,6 +11,8 @@ export const BASE_MESH_URL = isDev
export const API_ENDPOINTS = {
AUTH: {
LOGIN: `${BASE_URL}/login`,
SSO_LOGIN: `${BASE_URL}/auth/sso/login`,
SSO_EXCHANGE: `${BASE_URL}/auth/sso/exchange`,
LOGOUT: `${BASE_URL}/logout`,
CHANGE_PASSWORD: `${BASE_URL}/auth/change-password`,
CHANGE_PASSWORD_ADMIN: `${BASE_URL}/auth/admin/change-password`,
@ -19,6 +21,10 @@ export const API_ENDPOINTS = {
CREATE_ACCOUNT: `${BASE_URL}/auth/create-account`,
GET_USERS_LIST: `${BASE_URL}/users-info`,
},
USER: {
UPDATE_INFO: (id: number) => `${BASE_URL}/User/${id}`,
UPDATE_ROLE: (id: number) => `${BASE_URL}/User/${id}/role`,
},
APP_VERSION: {
//agent and app api
GET_VERSION: `${BASE_URL}/AppVersion/version`,
@ -35,8 +41,8 @@ export const API_ENDPOINTS = {
//require file api
GET_REQUIRED_FILES: `${BASE_URL}/AppVersion/requirefiles`,
ADD_REQUIRED_FILE: `${BASE_URL}/AppVersion/requirefile/add`,
DELETE_REQUIRED_FILE: (fileId: number) => `${BASE_URL}/AppVersion/requirefile/delete/${fileId}`,
DELETE_FILES: (fileId: number) => `${BASE_URL}/AppVersion/delete/${fileId}`,
DELETE_REQUIRED_FILE: `${BASE_URL}/AppVersion/requirefile/delete`,
DELETE_FILES: `${BASE_URL}/AppVersion/delete`,
},
DEVICE_COMM: {
DOWNLOAD_FILES: (roomName: string) => `${BASE_URL}/DeviceComm/downloadfile/${roomName}`,
@ -90,4 +96,16 @@ export const API_ENDPOINTS = {
GET_REMOTE_DESKTOP: (deviceId: string) =>
`${BASE_URL}/MeshCentral/devices/${encodeURIComponent(deviceId)}/remote-desktop`,
},
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: {
GET_AUDITS: `${BASE_URL}/Audit/audits`,
}
};

View File

@ -7,9 +7,15 @@ export * from "./useAppVersionQueries";
// Device Communication Queries
export * from "./useDeviceCommQueries";
// Dashboard Queries
export * from "./useDashboardQueries";
// Command Queries
export * from "./useCommandQueries";
// Audit Queries
export * from "./useAuditQueries";
// Permission Queries
export * from "./usePermissionQueries";

View File

@ -160,7 +160,7 @@ export function useDeleteRequiredFile() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (fileId: number) => appVersionService.deleteRequiredFile(fileId),
mutationFn: (data: { MsiFileIds: number[] }) => appVersionService.deleteRequiredFile(data),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: APP_VERSION_QUERY_KEYS.requiredFiles(),
@ -176,7 +176,7 @@ export function useDeleteFile() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (fileId: number) => appVersionService.deleteFile(fileId),
mutationFn: (data: { MsiFileIds: number[] }) => appVersionService.deleteFile(data),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: APP_VERSION_QUERY_KEYS.softwareList(),

View File

@ -0,0 +1,37 @@
import { useQuery } from "@tanstack/react-query";
import * as auditService from "@/services/audit.service";
import type { PageResult, Audits } from "@/types/audit";
const AUDIT_QUERY_KEYS = {
all: ["audit"] as const,
list: () => [...AUDIT_QUERY_KEYS.all, "list"] as const,
audits: (params: any) => [...AUDIT_QUERY_KEYS.all, "audits", params] as const,
};
export function useGetAudits(
params: {
pageNumber?: number;
pageSize?: number;
username?: string | null;
action?: string | null;
from?: string | null;
to?: string | null;
} = { pageNumber: 1, pageSize: 20 },
enabled = true
) {
const { pageNumber = 1, pageSize = 20, username, action, from, to } = params;
return useQuery<PageResult<Audits>>({
queryKey: AUDIT_QUERY_KEYS.audits({ pageNumber, pageSize, username, action, from, to }),
queryFn: () =>
auditService.getAudits(
pageNumber,
pageSize,
username ?? null,
action ?? null,
from ?? null,
to ?? null
),
enabled,
});
}

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

@ -1,6 +1,10 @@
import { useQuery } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import * as userService from "@/services/user.service";
import type { UserProfile } from "@/types/user-profile";
import type {
UserProfile,
UpdateUserInfoRequest,
UpdateUserRoleRequest,
} from "@/types/user-profile";
const USER_QUERY_KEYS = {
all: ["users"] as const,
@ -18,3 +22,47 @@ export function useGetUsersInfo(enabled = true) {
staleTime: 5 * 60 * 1000, // 5 minutes
});
}
/**
* Hook đ cập nhật thông tin người dùng
*/
export function useUpdateUserInfo() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
id,
data,
}: {
id: number;
data: UpdateUserInfoRequest;
}) => userService.updateUserInfo(id, data),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: USER_QUERY_KEYS.list(),
});
},
});
}
/**
* Hook đ cập nhật role người dùng
*/
export function useUpdateUserRole() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
id,
data,
}: {
id: number;
data: UpdateUserRoleRequest;
}) => userService.updateUserRole(id, data),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: USER_QUERY_KEYS.list(),
});
},
});
}

View File

@ -116,5 +116,10 @@
}
body {
@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

@ -19,6 +19,7 @@ import { Route as AuthDeviceIndexRouteImport } from './routes/_auth/device/index
import { Route as AuthDashboardIndexRouteImport } from './routes/_auth/dashboard/index'
import { Route as AuthCommandsIndexRouteImport } from './routes/_auth/commands/index'
import { Route as AuthBlacklistsIndexRouteImport } from './routes/_auth/blacklists/index'
import { Route as AuthAuditsIndexRouteImport } from './routes/_auth/audits/index'
import { Route as AuthAppsIndexRouteImport } from './routes/_auth/apps/index'
import { Route as AuthAgentIndexRouteImport } from './routes/_auth/agent/index'
import { Route as authLoginIndexRouteImport } from './routes/(auth)/login/index'
@ -27,7 +28,9 @@ import { Route as AuthRoomsRoomNameIndexRouteImport } from './routes/_auth/rooms
import { Route as AuthRoleCreateIndexRouteImport } from './routes/_auth/role/create/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 authSsoCallbackIndexRouteImport } from './routes/(auth)/sso/callback/index'
import { Route as AuthUserRoleRoleIdIndexRouteImport } from './routes/_auth/user/role/$roleId/index'
import { Route as AuthUserEditUserNameIndexRouteImport } from './routes/_auth/user/edit/$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 AuthRoomsRoomNameConnectIndexRouteImport } from './routes/_auth/rooms/$roomName/connect/index'
@ -82,6 +85,11 @@ const AuthBlacklistsIndexRoute = AuthBlacklistsIndexRouteImport.update({
path: '/blacklists/',
getParentRoute: () => AuthRoute,
} as any)
const AuthAuditsIndexRoute = AuthAuditsIndexRouteImport.update({
id: '/audits/',
path: '/audits/',
getParentRoute: () => AuthRoute,
} as any)
const AuthAppsIndexRoute = AuthAppsIndexRouteImport.update({
id: '/apps/',
path: '/apps/',
@ -124,11 +132,22 @@ const AuthProfileUserNameIndexRoute =
path: '/profile/$userName/',
getParentRoute: () => AuthRoute,
} as any)
const authSsoCallbackIndexRoute = authSsoCallbackIndexRouteImport.update({
id: '/(auth)/sso/callback/',
path: '/sso/callback/',
getParentRoute: () => rootRouteImport,
} as any)
const AuthUserRoleRoleIdIndexRoute = AuthUserRoleRoleIdIndexRouteImport.update({
id: '/user/role/$roleId/',
path: '/user/role/$roleId/',
getParentRoute: () => AuthRoute,
} as any)
const AuthUserEditUserNameIndexRoute =
AuthUserEditUserNameIndexRouteImport.update({
id: '/user/edit/$userName/',
path: '/user/edit/$userName/',
getParentRoute: () => AuthRoute,
} as any)
const AuthUserChangePasswordUserNameIndexRoute =
AuthUserChangePasswordUserNameIndexRouteImport.update({
id: '/user/change-password/$userName/',
@ -158,6 +177,7 @@ export interface FileRoutesByFullPath {
'/login': typeof authLoginIndexRoute
'/agent': typeof AuthAgentIndexRoute
'/apps': typeof AuthAppsIndexRoute
'/audits': typeof AuthAuditsIndexRoute
'/blacklists': typeof AuthBlacklistsIndexRoute
'/commands': typeof AuthCommandsIndexRoute
'/dashboard': typeof AuthDashboardIndexRoute
@ -166,6 +186,7 @@ export interface FileRoutesByFullPath {
'/role': typeof AuthRoleIndexRoute
'/rooms': typeof AuthRoomsIndexRoute
'/user': typeof AuthUserIndexRoute
'/sso/callback': typeof authSsoCallbackIndexRoute
'/profile/$userName': typeof AuthProfileUserNameIndexRoute
'/profile/change-password': typeof AuthProfileChangePasswordIndexRoute
'/role/create': typeof AuthRoleCreateIndexRoute
@ -175,6 +196,7 @@ export interface FileRoutesByFullPath {
'/rooms/$roomName/connect': typeof AuthRoomsRoomNameConnectIndexRoute
'/rooms/$roomName/folder-status': typeof AuthRoomsRoomNameFolderStatusIndexRoute
'/user/change-password/$userName': typeof AuthUserChangePasswordUserNameIndexRoute
'/user/edit/$userName': typeof AuthUserEditUserNameIndexRoute
'/user/role/$roleId': typeof AuthUserRoleRoleIdIndexRoute
}
export interface FileRoutesByTo {
@ -182,6 +204,7 @@ export interface FileRoutesByTo {
'/login': typeof authLoginIndexRoute
'/agent': typeof AuthAgentIndexRoute
'/apps': typeof AuthAppsIndexRoute
'/audits': typeof AuthAuditsIndexRoute
'/blacklists': typeof AuthBlacklistsIndexRoute
'/commands': typeof AuthCommandsIndexRoute
'/dashboard': typeof AuthDashboardIndexRoute
@ -190,6 +213,7 @@ export interface FileRoutesByTo {
'/role': typeof AuthRoleIndexRoute
'/rooms': typeof AuthRoomsIndexRoute
'/user': typeof AuthUserIndexRoute
'/sso/callback': typeof authSsoCallbackIndexRoute
'/profile/$userName': typeof AuthProfileUserNameIndexRoute
'/profile/change-password': typeof AuthProfileChangePasswordIndexRoute
'/role/create': typeof AuthRoleCreateIndexRoute
@ -199,6 +223,7 @@ export interface FileRoutesByTo {
'/rooms/$roomName/connect': typeof AuthRoomsRoomNameConnectIndexRoute
'/rooms/$roomName/folder-status': typeof AuthRoomsRoomNameFolderStatusIndexRoute
'/user/change-password/$userName': typeof AuthUserChangePasswordUserNameIndexRoute
'/user/edit/$userName': typeof AuthUserEditUserNameIndexRoute
'/user/role/$roleId': typeof AuthUserRoleRoleIdIndexRoute
}
export interface FileRoutesById {
@ -208,6 +233,7 @@ export interface FileRoutesById {
'/(auth)/login/': typeof authLoginIndexRoute
'/_auth/agent/': typeof AuthAgentIndexRoute
'/_auth/apps/': typeof AuthAppsIndexRoute
'/_auth/audits/': typeof AuthAuditsIndexRoute
'/_auth/blacklists/': typeof AuthBlacklistsIndexRoute
'/_auth/commands/': typeof AuthCommandsIndexRoute
'/_auth/dashboard/': typeof AuthDashboardIndexRoute
@ -216,6 +242,7 @@ export interface FileRoutesById {
'/_auth/role/': typeof AuthRoleIndexRoute
'/_auth/rooms/': typeof AuthRoomsIndexRoute
'/_auth/user/': typeof AuthUserIndexRoute
'/(auth)/sso/callback/': typeof authSsoCallbackIndexRoute
'/_auth/profile/$userName/': typeof AuthProfileUserNameIndexRoute
'/_auth/profile/change-password/': typeof AuthProfileChangePasswordIndexRoute
'/_auth/role/create/': typeof AuthRoleCreateIndexRoute
@ -225,6 +252,7 @@ export interface FileRoutesById {
'/_auth/rooms/$roomName/connect/': typeof AuthRoomsRoomNameConnectIndexRoute
'/_auth/rooms/$roomName/folder-status/': typeof AuthRoomsRoomNameFolderStatusIndexRoute
'/_auth/user/change-password/$userName/': typeof AuthUserChangePasswordUserNameIndexRoute
'/_auth/user/edit/$userName/': typeof AuthUserEditUserNameIndexRoute
'/_auth/user/role/$roleId/': typeof AuthUserRoleRoleIdIndexRoute
}
export interface FileRouteTypes {
@ -234,6 +262,7 @@ export interface FileRouteTypes {
| '/login'
| '/agent'
| '/apps'
| '/audits'
| '/blacklists'
| '/commands'
| '/dashboard'
@ -242,6 +271,7 @@ export interface FileRouteTypes {
| '/role'
| '/rooms'
| '/user'
| '/sso/callback'
| '/profile/$userName'
| '/profile/change-password'
| '/role/create'
@ -251,6 +281,7 @@ export interface FileRouteTypes {
| '/rooms/$roomName/connect'
| '/rooms/$roomName/folder-status'
| '/user/change-password/$userName'
| '/user/edit/$userName'
| '/user/role/$roleId'
fileRoutesByTo: FileRoutesByTo
to:
@ -258,6 +289,7 @@ export interface FileRouteTypes {
| '/login'
| '/agent'
| '/apps'
| '/audits'
| '/blacklists'
| '/commands'
| '/dashboard'
@ -266,6 +298,7 @@ export interface FileRouteTypes {
| '/role'
| '/rooms'
| '/user'
| '/sso/callback'
| '/profile/$userName'
| '/profile/change-password'
| '/role/create'
@ -275,6 +308,7 @@ export interface FileRouteTypes {
| '/rooms/$roomName/connect'
| '/rooms/$roomName/folder-status'
| '/user/change-password/$userName'
| '/user/edit/$userName'
| '/user/role/$roleId'
id:
| '__root__'
@ -283,6 +317,7 @@ export interface FileRouteTypes {
| '/(auth)/login/'
| '/_auth/agent/'
| '/_auth/apps/'
| '/_auth/audits/'
| '/_auth/blacklists/'
| '/_auth/commands/'
| '/_auth/dashboard/'
@ -291,6 +326,7 @@ export interface FileRouteTypes {
| '/_auth/role/'
| '/_auth/rooms/'
| '/_auth/user/'
| '/(auth)/sso/callback/'
| '/_auth/profile/$userName/'
| '/_auth/profile/change-password/'
| '/_auth/role/create/'
@ -300,6 +336,7 @@ export interface FileRouteTypes {
| '/_auth/rooms/$roomName/connect/'
| '/_auth/rooms/$roomName/folder-status/'
| '/_auth/user/change-password/$userName/'
| '/_auth/user/edit/$userName/'
| '/_auth/user/role/$roleId/'
fileRoutesById: FileRoutesById
}
@ -307,6 +344,7 @@ export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
AuthRoute: typeof AuthRouteWithChildren
authLoginIndexRoute: typeof authLoginIndexRoute
authSsoCallbackIndexRoute: typeof authSsoCallbackIndexRoute
}
declare module '@tanstack/react-router' {
@ -381,6 +419,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthBlacklistsIndexRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/audits/': {
id: '/_auth/audits/'
path: '/audits'
fullPath: '/audits'
preLoaderRoute: typeof AuthAuditsIndexRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/apps/': {
id: '/_auth/apps/'
path: '/apps'
@ -437,6 +482,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthProfileUserNameIndexRouteImport
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/': {
id: '/_auth/user/role/$roleId/'
path: '/user/role/$roleId'
@ -444,6 +496,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthUserRoleRoleIdIndexRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/user/edit/$userName/': {
id: '/_auth/user/edit/$userName/'
path: '/user/edit/$userName'
fullPath: '/user/edit/$userName'
preLoaderRoute: typeof AuthUserEditUserNameIndexRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/user/change-password/$userName/': {
id: '/_auth/user/change-password/$userName/'
path: '/user/change-password/$userName'
@ -478,6 +537,7 @@ declare module '@tanstack/react-router' {
interface AuthRouteChildren {
AuthAgentIndexRoute: typeof AuthAgentIndexRoute
AuthAppsIndexRoute: typeof AuthAppsIndexRoute
AuthAuditsIndexRoute: typeof AuthAuditsIndexRoute
AuthBlacklistsIndexRoute: typeof AuthBlacklistsIndexRoute
AuthCommandsIndexRoute: typeof AuthCommandsIndexRoute
AuthDashboardIndexRoute: typeof AuthDashboardIndexRoute
@ -495,12 +555,14 @@ interface AuthRouteChildren {
AuthRoomsRoomNameConnectIndexRoute: typeof AuthRoomsRoomNameConnectIndexRoute
AuthRoomsRoomNameFolderStatusIndexRoute: typeof AuthRoomsRoomNameFolderStatusIndexRoute
AuthUserChangePasswordUserNameIndexRoute: typeof AuthUserChangePasswordUserNameIndexRoute
AuthUserEditUserNameIndexRoute: typeof AuthUserEditUserNameIndexRoute
AuthUserRoleRoleIdIndexRoute: typeof AuthUserRoleRoleIdIndexRoute
}
const AuthRouteChildren: AuthRouteChildren = {
AuthAgentIndexRoute: AuthAgentIndexRoute,
AuthAppsIndexRoute: AuthAppsIndexRoute,
AuthAuditsIndexRoute: AuthAuditsIndexRoute,
AuthBlacklistsIndexRoute: AuthBlacklistsIndexRoute,
AuthCommandsIndexRoute: AuthCommandsIndexRoute,
AuthDashboardIndexRoute: AuthDashboardIndexRoute,
@ -520,6 +582,7 @@ const AuthRouteChildren: AuthRouteChildren = {
AuthRoomsRoomNameFolderStatusIndexRoute,
AuthUserChangePasswordUserNameIndexRoute:
AuthUserChangePasswordUserNameIndexRoute,
AuthUserEditUserNameIndexRoute: AuthUserEditUserNameIndexRoute,
AuthUserRoleRoleIdIndexRoute: AuthUserRoleRoleIdIndexRoute,
}
@ -529,6 +592,7 @@ const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
AuthRoute: AuthRouteWithChildren,
authLoginIndexRoute: authLoginIndexRoute,
authSsoCallbackIndexRoute: authSsoCallbackIndexRoute,
}
export const routeTree = rootRouteImport
._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

@ -7,11 +7,10 @@ import {
useUpdateAgent,
} from "@/hooks/queries";
import { toast } from "sonner";
import type { ColumnDef } from "@tanstack/react-table";
import type { AxiosProgressEvent } from "axios";
import type { Version } from "@/types/file";
import { ErrorFetchingPage } from "@/components/pages/error-fetching-page";
import { agentColumns } from "@/components/columns/agent-column";
export const Route = createFileRoute("/_auth/agent/")({
head: () => ({ meta: [{ title: "Quản lý Agent" }] }),
component: AgentsPage,
@ -71,26 +70,7 @@ function AgentsPage() {
};
// Cột bảng
const columns: ColumnDef<Version>[] = [
{ accessorKey: "version", header: "Phiên bản" },
{ accessorKey: "fileName", header: "Tên file" },
{
accessorKey: "updatedAt",
header: "Thời gian cập nhật",
cell: ({ getValue }) =>
getValue()
? new Date(getValue() as string).toLocaleString("vi-VN")
: "N/A",
},
{
accessorKey: "requestUpdateAt",
header: "Thời gian yêu cầu cập nhật",
cell: ({ getValue }) =>
getValue()
? new Date(getValue() as string).toLocaleString("vi-VN")
: "N/A",
},
];
return (
<AppManagerTemplate<Version>
@ -98,7 +78,7 @@ function AgentsPage() {
description="Quản lý và theo dõi các phiên bản Agent"
data={versionList}
isLoading={isLoading}
columns={columns}
columns={agentColumns}
onUpload={handleUpload}
onUpdate={handleUpdate}
updateLoading={updateMutation.isPending}

View File

@ -11,12 +11,10 @@ import {
useDownloadFiles,
} from "@/hooks/queries";
import { toast } from "sonner";
import type { ColumnDef } from "@tanstack/react-table";
import type { AxiosProgressEvent } from "axios";
import type { Version } from "@/types/file";
import { Check, X } from "lucide-react";
import { useState } from "react";
import { useMemo, useState } from "react";
import { createAppsColumns } from "@/components/columns/apps-column";
export const Route = createFileRoute("/_auth/apps/")({
head: () => ({ meta: [{ title: "Quản lý phần mềm" }] }),
component: AppsComponent,
@ -51,62 +49,10 @@ function AppsComponent() {
const deleteRequiredFileMutation = useDeleteRequiredFile();
// Cột bảng
const columns: ColumnDef<Version>[] = [
{ accessorKey: "version", header: "Phiên bản" },
{ accessorKey: "fileName", header: "Tên file" },
{
accessorKey: "updatedAt",
header: () => <div className="whitespace-normal max-w-xs">Thời gian cập nhật</div>,
cell: ({ getValue }) =>
getValue()
? new Date(getValue() as string).toLocaleString("vi-VN")
: "N/A",
},
{
accessorKey: "requestUpdateAt",
header: () => <div className="whitespace-normal max-w-xs">Thời gian yêu cầu cài đt/tải xuống</div>,
cell: ({ getValue }) =>
getValue()
? new Date(getValue() as string).toLocaleString("vi-VN")
: "N/A",
},
{
id: "required",
header: () => <div className="whitespace-normal max-w-xs">Đã thêm vào danh sách</div>,
cell: ({ row }) => {
const isRequired = row.original.isRequired;
return isRequired ? (
<div className="flex items-center gap-1">
<Check className="h-4 w-4 text-green-600" />
<span className="text-sm text-green-600"></span>
</div>
) : (
<div className="flex items-center gap-1">
<X className="h-4 w-4 text-gray-400" />
<span className="text-sm text-gray-400">Không</span>
</div>
const columns = useMemo(
() => createAppsColumns(installMutation.isPending),
[installMutation.isPending]
);
},
enableSorting: false,
enableHiding: false,
},
{
id: "select",
header: () => <div className="whitespace-normal max-w-xs">Chọn</div>,
cell: ({ row }) => (
<input
type="checkbox"
checked={row.getIsSelected?.() ?? false}
onChange={row.getToggleSelectedHandler?.()}
disabled={installMutation.isPending}
/>
),
enableSorting: false,
enableHiding: false,
},
];
// Upload file MSI
const handleUpload = async (
fd: FormData,
@ -191,11 +137,10 @@ function AppsComponent() {
return;
}
const MsiFileIds = selectedRows.map((row: any) => row.original.id);
try {
for (const row of selectedRows) {
const { id } = row.original;
await deleteMutation.mutateAsync(id);
}
await deleteMutation.mutateAsync({ MsiFileIds });
toast.success("Xóa phần mềm thành công!");
} catch (e) {
toast.error("Xóa phần mềm thất bại!");
@ -206,12 +151,15 @@ function AppsComponent() {
if (!table) return;
const selectedRows = table.getSelectedRowModel().rows;
if (selectedRows.length === 0) {
toast.error("Vui lòng chọn ít nhất một file để xóa!");
return;
}
const MsiFileIds = selectedRows.map((row: any) => row.original.id);
try {
for (const row of selectedRows) {
const { id } = row.original;
await deleteRequiredFileMutation.mutateAsync(id);
}
await deleteRequiredFileMutation.mutateAsync({ MsiFileIds });
toast.success("Xóa file khỏi danh sách thành công!");
if (table) {
table.setRowSelection({});
@ -226,12 +174,10 @@ function AppsComponent() {
if (!table) return;
const selectedRows = table.getSelectedRowModel().rows;
const MsiFileIds = selectedRows.map((row: any) => row.original.id);
try {
for (const row of selectedRows) {
const { id } = row.original;
await deleteMutation.mutateAsync(id);
}
await deleteMutation.mutateAsync({ MsiFileIds });
toast.success("Xóa phần mềm từ server thành công!");
if (table) {
table.setRowSelection({});

View File

@ -0,0 +1,92 @@
import { createFileRoute } from "@tanstack/react-router";
import { useState, useEffect } from "react";
import { useGetAudits } from "@/hooks/queries";
import type { Audits } from "@/types/audit";
import { AuditListTemplate } from "@/template/audit-list-template";
import { auditColumns } from "@/components/columns/audit-column";
export const Route = createFileRoute("/_auth/audits/")({
head: () => ({ meta: [{ title: "Audit Logs" }] }),
loader: async ({ context }) => {
context.breadcrumbs = [{ title: "Audit logs", path: "#" }];
},
component: AuditsPage,
});
function AuditsPage() {
const [pageNumber, setPageNumber] = useState(1);
const [pageSize] = useState(20);
const [username, setUsername] = useState<string | null>(null);
const [action, setAction] = useState<string | null>(null);
const [from, setFrom] = useState<string | null>(null);
const [to, setTo] = useState<string | null>(null);
const [selectedAudit, setSelectedAudit] = useState<Audits | null>(null);
const { data, isLoading, refetch, isFetching } = useGetAudits(
{
pageNumber,
pageSize,
username,
action,
from,
to,
},
true
) as any;
const items: Audits[] = data?.items ?? [];
const total: number = data?.totalCount ?? 0;
const pageCount = Math.max(1, Math.ceil(total / pageSize));
useEffect(() => {
refetch();
}, [pageNumber, pageSize]);
const handleSearch = () => {
setPageNumber(1);
refetch();
};
const handleReset = () => {
setUsername(null);
setAction(null);
setFrom(null);
setTo(null);
setPageNumber(1);
refetch();
};
return (
<AuditListTemplate
// data
items={items}
total={total}
columns={auditColumns}
isLoading={isLoading}
isFetching={isFetching}
// pagination
pageNumber={pageNumber}
pageSize={pageSize}
pageCount={pageCount}
canPreviousPage={pageNumber > 1}
canNextPage={pageNumber < pageCount}
onPreviousPage={() => setPageNumber((p) => Math.max(1, p - 1))}
onNextPage={() => setPageNumber((p) => Math.min(pageCount, p + 1))}
// filter
username={username}
action={action}
from={from}
to={to}
onUsernameChange={setUsername}
onActionChange={setAction}
onFromChange={setFrom}
onToChange={setTo}
onSearch={handleSearch}
onReset={handleReset}
// detail dialog
selectedAudit={selectedAudit}
onRowClick={setSelectedAudit}
onDialogClose={() => setSelectedAudit(null)}
/>
);
}

View File

@ -11,7 +11,6 @@ import {
useSendCommand,
} from "@/hooks/queries";
import { toast } from "sonner";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import { Check, X, Edit2, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import type { ColumnDef } from "@tanstack/react-table";

View File

@ -1,15 +1,75 @@
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/')({
component: RouteComponent,
head: () => ({ meta: [{ title: 'Dashboard' }] }),
loader: async ({ context }) => {
context.breadcrumbs = [
{ title: "Dashboard", path: "/_auth/dashboard/" },
{ title: "Dashboard", path: "#" },
];
},
})
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

@ -9,6 +9,9 @@ import { LoaderCircle } from "lucide-react";
import { useState } from "react";
export const Route = createFileRoute("/_auth/user/change-password/$userName/")({
head: () => ({
meta: [{ title: "Thay đổi mật khẩu" }],
}),
component: AdminChangePasswordComponent,
loader: async ({ context, params }) => {
context.breadcrumbs = [

View File

@ -22,6 +22,9 @@ import { UserPlus, ArrowLeft, Save, Loader2 } from "lucide-react";
import { toast } from "sonner";
export const Route = createFileRoute("/_auth/user/create/")({
head: () => ({
meta: [{ title: "Tạo người dùng mới" }],
}),
component: CreateUserComponent,
loader: async ({ context }) => {
context.breadcrumbs = [
@ -59,7 +62,8 @@ function CreateUserComponent() {
if (!formData.userName) {
newErrors.userName = "Tên đăng nhập không được để trống";
} else if (!validateUserName(formData.userName)) {
newErrors.userName = "Tên đăng nhập chỉ cho phép chữ cái, số, dấu chấm và gạch dưới (3-20 ký tự)";
newErrors.userName =
"Tên đăng nhập chỉ cho phép chữ cái, số, dấu chấm và gạch dưới (3-20 ký tự)";
}
// Validate name
@ -106,7 +110,8 @@ function CreateUserComponent() {
toast.success("Tạo tài khoản thành công!");
navigate({ to: "/dashboard" }); // TODO: Navigate to user list page when it exists
} catch (error: any) {
const errorMessage = error.response?.data?.message || "Tạo tài khoản thất bại!";
const errorMessage =
error.response?.data?.message || "Tạo tài khoản thất bại!";
toast.error(errorMessage);
}
};
@ -128,15 +133,14 @@ function CreateUserComponent() {
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Tạo người dùng mới</h1>
<h1 className="text-3xl font-bold tracking-tight">
Tạo người dùng mới
</h1>
<p className="text-muted-foreground mt-1">
Thêm tài khoản người dùng mới vào hệ thống
</p>
</div>
<Button
variant="outline"
onClick={() => navigate({ to: "/user" })}
>
<Button variant="outline" onClick={() => navigate({ to: "/user" })}>
<ArrowLeft className="h-4 w-4 mr-2" />
Quay lại
</Button>
@ -164,7 +168,9 @@ function CreateUserComponent() {
<Input
id="userName"
value={formData.userName}
onChange={(e) => handleInputChange("userName", e.target.value)}
onChange={(e) =>
handleInputChange("userName", e.target.value)
}
placeholder="Nhập tên đăng nhập (3-20 ký tự, chỉ chữ, số, . và _)"
disabled={createMutation.isPending}
className="h-10"
@ -202,7 +208,9 @@ function CreateUserComponent() {
id="password"
type="password"
value={formData.password}
onChange={(e) => handleInputChange("password", e.target.value)}
onChange={(e) =>
handleInputChange("password", e.target.value)
}
placeholder="Nhập mật khẩu (tối thiểu 6 ký tự)"
disabled={createMutation.isPending}
className="h-10"
@ -220,13 +228,17 @@ function CreateUserComponent() {
id="confirmPassword"
type="password"
value={formData.confirmPassword}
onChange={(e) => handleInputChange("confirmPassword", e.target.value)}
onChange={(e) =>
handleInputChange("confirmPassword", e.target.value)
}
placeholder="Nhập lại mật khẩu"
disabled={createMutation.isPending}
className="h-10"
/>
{errors.confirmPassword && (
<p className="text-sm text-destructive">{errors.confirmPassword}</p>
<p className="text-sm text-destructive">
{errors.confirmPassword}
</p>
)}
</div>
</div>

View File

@ -0,0 +1,361 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { useEffect, useMemo, useState } from "react";
import {
useGetRoleList,
useGetRoomList,
useGetUsersInfo,
useUpdateUserInfo,
useUpdateUserRole,
} from "@/hooks/queries";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { SelectDialog } from "@/components/dialogs/select-dialog";
import { ArrowLeft, Save } from "lucide-react";
import { toast } from "sonner";
import type { UserProfile } from "@/types/user-profile";
export const Route = createFileRoute("/_auth/user/edit/$userName/")({
head: () => ({
meta: [{ title: "Chỉnh sửa người dùng" }],
}),
component: EditUserComponent,
loader: async ({ context, params }) => {
context.breadcrumbs = [
{ title: "Quản lý người dùng", path: "/user" },
{
title: `Chỉnh sửa thông tin người dùng ${params.userName}`,
path: `/user/edit/${params.userName}`,
},
];
},
});
function EditUserComponent() {
const { userName } = Route.useParams();
const navigate = useNavigate();
const { data: users = [], isLoading } = useGetUsersInfo();
const { data: roomData = [], isLoading: roomsLoading } = useGetRoomList();
const { data: roles = [], isLoading: rolesLoading } = useGetRoleList();
const updateUserInfoMutation = useUpdateUserInfo();
const updateUserRoleMutation = useUpdateUserRole();
const user = useMemo(() => {
return users.find((u) => u.userName === userName) as
| UserProfile
| undefined;
}, [users, userName]);
const [editForm, setEditForm] = useState({
userName: "",
name: "",
});
const [selectedRoleId, setSelectedRoleId] = useState<string>("");
const [selectedRoomValues, setSelectedRoomValues] = useState<string[]>([]);
const [isRoomDialogOpen, setIsRoomDialogOpen] = useState(false);
const roomOptions = useMemo(() => {
const list = Array.isArray(roomData) ? roomData : [];
return list
.map((room: any) => {
const rawValue =
room.id ??
room.roomId ??
room.roomID ??
room.Id ??
room.ID ??
room.RoomId ??
room.RoomID ??
room.name ??
room.roomName ??
room.RoomName ??
"";
const label =
room.name ?? room.roomName ?? room.RoomName ?? (rawValue ? String(rawValue) : "");
if (!rawValue || !label) return null;
return { label: String(label), value: String(rawValue) };
})
.filter((item): item is { label: string; value: string } => !!item);
}, [roomData]);
const roomLabelMap = useMemo(() => {
return new Map(roomOptions.map((room) => [room.value, room.label]));
}, [roomOptions]);
useEffect(() => {
if (!user) return;
setEditForm({
userName: user.userName ?? "",
name: user.name ?? "",
});
setSelectedRoleId(user.roleId ? String(user.roleId) : "");
setSelectedRoomValues(
Array.isArray(user.accessRooms)
? user.accessRooms.map((roomId) => String(roomId))
: []
);
}, [user]);
const handleUpdateUserInfo = async () => {
if (!user?.userId) {
toast.error("Không tìm thấy userId để cập nhật.");
return;
}
const nextUserName = editForm.userName.trim();
const nextName = editForm.name.trim();
if (!nextUserName || !nextName) {
toast.error("Vui lòng nhập đầy đủ tên đăng nhập và họ tên.");
return;
}
try {
const accessRooms = selectedRoomValues
.map((value) => Number(value))
.filter((value) => Number.isFinite(value));
if (
selectedRoomValues.length > 0 &&
accessRooms.length !== selectedRoomValues.length
) {
toast.error("Danh sách phòng không hợp lệ, vui lòng chọn lại.");
return;
}
await updateUserInfoMutation.mutateAsync({
id: user.userId,
data: {
userName: nextUserName,
name: nextName,
accessRooms,
},
});
toast.success("Cập nhật thông tin người dùng thành công!");
} catch (error: any) {
const message = error?.response?.data?.message || "Cập nhật thất bại!";
toast.error(message);
}
};
const handleUpdateUserRole = async () => {
if (!user?.userId) {
toast.error("Không tìm thấy userId để cập nhật role.");
return;
}
if (!selectedRoleId) {
toast.error("Vui lòng chọn vai trò.");
return;
}
try {
await updateUserRoleMutation.mutateAsync({
id: user.userId,
data: { roleId: Number(selectedRoleId) },
});
toast.success("Cập nhật vai trò thành công!");
} catch (error: any) {
const message =
error?.response?.data?.message || "Cập nhật vai trò thất bại!";
toast.error(message);
}
};
if (isLoading) {
return (
<div className="w-full px-6 py-8">
<div className="flex items-center justify-center min-h-[320px]">
<div className="text-muted-foreground">
Đang tải thông tin người dùng...
</div>
</div>
</div>
);
}
if (!user) {
return (
<div className="w-full px-6 py-8 space-y-4">
<Button variant="outline" onClick={() => navigate({ to: "/user" })}>
<ArrowLeft className="h-4 w-4 mr-2" />
Quay lại
</Button>
<div className="text-muted-foreground">
Không tìm thấy người dùng cần chỉnh sửa.
</div>
</div>
);
}
return (
<div className="w-full px-6 py-6 space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">
Chỉnh sửa người dùng
</h1>
<p className="text-muted-foreground mt-1">
Tài khoản: {user.userName}
</p>
</div>
<Button variant="outline" onClick={() => navigate({ to: "/user" })}>
<ArrowLeft className="h-4 w-4 mr-2" />
Quay lại
</Button>
</div>
<Card className="shadow-sm">
<CardHeader>
<CardTitle>Thông tin người dùng</CardTitle>
<CardDescription>
Cập nhật họ tên, username danh sách phòng truy cập.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="edit-userName">Tên đăng nhập</Label>
<Input
id="edit-userName"
value={editForm.userName}
onChange={(e) =>
setEditForm((prev) => ({ ...prev, userName: e.target.value }))
}
disabled={updateUserInfoMutation.isPending}
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-name">Họ tên</Label>
<Input
id="edit-name"
value={editForm.name}
onChange={(e) =>
setEditForm((prev) => ({ ...prev, name: e.target.value }))
}
disabled={updateUserInfoMutation.isPending}
/>
</div>
</div>
<div className="space-y-2">
<Label>Các phòng phụ trách</Label>
<div className="flex flex-wrap gap-2">
{selectedRoomValues.length > 0 ? (
selectedRoomValues.map((value) => (
<Badge key={value} variant="secondary">
{roomLabelMap.get(value) ?? value}
</Badge>
))
) : (
<span className="text-xs text-muted-foreground">
Chưa chọn phòng.
</span>
)}
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
onClick={() => setIsRoomDialogOpen(true)}
disabled={roomsLoading || updateUserInfoMutation.isPending}
>
Chọn phòng
</Button>
{roomsLoading && (
<span className="text-xs text-muted-foreground">
Đang tải danh sách phòng...
</span>
)}
</div>
</div>
<div className="flex justify-end">
<Button
type="button"
onClick={handleUpdateUserInfo}
disabled={updateUserInfoMutation.isPending}
className="gap-2"
>
<Save className="h-4 w-4" />
{updateUserInfoMutation.isPending
? "Đang lưu..."
: "Lưu thông tin"}
</Button>
</div>
</CardContent>
</Card>
<Card className="shadow-sm">
<CardHeader>
<CardTitle>Vai trò</CardTitle>
<CardDescription>Cập nhật vai trò của người dùng.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2 max-w-md">
<Label>Vai trò</Label>
<Select
value={selectedRoleId}
onValueChange={setSelectedRoleId}
disabled={rolesLoading || updateUserRoleMutation.isPending}
>
<SelectTrigger className="w-full">
<SelectValue
placeholder={
rolesLoading ? "Đang tải vai trò..." : "Chọn vai trò"
}
/>
</SelectTrigger>
<SelectContent>
{roles.map((role) => (
<SelectItem key={role.id} value={String(role.id)}>
{role.roleName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex justify-end">
<Button
type="button"
onClick={handleUpdateUserRole}
disabled={updateUserRoleMutation.isPending || rolesLoading}
className="gap-2"
>
<Save className="h-4 w-4" />
{updateUserRoleMutation.isPending ? "Đang lưu..." : "Lưu vai trò"}
</Button>
</div>
</CardContent>
</Card>
<SelectDialog
open={isRoomDialogOpen}
onClose={() => setIsRoomDialogOpen(false)}
title="Chọn phòng phụ trách"
description="Chọn một hoặc nhiều phòng để gán quyền truy cập."
items={roomOptions}
selectedValues={selectedRoomValues}
onConfirm={(values) => setSelectedRoomValues(values)}
/>
</div>
);
}

View File

@ -10,10 +10,13 @@ import {
} from "@/components/ui/tooltip";
import type { ColumnDef } from "@tanstack/react-table";
import { VersionTable } from "@/components/tables/version-table";
import { Edit2, Trash2, Shield } from "lucide-react";
import { Edit2, Settings, Shield, Trash2 } from "lucide-react";
import { toast } from "sonner";
export const Route = createFileRoute("/_auth/user/")({
head: () => ({
meta: [{ title: "Danh sách người dùng" }],
}),
component: RouteComponent,
loader: async ({ context }) => {
context.breadcrumbs = [
@ -65,21 +68,6 @@ function RouteComponent() {
<div className="flex justify-center">{Array.isArray(getValue()) ? (getValue() as number[]).join(", ") : "-"}</div>
),
},
{
id: "select",
header: () => <div className="text-center whitespace-normal max-w-xs">Chọn</div>,
cell: ({ row }) => (
<div className="flex justify-center">
<input
type="checkbox"
checked={row.getIsSelected?.() ?? false}
onChange={row.getToggleSelectedHandler?.()}
/>
</div>
),
enableSorting: false,
enableHiding: false,
},
{
id: "actions",
header: () => (
@ -87,6 +75,26 @@ function RouteComponent() {
),
cell: ({ row }) => (
<div className="flex gap-2 justify-center items-center">
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
onClick={(e) => {
e.stopPropagation();
navigate({
to: "/user/edit/$userName",
params: { userName: row.original.userName },
} as any);
}}
>
<Settings className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">Đi thông tin</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
@ -100,16 +108,29 @@ function RouteComponent() {
>
<Edit2 className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">Đi mật khẩu</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
onClick={(e) => {
e.stopPropagation();
navigate({ to: "/user/role/$roleId", params: { roleId: String(row.original.roleId) } } as any);
navigate({
to: "/user/role/$roleId",
params: { roleId: String(row.original.roleId) },
} as any);
}}
>
<Shield className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">Xem quyền</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
@ -123,6 +144,9 @@ function RouteComponent() {
>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">Xóa người dùng</TooltipContent>
</Tooltip>
</div>
),
enableSorting: false,

View File

@ -14,7 +14,7 @@ import type { PermissionOnRole } from "@/types/permission";
export const Route = createFileRoute("/_auth/user/role/$roleId/")({
head: () => ({
meta: [{ title: "Quyền của người dùng | AccessControl" }]
meta: [{ title: "Quyền của người dùng" }]
}),
component: ViewRolePermissionsComponent,
loader: async ({ context, params }) => {

View File

@ -108,20 +108,24 @@ export async function addRequiredFile(data: any): Promise<{ message: string }> {
/**
* Xóa file bắt buộc
* @param fileId - ID file
* @param data - DownloadMsiRequest { MsiFileIds: number[] }
*/
export async function deleteRequiredFile(fileId: number): Promise<{ message: string }> {
export async function deleteRequiredFile(data: { MsiFileIds: number[] }): Promise<{ message: string }> {
const response = await axios.post(
API_ENDPOINTS.APP_VERSION.DELETE_REQUIRED_FILE(fileId)
API_ENDPOINTS.APP_VERSION.DELETE_REQUIRED_FILE,
data
);
return response.data;
}
/**
* Xóa file từ server
* @param fileId - ID file
* @param data - DownloadMsiRequest { MsiFileIds: number[] }
*/
export async function deleteFile(fileId: number): Promise<{ message: string }> {
const response = await axios.delete(API_ENDPOINTS.APP_VERSION.DELETE_FILES(fileId));
export async function deleteFile(data: { MsiFileIds: number[] }): Promise<{ message: string }> {
const response = await axios.delete(
API_ENDPOINTS.APP_VERSION.DELETE_FILES,
{ data }
);
return response.data;
}

View File

@ -0,0 +1,19 @@
import axios from "@/config/axios";
import { API_ENDPOINTS } from "@/config/api";
import type { PageResult, Audits } from "@/types/audit";
export async function getAudits(
pageNumber = 1,
pageSize = 20,
username?: string | null,
action?: string | null,
from?: string | null,
to?: string | null
): Promise<PageResult<Audits>> {
const response = await axios.get<PageResult<Audits>>(API_ENDPOINTS.AUDIT.GET_AUDITS, {
params: { pageNumber, pageSize, username, action, from, to },
});
// API trả về camelCase khớp với PageResult<Audits> — dùng trực tiếp, không cần map
return response.data;
}

View File

@ -15,6 +15,28 @@ export async function login(credentials: LoginResquest): Promise<LoginResponse>
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
*/

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
export * as meshCentralService from "./meshcentral.service";
// Dashboard API Services
export * as dashboardService from "./dashboard.service";
// Remote Control API Services
export * as remoteControlService from "./remote-control.service";

View File

@ -1,6 +1,20 @@
import axios from "@/config/axios";
import { API_ENDPOINTS } from "@/config/api";
import type { UserProfile } from "@/types/user-profile";
import type {
UserProfile,
UpdateUserInfoRequest,
UpdateUserRoleRequest,
UpdateUserInfoResponse,
UpdateUserRoleResponse,
} from "@/types/user-profile";
// Helper to extract data from wrapped or unwrapped response
function extractData<T>(responseData: any): T {
if (responseData && typeof responseData === "object" && "success" in responseData && "data" in responseData) {
return responseData.data as T;
}
return responseData as T;
}
/**
* Lấy danh sách thông tin người dùng chuyển sang camelCase keys
@ -11,6 +25,7 @@ export async function getUsersInfo(): Promise<UserProfile[]> {
const list = Array.isArray(response.data) ? response.data : [];
return list.map((u: any) => ({
userId: u.id ?? u.Id ?? u.userId ?? u.UserId ?? undefined,
userName: u.userName ?? u.UserName ?? "",
name: u.name ?? u.Name ?? "",
role: u.role ?? u.Role ?? "",
@ -31,4 +46,32 @@ export async function getUsersInfo(): Promise<UserProfile[]> {
}
}
export default { getUsersInfo };
/**
* Cập nhật thông tin người dùng
*/
export async function updateUserInfo(
userId: number,
data: UpdateUserInfoRequest
): Promise<UpdateUserInfoResponse> {
const response = await axios.put(
API_ENDPOINTS.USER.UPDATE_INFO(userId),
data
);
return extractData<UpdateUserInfoResponse>(response.data);
}
/**
* Cập nhật role người dùng
*/
export async function updateUserRole(
userId: number,
data: UpdateUserRoleRequest
): Promise<UpdateUserRoleResponse> {
const response = await axios.put(
API_ENDPOINTS.USER.UPDATE_ROLE(userId),
data
);
return extractData<UpdateUserRoleResponse>(response.data);
}
export default { getUsersInfo, updateUserInfo, updateUserRole };

View File

@ -0,0 +1,242 @@
import {
type ColumnDef,
flexRender,
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table";
import {
Table,
TableHeader,
TableBody,
TableRow,
TableHead,
TableCell,
} from "@/components/ui/table";
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { type Audits } from "@/types/audit";
import { AuditFilterBar } from "@/components/filters/audit-filter-bar";
import { AuditDetailDialog } from "@/components/dialogs/audit-detail-dialog";
interface AuditListTemplateProps {
// data
items: Audits[];
total: number;
columns: ColumnDef<Audits>[];
isLoading: boolean;
isFetching: boolean;
// pagination
pageNumber: number;
pageSize: number;
pageCount: number;
onPreviousPage: () => void;
onNextPage: () => void;
canPreviousPage: boolean;
canNextPage: boolean;
// filter
username: string | null;
action: string | null;
from: string | null;
to: string | null;
onUsernameChange: (v: string | null) => void;
onActionChange: (v: string | null) => void;
onFromChange: (v: string | null) => void;
onToChange: (v: string | null) => void;
onSearch: () => void;
onReset: () => void;
// detail dialog
selectedAudit: Audits | null;
onRowClick: (audit: Audits) => void;
onDialogClose: () => void;
}
export function AuditListTemplate({
items,
total,
columns,
isLoading,
isFetching,
pageNumber,
pageSize,
pageCount,
onPreviousPage,
onNextPage,
canPreviousPage,
canNextPage,
username,
action,
from,
to,
onUsernameChange,
onActionChange,
onFromChange,
onToChange,
onSearch,
onReset,
selectedAudit,
onRowClick,
onDialogClose,
}: AuditListTemplateProps) {
const table = useReactTable({
data: items,
columns,
state: {
pagination: { pageIndex: Math.max(0, pageNumber - 1), pageSize },
},
pageCount,
manualPagination: true,
onPaginationChange: (updater) => {
const next =
typeof updater === "function"
? updater({ pageIndex: Math.max(0, pageNumber - 1), pageSize })
: updater;
const newPage = (next.pageIndex ?? 0) + 1;
if (newPage > pageNumber) onNextPage();
else if (newPage < pageNumber) onPreviousPage();
},
getCoreRowModel: getCoreRowModel(),
});
return (
<div className="w-full px-4 md:px-6 space-y-4">
<div>
<h1 className="text-2xl md:text-3xl font-bold">Nhật hoạt đng</h1>
<p className="text-muted-foreground mt-1 text-sm">
Xem nhật audit hệ thống
</p>
</div>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Danh sách audit</CardTitle>
<CardDescription className="text-xs">
Lọc theo người dùng, loại, hành đng khoảng thời gian. Nhấn vào
dòng đ xem chi tiết.
</CardDescription>
</CardHeader>
<CardContent>
<AuditFilterBar
username={username}
action={action}
from={from}
to={to}
isLoading={isLoading}
isFetching={isFetching}
onUsernameChange={onUsernameChange}
onActionChange={onActionChange}
onFromChange={onFromChange}
onToChange={onToChange}
onSearch={onSearch}
onReset={onReset}
/>
<div className="rounded-md border overflow-x-auto">
<Table className="min-w-[640px] w-full">
<TableHeader className="sticky top-0 bg-background z-10">
{table.getHeaderGroups().map((hg) => (
<TableRow key={hg.id}>
{hg.headers.map((header) => (
<TableHead
key={header.id}
className="text-xs font-semibold whitespace-nowrap"
>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{isLoading || isFetching ? (
<TableRow>
<TableCell
colSpan={columns.length}
className="text-center py-10 text-muted-foreground text-sm"
>
Đang tải...
</TableCell>
</TableRow>
) : table.getRowModel().rows.length === 0 ? (
<TableRow>
<TableCell
colSpan={columns.length}
className="text-center py-10 text-muted-foreground text-sm"
>
Không dữ liệu
</TableCell>
</TableRow>
) : (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
className="hover:bg-muted/40 cursor-pointer"
onClick={() => onRowClick(row.original)}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id} className="py-2.5 align-middle">
{cell.column.columnDef.cell
? flexRender(
cell.column.columnDef.cell,
cell.getContext()
)
: String(cell.getValue() ?? "")}
</TableCell>
))}
</TableRow>
))
)}
</TableBody>
</Table>
</div>
<div className="flex items-center justify-between mt-4">
<span className="text-xs text-muted-foreground">
Hiển thị {items.length} / {total} mục
</span>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={!canPreviousPage || isFetching}
onClick={onPreviousPage}
>
Trước
</Button>
<span className="text-sm tabular-nums">
{pageNumber} / {pageCount}
</span>
<Button
variant="outline"
size="sm"
disabled={!canNextPage || isFetching}
onClick={onNextPage}
>
Sau
</Button>
</div>
</div>
</CardContent>
</Card>
<AuditDetailDialog
audit={selectedAudit}
open={!!selectedAudit}
onClose={onDialogClose}
/>
</div>
);
}

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 ngày
</Button>
<Button
type="button"
size="sm"
variant={usageRange === "monthly" ? "default" : "outline"}
onClick={() => setUsageRange("monthly")}
>
30 ngày
</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>
);
}

View File

@ -1,4 +1,4 @@
import { AppWindow, Building, CircleX, Folder, Home, Monitor, ShieldCheck, Terminal, UserPlus } from "lucide-react";
import { AppWindow, Building, CircleX, ClipboardList, Folder, Home, Monitor, ShieldCheck, Terminal, UserPlus} from "lucide-react";
import { PermissionEnum } from "./permission";
enum AppSidebarSectionCode {
@ -27,7 +27,7 @@ export const appSidebarSection = {
code: AppSidebarSectionCode.DASHBOARD,
icon: Home,
permissions: [PermissionEnum.ALLOW_ALL],
},
}
],
},
{
@ -40,6 +40,13 @@ export const appSidebarSection = {
icon: Building,
permissions: [PermissionEnum.VIEW_ROOM],
},
{
title: "Điều khiển trực tiếp",
url: "/remote-control",
code: AppSidebarSectionCode.REMOTE_LIVE_CONTROL,
icon: Monitor,
permissions: [PermissionEnum.VIEW_REMOTE_CONTROL],
}
],
},
{
@ -96,14 +103,13 @@ export const appSidebarSection = {
]
},
{
title: "Điều khiển từ xa",
title: "Audits",
items: [
{
title: "Điều khiển trực tiếp",
url: "/remote-control",
code: AppSidebarSectionCode.REMOTE_LIVE_CONTROL,
icon: Monitor,
permissions: [PermissionEnum.ALLOW_ALL],
title: "Lịch sử hoạt động",
url: "/audits",
icon: ClipboardList,
permissions: [PermissionEnum.VIEW_AUDIT_LOGS],
}
]
}

29
src/types/audit.ts Normal file
View File

@ -0,0 +1,29 @@
export interface Audits {
id: number;
username: string;
dateTime: string; // ISO string
// request identity
apiCall?: string; // Controller.ActionName
url?: string;
requestPayload?: string; // request body (redacted)
// DB fields — null if request didn't touch DB
action?: string;
tableName?: string;
entityId?: string;
oldValues?: string;
newValues?: string;
// result
isSuccess?: boolean;
errorMessage?: string;
}
export interface PageResult<T> {
items: T[];
totalCount: number;
pageNumber: number;
pageSize: number;
totalPages: number;
}

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;
}

View File

@ -44,6 +44,7 @@ export enum PermissionEnum {
EDIT_COMMAND = 53,
DEL_COMMAND = 54,
SEND_COMMAND = 55,
SEND_SENSITIVE_COMMAND = 56,
//DEVICE_OPERATION
DEVICE_OPERATION = 70,
@ -59,10 +60,12 @@ export enum PermissionEnum {
VIEW_ACCOUNT_ROOM = 115,
EDIT_ACCOUNT_ROOM = 116,
//WARNING_OPERATION
WARNING_OPERATION = 140,
VIEW_WARNING = 141,
//USER_OPERATION
USER_OPERATION = 150,
VIEW_USER_ROLE = 151,
@ -80,7 +83,7 @@ export enum PermissionEnum {
DEL_ROLE = 164,
// AGENT
APP_OPERATION = 170,
AGENT_OPERATION = 170,
VIEW_AGENT = 171,
UPDATE_AGENT = 173,
SEND_UPDATE_COMMAND = 174,
@ -94,9 +97,18 @@ export enum PermissionEnum {
ADD_APP_TO_SELECTED = 185,
DEL_APP_FROM_SELECTED = 186,
// AUDIT
AUDIT_OPERATION = 190,
VIEW_AUDIT_LOGS = 191,
//REMOTE CONTROL
REMOTE_CONTROL_OPERATION = 200,
VIEW_REMOTE_CONTROL = 201,
CONTROL_REMOTE = 202,
//Undefined
UNDEFINED = 9999,
//Allow All
ALLOW_ALL = 0,
ALLOW_ALL = 0
}

View File

@ -1,4 +1,5 @@
export type UserProfile = {
userId?: number;
userName: string;
name: string;
role: string;
@ -9,3 +10,32 @@ export type UserProfile = {
updatedAt?: string | null;
updatedBy?: string | null;
};
export type UpdateUserInfoRequest = {
name: string;
userName: string;
accessRooms?: number[];
};
export type UpdateUserRoleRequest = {
roleId: number;
};
export type UpdateUserInfoResponse = {
userId: number;
userName: string;
name: string;
roleId: number;
accessRooms: number[];
updatedAt?: string | null;
updatedBy?: string | null;
};
export type UpdateUserRoleResponse = {
userId: number;
userName: string;
roleId: number;
roleName?: string | null;
updatedAt?: string | null;
updatedBy?: string | null;
};