fix: frontend-backend integration — 14 issues resolved

- nginx: add proxy routes for /settings/, /templates/, /ipam/, /logs/, /admin/, /health
- dashboard: fix data format mismatch (servers[] not nodes[]/vms[])
- dashboard: add WebSocket subscription to dashboard_metrics for real-time updates
- dashboard: connect activity feed to /logs/api/logs endpoint
- dashboard: reduce polling to 15s with WS as primary update source
- graphs: fix rrddata response unwrap (backend returns {data:[],timeframe:''})
- graphs: add i18n translations for chart titles
- console: add xterm.js CSS import for proper terminal rendering
- console: add i18n translations (connecting, reconnect)
- workspace: add switcher dropdown in TopBar
- workspace: add edit dialog in WorkspacesPage
- workspace: create Zustand store with localStorage persistence
- workspace: add X-Active-Workspace header to API client
- sidebar: replace generic icon with actual favicon.svg logo
- instances: translate RowActionMenu and InstanceDetailPage strings
- locales: add graphs, console, workspace, instance tab keys to en/ru
This commit is contained in:
markmorado
2026-04-07 00:45:43 +02:00
parent 4546a798d0
commit 1d08c815a7
138 changed files with 17826 additions and 32024 deletions

24
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

12
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,12 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

73
frontend/README.md Normal file
View File

@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

25
frontend/components.json Normal file
View File

@@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-nova",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}

23
frontend/eslint.config.js Normal file
View File

@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

16
frontend/index.html Normal file
View File

@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<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=DM+Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
<title>PVEmanager</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

121
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,121 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# SPA fallback
location / {
try_files $uri $uri/ /index.html;
}
# API proxy
location /api/ {
proxy_pass http://app:8000/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 60s;
}
# Proxmox API proxy
location /proxmox/ {
proxy_pass http://app:8000/proxmox/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 60s;
}
# Settings API proxy
location /settings/ {
proxy_pass http://app:8000/settings/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 60s;
}
# Templates API proxy
location /templates/ {
proxy_pass http://app:8000/templates/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 60s;
}
# IPAM API proxy
location /ipam/ {
proxy_pass http://app:8000/ipam/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 60s;
}
# Logs API proxy
location /logs/ {
proxy_pass http://app:8000/logs/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 60s;
}
# Admin API proxy
location /admin/ {
proxy_pass http://app:8000/admin/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 60s;
}
# Health check proxy
location /health {
proxy_pass http://app:8000/health;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# WebSocket proxy
location /ws/ {
proxy_pass http://app:8000/ws/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 86400s;
}
# Proxmox WebSocket proxy
location /proxmox/ws/ {
proxy_pass http://app:8000/proxmox/ws/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 86400s;
}
# Cache static assets
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# Gzip
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript image/svg+xml;
gzip_min_length 1000;
}

7513
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

54
frontend/package.json Normal file
View File

@@ -0,0 +1,54 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@base-ui/react": "^1.3.0",
"@fontsource-variable/geist": "^5.2.8",
"@novnc/novnc": "^1.6.0",
"@tailwindcss/vite": "^4.2.2",
"@tanstack/react-query": "^5.96.2",
"@tanstack/react-table": "^8.21.3",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"i18next": "^26.0.3",
"i18next-browser-languagedetector": "^8.2.1",
"lucide-react": "^1.7.0",
"next-themes": "^0.4.6",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-i18next": "^17.0.2",
"react-router": "^7.14.0",
"recharts": "^3.8.1",
"shadcn": "^4.1.2",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.2",
"tw-animate-css": "^1.4.0",
"zustand": "^5.0.12"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@types/node": "^24.12.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"typescript": "~6.0.2",
"typescript-eslint": "^8.58.0",
"vite": "^8.0.4"
}
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

24
frontend/public/icons.svg Normal file
View File

@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

34
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,34 @@
import { useEffect } from 'react';
import { RouterProvider } from 'react-router';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { TooltipProvider } from '@/components/ui/tooltip';
import { Toaster } from '@/components/ui/sonner';
import { router } from '@/router';
import { useAuthStore } from '@/stores/auth-store';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
staleTime: 30_000,
refetchOnWindowFocus: false,
},
},
});
export default function App() {
const loadUser = useAuthStore((s) => s.loadUser);
useEffect(() => {
loadUser();
}, [loadUser]);
return (
<QueryClientProvider client={queryClient}>
<TooltipProvider delay={200}>
<RouterProvider router={router} />
<Toaster position="bottom-right" richColors />
</TooltipProvider>
</QueryClientProvider>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

View File

@@ -0,0 +1,91 @@
import { useTranslation } from 'react-i18next';
import { Bell, Check, CheckCheck, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import type { Notification } from '@/types';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
useNotifications,
useUnreadCount,
useMarkRead,
useMarkAllRead,
useDeleteNotification,
} from '@/hooks/use-notifications';
export default function NotificationsDropdown() {
const { t } = useTranslation();
const { data: notifResponse } = useNotifications();
const { data: countData } = useUnreadCount();
const markRead = useMarkRead();
const markAllRead = useMarkAllRead();
const deleteNotification = useDeleteNotification();
const notifications: Notification[] = notifResponse?.notifications ?? [];
const unread = countData?.count ?? 0;
return (
<DropdownMenu>
<DropdownMenuTrigger render={
<Button variant="ghost" size="icon" className="relative">
<Bell className="h-4 w-4" />
{unread > 0 && (
<Badge
variant="destructive"
className="absolute -right-0.5 -top-0.5 h-4 min-w-4 px-1 text-[10px]"
>
{unread > 99 ? '99+' : unread}
</Badge>
)}
</Button>
} />
<DropdownMenuContent align="end" className="w-80 max-h-96 overflow-y-auto">
<div className="flex items-center justify-between px-3 py-2">
<span className="text-sm font-semibold">{t('notifications.title')}</span>
{unread > 0 && (
<Button variant="ghost" size="sm" className="h-6 text-xs" onClick={() => markAllRead.mutate()}>
<CheckCheck className="h-3 w-3 mr-1" />{t('notifications.mark_all_read')}
</Button>
)}
</div>
<DropdownMenuSeparator />
{notifications.length === 0 ? (
<div className="px-3 py-6 text-center text-sm text-muted-foreground">{t('notifications.empty')}</div>
) : (
notifications.slice(0, 20).map(n => (
<DropdownMenuItem key={n.id} className="flex flex-col items-start gap-1 p-3 cursor-default" onSelect={e => e.preventDefault()}>
<div className="flex w-full items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<p className={`text-sm leading-tight ${n.is_read ? 'text-muted-foreground' : 'font-medium'}`}>
{n.title || n.message}
</p>
{n.title && n.message && (
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">{n.message}</p>
)}
<p className="text-[10px] text-muted-foreground mt-1">
{n.created_at ? new Date(n.created_at).toLocaleString() : ''}
</p>
</div>
<div className="flex gap-0.5 shrink-0">
{!n.is_read && (
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => markRead.mutate(n.id)}>
<Check className="h-3 w-3" />
</Button>
)}
<Button variant="ghost" size="icon" className="h-6 w-6 text-destructive" onClick={() => deleteNotification.mutate(n.id)}>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
</DropdownMenuItem>
))
)}
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,30 @@
import { Navigate, Outlet } from 'react-router';
import { useAuthStore } from '@/stores/auth-store';
export function ProtectedRoute() {
const { isAuthenticated, isLoading } = useAuthStore();
if (isLoading) {
return (
<div className="flex h-screen items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
);
}
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
return <Outlet />;
}
export function PublicRoute() {
const { isAuthenticated } = useAuthStore();
if (isAuthenticated) {
return <Navigate to="/dashboard" replace />;
}
return <Outlet />;
}

View File

@@ -0,0 +1,28 @@
import { cn } from '@/lib/utils';
const statusColors: Record<string, string> = {
running: 'bg-green-500',
stopped: 'bg-muted-foreground/40',
paused: 'bg-amber-500',
suspended: 'bg-amber-500',
unknown: 'bg-muted-foreground/30',
online: 'bg-green-500',
offline: 'bg-red-500',
};
interface StatusDotProps {
status: string;
className?: string;
pulse?: boolean;
}
export function StatusDot({ status, className, pulse }: StatusDotProps) {
const color = statusColors[status] ?? statusColors.unknown;
return (
<span className={cn('relative inline-block h-2 w-2 rounded-full', color, className)}>
{pulse && status === 'running' && (
<span className={cn('absolute inset-0 animate-ping rounded-full opacity-75', color)} />
)}
</span>
);
}

View File

@@ -0,0 +1,107 @@
import * as React from "react"
import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar"
import { cn } from "@/lib/utils"
function Avatar({
className,
size = "default",
...props
}: AvatarPrimitive.Root.Props & {
size?: "default" | "sm" | "lg"
}) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
data-size={size}
className={cn(
"group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
className
)}
{...props}
/>
)
}
function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn(
"aspect-square size-full rounded-full object-cover",
className
)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: AvatarPrimitive.Fallback.Props) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
className
)}
{...props}
/>
)
}
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="avatar-badge"
className={cn(
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none",
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
className
)}
{...props}
/>
)
}
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group"
className={cn(
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
className
)}
{...props}
/>
)
}
function AvatarGroupCount({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group-count"
className={cn(
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
className
)}
{...props}
/>
)
}
export {
Avatar,
AvatarImage,
AvatarFallback,
AvatarGroup,
AvatarGroupCount,
AvatarBadge,
}

View File

@@ -0,0 +1,52 @@
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary:
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive:
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
outline:
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost:
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
render,
...props
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
return useRender({
defaultTagName: "span",
props: mergeProps<"span">(
{
className: cn(badgeVariants({ variant }), className),
},
props
),
render,
state: {
slot: "badge",
variant,
},
})
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,58 @@
import { Button as ButtonPrimitive } from "@base-ui/react/button"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
return (
<ButtonPrimitive
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View File

@@ -0,0 +1,103 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({
className,
size = "default",
...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
return (
<div
data-slot="card"
data-size={size}
className={cn(
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn(
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
className
)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn(
"flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
className
)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@@ -0,0 +1,160 @@
"use client"
import * as React from "react"
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: DialogPrimitive.Backdrop.Props) {
return (
<DialogPrimitive.Backdrop
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: DialogPrimitive.Popup.Props & {
showCloseButton?: boolean
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Popup
data-slot="dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
render={
<Button
variant="ghost"
className="absolute top-2 right-2"
size="icon-sm"
/>
}
>
<XIcon
/>
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Popup>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close render={<Button variant="outline" />}>
Close
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn(
"font-heading text-base leading-none font-medium",
className
)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: DialogPrimitive.Description.Props) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View File

@@ -0,0 +1,266 @@
import * as React from "react"
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
import { cn } from "@/lib/utils"
import { ChevronRightIcon, CheckIcon } from "lucide-react"
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
}
function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
}
function DropdownMenuContent({
align = "start",
alignOffset = 0,
side = "bottom",
sideOffset = 4,
className,
...props
}: MenuPrimitive.Popup.Props &
Pick<
MenuPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<MenuPrimitive.Portal>
<MenuPrimitive.Positioner
className="isolate z-50 outline-none"
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
>
<MenuPrimitive.Popup
data-slot="dropdown-menu-content"
className={cn("z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
</MenuPrimitive.Positioner>
</MenuPrimitive.Portal>
)
}
function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
}
function DropdownMenuLabel({
className,
inset,
...props
}: MenuPrimitive.GroupLabel.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.GroupLabel
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
className
)}
{...props}
/>
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: MenuPrimitive.Item.Props & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<MenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: MenuPrimitive.SubmenuTrigger.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.SubmenuTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</MenuPrimitive.SubmenuTrigger>
)
}
function DropdownMenuSubContent({
align = "start",
alignOffset = -3,
side = "right",
sideOffset = 0,
className,
...props
}: React.ComponentProps<typeof DropdownMenuContent>) {
return (
<DropdownMenuContent
data-slot="dropdown-menu-sub-content"
className={cn("w-auto min-w-[96px] rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
inset,
...props
}: MenuPrimitive.CheckboxItem.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-checkbox-item-indicator"
>
<MenuPrimitive.CheckboxItemIndicator>
<CheckIcon
/>
</MenuPrimitive.CheckboxItemIndicator>
</span>
{children}
</MenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
return (
<MenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
inset,
...props
}: MenuPrimitive.RadioItem.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-radio-item-indicator"
>
<MenuPrimitive.RadioItemIndicator>
<CheckIcon
/>
</MenuPrimitive.RadioItemIndicator>
</span>
{children}
</MenuPrimitive.RadioItem>
)
}
function DropdownMenuSeparator({
className,
...props
}: MenuPrimitive.Separator.Props) {
return (
<MenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}

View File

@@ -0,0 +1,20 @@
import * as React from "react"
import { Input as InputPrimitive } from "@base-ui/react/input"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<InputPrimitive
type={type}
data-slot="input"
className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }

View File

@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Label({ className, ...props }: React.ComponentProps<"label">) {
return (
<label
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

View File

@@ -0,0 +1,201 @@
"use client"
import * as React from "react"
import { Select as SelectPrimitive } from "@base-ui/react/select"
import { cn } from "@/lib/utils"
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
const Select = SelectPrimitive.Root
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
return (
<SelectPrimitive.Group
data-slot="select-group"
className={cn("scroll-my-1 p-1", className)}
{...props}
/>
)
}
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
return (
<SelectPrimitive.Value
data-slot="select-value"
className={cn("flex flex-1 text-left", className)}
{...props}
/>
)
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: SelectPrimitive.Trigger.Props & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon
render={
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
}
/>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
side = "bottom",
sideOffset = 4,
align = "center",
alignOffset = 0,
alignItemWithTrigger = true,
...props
}: SelectPrimitive.Popup.Props &
Pick<
SelectPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
alignItemWithTrigger={alignItemWithTrigger}
className="isolate z-50"
>
<SelectPrimitive.Popup
data-slot="select-content"
data-align-trigger={alignItemWithTrigger}
className={cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.List>{children}</SelectPrimitive.List>
<SelectScrollDownButton />
</SelectPrimitive.Popup>
</SelectPrimitive.Positioner>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: SelectPrimitive.GroupLabel.Props) {
return (
<SelectPrimitive.GroupLabel
data-slot="select-label"
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: SelectPrimitive.Item.Props) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
{children}
</SelectPrimitive.ItemText>
<SelectPrimitive.ItemIndicator
render={
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
}
>
<CheckIcon className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: SelectPrimitive.Separator.Props) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
return (
<SelectPrimitive.ScrollUpArrow
data-slot="select-scroll-up-button"
className={cn(
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronUpIcon
/>
</SelectPrimitive.ScrollUpArrow>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
return (
<SelectPrimitive.ScrollDownArrow
data-slot="select-scroll-down-button"
className={cn(
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronDownIcon
/>
</SelectPrimitive.ScrollDownArrow>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

View File

@@ -0,0 +1,25 @@
"use client"
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
...props
}: SeparatorPrimitive.Props) {
return (
<SeparatorPrimitive
data-slot="separator"
orientation={orientation}
className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className
)}
{...props}
/>
)
}
export { Separator }

View File

@@ -0,0 +1,136 @@
import * as React from "react"
import { Dialog as SheetPrimitive } from "@base-ui/react/dialog"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Sheet({ ...props }: SheetPrimitive.Root.Props) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({ ...props }: SheetPrimitive.Close.Props) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
return (
<SheetPrimitive.Backdrop
data-slot="sheet-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/10 transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
showCloseButton = true,
...props
}: SheetPrimitive.Popup.Props & {
side?: "top" | "right" | "bottom" | "left"
showCloseButton?: boolean
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Popup
data-slot="sheet-content"
data-side={side}
className={cn(
"fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-ending-style:opacity-0 data-starting-style:opacity-0 data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=bottom]:data-ending-style:translate-y-[2.5rem] data-[side=bottom]:data-starting-style:translate-y-[2.5rem] data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=left]:data-ending-style:translate-x-[-2.5rem] data-[side=left]:data-starting-style:translate-x-[-2.5rem] data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=right]:data-ending-style:translate-x-[2.5rem] data-[side=right]:data-starting-style:translate-x-[2.5rem] data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=top]:data-ending-style:translate-y-[-2.5rem] data-[side=top]:data-starting-style:translate-y-[-2.5rem] data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm",
className
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close
data-slot="sheet-close"
render={
<Button
variant="ghost"
className="absolute top-3 right-3"
size="icon-sm"
/>
}
>
<XIcon
/>
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Popup>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-0.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn(
"font-heading text-base font-medium text-foreground",
className
)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: SheetPrimitive.Description.Props) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

View File

@@ -0,0 +1,47 @@
import { useTheme } from "next-themes"
import { Toaster as Sonner, type ToasterProps } from "sonner"
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
icons={{
success: (
<CircleCheckIcon className="size-4" />
),
info: (
<InfoIcon className="size-4" />
),
warning: (
<TriangleAlertIcon className="size-4" />
),
error: (
<OctagonXIcon className="size-4" />
),
loading: (
<Loader2Icon className="size-4 animate-spin" />
),
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
} as React.CSSProperties
}
toastOptions={{
classNames: {
toast: "cn-toast",
},
}}
{...props}
/>
)
}
export { Toaster }

View File

@@ -0,0 +1,116 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@@ -0,0 +1,80 @@
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
function Tabs({
className,
orientation = "horizontal",
...props
}: TabsPrimitive.Root.Props) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
className={cn(
"group/tabs flex gap-2 data-horizontal:flex-col",
className
)}
{...props}
/>
)
}
const tabsListVariants = cva(
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
{
variants: {
variant: {
default: "bg-muted",
line: "gap-1 bg-transparent",
},
},
defaultVariants: {
variant: "default",
},
}
)
function TabsList({
className,
variant = "default",
...props
}: TabsPrimitive.List.Props & VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
)
}
function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
return (
<TabsPrimitive.Tab
data-slot="tabs-trigger"
className={cn(
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
className
)}
{...props}
/>
)
}
function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
return (
<TabsPrimitive.Panel
data-slot="tabs-content"
className={cn("flex-1 text-sm outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }

View File

@@ -0,0 +1,66 @@
"use client"
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
import { cn } from "@/lib/utils"
function TooltipProvider({
delay = 0,
...props
}: TooltipPrimitive.Provider.Props) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delay={delay}
{...props}
/>
)
}
function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
side = "top",
sideOffset = 4,
align = "center",
alignOffset = 0,
children,
...props
}: TooltipPrimitive.Popup.Props &
Pick<
TooltipPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Positioner
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
className="isolate z-50"
>
<TooltipPrimitive.Popup
data-slot="tooltip-content"
className={cn(
"z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
</TooltipPrimitive.Popup>
</TooltipPrimitive.Positioner>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View File

@@ -0,0 +1,87 @@
import { useState, type FormEvent } from 'react';
import { useNavigate } from 'react-router';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { useAuthStore } from '@/stores/auth-store';
import { useTranslation } from 'react-i18next';
export default function LoginPage() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const { login, isLoading, error, clearError } = useAuthStore();
const navigate = useNavigate();
const { t } = useTranslation();
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
try {
await login({ username, password });
navigate('/dashboard');
} catch {
// Error is already handled in store
}
};
return (
<Card>
<CardHeader className="text-center">
<CardTitle className="text-lg">{t('login.title', 'Sign in to your account')}</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</div>
)}
<div className="space-y-2">
<Label htmlFor="username">{t('login.username', 'Username')}</Label>
<Input
id="username"
type="text"
value={username}
onChange={(e) => {
setUsername(e.target.value);
clearError();
}}
placeholder={t('login.username_placeholder', 'Enter username')}
autoComplete="username"
autoFocus
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">{t('login.password', 'Password')}</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => {
setPassword(e.target.value);
clearError();
}}
placeholder={t('login.password_placeholder', 'Enter password')}
autoComplete="current-password"
required
/>
</div>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? (
<span className="flex items-center gap-2">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
{t('login.signing_in', 'Signing in...')}
</span>
) : (
t('login.sign_in', 'Sign in')
)}
</Button>
</form>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,150 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Archive, Play, Calendar } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { useServers } from '@/hooks/use-nodes';
import { useBackupJobs, useBackupList, useBackupStorages, useToggleBackupJob, useRunBackupJob } from '@/hooks/use-backups';
import { formatBytes } from '@/lib/format';
import { toast } from 'sonner';
export default function BackupsPage() {
const { t } = useTranslation();
const [tab, setTab] = useState('files');
const [selectedServer, setSelectedServer] = useState<string>('');
const [selectedNode, setSelectedNode] = useState('');
const [selectedStorage, setSelectedStorage] = useState('');
const { data: servers = [] } = useServers();
const sid = selectedServer ? Number(selectedServer) : 0;
const { data: storagesData } = useBackupStorages(sid);
const { data: backupsData } = useBackupList(sid, selectedNode, selectedStorage);
const { data: jobsData } = useBackupJobs();
const toggleJob = useToggleBackupJob();
const runJob = useRunBackupJob();
const storages = (storagesData?.storages || []) as { storage: string; type: string; content: string; used: number; total: number }[];
const backups = (backupsData?.backups || []) as { volid: string; vmid: number; size: number; ctime: number; format: string; notes?: string }[];
const jobs = (jobsData?.jobs || []) as { id: number; server_id: number; vmids: number[]; storage: string; mode: string; cron_expression: string; enabled: boolean; notes?: string }[];
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold">{t('nav.backups')}</h1>
<Tabs value={tab} onValueChange={setTab}>
<TabsList>
<TabsTrigger value="files">{t('backups.files')}</TabsTrigger>
<TabsTrigger value="jobs">{t('backups.scheduled_jobs')}</TabsTrigger>
</TabsList>
<TabsContent value="files" className="space-y-4 mt-4">
{/* Filters */}
<div className="flex flex-wrap gap-3">
<Select value={selectedServer} onValueChange={v => { if (v !== null) { setSelectedServer(v); setSelectedNode(''); setSelectedStorage(''); } }}>
<SelectTrigger className="w-[200px]"><SelectValue placeholder={t('backups.select_server')} /></SelectTrigger>
<SelectContent>
{servers.map(s => <SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>)}
</SelectContent>
</Select>
<Input className="w-[160px]" placeholder={t('backups.node')} value={selectedNode} onChange={e => setSelectedNode(e.target.value)} />
<Select value={selectedStorage} onValueChange={v => { if (v !== null) setSelectedStorage(v); }}>
<SelectTrigger className="w-[200px]"><SelectValue placeholder={t('backups.select_storage')} /></SelectTrigger>
<SelectContent>
{storages.map(s => <SelectItem key={s.storage} value={s.storage}>{s.storage} ({s.type})</SelectItem>)}
</SelectContent>
</Select>
</div>
{/* Backup files table */}
{backups.length > 0 ? (
<div className="rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead>VMID</TableHead>
<TableHead>{t('backups.volume')}</TableHead>
<TableHead>{t('backups.size')}</TableHead>
<TableHead>{t('backups.date')}</TableHead>
<TableHead>{t('backups.format')}</TableHead>
<TableHead>{t('backups.notes')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{backups.map((b, i) => (
<TableRow key={i}>
<TableCell className="font-mono">{b.vmid}</TableCell>
<TableCell className="font-mono text-xs max-w-[300px] truncate">{b.volid}</TableCell>
<TableCell>{formatBytes(b.size)}</TableCell>
<TableCell>{new Date(b.ctime * 1000).toLocaleString()}</TableCell>
<TableCell><Badge variant="outline">{b.format}</Badge></TableCell>
<TableCell className="text-xs text-muted-foreground">{b.notes || '—'}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
) : (
<div className="text-center py-16 text-muted-foreground">
<Archive className="mx-auto h-12 w-12 mb-3 opacity-50" />
<p>{selectedServer ? t('backups.no_backups') : t('backups.select_server_first')}</p>
</div>
)}
</TabsContent>
<TabsContent value="jobs" className="space-y-4 mt-4">
{jobs.length > 0 ? (
<div className="rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead>{t('backups.vmids')}</TableHead>
<TableHead>{t('backups.storage_col')}</TableHead>
<TableHead>{t('backups.schedule')}</TableHead>
<TableHead>{t('common.status')}</TableHead>
<TableHead>{t('common.actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{jobs.map(job => (
<TableRow key={job.id}>
<TableCell>{job.id}</TableCell>
<TableCell className="font-mono text-xs">{job.vmids?.join(', ') || '—'}</TableCell>
<TableCell>{job.storage}</TableCell>
<TableCell className="font-mono text-xs">{job.cron_expression}</TableCell>
<TableCell>
<Badge variant={job.enabled ? 'default' : 'secondary'}>
{job.enabled ? t('backups.enabled') : t('backups.disabled')}
</Badge>
</TableCell>
<TableCell>
<div className="flex gap-1">
<Button variant="ghost" size="sm" onClick={() => toggleJob.mutate(job.id)}>
{job.enabled ? t('backups.disable') : t('backups.enable')}
</Button>
<Button variant="ghost" size="sm" onClick={() => { runJob.mutate(job.id); toast.success(t('backups.job_started')); }}>
<Play className="h-3.5 w-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
) : (
<div className="text-center py-16 text-muted-foreground">
<Calendar className="mx-auto h-12 w-12 mb-3 opacity-50" />
<p>{t('backups.no_jobs')}</p>
</div>
)}
</TabsContent>
</Tabs>
</div>
);
}

View File

@@ -0,0 +1,333 @@
import { useEffect, useRef, useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useParams, useSearchParams } from 'react-router';
import { Maximize, Minimize, Keyboard, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { apiClient } from '@/lib/api-client';
import { vmTypeLabel } from '@/lib/format';
import '@xterm/xterm/css/xterm.css';
// noVNC and xterm.js will be loaded dynamically
// We use dynamic imports for code splitting
interface VNCData {
port: number;
ticket: string;
password?: string;
host: string;
node: string;
vmid: number;
type: string;
auth_ticket?: string;
}
export default function ConsolePage() {
const { serverId, vmid } = useParams<{ serverId: string; vmid: string }>();
const [searchParams] = useSearchParams();
const { t } = useTranslation();
const node = searchParams.get('node') || '';
const type = searchParams.get('type') || 'qemu';
const [status, setStatus] = useState<'connecting' | 'connected' | 'error'>('connecting');
const [errorMsg, setErrorMsg] = useState('');
const [isFullscreen, setIsFullscreen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const rfbRef = useRef<unknown>(null);
const termRef = useRef<unknown>(null);
const wsRef = useRef<WebSocket | null>(null);
const keepaliveRef = useRef<ReturnType<typeof setInterval> | null>(null);
const sid = Number(serverId);
const vid = Number(vmid);
// ==================== VNC Console (QEMU) ====================
const connectVNC = useCallback(async () => {
try {
const data = await apiClient.get<VNCData>(
`/proxmox/api/${sid}/vm/${vid}/vnc?node=${node}`
);
// Dynamic import noVNC
// @ts-expect-error noVNC doesn't have TS declarations
const { default: RFB } = await import('@novnc/novnc/lib/rfb');
const wsProto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const params = new URLSearchParams({
port: String(data.port),
vncticket: data.ticket,
});
if (data.password) params.set('vnc_password', data.password);
if (data.auth_ticket) params.set('auth_ticket', data.auth_ticket);
const wsUrl = `${wsProto}//${window.location.host}/proxmox/ws/vnc/${sid}/${data.node}/qemu/${vid}?${params}`;
if (!containerRef.current) return;
// Pass URL string to RFB, NOT an open WebSocket
const rfb = new RFB(containerRef.current, wsUrl, {
credentials: { password: data.password || '' },
});
rfb.scaleViewport = true;
rfb.resizeSession = true;
rfb.addEventListener('connect', () => {
setStatus('connected');
});
rfb.addEventListener('disconnect', (e: CustomEvent) => {
if (e.detail?.clean) {
setStatus('error');
setErrorMsg('Connection closed');
} else {
setStatus('error');
setErrorMsg('Connection lost');
}
});
rfbRef.current = rfb;
} catch (err) {
setStatus('error');
setErrorMsg(err instanceof Error ? err.message : 'Failed to connect');
}
}, [sid, vid, node]);
// ==================== Terminal Console (LXC) ====================
const connectTerminal = useCallback(async () => {
try {
const data = await apiClient.get<VNCData>(
`/proxmox/api/${sid}/container/${vid}/terminal?node=${node}`
);
// Dynamic import xterm.js
const [{ Terminal }, { FitAddon }, { WebLinksAddon }] = await Promise.all([
import('@xterm/xterm'),
import('@xterm/addon-fit'),
import('@xterm/addon-web-links'),
]);
if (!containerRef.current) return;
const fitAddon = new FitAddon();
const webLinksAddon = new WebLinksAddon();
const terminal = new Terminal({
cursorBlink: true,
fontSize: 14,
fontFamily: 'JetBrains Mono, monospace',
theme: {
background: '#09090B',
foreground: '#F0F0F3',
cursor: '#F0F0F3',
selectionBackground: 'rgba(255,255,255,0.2)',
},
});
terminal.loadAddon(fitAddon);
terminal.loadAddon(webLinksAddon);
terminal.open(containerRef.current);
// Wait a frame, then fit
requestAnimationFrame(() => {
fitAddon.fit();
});
// WebSocket connection
const wsProto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${wsProto}//${window.location.host}/proxmox/ws/terminal/${sid}/${data.node}/${vid}`;
const ws = new WebSocket(wsUrl);
ws.binaryType = 'arraybuffer';
wsRef.current = ws;
ws.onopen = () => {
setStatus('connected');
// Send resize
const { cols, rows } = terminal;
ws.send(`1:${cols}:${rows}:`);
// Keepalive every 120s
keepaliveRef.current = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send('2');
}
}, 120000);
};
ws.onmessage = (event) => {
if (event.data instanceof ArrayBuffer) {
terminal.write(new Uint8Array(event.data));
} else {
terminal.write(event.data);
}
};
ws.onclose = () => {
setStatus('error');
setErrorMsg('Terminal connection closed');
};
ws.onerror = () => {
setStatus('error');
setErrorMsg('Terminal connection error');
};
// User input → WebSocket
terminal.onData((data: string) => {
if (ws.readyState === WebSocket.OPEN) {
const encoder = new TextEncoder();
const byteLength = encoder.encode(data).byteLength;
ws.send(`0:${byteLength}:${data}`);
}
});
// Resize handler
terminal.onResize(({ cols, rows }) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(`1:${cols}:${rows}:`);
}
});
// Window resize
const handleResize = () => fitAddon.fit();
window.addEventListener('resize', handleResize);
termRef.current = { terminal, fitAddon, cleanup: () => window.removeEventListener('resize', handleResize) };
} catch (err) {
setStatus('error');
setErrorMsg(err instanceof Error ? err.message : 'Failed to connect');
}
}, [sid, vid, node]);
// ==================== Connect on mount ====================
useEffect(() => {
if (type === 'qemu') {
connectVNC();
} else {
connectTerminal();
}
return () => {
// Cleanup
if (rfbRef.current) {
try { (rfbRef.current as { disconnect: () => void }).disconnect(); } catch { /* */ }
}
if (wsRef.current) {
wsRef.current.close();
}
if (keepaliveRef.current) {
clearInterval(keepaliveRef.current);
}
if (termRef.current) {
const t = termRef.current as { terminal: { dispose: () => void }; cleanup: () => void };
t.cleanup();
t.terminal.dispose();
}
};
}, [type, connectVNC, connectTerminal]);
// ==================== Fullscreen ====================
const toggleFullscreen = () => {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen();
setIsFullscreen(true);
} else {
document.exitFullscreen();
setIsFullscreen(false);
}
};
// ==================== Send Ctrl+Alt+Del (VNC) ====================
const sendCtrlAltDel = () => {
if (rfbRef.current) {
(rfbRef.current as { sendCtrlAltDel: () => void }).sendCtrlAltDel();
}
};
return (
<div className="flex h-screen flex-col bg-[#09090B]">
{/* Toolbar */}
<div className="flex items-center justify-between border-b border-border/30 bg-[#111113] px-3 py-1.5">
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-xs border-border/50">
{vmTypeLabel(type)} #{vmid}
</Badge>
<span className="text-xs text-muted-foreground">Node: {node}</span>
<Badge
variant="secondary"
className={`text-[10px] ${
status === 'connected'
? 'bg-green-500/10 text-green-500'
: status === 'connecting'
? 'bg-amber-500/10 text-amber-500'
: 'bg-red-500/10 text-red-500'
}`}
>
{status}
</Badge>
</div>
<div className="flex items-center gap-1">
{type === 'qemu' && (
<Button
variant="ghost"
size="sm"
className="h-7 text-xs text-muted-foreground"
onClick={sendCtrlAltDel}
>
<Keyboard className="mr-1 h-3 w-3" />
Ctrl+Alt+Del
</Button>
)}
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground"
onClick={toggleFullscreen}
>
{isFullscreen ? <Minimize className="h-3.5 w-3.5" /> : <Maximize className="h-3.5 w-3.5" />}
</Button>
</div>
</div>
{/* Console viewport */}
<div className="relative flex-1">
{status === 'connecting' && (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-[#09090B]">
<div className="flex flex-col items-center gap-3 text-muted-foreground">
<Loader2 className="h-8 w-8 animate-spin" />
<p className="text-sm">{t('console.connecting')}</p>
</div>
</div>
)}
{status === 'error' && (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-[#09090B]">
<div className="flex flex-col items-center gap-3 text-center">
<p className="text-sm text-red-500">{errorMsg}</p>
<Button
variant="outline"
size="sm"
onClick={() => {
setStatus('connecting');
setErrorMsg('');
if (type === 'qemu') connectVNC();
else connectTerminal();
}}
>
{t('console.reconnect')}
</Button>
</div>
</div>
)}
<div
ref={containerRef}
className="h-full w-full"
style={{ minHeight: 'calc(100vh - 40px)' }}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,344 @@
import { useEffect } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import {
Server,
Monitor,
Container,
AlertTriangle,
Cpu,
MemoryStick,
HardDrive,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { apiClient } from '@/lib/api-client';
import { useTranslation } from 'react-i18next';
import { getTasksWebSocket } from '@/lib/websocket';
// ==================== Stat Card ====================
interface StatCardProps {
label: string;
value: number | string;
subtitle?: string;
icon: React.ComponentType<{ className?: string }>;
color?: string;
}
function StatCard({ label, value, subtitle, icon: Icon, color = 'text-primary' }: StatCardProps) {
return (
<Card>
<CardContent className="flex items-center gap-4 p-5">
<div className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 ${color}`}>
<Icon className="h-5 w-5" />
</div>
<div>
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
{label}
</p>
<p className="text-2xl font-bold tabular-nums">{value}</p>
{subtitle && <p className="text-xs text-muted-foreground">{subtitle}</p>}
</div>
</CardContent>
</Card>
);
}
// ==================== Resource Card ====================
interface ResourceCardProps {
label: string;
percent: number;
used: string;
total: string;
icon: React.ComponentType<{ className?: string }>;
}
function ResourceCard({ label, percent, used, total, icon: Icon }: ResourceCardProps) {
const barColor =
percent >= 90 ? 'bg-red' : percent >= 70 ? 'bg-amber' : 'bg-green';
return (
<Card>
<CardContent className="p-5">
<div className="mb-3 flex items-center gap-2">
<Icon className="h-4 w-4 text-muted-foreground" />
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
{label}
</span>
</div>
<p className="mb-2 text-2xl font-bold tabular-nums">{percent.toFixed(1)}%</p>
<div className="mb-2 h-1.5 overflow-hidden rounded-full bg-muted">
<div
className={`h-full rounded-full transition-all ${barColor}`}
style={{ width: `${Math.min(percent, 100)}%` }}
/>
</div>
<p className="text-xs text-muted-foreground">
{used} / {total}
</p>
</CardContent>
</Card>
);
}
// ==================== Activity Item ====================
interface ActivityItem {
time: string;
user?: string;
action: string;
resource?: string;
type?: string;
}
function ActivityFeed({ items }: { items: ActivityItem[] }) {
const { t } = useTranslation();
const typeColor: Record<string, string> = {
success: 'bg-green/20 text-green',
error: 'bg-red/20 text-red',
warning: 'bg-amber/20 text-amber',
info: 'bg-blue/20 text-blue',
};
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-semibold">{t('dashboard.recent_activity', 'Recent Activity')}</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{items.length === 0 ? (
<p className="py-4 text-center text-sm text-muted-foreground">{t('common.no_data', 'No recent activity')}</p>
) : (
items.map((item, i) => (
<div key={i} className="flex items-start gap-3 text-sm">
<span className="shrink-0 pt-0.5 text-[11px] text-muted-foreground font-mono tabular-nums">
{item.time}
</span>
<div className="min-w-0 flex-1">
<span className="text-muted-foreground">{item.user && `${item.user} `}</span>
<span>{item.action}</span>
{item.resource && (
<span className="font-medium"> {item.resource}</span>
)}
</div>
{item.type && (
<Badge variant="secondary" className={`shrink-0 text-[10px] ${typeColor[item.type] ?? ''}`}>
{item.type}
</Badge>
)}
</div>
))
)}
</CardContent>
</Card>
);
}
// ==================== Format Helpers ====================
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`;
}
// ==================== Dashboard Page ====================
export default function DashboardPage() {
const { t } = useTranslation();
const qc = useQueryClient();
// Fetch all resources
const { data: resources } = useQuery({
queryKey: ['resources'],
queryFn: () => apiClient.get<Record<string, unknown>>('/proxmox/api/resources/all'),
refetchInterval: 15000,
});
// Fetch alerts
const { data: alerts } = useQuery({
queryKey: ['dashboard-alerts'],
queryFn: () => apiClient.get<{ alerts: unknown[]; count: number }>('/api/dashboard/alerts'),
refetchInterval: 60000,
});
// Fetch recent activity from audit logs
const { data: recentLogs } = useQuery({
queryKey: ['dashboard-activity'],
queryFn: () => apiClient.get<{ items: Array<{ timestamp: string; username?: string; message: string; category?: string; level?: string }> }>('/logs/api/logs?limit=10'),
refetchInterval: 30000,
});
// Subscribe to WebSocket 'dashboard_metrics' for real-time updates
useEffect(() => {
const ws = getTasksWebSocket();
if (!ws.isConnected) ws.connect();
const unsub = ws.subscribe('dashboard_metrics', (data: unknown) => {
const payload = data as { data?: Record<string, unknown> };
if (payload?.data) {
qc.setQueryData(['resources'], payload.data);
}
});
return unsub;
}, [qc]);
// Parse resource data — API returns { servers: [{vms, containers, nodes}], total_vms, total_containers }
const serverList = ((resources as Record<string, unknown>)?.servers as Array<Record<string, unknown>>) ?? [];
const allVMs = serverList.flatMap((s) => [
...((s.vms as Array<Record<string, unknown>>) ?? []),
...((s.containers as Array<Record<string, unknown>>) ?? []),
]);
const nodesOnline = serverList.filter((s) => !s.error).length;
const qemuVMs = allVMs.filter((v) => v.type === 'qemu');
const lxcCTs = allVMs.filter((v) => v.type === 'lxc');
const runningVMs = qemuVMs.filter((v) => v.status === 'running').length;
const runningCTs = lxcCTs.filter((v) => v.status === 'running').length;
// Aggregate resource metrics from node-level data (WS) or fallback to VM-level
const nodeStats = serverList.flatMap((s) => (s.nodes as Array<Record<string, unknown>>) ?? []);
let cpuPercent = 0;
let cpuUsedLabel = '';
let cpuTotalLabel = '';
let memPercent = 0;
let memUsedLabel = '';
let memTotalLabel = '';
let diskPercent = 0;
let diskUsedLabel = '';
let diskTotalLabel = '';
if (nodeStats.length > 0) {
// Use precise node-level stats from WebSocket metrics
cpuPercent = nodeStats.reduce((s, n) => s + (Number(n.cpu_pct) || 0), 0) / nodeStats.length;
cpuUsedLabel = `${cpuPercent.toFixed(1)}%`;
cpuTotalLabel = `${nodeStats.length} nodes`;
memPercent = nodeStats.reduce((s, n) => s + (Number(n.ram_pct) || 0), 0) / nodeStats.length;
memUsedLabel = `${memPercent.toFixed(1)}%`;
memTotalLabel = `${nodeStats.length} nodes`;
diskPercent = nodeStats.reduce((s, n) => s + (Number(n.disk_pct) || 0), 0) / nodeStats.length;
diskUsedLabel = `${diskPercent.toFixed(1)}%`;
diskTotalLabel = `${nodeStats.length} nodes`;
} else {
// Fallback: aggregate from VM data
const totalCPU = allVMs.reduce((s, v) => s + (Number(v.maxcpu) || 0), 0);
const usedCPU = allVMs.reduce((s, v) => s + (Number(v.cpu) || 0) * (Number(v.maxcpu) || 0), 0);
cpuPercent = totalCPU > 0 ? (usedCPU / totalCPU) * 100 : 0;
cpuUsedLabel = `${usedCPU.toFixed(1)} cores`;
cpuTotalLabel = `${totalCPU} cores`;
const totalMem = allVMs.reduce((s, v) => s + (Number(v.maxmem) || 0), 0);
const usedMem = allVMs.reduce((s, v) => s + (Number(v.mem) || 0), 0);
memPercent = totalMem > 0 ? (usedMem / totalMem) * 100 : 0;
memUsedLabel = formatBytes(usedMem);
memTotalLabel = formatBytes(totalMem);
const totalDisk = allVMs.reduce((s, v) => s + (Number(v.maxdisk) || 0), 0);
const usedDisk = allVMs.reduce((s, v) => s + (Number(v.disk) || 0), 0);
diskPercent = totalDisk > 0 ? (usedDisk / totalDisk) * 100 : 0;
diskUsedLabel = formatBytes(usedDisk);
diskTotalLabel = formatBytes(totalDisk);
}
return (
<div className="space-y-6">
<h1 className="text-[22px] font-semibold">{t('dashboard.title', 'Dashboard')}</h1>
{/* Stat Cards */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatCard
label={t('dashboard.nodes', 'Nodes')}
value={serverList.length}
subtitle={`${nodesOnline} online`}
icon={Server}
/>
<StatCard
label={t('dashboard.virtual_machines', 'Virtual Machines')}
value={qemuVMs.length}
subtitle={`${runningVMs} running`}
icon={Monitor}
color="text-blue"
/>
<StatCard
label={t('dashboard.containers', 'Containers')}
value={lxcCTs.length}
subtitle={`${runningCTs} running`}
icon={Container}
color="text-green"
/>
<StatCard
label={t('dashboard.alerts', 'Alerts')}
value={alerts?.count ?? 0}
icon={AlertTriangle}
color="text-amber"
/>
</div>
{/* Resource Cards */}
<div className="grid gap-4 sm:grid-cols-3">
<ResourceCard
label="CPU"
percent={cpuPercent}
used={cpuUsedLabel}
total={cpuTotalLabel}
icon={Cpu}
/>
<ResourceCard
label={t('dashboard.memory', 'Memory')}
percent={memPercent}
used={memUsedLabel}
total={memTotalLabel}
icon={MemoryStick}
/>
<ResourceCard
label={t('dashboard.storage', 'Storage')}
percent={diskPercent}
used={diskUsedLabel}
total={diskTotalLabel}
icon={HardDrive}
/>
</div>
{/* Bottom row */}
<div className="grid gap-4 lg:grid-cols-2">
{/* Recent instances */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-semibold">{t('dashboard.instances', 'Instances')}</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
{allVMs.slice(0, 6).map((vm, i) => (
<div key={i} className="flex items-center gap-3 rounded-md px-2 py-1.5 text-sm hover:bg-muted/50">
<span
className={`h-2 w-2 shrink-0 rounded-full ${
vm.status === 'running' ? 'bg-green' : 'bg-muted-foreground'
}`}
/>
<span className="min-w-0 flex-1 truncate font-medium">
{String(vm.name ?? `VM ${vm.vmid}`)}
</span>
<Badge variant="secondary" className="text-[10px]">
{String(vm.type ?? 'vm').toUpperCase()}
</Badge>
<span className="text-xs text-muted-foreground">{String(vm.node ?? '')}</span>
</div>
))}
{allVMs.length === 0 && (
<p className="py-4 text-center text-sm text-muted-foreground">{t('common.no_data')}</p>
)}
</div>
</CardContent>
</Card>
{/* Activity */}
<ActivityFeed items={(recentLogs?.items ?? []).map((log) => {
const d = new Date(log.timestamp);
return {
time: `${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`,
user: log.username,
action: log.message,
type: log.level === 'ERROR' ? 'error' : log.level === 'WARNING' ? 'warning' : log.category === 'auth' ? 'info' : 'success',
};
})} />
</div>
</div>
);
}

View File

@@ -0,0 +1,388 @@
import { useState } from 'react';
import { useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next';
import { useMutation } from '@tanstack/react-query';
import { Server, Cpu, CheckCircle, ChevronLeft, ChevronRight, Loader2, Monitor } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useServers } from '@/hooks/use-nodes';
import { useTemplates, useTemplateGroups } from '@/hooks/use-templates';
import { useIPAMNetworks } from '@/hooks/use-ipam';
import { apiClient } from '@/lib/api-client';
import type { VMDeployRequest, OSTemplate, ProxmoxServer } from '@/types';
import { toast } from 'sonner';
const STEPS = ['server', 'template', 'config', 'confirm'] as const;
type Step = (typeof STEPS)[number];
export default function CreateInstanceWizard({ onClose }: { onClose?: () => void }) {
const { t } = useTranslation();
const navigate = useNavigate();
const handleClose = onClose ?? (() => navigate(-1));
const [step, setStep] = useState<Step>('server');
const stepIdx = STEPS.indexOf(step);
// State
const [selectedServer, setSelectedServer] = useState<ProxmoxServer | null>(null);
const [selectedNode, setSelectedNode] = useState('');
const [selectedTemplate, setSelectedTemplate] = useState<OSTemplate | null>(null);
const [config, setConfig] = useState<{
name: string;
cores: number;
memory: number;
disk: number;
storage: string;
bridge: string;
ipam_network_id: number | null;
ip_address: string;
gateway: string;
start_after_create: boolean;
cloud_init_user: string;
cloud_init_password: string;
ssh_keys: string;
}>({
name: '',
cores: 2,
memory: 2048,
disk: 30,
storage: 'local-lvm',
bridge: 'vmbr0',
ipam_network_id: null,
ip_address: '',
gateway: '',
start_after_create: true,
cloud_init_user: '',
cloud_init_password: '',
ssh_keys: '',
});
// Queries
const { data: servers = [] } = useServers();
const { data: groups = [] } = useTemplateGroups();
const { data: templates = [] } = useTemplates(undefined, selectedServer?.id);
const { data: networks = [] } = useIPAMNetworks();
const deploy = useMutation({
mutationFn: (data: VMDeployRequest) => apiClient.post<{ success: boolean; vmid: number; name: string }>('/proxmox/api/deploy', data),
onSuccess: () => {
toast.success(t('wizard.deploy_success', { name: config.name }));
handleClose();
navigate('/instances');
},
onError: (err: Error) => toast.error(err.message),
});
const next = () => {
const i = STEPS.indexOf(step);
if (i < STEPS.length - 1) setStep(STEPS[i + 1]);
};
const prev = () => {
const i = STEPS.indexOf(step);
if (i > 0) setStep(STEPS[i - 1]);
};
const canNext = () => {
if (step === 'server') return !!selectedServer;
if (step === 'template') return !!selectedTemplate;
if (step === 'config') return config.name.length >= 2;
return true;
};
const handleDeploy = () => {
if (!selectedTemplate) return;
deploy.mutate({
template_id: selectedTemplate.id,
name: config.name,
target_node: selectedNode || undefined,
cores: config.cores,
memory: config.memory,
disk: config.disk,
target_storage: config.storage,
start_after_create: config.start_after_create,
network_bridge: config.bridge,
ip_address: config.ip_address || undefined,
gateway: config.gateway || undefined,
ipam_network_id: config.ipam_network_id || undefined,
cloud_init_user: config.cloud_init_user || undefined,
cloud_init_password: config.cloud_init_password || undefined,
ssh_keys: config.ssh_keys || undefined,
});
};
// Apply template defaults when template selected
const selectTemplate = (tpl: OSTemplate) => {
setSelectedTemplate(tpl);
setConfig(prev => ({
...prev,
cores: tpl.default_cores,
memory: tpl.default_memory,
disk: tpl.default_disk,
}));
};
return (
<div className="space-y-6">
{/* Step indicator */}
<div className="flex items-center gap-2">
{STEPS.map((s, i) => (
<div key={s} className="flex items-center gap-2">
<div className={`flex h-8 w-8 items-center justify-center rounded-full text-sm font-medium ${
i < stepIdx ? 'bg-green-500/20 text-green-400' :
i === stepIdx ? 'bg-blue-500/20 text-blue-400 ring-2 ring-blue-500/50' :
'bg-muted text-muted-foreground'
}`}>
{i < stepIdx ? <CheckCircle className="h-4 w-4" /> : i + 1}
</div>
{i < STEPS.length - 1 && <div className={`h-0.5 w-8 ${i < stepIdx ? 'bg-green-500/50' : 'bg-border'}`} />}
</div>
))}
</div>
{/* Step 1: Server */}
{step === 'server' && (
<div className="space-y-4">
<h3 className="text-lg font-semibold">{t('wizard.select_server')}</h3>
<div className="grid gap-3 sm:grid-cols-2">
{servers.map(srv => (
<Card
key={srv.id}
className={`cursor-pointer transition-colors hover:border-blue-500/50 ${selectedServer?.id === srv.id ? 'border-blue-500 bg-blue-500/5' : ''}`}
onClick={() => setSelectedServer(srv)}
>
<CardContent className="flex items-center gap-3 p-4">
<Server className="h-8 w-8 text-muted-foreground" />
<div className="min-w-0 flex-1">
<p className="font-medium truncate">{srv.name}</p>
<p className="text-sm text-muted-foreground font-mono">{srv.ip_address}:{srv.port}</p>
</div>
<Badge variant={srv.is_online ? 'default' : 'destructive'}>
{srv.is_online ? t('common.online') : t('common.offline')}
</Badge>
</CardContent>
</Card>
))}
</div>
</div>
)}
{/* Step 2: Template */}
{step === 'template' && (
<div className="space-y-4">
<h3 className="text-lg font-semibold">{t('wizard.select_template')}</h3>
{groups.map(group => {
const groupTemplates = templates.filter(t => t.group_id === group.id);
if (groupTemplates.length === 0) return null;
return (
<div key={group.id} className="space-y-2">
<p className="text-sm font-medium text-muted-foreground">{group.name}</p>
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
{groupTemplates.map(tpl => (
<Card
key={tpl.id}
className={`cursor-pointer transition-colors hover:border-blue-500/50 ${selectedTemplate?.id === tpl.id ? 'border-blue-500 bg-blue-500/5' : ''}`}
onClick={() => selectTemplate(tpl)}
>
<CardContent className="p-3">
<div className="flex items-center gap-2">
{tpl.group_icon ? (
<span className="text-lg" dangerouslySetInnerHTML={{ __html: tpl.group_icon }} />
) : (
<Monitor className="h-5 w-5 text-muted-foreground" />
)}
<span className="font-medium text-sm truncate">{tpl.name}</span>
</div>
<p className="mt-1 text-xs text-muted-foreground">
{tpl.default_cores} vCPU · {tpl.default_memory} MB · {tpl.default_disk} GB
</p>
</CardContent>
</Card>
))}
</div>
</div>
);
})}
{templates.length === 0 && (
<p className="text-center text-muted-foreground py-8">{t('wizard.no_templates')}</p>
)}
</div>
)}
{/* Step 3: Configuration */}
{step === 'config' && (
<div className="space-y-6">
<h3 className="text-lg font-semibold">{t('wizard.configure')}</h3>
<div className="grid gap-6 sm:grid-cols-2">
{/* Basic */}
<Card>
<CardHeader className="pb-3"><CardTitle className="text-sm">{t('wizard.basic')}</CardTitle></CardHeader>
<CardContent className="space-y-3">
<div>
<Label>{t('common.name')}</Label>
<Input value={config.name} onChange={e => setConfig(p => ({ ...p, name: e.target.value }))} placeholder="my-vm-01" />
</div>
<div>
<Label>{t('wizard.target_node')}</Label>
<Input value={selectedNode} onChange={e => setSelectedNode(e.target.value)} placeholder={t('wizard.auto_select')} />
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="start_after"
checked={config.start_after_create}
onChange={e => setConfig(p => ({ ...p, start_after_create: e.target.checked }))}
className="rounded"
/>
<Label htmlFor="start_after">{t('wizard.start_after_create')}</Label>
</div>
</CardContent>
</Card>
{/* Resources */}
<Card>
<CardHeader className="pb-3"><CardTitle className="text-sm flex items-center gap-2"><Cpu className="h-4 w-4" />{t('wizard.resources')}</CardTitle></CardHeader>
<CardContent className="space-y-3">
<div>
<Label>vCPU</Label>
<Input
type="number"
min={selectedTemplate?.min_cores || 1}
value={config.cores}
onChange={e => setConfig(p => ({ ...p, cores: Number(e.target.value) }))}
/>
</div>
<div>
<Label>{t('wizard.memory_mb')}</Label>
<Input
type="number"
min={selectedTemplate?.min_memory || 256}
step={256}
value={config.memory}
onChange={e => setConfig(p => ({ ...p, memory: Number(e.target.value) }))}
/>
</div>
<div>
<Label>{t('wizard.disk_gb')}</Label>
<Input
type="number"
min={selectedTemplate?.min_disk || 5}
value={config.disk}
onChange={e => setConfig(p => ({ ...p, disk: Number(e.target.value) }))}
/>
</div>
</CardContent>
</Card>
{/* Network */}
<Card>
<CardHeader className="pb-3"><CardTitle className="text-sm">{t('wizard.network')}</CardTitle></CardHeader>
<CardContent className="space-y-3">
<div>
<Label>{t('wizard.bridge')}</Label>
<Input value={config.bridge} onChange={e => setConfig(p => ({ ...p, bridge: e.target.value }))} />
</div>
<div>
<Label>IPAM {t('wizard.network')}</Label>
<Select value={String(config.ipam_network_id || '')} onValueChange={v => setConfig(p => ({ ...p, ipam_network_id: v ? Number(v) : null }))}>
<SelectTrigger><SelectValue placeholder={t('wizard.manual_ip')} /></SelectTrigger>
<SelectContent>
{networks.map(n => (
<SelectItem key={n.id} value={String(n.id)}>{n.name} ({n.network})</SelectItem>
))}
</SelectContent>
</Select>
</div>
{!config.ipam_network_id && (
<>
<div>
<Label>IP</Label>
<Input value={config.ip_address} onChange={e => setConfig(p => ({ ...p, ip_address: e.target.value }))} placeholder="10.10.10.5" />
</div>
<div>
<Label>Gateway</Label>
<Input value={config.gateway} onChange={e => setConfig(p => ({ ...p, gateway: e.target.value }))} placeholder="10.10.10.1" />
</div>
</>
)}
</CardContent>
</Card>
{/* Cloud-init */}
<Card>
<CardHeader className="pb-3"><CardTitle className="text-sm">Cloud-Init</CardTitle></CardHeader>
<CardContent className="space-y-3">
<div>
<Label>{t('wizard.ci_user')}</Label>
<Input value={config.cloud_init_user} onChange={e => setConfig(p => ({ ...p, cloud_init_user: e.target.value }))} placeholder="ubuntu" />
</div>
<div>
<Label>{t('wizard.ci_password')}</Label>
<Input type="password" value={config.cloud_init_password} onChange={e => setConfig(p => ({ ...p, cloud_init_password: e.target.value }))} />
</div>
<div>
<Label>SSH Keys</Label>
<textarea
className="w-full rounded-md border bg-transparent px-3 py-2 text-sm font-mono min-h-[60px]"
value={config.ssh_keys}
onChange={e => setConfig(p => ({ ...p, ssh_keys: e.target.value }))}
placeholder="ssh-rsa AAAA..."
/>
</div>
</CardContent>
</Card>
</div>
</div>
)}
{/* Step 4: Confirm */}
{step === 'confirm' && (
<div className="space-y-4">
<h3 className="text-lg font-semibold">{t('wizard.confirm')}</h3>
<Card>
<CardContent className="p-6 space-y-3">
<Row label={t('wizard.server_label')} value={selectedServer?.name || ''} />
<Row label={t('wizard.template_label')} value={selectedTemplate?.name || ''} />
<Row label={t('common.name')} value={config.name} />
<Row label="vCPU" value={String(config.cores)} />
<Row label={t('wizard.memory_mb')} value={`${config.memory} MB`} />
<Row label={t('wizard.disk_gb')} value={`${config.disk} GB`} />
<Row label={t('wizard.bridge')} value={config.bridge} />
{config.ipam_network_id && <Row label="IPAM" value={networks.find(n => n.id === config.ipam_network_id)?.name || 'Auto'} />}
{config.ip_address && <Row label="IP" value={config.ip_address} />}
{config.cloud_init_user && <Row label={t('wizard.ci_user')} value={config.cloud_init_user} />}
</CardContent>
</Card>
</div>
)}
{/* Navigation */}
<div className="flex items-center justify-between border-t pt-4">
<Button variant="outline" onClick={stepIdx === 0 ? handleClose : prev}>
{stepIdx === 0 ? t('common.cancel') : <><ChevronLeft className="mr-1 h-4 w-4" />{t('wizard.back')}</>}
</Button>
{step === 'confirm' ? (
<Button onClick={handleDeploy} disabled={deploy.isPending}>
{deploy.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('wizard.deploy')}
</Button>
) : (
<Button onClick={next} disabled={!canNext()}>
{t('wizard.next')}<ChevronRight className="ml-1 h-4 w-4" />
</Button>
)}
</div>
</div>
);
}
function Row({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">{label}</span>
<span className="text-sm font-medium">{value}</span>
</div>
);
}

View File

@@ -0,0 +1,183 @@
import { useParams, useSearchParams, Link } from 'react-router';
import { useTranslation } from 'react-i18next';
import {
ArrowLeft,
Play,
Square,
RotateCcw,
Terminal,
Monitor,
Container,
Loader2,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { StatusDot } from '@/components/shared/status-dot';
import { useVirtualMachines, usePowerAction } from '@/hooks/use-instances';
import { vmTypeLabel } from '@/lib/format';
import { toast } from 'sonner';
import OverviewTab from './tabs/OverviewTab';
import GraphsTab from './tabs/GraphsTab';
import SnapshotsTab from './tabs/SnapshotsTab';
import NetworkingTab from './tabs/NetworkingTab';
import SettingsTab from './tabs/SettingsTab';
import DestroyTab from './tabs/DestroyTab';
export default function InstanceDetailPage() {
const { serverId, vmid } = useParams<{ serverId: string; vmid: string }>();
const [searchParams, setSearchParams] = useSearchParams();
const { t } = useTranslation();
const node = searchParams.get('node') || '';
const type = searchParams.get('type') || 'qemu';
const activeTab = searchParams.get('tab') || 'overview';
const sid = Number(serverId);
const vid = Number(vmid);
// Find VM from cached list for basic info
const { data: allVMs, isLoading } = useVirtualMachines();
const vm = allVMs?.find((v) => v.server_id === sid && v.vmid === vid);
const power = usePowerAction(sid, vid, type, node);
const isRunning = vm?.status === 'running';
const TypeIcon = type === 'qemu' ? Monitor : Container;
const handlePower = (action: string) => {
power.mutate(
{ action },
{
onSuccess: () => toast.success(`${action} sent successfully`),
onError: (err) => toast.error(err.message),
}
);
};
const setTab = (tab: string) => {
const params = new URLSearchParams(searchParams);
params.set('tab', tab);
setSearchParams(params, { replace: true });
};
if (isLoading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="space-y-6">
{/* Back link + header */}
<div>
<Link to="/instances" className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors mb-3">
<ArrowLeft className="h-4 w-4" />
{t('nav.instances', 'Instances')}
</Link>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted">
<TypeIcon className="h-5 w-5 text-muted-foreground" />
</div>
<div>
<div className="flex items-center gap-2">
<h1 className="text-[22px] font-semibold">{vm?.name || `${vmTypeLabel(type)} ${vmid}`}</h1>
<Badge variant="outline" className="text-xs">#{vmid}</Badge>
<Badge variant="secondary" className="text-xs">{vmTypeLabel(type)}</Badge>
</div>
<div className="flex items-center gap-3 text-sm text-muted-foreground">
<span className="flex items-center gap-1.5">
<StatusDot status={vm?.status || 'unknown'} pulse />
{vm?.status || 'unknown'}
</span>
<span>Node: {node}</span>
{vm?.ip_address && <span className="font-mono">{vm.ip_address}</span>}
</div>
</div>
</div>
{/* Power buttons */}
<div className="flex items-center gap-2">
<Button
render={<Link to={`/console/${serverId}/${vmid}?node=${node}&type=${type}`} target="_blank" />}
variant="outline"
size="sm"
>
<Terminal className="mr-1.5 h-4 w-4" />
{t('common.console', 'Console')}
</Button>
{!isRunning ? (
<Button
size="sm"
onClick={() => handlePower('start')}
disabled={power.isPending}
>
<Play className="mr-1.5 h-4 w-4" />
{t('common.start', 'Start')}
</Button>
) : (
<>
<Button
variant="outline"
size="sm"
onClick={() => handlePower('restart')}
disabled={power.isPending}
>
<RotateCcw className="mr-1.5 h-4 w-4" />
{t('common.restart', 'Restart')}
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => handlePower('stop')}
disabled={power.isPending}
>
<Square className="mr-1.5 h-4 w-4" />
{t('common.stop', 'Stop')}
</Button>
</>
)}
</div>
</div>
</div>
{/* Tabs */}
<Tabs value={activeTab} onValueChange={setTab}>
<TabsList>
<TabsTrigger value="overview">{t('instances.overview', 'Overview')}</TabsTrigger>
<TabsTrigger value="graphs">{t('instances.graphs', 'Graphs')}</TabsTrigger>
<TabsTrigger value="snapshots">{t('common.snapshots', 'Snapshots')}</TabsTrigger>
<TabsTrigger value="networking">{t('instances.networking', 'Networking')}</TabsTrigger>
<TabsTrigger value="settings">{t('nav.settings', 'Settings')}</TabsTrigger>
<TabsTrigger value="destroy" className="text-destructive data-[state=active]:text-destructive">
{t('instances.destroy', 'Destroy')}
</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="mt-4">
<OverviewTab serverId={sid} vmid={vid} type={type} node={node} />
</TabsContent>
<TabsContent value="graphs" className="mt-4">
<GraphsTab serverId={sid} vmid={vid} type={type} node={node} />
</TabsContent>
<TabsContent value="snapshots" className="mt-4">
<SnapshotsTab serverId={sid} vmid={vid} type={type} node={node} />
</TabsContent>
<TabsContent value="networking" className="mt-4">
<NetworkingTab serverId={sid} vmid={vid} type={type} node={node} />
</TabsContent>
<TabsContent value="settings" className="mt-4">
<SettingsTab serverId={sid} vmid={vid} type={type} node={node} />
</TabsContent>
<TabsContent value="destroy" className="mt-4">
<DestroyTab serverId={sid} vmid={vid} type={type} node={node} name={vm?.name} />
</TabsContent>
</Tabs>
</div>
);
}

View File

@@ -0,0 +1,598 @@
import { useState, useMemo } from 'react';
import { Link } from 'react-router';
import { useTranslation } from 'react-i18next';
import {
useReactTable,
getCoreRowModel,
getFilteredRowModel,
getSortedRowModel,
getPaginationRowModel,
flexRender,
type ColumnDef,
type SortingState,
type RowSelectionState,
} from '@tanstack/react-table';
import {
Plus,
Search,
Play,
Square,
Trash2,
MoreHorizontal,
Terminal,
RotateCcw,
Camera,
ArrowUpDown,
ChevronLeft,
ChevronRight,
Monitor,
Container,
Loader2,
} from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { StatusDot } from '@/components/shared/status-dot';
import { useVirtualMachines, useBulkOperation, usePowerAction } from '@/hooks/use-instances';
import { formatBytes, vmTypeLabel, formatUptime } from '@/lib/format';
import type { VMInstance } from '@/types';
import { toast } from 'sonner';
// ==================== Inline Progress Bar ====================
function InlineProgress({ value, max, className }: { value: number; max: number; className?: string }) {
const pct = max > 0 ? Math.min((value / max) * 100, 100) : 0;
const color = pct >= 90 ? 'bg-red-500' : pct >= 70 ? 'bg-amber-500' : 'bg-primary';
return (
<div className="flex items-center gap-2">
<div className={`h-1.5 w-16 overflow-hidden rounded-full bg-muted ${className}`}>
<div className={`h-full rounded-full ${color}`} style={{ width: `${pct}%` }} />
</div>
<span className="text-xs tabular-nums text-muted-foreground">{pct.toFixed(0)}%</span>
</div>
);
}
// ==================== Row Action Menu ====================
function RowActionMenu({ vm }: { vm: VMInstance }) {
const { t } = useTranslation();
const power = usePowerAction(vm.server_id!, vm.vmid, vm.type, vm.node);
const isRunning = vm.status === 'running';
const handleAction = (action: string) => {
power.mutate(
{ action },
{
onSuccess: () => toast.success(`${action} sent for ${vm.name || vm.vmid}`),
onError: (err) => toast.error(err.message),
}
);
};
return (
<DropdownMenu>
<DropdownMenuTrigger render={<Button variant="ghost" size="icon" className="h-8 w-8" />}>
<MoreHorizontal className="h-4 w-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem render={<Link to={`/console/${vm.server_id}/${vm.vmid}?node=${vm.node}&type=${vm.type}`} />}>
<Terminal className="mr-2 h-4 w-4" /> {t('common.console', 'Console')}
</DropdownMenuItem>
<DropdownMenuSeparator />
{!isRunning ? (
<DropdownMenuItem onClick={() => handleAction('start')}>
<Play className="mr-2 h-4 w-4" /> {t('common.start', 'Start')}
</DropdownMenuItem>
) : (
<>
<DropdownMenuItem onClick={() => handleAction('restart')}>
<RotateCcw className="mr-2 h-4 w-4" /> {t('common.restart', 'Restart')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleAction('stop')}>
<Square className="mr-2 h-4 w-4" /> {t('common.stop', 'Stop')}
</DropdownMenuItem>
</>
)}
<DropdownMenuSeparator />
<DropdownMenuItem render={<Link to={`/instances/${vm.server_id}/${vm.vmid}?node=${vm.node}&type=${vm.type}&tab=snapshots`} />}>
<Camera className="mr-2 h-4 w-4" /> {t('common.snapshots', 'Snapshots')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
// ==================== Instances Page ====================
export default function InstancesPage() {
const { t } = useTranslation();
const { data: vms, isLoading } = useVirtualMachines();
const bulkOp = useBulkOperation();
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState<string>('all');
const [nodeFilter, setNodeFilter] = useState<string>('all');
const [typeTab, setTypeTab] = useState<string>('all');
const [sorting, setSorting] = useState<SortingState>([]);
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
// Filter data
const filteredVMs = useMemo(() => {
if (!vms) return [];
return vms.filter((vm) => {
// Type tab filter
if (typeTab === 'vm' && vm.type !== 'qemu') return false;
if (typeTab === 'lxc' && vm.type !== 'lxc') return false;
// Status filter
if (statusFilter !== 'all' && vm.status !== statusFilter) return false;
// Node filter
if (nodeFilter !== 'all' && vm.node !== nodeFilter) return false;
// Search filter
if (search) {
const q = search.toLowerCase();
const name = (vm.name || '').toLowerCase();
const ip = (vm.ip_address || '').toLowerCase();
const vmid = String(vm.vmid);
if (!name.includes(q) && !ip.includes(q) && !vmid.includes(q)) return false;
}
return true;
});
}, [vms, typeTab, statusFilter, nodeFilter, search]);
// Unique nodes for filter
const uniqueNodes = useMemo(() => {
if (!vms) return [];
return [...new Set(vms.map((v) => v.node))].sort();
}, [vms]);
// Counts for tabs
const counts = useMemo(() => {
if (!vms) return { all: 0, vm: 0, lxc: 0 };
return {
all: vms.length,
vm: vms.filter((v) => v.type === 'qemu').length,
lxc: vms.filter((v) => v.type === 'lxc').length,
};
}, [vms]);
// Selected VMs
const selectedVMs = useMemo(() => {
return Object.keys(rowSelection)
.filter((k) => rowSelection[k])
.map((idx) => filteredVMs[Number(idx)])
.filter(Boolean);
}, [rowSelection, filteredVMs]);
// Bulk action handler
const handleBulk = (action: string) => {
if (!selectedVMs.length) return;
bulkOp.mutate(
{
action,
items: selectedVMs.map((vm) => ({
server_id: vm.server_id!,
vmid: vm.vmid,
vm_type: vm.type,
name: vm.name || String(vm.vmid),
node: vm.node,
})),
},
{
onSuccess: () => {
toast.success(`Bulk ${action}: ${selectedVMs.length} items queued`);
setRowSelection({});
},
onError: (err) => toast.error(err.message),
}
);
};
// ==================== Table Columns ====================
const columns = useMemo<ColumnDef<VMInstance>[]>(
() => [
{
id: 'select',
header: ({ table }) => (
<input
type="checkbox"
className="rounded border-border"
checked={table.getIsAllPageRowsSelected()}
onChange={table.getToggleAllPageRowsSelectedHandler()}
/>
),
cell: ({ row }) => (
<input
type="checkbox"
className="rounded border-border"
checked={row.getIsSelected()}
onChange={row.getToggleSelectedHandler()}
/>
),
size: 32,
enableSorting: false,
},
{
accessorKey: 'name',
header: ({ column }) => (
<Button
variant="ghost"
size="sm"
className="-ml-3 h-8"
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
>
{t('common.name', 'Name')}
<ArrowUpDown className="ml-1 h-3 w-3" />
</Button>
),
cell: ({ row }) => {
const vm = row.original;
return (
<Link
to={`/instances/${vm.server_id}/${vm.vmid}?node=${vm.node}&type=${vm.type}`}
className="group flex items-center gap-2"
>
<StatusDot status={vm.status} pulse />
<div>
<span className="font-medium text-foreground group-hover:text-primary transition-colors">
{vm.name || `VM ${vm.vmid}`}
</span>
<span className="ml-2 text-xs text-muted-foreground">#{vm.vmid}</span>
</div>
</Link>
);
},
},
{
accessorKey: 'status',
header: t('common.status', 'Status'),
cell: ({ getValue }) => {
const status = getValue<string>();
return (
<Badge
variant="secondary"
className={
status === 'running'
? 'bg-green-500/10 text-green-500'
: 'bg-muted text-muted-foreground'
}
>
{status}
</Badge>
);
},
size: 100,
},
{
accessorKey: 'node',
header: t('common.node', 'Node'),
cell: ({ getValue }) => (
<span className="text-muted-foreground">{getValue<string>()}</span>
),
size: 100,
},
{
accessorKey: 'type',
header: t('common.type', 'Type'),
cell: ({ getValue }) => {
const type = getValue<string>();
const Icon = type === 'qemu' ? Monitor : Container;
return (
<Tooltip>
<TooltipTrigger render={<span />}>
<Badge variant="outline" className="gap-1 font-normal">
<Icon className="h-3 w-3" />
{vmTypeLabel(type)}
</Badge>
</TooltipTrigger>
<TooltipContent>{type === 'qemu' ? 'QEMU Virtual Machine' : 'LXC Container'}</TooltipContent>
</Tooltip>
);
},
size: 80,
},
{
id: 'ip',
accessorKey: 'ip_address',
header: 'IP',
cell: ({ getValue }) => {
const ip = getValue<string>();
return ip ? (
<span className="font-mono text-xs">{ip}</span>
) : (
<span className="text-xs text-muted-foreground">—</span>
);
},
size: 130,
},
{
id: 'cpu',
header: 'CPU',
cell: ({ row }) => {
const vm = row.original;
if (!vm.cpu && vm.cpu !== 0) return <span className="text-xs text-muted-foreground">—</span>;
return <InlineProgress value={vm.cpu * 100} max={100} />;
},
size: 120,
},
{
id: 'memory',
header: t('dashboard.memory', 'Memory'),
cell: ({ row }) => {
const vm = row.original;
if (!vm.mem || !vm.maxmem) return <span className="text-xs text-muted-foreground">—</span>;
return (
<Tooltip>
<TooltipTrigger>
<InlineProgress value={vm.mem} max={vm.maxmem} />
</TooltipTrigger>
<TooltipContent>
{formatBytes(vm.mem)} / {formatBytes(vm.maxmem)}
</TooltipContent>
</Tooltip>
);
},
size: 120,
},
{
id: 'disk',
header: 'Disk',
cell: ({ row }) => {
const vm = row.original;
if (!vm.disk && !vm.maxdisk) return <span className="text-xs text-muted-foreground">—</span>;
return (
<Tooltip>
<TooltipTrigger>
<InlineProgress value={vm.disk || 0} max={vm.maxdisk || 0} />
</TooltipTrigger>
<TooltipContent>
{formatBytes(vm.disk || 0)} / {formatBytes(vm.maxdisk || 0)}
</TooltipContent>
</Tooltip>
);
},
size: 120,
},
{
id: 'uptime',
header: 'Uptime',
cell: ({ row }) => {
const up = row.original.uptime;
return <span className="text-xs text-muted-foreground">{up ? formatUptime(up) : '—'}</span>;
},
size: 80,
},
{
id: 'tags',
accessorKey: 'tags',
header: 'Tags',
cell: ({ getValue }) => {
const tags = getValue<string>();
if (!tags) return null;
return (
<div className="flex gap-1 flex-wrap">
{tags.split(/[;,]/).filter(Boolean).map((tag) => (
<Badge key={tag} variant="secondary" className="text-[10px] leading-tight">
{tag.trim()}
</Badge>
))}
</div>
);
},
size: 120,
},
{
id: 'actions',
cell: ({ row }) => <RowActionMenu vm={row.original} />,
size: 48,
enableSorting: false,
},
],
[t]
);
// ==================== TanStack Table ====================
const table = useReactTable({
data: filteredVMs,
columns,
state: { sorting, rowSelection },
onSortingChange: setSorting,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
initialState: { pagination: { pageSize: 25 } },
});
// ==================== Render ====================
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<h1 className="text-[22px] font-semibold">{t('nav.instances', 'Instances')}</h1>
<Button render={<Link to="/instances/create" />} size="sm">
<Plus className="mr-1.5 h-4 w-4" />
{t('instances.create', 'Create Instance')}
</Button>
</div>
{/* Type Tabs */}
<Tabs value={typeTab} onValueChange={setTypeTab}>
<TabsList>
<TabsTrigger value="all">
All <Badge variant="secondary" className="ml-1.5 text-[10px]">{counts.all}</Badge>
</TabsTrigger>
<TabsTrigger value="vm">
<Monitor className="mr-1 h-3.5 w-3.5" /> VM
<Badge variant="secondary" className="ml-1.5 text-[10px]">{counts.vm}</Badge>
</TabsTrigger>
<TabsTrigger value="lxc">
<Container className="mr-1 h-3.5 w-3.5" /> LXC
<Badge variant="secondary" className="ml-1.5 text-[10px]">{counts.lxc}</Badge>
</TabsTrigger>
</TabsList>
</Tabs>
{/* Filters bar */}
<div className="flex flex-wrap items-center gap-3">
<div className="relative flex-1 min-w-[200px] max-w-xs">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder={t('instances.search_placeholder', 'Search by name, IP, or VMID...')}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9"
/>
</div>
<Select value={statusFilter} onValueChange={v => { if (v !== null) setStatusFilter(v); }}>
<SelectTrigger className="w-[130px]">
<SelectValue placeholder={t('common.status', 'Status')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('instances.all_statuses', 'All statuses')}</SelectItem>
<SelectItem value="running">{t('common.running', 'Running')}</SelectItem>
<SelectItem value="stopped">{t('common.stopped', 'Stopped')}</SelectItem>
</SelectContent>
</Select>
<Select value={nodeFilter} onValueChange={v => { if (v !== null) setNodeFilter(v); }}>
<SelectTrigger className="w-[130px]">
<SelectValue placeholder={t('common.node', 'Node')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('instances.all_nodes', 'All nodes')}</SelectItem>
{uniqueNodes.map((n) => (
<SelectItem key={n} value={n}>{n}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Bulk actions toolbar */}
{selectedVMs.length > 0 && (
<div className="flex items-center gap-2 rounded-lg border bg-muted/50 p-2">
<span className="text-sm font-medium">{selectedVMs.length} selected</span>
<Button variant="outline" size="sm" onClick={() => handleBulk('start')} disabled={bulkOp.isPending}>
<Play className="mr-1 h-3 w-3" /> Start
</Button>
<Button variant="outline" size="sm" onClick={() => handleBulk('stop')} disabled={bulkOp.isPending}>
<Square className="mr-1 h-3 w-3" /> Stop
</Button>
<Button variant="destructive" size="sm" onClick={() => handleBulk('delete')} disabled={bulkOp.isPending}>
<Trash2 className="mr-1 h-3 w-3" /> Delete
</Button>
<Button variant="ghost" size="sm" onClick={() => setRowSelection({})}>
Clear
</Button>
</div>
)}
{/* Table */}
<Card>
<CardContent className="p-0">
{isLoading ? (
<div className="flex items-center justify-center py-20">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : filteredVMs.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
<Monitor className="mb-3 h-10 w-10 opacity-40" />
<p className="text-sm font-medium">{t('instances.no_instances', 'No instances found')}</p>
<p className="text-xs">{search ? t('instances.try_other_search', 'Try a different search query') : t('instances.create_first', 'Create your first instance')}</p>
</div>
) : (
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id} style={{ width: header.getSize() }}>
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.map((row) => (
<TableRow key={row.id} data-state={row.getIsSelected() && 'selected'}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
{/* Pagination */}
{filteredVMs.length > 0 && (
<div className="flex items-center justify-between text-sm text-muted-foreground">
<span>
{t('instances.showing', 'Showing')} {table.getState().pagination.pageIndex * table.getState().pagination.pageSize + 1}–
{Math.min(
(table.getState().pagination.pageIndex + 1) * table.getState().pagination.pageSize,
filteredVMs.length
)}{' '}
{t('instances.of', 'of')} {filteredVMs.length}
</span>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="px-2 tabular-nums">
{table.getState().pagination.pageIndex + 1} / {table.getPageCount()}
</span>
<Button
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,83 @@
import { useState } from 'react';
import { useNavigate } from 'react-router';
import { AlertTriangle, Loader2 } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useDeleteVM } from '@/hooks/use-instances';
import { vmTypeLabel } from '@/lib/format';
import { toast } from 'sonner';
interface Props {
serverId: number;
vmid: number;
type: string;
node: string;
name?: string;
}
export default function DestroyTab({ serverId, vmid, type, node, name }: Props) {
const navigate = useNavigate();
const deleteVM = useDeleteVM(serverId, vmid, type, node);
const [confirmation, setConfirmation] = useState('');
const displayName = name || `${vmTypeLabel(type)} ${vmid}`;
const canDelete = confirmation === displayName;
const handleDestroy = () => {
if (!canDelete) return;
deleteVM.mutate(undefined, {
onSuccess: () => {
toast.success(`${displayName} has been destroyed`);
navigate('/instances');
},
onError: (err) => toast.error(err.message),
});
};
return (
<div className="max-w-xl">
<Card className="border-destructive/30">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-destructive">
<AlertTriangle className="h-5 w-5" />
Destroy {vmTypeLabel(type)}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
<p className="font-medium">This action is irreversible!</p>
<p className="mt-1 text-destructive/80">
This will permanently delete <strong>{displayName}</strong> (#{vmid}) and all its data,
including disks and snapshots. IP allocations will be released.
</p>
</div>
<div>
<Label htmlFor="confirm-name">
Type <strong className="text-foreground">{displayName}</strong> to confirm
</Label>
<Input
id="confirm-name"
value={confirmation}
onChange={(e) => setConfirmation(e.target.value)}
placeholder={displayName}
className="mt-1"
/>
</div>
<Button
variant="destructive"
onClick={handleDestroy}
disabled={!canDelete || deleteVM.isPending}
className="w-full"
>
{deleteVM.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Permanently Destroy {vmTypeLabel(type)}
</Button>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,188 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Area,
AreaChart,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from 'recharts';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Loader2 } from 'lucide-react';
import { useVMRrddata } from '@/hooks/use-instances';
import { formatBytes } from '@/lib/format';
interface Props {
serverId: number;
vmid: number;
type: string;
node: string;
}
const TIMEFRAMES = [
{ value: 'hour', label: '1h' },
{ value: 'day', label: '24h' },
{ value: 'week', label: '7d' },
{ value: 'month', label: '30d' },
{ value: 'year', label: '1y' },
];
function formatTime(ts: number): string {
const d = new Date(ts * 1000);
return `${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`;
}
interface ChartCardProps {
title: string;
data: Array<Record<string, unknown>>;
dataKey: string;
color: string;
formatValue?: (v: number) => string;
unit?: string;
}
function ChartCard({ title, data, dataKey, color, formatValue, unit }: ChartCardProps) {
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold">{title}</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={200}>
<AreaChart data={data} margin={{ top: 5, right: 5, bottom: 0, left: 0 }}>
<defs>
<linearGradient id={`grad-${dataKey}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={color} stopOpacity={0.3} />
<stop offset="100%" stopColor={color} stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
<XAxis
dataKey="time"
tickFormatter={formatTime}
className="text-[10px] fill-muted-foreground"
tick={{ fontSize: 10 }}
/>
<YAxis
className="text-[10px] fill-muted-foreground"
tick={{ fontSize: 10 }}
tickFormatter={(v) => (formatValue ? formatValue(v) : String(v))}
width={50}
/>
<Tooltip
contentStyle={{
backgroundColor: 'hsl(var(--popover))',
border: '1px solid hsl(var(--border))',
borderRadius: '6px',
fontSize: 12,
}}
formatter={(val: any) => [
val != null && typeof val === 'number' && formatValue ? formatValue(val) : `${val ?? ''}${unit || ''}`,
title,
]}
labelFormatter={(ts) => new Date((ts as number) * 1000).toLocaleString()}
/>
<Area
type="monotone"
dataKey={dataKey}
stroke={color}
strokeWidth={1.5}
fill={`url(#grad-${dataKey})`}
/>
</AreaChart>
</ResponsiveContainer>
</CardContent>
</Card>
);
}
export default function GraphsTab({ serverId, vmid, type, node }: Props) {
const { t } = useTranslation();
const [timeframe, setTimeframe] = useState('hour');
const { data: rrdData, isLoading } = useVMRrddata(serverId, vmid, type, node, timeframe);
// Parse RRD data into chart-friendly format
const chartData = (rrdData as Array<Record<string, unknown>> | undefined)?.map((point) => ({
time: Number(point.time),
cpu: (Number(point.cpu) || 0) * 100,
mem: Number(point.mem) || Number(point.memused) || 0,
maxmem: Number(point.maxmem) || 0,
netin: Number(point.netin) || 0,
netout: Number(point.netout) || 0,
diskread: Number(point.diskread) || 0,
diskwrite: Number(point.diskwrite) || 0,
})) || [];
return (
<div className="space-y-4">
{/* Timeframe selector */}
<div className="flex gap-1">
{TIMEFRAMES.map((tf) => (
<Button
key={tf.value}
variant={timeframe === tf.value ? 'default' : 'outline'}
size="sm"
onClick={() => setTimeframe(tf.value)}
>
{tf.label}
</Button>
))}
</div>
{isLoading ? (
<div className="flex items-center justify-center py-20">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : (
<div className="grid gap-4 lg:grid-cols-2">
<ChartCard
title={t('graphs.cpu_usage')}
data={chartData}
dataKey="cpu"
color="hsl(217, 91%, 60%)"
formatValue={(v) => `${v.toFixed(1)}%`}
unit="%"
/>
<ChartCard
title={t('graphs.memory_usage')}
data={chartData}
dataKey="mem"
color="hsl(142, 76%, 36%)"
formatValue={(v) => formatBytes(v)}
/>
<ChartCard
title={t('graphs.network_in')}
data={chartData}
dataKey="netin"
color="hsl(262, 83%, 58%)"
formatValue={(v) => formatBytes(v)}
/>
<ChartCard
title={t('graphs.network_out')}
data={chartData}
dataKey="netout"
color="hsl(25, 95%, 53%)"
formatValue={(v) => formatBytes(v)}
/>
<ChartCard
title={t('graphs.disk_read')}
data={chartData}
dataKey="diskread"
color="hsl(199, 89%, 48%)"
formatValue={(v) => formatBytes(v)}
/>
<ChartCard
title={t('graphs.disk_write')}
data={chartData}
dataKey="diskwrite"
color="hsl(0, 84%, 60%)"
formatValue={(v) => formatBytes(v)}
/>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,120 @@
import { Globe, Wifi } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { useVMInterfaces, useVMConfig } from '@/hooks/use-instances';
import { formatBytes } from '@/lib/format';
import { useVMStatus } from '@/hooks/use-instances';
interface Props {
serverId: number;
vmid: number;
type: string;
node: string;
}
export default function NetworkingTab({ serverId, vmid, type, node }: Props) {
const { data: ifaces } = useVMInterfaces(serverId, vmid, type, node);
const { data: config } = useVMConfig(serverId, vmid, type, node);
const { data: status } = useVMStatus(serverId, vmid, type, node);
// Extract network configs from VM config (net0, net1, ...)
const netDevices = config
? Object.entries(config)
.filter(([k]) => /^net\d+$/.test(k))
.sort(([a], [b]) => a.localeCompare(b))
: [];
return (
<div className="space-y-4">
{/* Traffic summary */}
<div className="grid gap-4 sm:grid-cols-2">
<Card>
<CardContent className="p-4">
<p className="text-xs font-medium uppercase tracking-wider text-muted-foreground mb-1">Network In</p>
<p className="text-xl font-bold tabular-nums">{status?.netin !== undefined ? formatBytes(status.netin) : '—'}</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<p className="text-xs font-medium uppercase tracking-wider text-muted-foreground mb-1">Network Out</p>
<p className="text-xl font-bold tabular-nums">{status?.netout !== undefined ? formatBytes(status.netout) : '—'}</p>
</CardContent>
</Card>
</div>
{/* Configured network devices */}
{netDevices.length > 0 && (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<Globe className="h-4 w-4" />
Network Devices
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{netDevices.map(([key, value]) => {
const parts = String(value).split(',');
const bridgePart = parts.find((p) => p.includes('bridge='));
const bridge = bridgePart?.split('=')[1];
const modelPart = parts[0]; // e.g. "virtio=XX:XX:XX:XX:XX:XX"
const [model, mac] = modelPart?.includes('=') ? modelPart.split('=') : [modelPart, ''];
return (
<div key={key} className="rounded-md border p-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Badge variant="outline">{key}</Badge>
<span className="text-sm font-medium">{model}</span>
</div>
{bridge && (
<Badge variant="secondary" className="text-xs">{bridge}</Badge>
)}
</div>
{mac && (
<p className="mt-1 font-mono text-xs text-muted-foreground">{mac}</p>
)}
</div>
);
})}
</CardContent>
</Card>
)}
{/* Live interfaces */}
{ifaces?.interfaces && ifaces.interfaces.length > 0 && (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<Wifi className="h-4 w-4" />
Live Interfaces (Guest Agent)
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{ifaces.interfaces.map((iface) => (
<div key={iface.name} className="rounded-md border p-3">
<div className="flex items-center gap-2 mb-1">
<span className="text-sm font-medium">{iface.name}</span>
{iface.mac && (
<span className="font-mono text-xs text-muted-foreground">{iface.mac}</span>
)}
</div>
{iface.ips && iface.ips.length > 0 ? (
<div className="space-y-0.5">
{iface.ips.map((ip) => (
<div key={ip.address} className="flex items-center gap-2">
<span className="font-mono text-xs">{ip.address}/{ip.prefix}</span>
<Badge variant="secondary" className="text-[10px]">{ip.type}</Badge>
</div>
))}
</div>
) : (
<p className="text-xs text-muted-foreground">No addresses</p>
)}
</div>
))}
</CardContent>
</Card>
)}
</div>
);
}

View File

@@ -0,0 +1,175 @@
import { Cpu, MemoryStick, HardDrive, Clock, Wifi, Globe, Server } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { useVMStatus, useVMConfig, useVMInterfaces } from '@/hooks/use-instances';
import { formatBytes, formatUptime, formatPercent } from '@/lib/format';
interface Props {
serverId: number;
vmid: number;
type: string;
node: string;
}
function MetricCard({
label,
value,
subtitle,
percent,
icon: Icon,
}: {
label: string;
value: string;
subtitle?: string;
percent?: number;
icon: React.ComponentType<{ className?: string }>;
}) {
const barColor =
percent !== undefined
? percent >= 90
? 'bg-red-500'
: percent >= 70
? 'bg-amber-500'
: 'bg-primary'
: 'bg-primary';
return (
<Card>
<CardContent className="p-4">
<div className="mb-2 flex items-center gap-2 text-muted-foreground">
<Icon className="h-4 w-4" />
<span className="text-xs font-medium uppercase tracking-wider">{label}</span>
</div>
<p className="text-xl font-bold tabular-nums">{value}</p>
{percent !== undefined && (
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-muted">
<div
className={`h-full rounded-full transition-all ${barColor}`}
style={{ width: `${Math.min(percent, 100)}%` }}
/>
</div>
)}
{subtitle && <p className="mt-1 text-xs text-muted-foreground">{subtitle}</p>}
</CardContent>
</Card>
);
}
function InfoRow({ label, value }: { label: string; value?: string | number | null }) {
return (
<div className="flex justify-between py-2 border-b border-border/50 last:border-0">
<span className="text-sm text-muted-foreground">{label}</span>
<span className="text-sm font-medium">{value ?? '—'}</span>
</div>
);
}
export default function OverviewTab({ serverId, vmid, type, node }: Props) {
const { data: status } = useVMStatus(serverId, vmid, type, node);
const { data: config } = useVMConfig(serverId, vmid, type, node);
const { data: ifaces } = useVMInterfaces(serverId, vmid, type, node);
const cpuPercent = status ? (status.cpu || 0) * 100 : 0;
const memPercent = status && status.maxmem ? (status.mem / status.maxmem) * 100 : 0;
const diskPercent = status && status.maxdisk ? (status.disk / status.maxdisk) * 100 : 0;
const primaryIP = ifaces?.interfaces
?.flatMap((i) => i.ips || [])
.find((ip) => ip.type === 'ipv4' && !ip.address.startsWith('127.'));
return (
<div className="space-y-6">
{/* Metric cards */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<MetricCard
label="CPU"
value={formatPercent(cpuPercent)}
subtitle={config?.cores ? `${config.cores} cores` : undefined}
percent={cpuPercent}
icon={Cpu}
/>
<MetricCard
label="Memory"
value={status ? formatBytes(status.mem) : '—'}
subtitle={status ? `of ${formatBytes(status.maxmem)}` : undefined}
percent={memPercent}
icon={MemoryStick}
/>
<MetricCard
label="Disk"
value={status ? formatBytes(status.disk) : '—'}
subtitle={status ? `of ${formatBytes(status.maxdisk)}` : undefined}
percent={diskPercent}
icon={HardDrive}
/>
<MetricCard
label="Uptime"
value={status?.uptime ? formatUptime(status.uptime) : '—'}
icon={Clock}
/>
</div>
<div className="grid gap-4 lg:grid-cols-2">
{/* Info card */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<Server className="h-4 w-4" />
Instance Info
</CardTitle>
</CardHeader>
<CardContent>
<InfoRow label="VMID" value={vmid} />
<InfoRow label="Node" value={node} />
<InfoRow label="Type" value={type === 'qemu' ? 'QEMU Virtual Machine' : 'LXC Container'} />
<InfoRow label="vCPU" value={config?.cores} />
<InfoRow label="Memory" value={config?.memory ? `${config.memory} MB` : undefined} />
<InfoRow label="OS Type" value={config?.ostype as string} />
<InfoRow label="Boot Order" value={config?.boot as string} />
<InfoRow label="QEMU Agent" value={config?.agent ? 'Enabled' : 'Disabled'} />
<InfoRow label="Start on Boot" value={config?.onboot ? 'Yes' : 'No'} />
</CardContent>
</Card>
{/* Network card */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<Globe className="h-4 w-4" />
Network
</CardTitle>
</CardHeader>
<CardContent>
{primaryIP && <InfoRow label="Primary IP" value={primaryIP.address} />}
{status?.netin !== undefined && (
<InfoRow label="Network In" value={formatBytes(status.netin)} />
)}
{status?.netout !== undefined && (
<InfoRow label="Network Out" value={formatBytes(status.netout)} />
)}
{config?.net0 && <InfoRow label="net0" value={String(config.net0)} />}
{ifaces?.interfaces && ifaces.interfaces.length > 0 && (
<div className="mt-3 space-y-2">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Interfaces</p>
{ifaces.interfaces.map((iface) => (
<div key={iface.name} className="rounded-md border p-2 text-xs">
<div className="flex items-center gap-2 font-medium">
<Wifi className="h-3 w-3 text-muted-foreground" />
{iface.name}
{iface.mac && <span className="text-muted-foreground font-mono">{iface.mac}</span>}
</div>
{iface.ips?.map((ip) => (
<div key={ip.address} className="ml-5 text-muted-foreground font-mono">
{ip.address}/{ip.prefix} ({ip.type})
</div>
))}
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,172 @@
import { useState } from 'react';
import { Settings, Loader2 } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useVMConfig, useUpdateConfig, useResizeDisk } from '@/hooks/use-instances';
import { toast } from 'sonner';
interface Props {
serverId: number;
vmid: number;
type: string;
node: string;
}
export default function SettingsTab({ serverId, vmid, type, node }: Props) {
const { data: config } = useVMConfig(serverId, vmid, type, node);
const updateConfig = useUpdateConfig(serverId, vmid, type);
const resizeDisk = useResizeDisk(serverId, vmid, type);
const [cores, setCores] = useState('');
const [memory, setMemory] = useState('');
const [diskSize, setDiskSize] = useState('');
const [diskDevice, setDiskDevice] = useState('scsi0');
// Initialize from config
const currentCores = config?.cores || 0;
const currentMemory = config?.memory || 0;
const handleConfigUpdate = () => {
const updates: Record<string, unknown> = {};
if (cores && Number(cores) !== currentCores) updates.cores = Number(cores);
if (memory && Number(memory) !== currentMemory) updates.memory = Number(memory);
if (Object.keys(updates).length === 0) {
toast.info('No changes to apply');
return;
}
updateConfig.mutate(updates, {
onSuccess: () => {
toast.success('Configuration updated');
setCores('');
setMemory('');
},
onError: (err) => toast.error(err.message),
});
};
const handleDiskResize = () => {
if (!diskSize) return;
resizeDisk.mutate(
{ disk: diskDevice, size: `${diskSize}G` },
{
onSuccess: () => {
toast.success(`Disk resized to ${diskSize}G`);
setDiskSize('');
},
onError: (err) => toast.error(err.message),
}
);
};
return (
<div className="space-y-6 max-w-2xl">
{/* CPU & Memory */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<Settings className="h-4 w-4" />
Compute Resources
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div>
<Label htmlFor="cores">vCPU Cores</Label>
<Input
id="cores"
type="number"
min={1}
max={128}
placeholder={String(currentCores)}
value={cores}
onChange={(e) => setCores(e.target.value)}
/>
<p className="mt-1 text-xs text-muted-foreground">Current: {currentCores} cores</p>
</div>
<div>
<Label htmlFor="memory">Memory (MB)</Label>
<Input
id="memory"
type="number"
min={128}
step={128}
placeholder={String(currentMemory)}
value={memory}
onChange={(e) => setMemory(e.target.value)}
/>
<p className="mt-1 text-xs text-muted-foreground">Current: {currentMemory} MB</p>
</div>
</div>
<Button onClick={handleConfigUpdate} disabled={updateConfig.isPending} size="sm">
{updateConfig.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Apply Changes
</Button>
<p className="text-xs text-muted-foreground">
Note: VM may need to be restarted for changes to take effect.
</p>
</CardContent>
</Card>
{/* Disk Resize */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold">Disk Resize</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div>
<Label htmlFor="disk-device">Disk Device</Label>
<Input
id="disk-device"
value={diskDevice}
onChange={(e) => setDiskDevice(e.target.value)}
placeholder="scsi0"
/>
</div>
<div>
<Label htmlFor="disk-size">New Size (GB)</Label>
<Input
id="disk-size"
type="number"
min={1}
value={diskSize}
onChange={(e) => setDiskSize(e.target.value)}
placeholder="e.g. 100"
/>
</div>
</div>
<Button
onClick={handleDiskResize}
disabled={!diskSize || resizeDisk.isPending}
size="sm"
variant="outline"
>
{resizeDisk.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Resize Disk
</Button>
<p className="text-xs text-muted-foreground">
Disks can only be enlarged. Use the full target size.
</p>
</CardContent>
</Card>
{/* Raw Config */}
{config && (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold">Raw Configuration</CardTitle>
</CardHeader>
<CardContent>
<pre className="overflow-auto rounded-md bg-muted p-3 text-xs font-mono max-h-64">
{JSON.stringify(config, null, 2)}
</pre>
</CardContent>
</Card>
)}
</div>
);
}

View File

@@ -0,0 +1,263 @@
import { useState } from 'react';
import { Plus, Trash2, RotateCcw, Loader2, Camera } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
useSnapshots,
useCreateSnapshot,
useDeleteSnapshot,
useRollbackSnapshot,
} from '@/hooks/use-instances';
import { toast } from 'sonner';
interface Props {
serverId: number;
vmid: number;
type: string;
node: string;
}
export default function SnapshotsTab({ serverId, vmid, type, node }: Props) {
const { data, isLoading } = useSnapshots(serverId, vmid, type, node);
const createSnap = useCreateSnapshot(serverId, vmid, type);
const deleteSnap = useDeleteSnapshot(serverId, vmid, type, node);
const rollbackSnap = useRollbackSnapshot(serverId, vmid, type, node);
const [showCreate, setShowCreate] = useState(false);
const [snapName, setSnapName] = useState('');
const [snapDesc, setSnapDesc] = useState('');
const [includeRAM, setIncludeRAM] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
const [rollbackTarget, setRollbackTarget] = useState<string | null>(null);
const snapshots = data?.snapshots || [];
const handleCreate = () => {
if (!snapName.trim()) return;
createSnap.mutate(
{ snapname: snapName.trim(), description: snapDesc, vmstate: includeRAM },
{
onSuccess: () => {
toast.success(`Snapshot "${snapName}" created`);
setShowCreate(false);
setSnapName('');
setSnapDesc('');
setIncludeRAM(false);
},
onError: (err) => toast.error(err.message),
}
);
};
const handleDelete = () => {
if (!deleteTarget) return;
deleteSnap.mutate(deleteTarget, {
onSuccess: () => {
toast.success(`Snapshot "${deleteTarget}" deleted`);
setDeleteTarget(null);
},
onError: (err) => toast.error(err.message),
});
};
const handleRollback = () => {
if (!rollbackTarget) return;
rollbackSnap.mutate(rollbackTarget, {
onSuccess: () => {
toast.success(`Rolled back to "${rollbackTarget}"`);
setRollbackTarget(null);
},
onError: (err) => toast.error(err.message),
});
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-muted-foreground">
{snapshots.length} snapshot{snapshots.length !== 1 ? 's' : ''}
</h3>
<Button size="sm" onClick={() => setShowCreate(true)}>
<Plus className="mr-1.5 h-4 w-4" />
Create Snapshot
</Button>
</div>
<Card>
<CardContent className="p-0">
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : snapshots.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<Camera className="mb-2 h-8 w-8 opacity-40" />
<p className="text-sm">No snapshots</p>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Description</TableHead>
<TableHead>Date</TableHead>
<TableHead>RAM</TableHead>
<TableHead className="w-[100px]">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{snapshots.map((snap) => (
<TableRow key={snap.name}>
<TableCell className="font-medium">{snap.name}</TableCell>
<TableCell className="text-muted-foreground text-sm">
{snap.description || '—'}
</TableCell>
<TableCell className="text-sm tabular-nums">
{snap.snaptime
? new Date(snap.snaptime * 1000).toLocaleString()
: '—'}
</TableCell>
<TableCell>
{snap.vmstate ? (
<Badge variant="secondary" className="text-[10px]">RAM</Badge>
) : (
<span className="text-xs text-muted-foreground">No</span>
)}
</TableCell>
<TableCell>
<div className="flex gap-1">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => setRollbackTarget(snap.name)}
title="Rollback"
>
<RotateCcw className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive hover:text-destructive"
onClick={() => setDeleteTarget(snap.name)}
title="Delete"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
{/* Create Dialog */}
<Dialog open={showCreate} onOpenChange={setShowCreate}>
<DialogContent>
<DialogHeader>
<DialogTitle>Create Snapshot</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<Label htmlFor="snap-name">Name</Label>
<Input
id="snap-name"
value={snapName}
onChange={(e) => setSnapName(e.target.value)}
placeholder="e.g. before-update"
/>
</div>
<div>
<Label htmlFor="snap-desc">Description (optional)</Label>
<Input
id="snap-desc"
value={snapDesc}
onChange={(e) => setSnapDesc(e.target.value)}
placeholder="Description"
/>
</div>
{type === 'qemu' && (
<div className="flex items-center gap-2">
<input
id="snap-ram"
type="checkbox"
checked={includeRAM}
onChange={(e) => setIncludeRAM(e.target.checked)}
className="rounded border-border"
/>
<Label htmlFor="snap-ram" className="text-sm">Include VM memory (RAM state)</Label>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowCreate(false)}>Cancel</Button>
<Button onClick={handleCreate} disabled={!snapName.trim() || createSnap.isPending}>
{createSnap.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Create
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete confirmation */}
<Dialog open={!!deleteTarget} onOpenChange={() => setDeleteTarget(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Snapshot</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
Are you sure you want to delete snapshot <strong>"{deleteTarget}"</strong>? This cannot be undone.
</p>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteTarget(null)}>Cancel</Button>
<Button variant="destructive" onClick={handleDelete} disabled={deleteSnap.isPending}>
{deleteSnap.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Rollback confirmation */}
<Dialog open={!!rollbackTarget} onOpenChange={() => setRollbackTarget(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Rollback to Snapshot</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
Are you sure you want to rollback to <strong>"{rollbackTarget}"</strong>?
Current state will be lost.
</p>
<DialogFooter>
<Button variant="outline" onClick={() => setRollbackTarget(null)}>Cancel</Button>
<Button onClick={handleRollback} disabled={rollbackSnap.isPending}>
{rollbackSnap.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Rollback
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,110 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Search } from 'lucide-react';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Button } from '@/components/ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useIPAMAllocations, useIPAMNetworks, useDeleteAllocation } from '@/hooks/use-ipam';
import { Trash2 } from 'lucide-react';
export default function IPAMAllocationsPage() {
const { t } = useTranslation();
const [networkId, setNetworkId] = useState<string>('all');
const [statusFilter, setStatusFilter] = useState<string>('all');
const [search, setSearch] = useState('');
const { data: networks = [] } = useIPAMNetworks();
const { data: allocations = [] } = useIPAMAllocations({
network_id: networkId !== 'all' ? Number(networkId) : undefined,
limit: 200,
});
const deleteAllocation = useDeleteAllocation();
const filtered = allocations.filter(a => {
if (statusFilter !== 'all' && a.status !== statusFilter) return false;
if (search) {
const s = search.toLowerCase();
return (
a.ip_address?.toLowerCase().includes(s) ||
a.resource_name?.toLowerCase().includes(s) ||
a.mac_address?.toLowerCase().includes(s) ||
a.description?.toLowerCase().includes(s)
);
}
return true;
});
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold">{t('ipam.allocations')}</h1>
<Card>
<CardHeader>
<div className="flex flex-wrap gap-3 items-center">
<div className="relative flex-1 min-w-[200px]">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input placeholder={t('common.search')} value={search} onChange={e => setSearch(e.target.value)} className="pl-8" />
</div>
<Select value={networkId} onValueChange={v => { if (v !== null) setNetworkId(v); }}>
<SelectTrigger className="w-[200px]"><SelectValue placeholder={t('ipam.network')} /></SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('common.all')}</SelectItem>
{networks.map(n => (
<SelectItem key={n.id} value={String(n.id)}>{n.name} ({n.network})</SelectItem>
))}
</SelectContent>
</Select>
<Select value={statusFilter} onValueChange={v => { if (v !== null) setStatusFilter(v); }}>
<SelectTrigger className="w-[160px]"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('common.all')}</SelectItem>
<SelectItem value="allocated">Allocated</SelectItem>
<SelectItem value="reserved">Reserved</SelectItem>
<SelectItem value="available">Available</SelectItem>
</SelectContent>
</Select>
</div>
</CardHeader>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>IP</TableHead>
<TableHead>{t('common.status')}</TableHead>
<TableHead>{t('ipam.network')}</TableHead>
<TableHead>{t('ipam.resource')}</TableHead>
<TableHead>MAC</TableHead>
<TableHead>{t('ipam.description')}</TableHead>
<TableHead />
</TableRow>
</TableHeader>
<TableBody>
{filtered.map(a => (
<TableRow key={a.id}>
<TableCell className="font-mono">{a.ip_address}</TableCell>
<TableCell>
<Badge variant={a.status === 'allocated' ? 'default' : a.status === 'reserved' ? 'secondary' : 'outline'}>{a.status}</Badge>
</TableCell>
<TableCell className="text-xs">{a.network_name || '—'}</TableCell>
<TableCell className="text-xs">{a.resource_name || '—'}</TableCell>
<TableCell className="font-mono text-xs">{a.mac_address || '—'}</TableCell>
<TableCell className="text-xs text-muted-foreground max-w-[200px] truncate">{a.description || '—'}</TableCell>
<TableCell>
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => deleteAllocation.mutate(a.id)}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</TableCell>
</TableRow>
))}
{filtered.length === 0 && (
<TableRow><TableCell colSpan={7} className="text-center text-muted-foreground py-8">{t('common.no_data')}</TableCell></TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,76 @@
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router';
import { Network, Globe, AlertTriangle, Activity } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { useIPAMSummary, useIPAMNetworks } from '@/hooks/use-ipam';
export default function IPAMDashboardPage() {
const { t } = useTranslation();
const { data: summary } = useIPAMSummary();
const { data: networks = [] } = useIPAMNetworks();
const s = summary as Record<string, number> | undefined;
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold">{t('nav.ipam')}</h1>
{/* Summary cards */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<SummaryCard icon={Globe} label={t('ipam.networks_count')} value={s?.networks_count ?? 0} to="/ipam/networks" />
<SummaryCard icon={Network} label={t('ipam.total_allocated')} value={s?.total_allocated ?? 0} to="/ipam/allocations" />
<SummaryCard icon={Activity} label={t('ipam.total_reserved')} value={s?.total_reserved ?? 0} />
<SummaryCard icon={AlertTriangle} label={t('ipam.vm_count')} value={(s?.vm_count ?? 0) + (s?.lxc_count ?? 0)} />
</div>
{/* Quick network overview */}
<div className="space-y-3">
<h2 className="text-lg font-semibold">{t('ipam.networks')}</h2>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{networks.slice(0, 6).map(net => (
<Link key={net.id} to={`/ipam/network/${net.id}`}>
<Card className="hover:border-blue-500/50 transition-colors h-full">
<CardContent className="p-4 space-y-2">
<div className="flex items-center justify-between">
<span className="font-medium text-sm">{net.name}</span>
<span className="text-xs font-mono text-muted-foreground">{net.network}</span>
</div>
<div className="h-2 rounded-full bg-muted overflow-hidden">
<div
className="h-full bg-blue-500 rounded-full transition-all"
style={{ width: `${Math.min(net.utilization_percent || 0, 100)}%` }}
/>
</div>
<div className="flex justify-between text-xs text-muted-foreground">
<span>{net.used_ips ?? 0} / {net.total_ips ?? 0}</span>
<span>{(net.utilization_percent ?? 0).toFixed(1)}%</span>
</div>
</CardContent>
</Card>
</Link>
))}
</div>
{networks.length > 6 && (
<Link to="/ipam/networks" className="text-sm text-blue-400 hover:underline">{t('ipam.view_all_networks')}</Link>
)}
</div>
</div>
);
}
function SummaryCard({ icon: Icon, label, value, to }: { icon: React.ElementType; label: string; value: number; to?: string }) {
const content = (
<Card className={to ? 'hover:border-blue-500/50 transition-colors' : ''}>
<CardContent className="p-4 flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted">
<Icon className="h-5 w-5" />
</div>
<div>
<p className="text-2xl font-bold">{value}</p>
<p className="text-xs text-muted-foreground">{label}</p>
</div>
</CardContent>
</Card>
);
return to ? <Link to={to}>{content}</Link> : content;
}

View File

@@ -0,0 +1,92 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Search, History } from 'lucide-react';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useIPAMHistory, useIPAMNetworks } from '@/hooks/use-ipam';
export default function IPAMHistoryPage() {
const { t } = useTranslation();
const [networkId, setNetworkId] = useState<string>('all');
const [search, setSearch] = useState('');
const { data: networks = [] } = useIPAMNetworks();
const { data: history = [] } = useIPAMHistory(networkId !== 'all' ? { network_id: Number(networkId) } : undefined);
const filtered = history.filter(h => {
if (!search) return true;
const s = search.toLowerCase();
return (
h.action?.toLowerCase().includes(s) ||
h.ip_address?.toLowerCase().includes(s) ||
h.username?.toLowerCase().includes(s) ||
h.details?.toLowerCase().includes(s)
);
});
const actionColor = (action: string) => {
if (action.includes('create') || action.includes('allocate')) return 'default';
if (action.includes('delete') || action.includes('release')) return 'destructive';
return 'secondary';
};
return (
<div className="space-y-6">
<div className="flex items-center gap-2">
<History className="h-6 w-6" />
<h1 className="text-2xl font-bold">{t('ipam.history')}</h1>
</div>
<Card>
<CardHeader>
<div className="flex flex-wrap gap-3 items-center">
<div className="relative flex-1 min-w-[200px]">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input placeholder={t('common.search')} value={search} onChange={e => setSearch(e.target.value)} className="pl-8" />
</div>
<Select value={networkId} onValueChange={v => { if (v !== null) setNetworkId(v); }}>
<SelectTrigger className="w-[200px]"><SelectValue placeholder={t('ipam.network')} /></SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('common.all')}</SelectItem>
{networks.map(n => (
<SelectItem key={n.id} value={String(n.id)}>{n.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</CardHeader>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('common.date')}</TableHead>
<TableHead>{t('ipam.action')}</TableHead>
<TableHead>IP</TableHead>
<TableHead>{t('ipam.network')}</TableHead>
<TableHead>{t('common.user')}</TableHead>
<TableHead>{t('ipam.details')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.map((h, i) => (
<TableRow key={h.id ?? i}>
<TableCell className="text-xs whitespace-nowrap">{h.created_at ? new Date(h.created_at).toLocaleString() : '—'}</TableCell>
<TableCell><Badge variant={actionColor(h.action || '') as any}>{h.action}</Badge></TableCell>
<TableCell className="font-mono text-xs">{h.ip_address || '—'}</TableCell>
<TableCell className="text-xs">{h.network_name || '—'}</TableCell>
<TableCell className="text-xs">{h.username || '—'}</TableCell>
<TableCell className="text-xs text-muted-foreground max-w-[300px] truncate">{h.details || '—'}</TableCell>
</TableRow>
))}
{filtered.length === 0 && (
<TableRow><TableCell colSpan={6} className="text-center text-muted-foreground py-8">{t('common.no_data')}</TableCell></TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,153 @@
import { useParams, Link } from 'react-router';
import { useTranslation } from 'react-i18next';
import { ArrowLeft } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { useIPAMNetwork, useIPAMNetworkStats, useIPAMAllocations, useIPAMPools } from '@/hooks/use-ipam';
export default function IPAMNetworkDetailPage() {
const { t } = useTranslation();
const { id } = useParams<{ id: string }>();
const nid = Number(id);
const { data: network } = useIPAMNetwork(nid);
const { data: stats } = useIPAMNetworkStats(nid);
const { data: pools = [] } = useIPAMPools(nid);
const { data: allocations = [] } = useIPAMAllocations({ network_id: nid, limit: 100 });
if (!network) return <div className="py-12 text-center text-muted-foreground">{t('common.loading')}</div>;
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<Button render={<Link to="/ipam/networks" />} variant="ghost" size="sm"><ArrowLeft className="h-4 w-4" /></Button>
<div>
<h1 className="text-2xl font-bold">{network.name}</h1>
<p className="text-sm text-muted-foreground font-mono">{network.network}</p>
</div>
<Badge variant={network.is_active ? 'default' : 'secondary'} className="ml-auto">{network.is_active ? 'Active' : 'Inactive'}</Badge>
</div>
{/* Stats */}
<div className="grid gap-4 sm:grid-cols-4">
<StatCard label={t('ipam.total_ips')} value={stats?.total_ips ?? network.total_ips ?? 0} />
<StatCard label={t('ipam.allocated')} value={stats?.allocated_ips ?? network.used_ips ?? 0} />
<StatCard label={t('ipam.reserved')} value={stats?.reserved_ips ?? 0} />
<StatCard label={t('ipam.available')} value={stats?.available_ips ?? network.available_ips ?? 0} />
</div>
{/* Utilization bar */}
<Card>
<CardContent className="p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium">{t('ipam.utilization')}</span>
<span className="text-sm">{(stats?.utilization_percent ?? network.utilization_percent ?? 0).toFixed(1)}%</span>
</div>
<div className="h-3 rounded-full bg-muted overflow-hidden">
<div
className="h-full bg-blue-500 rounded-full transition-all"
style={{ width: `${Math.min(stats?.utilization_percent ?? network.utilization_percent ?? 0, 100)}%` }}
/>
</div>
</CardContent>
</Card>
{/* Network info */}
<Card>
<CardHeader><CardTitle className="text-sm">{t('ipam.network_info')}</CardTitle></CardHeader>
<CardContent className="grid gap-2 text-sm sm:grid-cols-2">
<InfoRow label="Gateway" value={network.gateway || '—'} />
<InfoRow label="VLAN" value={network.vlan_id ? String(network.vlan_id) : '—'} />
<InfoRow label="DNS" value={network.dns_primary || '—'} />
<InfoRow label={t('ipam.server')} value={network.server_name || '—'} />
<InfoRow label={t('ipam.bridge')} value={network.proxmox_bridge || '—'} />
{network.description && <InfoRow label={t('ipam.description')} value={network.description} />}
</CardContent>
</Card>
{/* Pools */}
{pools.length > 0 && (
<Card>
<CardHeader><CardTitle className="text-sm">{t('ipam.pools')} ({pools.length})</CardTitle></CardHeader>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('common.name')}</TableHead>
<TableHead>{t('common.type')}</TableHead>
<TableHead>{t('ipam.range')}</TableHead>
<TableHead>{t('ipam.usage')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pools.map(pool => (
<TableRow key={pool.id}>
<TableCell className="font-medium">{pool.name}</TableCell>
<TableCell><Badge variant="outline">{pool.pool_type}</Badge></TableCell>
<TableCell className="font-mono text-xs">{pool.range_start} — {pool.range_end}</TableCell>
<TableCell className="text-xs">{pool.used_ips ?? 0} / {pool.total_ips ?? 0}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)}
{/* Allocations */}
<Card>
<CardHeader><CardTitle className="text-sm">{t('ipam.allocations')} ({allocations.length})</CardTitle></CardHeader>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>IP</TableHead>
<TableHead>{t('common.status')}</TableHead>
<TableHead>{t('ipam.resource')}</TableHead>
<TableHead>MAC</TableHead>
<TableHead>{t('ipam.description')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{allocations.map(a => (
<TableRow key={a.id}>
<TableCell className="font-mono">{a.ip_address}</TableCell>
<TableCell>
<Badge variant={a.status === 'allocated' ? 'default' : a.status === 'reserved' ? 'secondary' : 'outline'}>{a.status}</Badge>
</TableCell>
<TableCell className="text-xs">{a.resource_name || '—'}</TableCell>
<TableCell className="font-mono text-xs">{a.mac_address || '—'}</TableCell>
<TableCell className="text-xs text-muted-foreground">{a.description || '—'}</TableCell>
</TableRow>
))}
{allocations.length === 0 && (
<TableRow><TableCell colSpan={5} className="text-center text-muted-foreground py-8">{t('common.no_data')}</TableCell></TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}
function StatCard({ label, value }: { label: string; value: number }) {
return (
<Card>
<CardContent className="p-4 text-center">
<p className="text-2xl font-bold">{value}</p>
<p className="text-xs text-muted-foreground">{label}</p>
</CardContent>
</Card>
);
}
function InfoRow({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-center justify-between py-1">
<span className="text-muted-foreground">{label}</span>
<span className="font-medium font-mono">{value}</span>
</div>
);
}

View File

@@ -0,0 +1,95 @@
import { useState } from 'react';
import { Link } from 'react-router';
import { useTranslation } from 'react-i18next';
import { Plus, Globe, Search } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import { useIPAMNetworks, useCreateNetwork } from '@/hooks/use-ipam';
import { toast } from 'sonner';
export default function IPAMNetworksPage() {
const { t } = useTranslation();
const [search, setSearch] = useState('');
const [dialogOpen, setDialogOpen] = useState(false);
const [form, setForm] = useState({ name: '', network: '', gateway: '', description: '' });
const { data: networks = [], isLoading } = useIPAMNetworks();
const createNet = useCreateNetwork();
const filtered = networks.filter(n =>
n.name.toLowerCase().includes(search.toLowerCase()) ||
n.network.includes(search)
);
const handleCreate = () => {
createNet.mutate(form, {
onSuccess: () => { toast.success(t('ipam.network_created')); setDialogOpen(false); setForm({ name: '', network: '', gateway: '', description: '' }); },
onError: (err: Error) => toast.error(err.message),
});
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">{t('ipam.networks')}</h1>
<Button onClick={() => setDialogOpen(true)}><Plus className="mr-2 h-4 w-4" />{t('ipam.add_network')}</Button>
</div>
<div className="relative max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input className="pl-9" placeholder={t('common.search')} value={search} onChange={e => setSearch(e.target.value)} />
</div>
{isLoading ? (
<p className="text-center py-12 text-muted-foreground">{t('common.loading')}</p>
) : filtered.length === 0 ? (
<div className="text-center py-16 text-muted-foreground">
<Globe className="mx-auto h-12 w-12 mb-3 opacity-50" />
<p>{t('ipam.no_networks')}</p>
</div>
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{filtered.map(net => (
<Link key={net.id} to={`/ipam/network/${net.id}`}>
<Card className="hover:border-blue-500/50 transition-colors h-full">
<CardContent className="p-4 space-y-3">
<div className="flex items-center justify-between">
<span className="font-semibold">{net.name}</span>
<Badge variant={net.is_active ? 'default' : 'secondary'}>{net.is_active ? 'Active' : 'Inactive'}</Badge>
</div>
<p className="text-sm font-mono text-muted-foreground">{net.network}</p>
{net.gateway && <p className="text-xs text-muted-foreground">GW: {net.gateway}</p>}
<div className="h-2 rounded-full bg-muted overflow-hidden">
<div className="h-full bg-blue-500 rounded-full" style={{ width: `${Math.min(net.utilization_percent || 0, 100)}%` }} />
</div>
<div className="flex justify-between text-xs text-muted-foreground">
<span>{net.used_ips ?? 0} / {net.total_ips ?? 0} {t('ipam.ips_used')}</span>
<span>{(net.utilization_percent ?? 0).toFixed(1)}%</span>
</div>
{net.server_name && <p className="text-xs text-muted-foreground">{net.server_name}</p>}
</CardContent>
</Card>
</Link>
))}
</div>
)}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent>
<DialogHeader><DialogTitle>{t('ipam.add_network')}</DialogTitle></DialogHeader>
<div className="space-y-3">
<div><Label>{t('common.name')}</Label><Input value={form.name} onChange={e => setForm(p => ({ ...p, name: e.target.value }))} /></div>
<div><Label>CIDR</Label><Input value={form.network} onChange={e => setForm(p => ({ ...p, network: e.target.value }))} placeholder="10.0.0.0/24" /></div>
<div><Label>Gateway</Label><Input value={form.gateway} onChange={e => setForm(p => ({ ...p, gateway: e.target.value }))} placeholder="10.0.0.1" /></div>
<div><Label>{t('ipam.description')}</Label><Input value={form.description} onChange={e => setForm(p => ({ ...p, description: e.target.value }))} /></div>
<Button onClick={handleCreate} disabled={!form.name || !form.network || createNet.isPending} className="w-full">{t('common.create')}</Button>
</div>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,87 @@
import { useParams, Link } from 'react-router';
import { useTranslation } from 'react-i18next';
import { ArrowLeft, Cpu, MemoryStick, HardDrive, Clock } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { useServer, useNodes } from '@/hooks/use-nodes';
import { useVirtualMachines } from '@/hooks/use-instances';
import { StatusDot } from '@/components/shared/status-dot';
import { formatBytes, formatUptime, formatPercent } from '@/lib/format';
export default function NodeDetailPage() {
const { t } = useTranslation();
const { serverId } = useParams<{ serverId: string }>();
const sid = Number(serverId);
const { data: server } = useServer(sid);
const { data: nodesData } = useNodes(sid);
const { data: allVMs = [] } = useVirtualMachines();
const nodes = nodesData?.nodes || [];
const serverVMs = allVMs.filter(v => v.server_id === sid);
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<Button render={<Link to="/nodes" />} variant="ghost" size="sm">
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-2xl font-bold">{server?.name || `Server #${sid}`}</h1>
<p className="text-sm text-muted-foreground font-mono">{server?.ip_address}:{server?.port}</p>
</div>
<Badge variant={server?.is_online ? 'default' : 'destructive'} className="ml-auto">
{server?.is_online ? t('common.online') : t('common.offline')}
</Badge>
</div>
{/* Nodes in cluster */}
{nodes.length > 0 && (
<div className="space-y-3">
<h2 className="text-lg font-semibold">{t('nodes.cluster_nodes')}</h2>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{nodes.map(node => (
<Card key={node.node}>
<CardContent className="p-4 space-y-3">
<div className="flex items-center justify-between">
<span className="font-medium">{node.node}</span>
<StatusDot status={node.status} />
</div>
<div className="grid grid-cols-2 gap-2 text-xs text-muted-foreground">
<div className="flex items-center gap-1"><Cpu className="h-3 w-3" />{formatPercent(node.cpu || 0)} CPU</div>
<div className="flex items-center gap-1"><MemoryStick className="h-3 w-3" />{formatBytes(node.mem || 0)} / {formatBytes(node.maxmem || 0)}</div>
<div className="flex items-center gap-1"><HardDrive className="h-3 w-3" />{formatBytes(node.disk || 0)} / {formatBytes(node.maxdisk || 0)}</div>
<div className="flex items-center gap-1"><Clock className="h-3 w-3" />{formatUptime(node.uptime || 0)}</div>
</div>
</CardContent>
</Card>
))}
</div>
</div>
)}
{/* VMs on this server */}
<div className="space-y-3">
<h2 className="text-lg font-semibold">{t('nodes.instances_on_server')} ({serverVMs.length})</h2>
<div className="rounded-lg border divide-y">
{serverVMs.map(vm => (
<Link
key={`${vm.server_id}-${vm.vmid}`}
to={`/instances/${vm.server_id}/${vm.vmid}?node=${vm.node}&type=${vm.type}`}
className="flex items-center gap-3 px-4 py-3 hover:bg-muted/50 transition-colors"
>
<StatusDot status={vm.status} />
<span className="font-medium min-w-0 truncate flex-1">{vm.name || `VM ${vm.vmid}`}</span>
<Badge variant="outline" className="text-xs">{vm.type === 'qemu' ? 'VM' : 'LXC'}</Badge>
<span className="text-xs text-muted-foreground">#{vm.vmid}</span>
<span className="text-xs text-muted-foreground">{vm.node}</span>
</Link>
))}
{serverVMs.length === 0 && (
<p className="px-4 py-6 text-center text-sm text-muted-foreground">{t('common.no_data')}</p>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,74 @@
import { Link } from 'react-router';
import { useTranslation } from 'react-i18next';
import { Server, Monitor, Container } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { useServers } from '@/hooks/use-nodes';
import { useVirtualMachines } from '@/hooks/use-instances';
export default function NodesPage() {
const { t } = useTranslation();
const { data: servers = [], isLoading } = useServers();
const { data: vms = [] } = useVirtualMachines();
if (isLoading) {
return <div className="flex items-center justify-center py-20 text-muted-foreground">{t('common.loading')}</div>;
}
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold">{t('nav.nodes')}</h1>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{servers.map(srv => {
const srvVMs = vms.filter(v => v.server_id === srv.id && v.type === 'qemu');
const srvCTs = vms.filter(v => v.server_id === srv.id && v.type === 'lxc');
return (
<Link key={srv.id} to={`/nodes/${srv.id}`}>
<Card className="transition-colors hover:border-blue-500/50 h-full">
<CardContent className="p-5 space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted">
<Server className="h-5 w-5" />
</div>
<div>
<p className="font-semibold">{srv.name}</p>
<p className="text-xs text-muted-foreground font-mono">{srv.ip_address}</p>
</div>
</div>
<Badge variant={srv.is_online ? 'default' : 'destructive'}>
{srv.is_online ? t('common.online') : t('common.offline')}
</Badge>
</div>
<div className="grid grid-cols-2 gap-3 text-sm">
<div className="flex items-center gap-2 text-muted-foreground">
<Monitor className="h-3.5 w-3.5" />
<span>{srvVMs.length} VM</span>
</div>
<div className="flex items-center gap-2 text-muted-foreground">
<Container className="h-3.5 w-3.5" />
<span>{srvCTs.length} LXC</span>
</div>
</div>
{srv.description && (
<p className="text-xs text-muted-foreground truncate">{srv.description}</p>
)}
</CardContent>
</Card>
</Link>
);
})}
</div>
{servers.length === 0 && (
<div className="text-center py-16 text-muted-foreground">
<Server className="mx-auto h-12 w-12 mb-3 opacity-50" />
<p>{t('nodes.no_servers')}</p>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,221 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { toast } from 'sonner';
import {
useProfile, useUpdateProfile, useChangePassword,
usePanelSettings, useUpdatePanelSettings,
useSecuritySettings, useUpdateSecuritySettings,
useNotificationChannels, useUpdateSMTP, useUpdateTelegram,
useAppVersion, useCheckUpdates,
} from '@/hooks/use-settings';
export default function SettingsPage() {
const { t } = useTranslation();
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold">{t('nav.settings')}</h1>
<Tabs defaultValue="profile">
<TabsList>
<TabsTrigger value="profile">{t('settings.profile')}</TabsTrigger>
<TabsTrigger value="panel">{t('settings.panel')}</TabsTrigger>
<TabsTrigger value="security">{t('settings.security')}</TabsTrigger>
<TabsTrigger value="notifications">{t('settings.notifications')}</TabsTrigger>
<TabsTrigger value="about">{t('settings.about')}</TabsTrigger>
</TabsList>
<TabsContent value="profile"><ProfileTab /></TabsContent>
<TabsContent value="panel"><PanelTab /></TabsContent>
<TabsContent value="security"><SecurityTab /></TabsContent>
<TabsContent value="notifications"><NotificationsTab /></TabsContent>
<TabsContent value="about"><AboutTab /></TabsContent>
</Tabs>
</div>
);
}
function ProfileTab() {
const { t } = useTranslation();
const { data: profile } = useProfile();
const updateProfile = useUpdateProfile();
const changePassword = useChangePassword();
const [displayName, setDisplayName] = useState('');
const [email, setEmail] = useState('');
const [currentPwd, setCurrentPwd] = useState('');
const [newPwd, setNewPwd] = useState('');
const [confirmPwd, setConfirmPwd] = useState('');
// Init from profile
const inited = displayName || email;
if (profile && !inited) {
setDisplayName(profile.full_name || '');
setEmail(profile.email || '');
}
return (
<div className="space-y-4">
<Card>
<CardHeader><CardTitle className="text-sm">{t('settings.profile_info')}</CardTitle></CardHeader>
<CardContent className="space-y-3">
<div><Label>{t('settings.username')}</Label><Input value={profile?.username || ''} disabled className="mt-1" /></div>
<div><Label>{t('settings.display_name')}</Label><Input value={displayName} onChange={e => setDisplayName(e.target.value)} className="mt-1" /></div>
<div><Label>Email</Label><Input value={email} onChange={e => setEmail(e.target.value)} className="mt-1" /></div>
<Button size="sm" onClick={() => updateProfile.mutate({ full_name: displayName, email })}>{t('common.save')}</Button>
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle className="text-sm">{t('settings.change_password')}</CardTitle></CardHeader>
<CardContent className="space-y-3">
<div><Label>{t('settings.current_password')}</Label><Input type="password" value={currentPwd} onChange={e => setCurrentPwd(e.target.value)} className="mt-1" /></div>
<div><Label>{t('settings.new_password')}</Label><Input type="password" value={newPwd} onChange={e => setNewPwd(e.target.value)} className="mt-1" /></div>
<div><Label>{t('settings.confirm_password')}</Label><Input type="password" value={confirmPwd} onChange={e => setConfirmPwd(e.target.value)} className="mt-1" /></div>
<Button size="sm" onClick={() => {
if (newPwd !== confirmPwd) { toast.error(t('settings.password_mismatch')); return; }
changePassword.mutate({ current_password: currentPwd, new_password: newPwd, confirm_password: confirmPwd });
setCurrentPwd(''); setNewPwd(''); setConfirmPwd('');
}}>{t('settings.change_password')}</Button>
</CardContent>
</Card>
</div>
);
}
function PanelTab() {
const { t } = useTranslation();
const { data: settings } = usePanelSettings();
const updatePanel = useUpdatePanelSettings();
const [panelName, setPanelName] = useState('');
const [lang, setLang] = useState('');
if (settings && !panelName) {
setPanelName(settings.panel_name || '');
setLang(settings.language || 'ru');
}
return (
<Card>
<CardHeader><CardTitle className="text-sm">{t('settings.panel_settings')}</CardTitle></CardHeader>
<CardContent className="space-y-3">
<div><Label>{t('settings.panel_name')}</Label><Input value={panelName} onChange={e => setPanelName(e.target.value)} className="mt-1" /></div>
<div>
<Label>{t('settings.language')}</Label>
<select value={lang} onChange={e => setLang(e.target.value)} className="mt-1 block w-full rounded-md border bg-background px-3 py-2 text-sm">
<option value="ru">Русский</option>
<option value="en">English</option>
</select>
</div>
<Button size="sm" onClick={() => updatePanel.mutate({ panel_name: panelName, language: lang })}>{t('common.save')}</Button>
</CardContent>
</Card>
);
}
function SecurityTab() {
const { t } = useTranslation();
const { data: security } = useSecuritySettings();
const updateSecurity = useUpdateSecuritySettings();
const [maxAttempts, setMaxAttempts] = useState('');
const [lockoutDuration, setLockoutDuration] = useState('');
const [sessionTimeout, setSessionTimeout] = useState('');
if (security && !maxAttempts) {
setMaxAttempts(String(security.max_login_attempts ?? 5));
setLockoutDuration(String(security.lockout_duration ?? 300));
setSessionTimeout(String(security.session_timeout ?? 3600));
}
return (
<Card>
<CardHeader><CardTitle className="text-sm">{t('settings.security_settings')}</CardTitle></CardHeader>
<CardContent className="space-y-3">
<div><Label>{t('settings.max_attempts')}</Label><Input type="number" value={maxAttempts} onChange={e => setMaxAttempts(e.target.value)} className="mt-1" /></div>
<div><Label>{t('settings.lockout_duration')}</Label><Input type="number" value={lockoutDuration} onChange={e => setLockoutDuration(e.target.value)} className="mt-1" /><p className="text-xs text-muted-foreground mt-1">{t('settings.seconds')}</p></div>
<div><Label>{t('settings.session_timeout')}</Label><Input type="number" value={sessionTimeout} onChange={e => setSessionTimeout(e.target.value)} className="mt-1" /><p className="text-xs text-muted-foreground mt-1">{t('settings.seconds')}</p></div>
<Button size="sm" onClick={() => updateSecurity.mutate({
max_login_attempts: Number(maxAttempts),
lockout_duration: Number(lockoutDuration),
session_timeout: Number(sessionTimeout),
})}>{t('common.save')}</Button>
</CardContent>
</Card>
);
}
function NotificationsTab() {
const { t } = useTranslation();
const { data: channels } = useNotificationChannels();
const updateSmtp = useUpdateSMTP();
const updateTg = useUpdateTelegram();
const [smtpHost, setSmtpHost] = useState('');
const [smtpPort, setSmtpPort] = useState('');
const [smtpUser, setSmtpUser] = useState('');
const [smtpPass, setSmtpPass] = useState('');
const [smtpFrom, setSmtpFrom] = useState('');
const [tgToken, setTgToken] = useState('');
const [tgChatId, setTgChatId] = useState('');
if (channels?.smtp && !smtpHost) {
setSmtpHost(String(channels.smtp.host ?? ''));
setSmtpPort(String(channels.smtp.port ?? 587));
setSmtpUser(String(channels.smtp.username ?? ''));
setSmtpFrom(String(channels.smtp.from_email ?? ''));
}
if (channels?.telegram && !tgToken) {
setTgToken(String(channels.telegram.telegram_bot_token ?? channels.telegram.bot_token ?? ''));
setTgChatId(String(channels.telegram.chat_id ?? ''));
}
return (
<div className="space-y-4">
<Card>
<CardHeader><CardTitle className="text-sm">SMTP</CardTitle></CardHeader>
<CardContent className="space-y-3">
<div className="grid sm:grid-cols-2 gap-3">
<div><Label>Host</Label><Input value={smtpHost} onChange={e => setSmtpHost(e.target.value)} className="mt-1" /></div>
<div><Label>Port</Label><Input type="number" value={smtpPort} onChange={e => setSmtpPort(e.target.value)} className="mt-1" /></div>
<div><Label>Username</Label><Input value={smtpUser} onChange={e => setSmtpUser(e.target.value)} className="mt-1" /></div>
<div><Label>Password</Label><Input type="password" value={smtpPass} onChange={e => setSmtpPass(e.target.value)} className="mt-1" /></div>
<div><Label>From Email</Label><Input value={smtpFrom} onChange={e => setSmtpFrom(e.target.value)} className="mt-1" /></div>
</div>
<Button size="sm" onClick={() => updateSmtp.mutate({ host: smtpHost, port: Number(smtpPort), username: smtpUser, password: smtpPass || undefined, from_email: smtpFrom })}>{t('common.save')}</Button>
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle className="text-sm">Telegram</CardTitle></CardHeader>
<CardContent className="space-y-3">
<div><Label>Bot Token</Label><Input value={tgToken} onChange={e => setTgToken(e.target.value)} className="mt-1" /></div>
<div><Label>Chat ID</Label><Input value={tgChatId} onChange={e => setTgChatId(e.target.value)} className="mt-1" /></div>
<Button size="sm" onClick={() => updateTg.mutate({ telegram_bot_token: tgToken })}>{t('common.save')}</Button>
</CardContent>
</Card>
</div>
);
}
function AboutTab() {
const { t } = useTranslation();
const { data: version } = useAppVersion();
const checkUpdates = useCheckUpdates();
return (
<Card>
<CardHeader><CardTitle className="text-sm">{t('settings.about')}</CardTitle></CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">{t('settings.version')}:</span>
<Badge variant="outline">{version?.version || '—'}</Badge>
</div>
<Button size="sm" variant="outline" onClick={() => checkUpdates.mutate()}>
{t('settings.check_updates')}
</Button>
{checkUpdates.data && (
<p className="text-sm">{checkUpdates.data.has_update ? `${t('settings.update_available')}: ${checkUpdates.data.latest_version}` : t('settings.up_to_date')}</p>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,120 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Trash2, X, ClipboardList, RefreshCw } from 'lucide-react';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useAllTasks, useCancelTask, useClearCompletedTasks } from '@/hooks/use-tasks';
export default function TasksPage() {
const { t } = useTranslation();
const [statusFilter, setStatusFilter] = useState('all');
const { data: tasksData, refetch } = useAllTasks();
const tasks = tasksData?.tasks ?? [];
const cancelTask = useCancelTask();
const clearCompleted = useClearCompletedTasks();
const filtered = tasks.filter(task => {
if (statusFilter === 'all') return true;
return task.status === statusFilter;
});
const statusColor = (status: string) => {
switch (status) {
case 'running': case 'in_progress': return 'default';
case 'completed': case 'success': return 'secondary';
case 'failed': case 'error': return 'destructive';
case 'queued': case 'pending': return 'outline';
default: return 'outline';
}
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<ClipboardList className="h-6 w-6" />
<h1 className="text-2xl font-bold">{t('nav.tasks')}</h1>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => refetch()}>
<RefreshCw className="h-4 w-4 mr-1" />{t('common.refresh')}
</Button>
<Button variant="outline" size="sm" onClick={() => clearCompleted.mutate()}>
<Trash2 className="h-4 w-4 mr-1" />{t('tasks.clear_completed')}
</Button>
</div>
</div>
<Card>
<CardHeader>
<div className="flex items-center gap-3">
<Select value={statusFilter} onValueChange={v => { if (v !== null) setStatusFilter(v); }}>
<SelectTrigger className="w-[160px]"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('common.all')}</SelectItem>
<SelectItem value="running">{t('tasks.running')}</SelectItem>
<SelectItem value="queued">{t('tasks.queued')}</SelectItem>
<SelectItem value="completed">{t('tasks.completed')}</SelectItem>
<SelectItem value="failed">{t('tasks.failed')}</SelectItem>
</SelectContent>
</Select>
<span className="text-sm text-muted-foreground">{filtered.length} {t('tasks.tasks_count')}</span>
</div>
</CardHeader>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('common.status')}</TableHead>
<TableHead>{t('common.type')}</TableHead>
<TableHead>{t('tasks.description')}</TableHead>
<TableHead>{t('tasks.server')}</TableHead>
<TableHead>{t('tasks.node')}</TableHead>
<TableHead>{t('common.user')}</TableHead>
<TableHead>{t('tasks.started')}</TableHead>
<TableHead>{t('tasks.progress')}</TableHead>
<TableHead />
</TableRow>
</TableHeader>
<TableBody>
{filtered.map((task, i) => (
<TableRow key={task.id ?? task.upid ?? i}>
<TableCell><Badge variant={statusColor(task.status) as any}>{task.status}</Badge></TableCell>
<TableCell className="text-xs font-mono">{task.type || String(task.task_type ?? '') || '—'}</TableCell>
<TableCell className="text-xs max-w-[250px] truncate">{String(task.description ?? '') || task.upid || '—'}</TableCell>
<TableCell className="text-xs">{task.server_name || '—'}</TableCell>
<TableCell className="text-xs">{task.node || '—'}</TableCell>
<TableCell className="text-xs">{task.user || '—'}</TableCell>
<TableCell className="text-xs whitespace-nowrap">{task.starttime ? new Date(task.starttime * 1000).toLocaleString() : task.created_at ? new Date(task.created_at).toLocaleString() : '—'}</TableCell>
<TableCell className="text-xs">
{task.progress != null ? (
<div className="flex items-center gap-2">
<div className="h-1.5 w-16 rounded-full bg-muted overflow-hidden">
<div className="h-full bg-blue-500 rounded-full" style={{ width: `${task.progress}%` }} />
</div>
<span>{Number(task.progress ?? 0)}%</span>
</div>
) : '—'}
</TableCell>
<TableCell>
{(task.status === 'running' || task.status === 'queued' || task.status === 'in_progress') && (
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => cancelTask.mutate(Number(task.id ?? 0))}>
<X className="h-3.5 w-3.5" />
</Button>
)}
</TableCell>
</TableRow>
))}
{filtered.length === 0 && (
<TableRow><TableCell colSpan={9} className="text-center text-muted-foreground py-8">{t('common.no_data')}</TableCell></TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,148 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Monitor, Search, Download, Trash2, Loader2 } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useTemplates, useTemplateGroups, useAutoImportTemplates, useDeleteTemplate } from '@/hooks/use-templates';
import { useServers } from '@/hooks/use-nodes';
import { toast } from 'sonner';
export default function TemplatesPage() {
const { t } = useTranslation();
const [search, setSearch] = useState('');
const [groupFilter, setGroupFilter] = useState<string>('');
const [serverFilter, setServerFilter] = useState<string>('');
const [importDialogOpen, setImportDialogOpen] = useState(false);
const [importServerId, setImportServerId] = useState<string>('');
const { data: groups = [] } = useTemplateGroups();
const { data: templates = [], isLoading } = useTemplates(
groupFilter ? Number(groupFilter) : undefined,
serverFilter ? Number(serverFilter) : undefined,
);
const { data: servers = [] } = useServers();
const autoImport = useAutoImportTemplates();
const deleteTpl = useDeleteTemplate();
const filtered = templates.filter(tpl =>
tpl.name.toLowerCase().includes(search.toLowerCase())
);
const handleImport = () => {
if (!importServerId) return;
autoImport.mutate(Number(importServerId), {
onSuccess: (res) => {
toast.success(`${t('templates.imported')}: ${res.count}`);
setImportDialogOpen(false);
},
onError: (err: Error) => toast.error(err.message),
});
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">{t('nav.templates')}</h1>
<Button onClick={() => setImportDialogOpen(true)}>
<Download className="mr-2 h-4 w-4" />{t('templates.auto_import')}
</Button>
</div>
{/* Filters */}
<div className="flex flex-wrap gap-3">
<div className="relative flex-1 min-w-[200px]">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input className="pl-9" placeholder={t('common.search')} value={search} onChange={e => setSearch(e.target.value)} />
</div>
<Select value={groupFilter} onValueChange={v => { if (v !== null) setGroupFilter(v); }}>
<SelectTrigger className="w-[180px]"><SelectValue placeholder={t('templates.all_groups')} /></SelectTrigger>
<SelectContent>
<SelectItem value="">{t('templates.all_groups')}</SelectItem>
{groups.map(g => <SelectItem key={g.id} value={String(g.id)}>{g.name}</SelectItem>)}
</SelectContent>
</Select>
<Select value={serverFilter} onValueChange={v => { if (v !== null) setServerFilter(v); }}>
<SelectTrigger className="w-[180px]"><SelectValue placeholder={t('templates.all_servers')} /></SelectTrigger>
<SelectContent>
<SelectItem value="">{t('templates.all_servers')}</SelectItem>
{servers.map(s => <SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
{/* Templates grid */}
{isLoading ? (
<div className="text-center py-16 text-muted-foreground">{t('common.loading')}</div>
) : filtered.length === 0 ? (
<div className="text-center py-16 text-muted-foreground">
<Monitor className="mx-auto h-12 w-12 mb-3 opacity-50" />
<p>{t('templates.no_templates')}</p>
</div>
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{filtered.map(tpl => (
<Card key={tpl.id} className="group">
<CardContent className="p-4 space-y-3">
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
{tpl.group_icon ? (
<span className="text-xl" dangerouslySetInnerHTML={{ __html: tpl.group_icon }} />
) : (
<Monitor className="h-5 w-5 text-muted-foreground" />
)}
<div className="min-w-0">
<p className="font-medium text-sm truncate">{tpl.name}</p>
<p className="text-xs text-muted-foreground">{tpl.group_name}</p>
</div>
</div>
<Button
variant="ghost"
size="sm"
className="opacity-0 group-hover:opacity-100 transition-opacity h-7 w-7 p-0 text-destructive"
onClick={() => { if (confirm(t('common.confirm_delete'))) deleteTpl.mutate(tpl.id); }}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
<div className="flex items-center gap-2 flex-wrap">
<Badge variant="outline" className="text-xs">{tpl.default_cores} vCPU</Badge>
<Badge variant="outline" className="text-xs">{tpl.default_memory} MB</Badge>
<Badge variant="outline" className="text-xs">{tpl.default_disk} GB</Badge>
</div>
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>{tpl.server_name}</span>
<span>VMID: {tpl.vmid}</span>
</div>
</CardContent>
</Card>
))}
</div>
)}
{/* Import Dialog */}
<Dialog open={importDialogOpen} onOpenChange={setImportDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('templates.auto_import')}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<Select value={importServerId} onValueChange={v => { if (v !== null) setImportServerId(v); }}>
<SelectTrigger><SelectValue placeholder={t('templates.select_server')} /></SelectTrigger>
<SelectContent>
{servers.map(s => <SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>)}
</SelectContent>
</Select>
<Button onClick={handleImport} disabled={!importServerId || autoImport.isPending} className="w-full">
{autoImport.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('templates.import_btn')}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,264 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Search, Plus, Shield, Trash2, RotateCcw, Unlock } from 'lucide-react';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogClose, DialogFooter } from '@/components/ui/dialog';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import {
useUsers, useCreateUser, useDeleteUser,
useResetPassword, useUnlockUser,
useRoles, useCreateRole, useDeleteRole,
useSessions, useTerminateSession,
} from '@/hooks/use-users';
export default function UsersPage() {
const { t } = useTranslation();
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold">{t('nav.users')}</h1>
<Tabs defaultValue="users">
<TabsList>
<TabsTrigger value="users">{t('users.users')}</TabsTrigger>
<TabsTrigger value="roles">{t('users.roles')}</TabsTrigger>
<TabsTrigger value="sessions">{t('users.sessions')}</TabsTrigger>
</TabsList>
<TabsContent value="users"><UsersTab /></TabsContent>
<TabsContent value="roles"><RolesTab /></TabsContent>
<TabsContent value="sessions"><SessionsTab /></TabsContent>
</Tabs>
</div>
);
}
function UsersTab() {
const { t } = useTranslation();
const [search, setSearch] = useState('');
const [open, setOpen] = useState(false);
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [displayName, setDisplayName] = useState('');
const [email, setEmail] = useState('');
const [role, setRole] = useState('user');
const { data: users = [] } = useUsers();
const { data: roles = [] } = useRoles();
const createUser = useCreateUser();
const deleteUser = useDeleteUser();
const resetPassword = useResetPassword();
const unlockUser = useUnlockUser();
const filtered = users.filter(u => {
if (!search) return true;
const s = search.toLowerCase();
return u.username?.toLowerCase().includes(s) || u.full_name?.toLowerCase().includes(s) || u.email?.toLowerCase().includes(s);
});
const handleCreate = () => {
createUser.mutate({ username, password, full_name: displayName, email }, {
onSuccess: () => { setOpen(false); setUsername(''); setPassword(''); setDisplayName(''); setEmail(''); setRole('user'); },
});
};
return (
<Card>
<CardHeader>
<div className="flex flex-wrap gap-3 items-center">
<div className="relative flex-1 min-w-[200px]">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input placeholder={t('common.search')} value={search} onChange={e => setSearch(e.target.value)} className="pl-8" />
</div>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={<Button size="sm" />}><Plus className="h-4 w-4 mr-1" />{t('users.create')}</DialogTrigger>
<DialogContent>
<DialogHeader><DialogTitle>{t('users.create')}</DialogTitle></DialogHeader>
<div className="space-y-3">
<div><Label>{t('settings.username')}</Label><Input value={username} onChange={e => setUsername(e.target.value)} className="mt-1" /></div>
<div><Label>{t('settings.new_password')}</Label><Input type="password" value={password} onChange={e => setPassword(e.target.value)} className="mt-1" /></div>
<div><Label>{t('settings.display_name')}</Label><Input value={displayName} onChange={e => setDisplayName(e.target.value)} className="mt-1" /></div>
<div><Label>Email</Label><Input value={email} onChange={e => setEmail(e.target.value)} className="mt-1" /></div>
<div>
<Label>{t('users.role')}</Label>
<Select value={role} onValueChange={v => { if (v !== null) setRole(v); }}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectContent>
{roles.map(r => <SelectItem key={r.id ?? r.name} value={r.name}>{r.name}</SelectItem>)}
{roles.length === 0 && <>
<SelectItem value="admin">admin</SelectItem>
<SelectItem value="user">user</SelectItem>
</>}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<DialogClose render={<Button variant="outline" />}>{t('common.cancel')}</DialogClose>
<Button onClick={handleCreate}>{t('common.create')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</CardHeader>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('settings.username')}</TableHead>
<TableHead>{t('settings.display_name')}</TableHead>
<TableHead>Email</TableHead>
<TableHead>{t('users.role')}</TableHead>
<TableHead>{t('common.status')}</TableHead>
<TableHead>{t('users.last_login')}</TableHead>
<TableHead />
</TableRow>
</TableHeader>
<TableBody>
{filtered.map(u => (
<TableRow key={u.id}>
<TableCell className="font-medium">{u.username}</TableCell>
<TableCell>{u.full_name || '—'}</TableCell>
<TableCell className="text-xs">{u.email || '—'}</TableCell>
<TableCell><Badge variant="outline">{u.role_name || '—'}</Badge></TableCell>
<TableCell>
<Badge variant={u.is_locked ? 'destructive' : u.is_active !== false ? 'default' : 'secondary'}>
{u.is_locked ? t('users.locked') : u.is_active !== false ? t('users.active') : t('users.inactive')}
</Badge>
</TableCell>
<TableCell className="text-xs">{u.last_login ? new Date(u.last_login).toLocaleString() : '—'}</TableCell>
<TableCell>
<div className="flex gap-1">
<Button variant="ghost" size="icon" className="h-7 w-7" title={t('users.reset_password')} onClick={() => resetPassword.mutate(u.id)}>
<RotateCcw className="h-3.5 w-3.5" />
</Button>
{u.is_locked && (
<Button variant="ghost" size="icon" className="h-7 w-7" title={t('users.unlock')} onClick={() => unlockUser.mutate(u.id)}>
<Unlock className="h-3.5 w-3.5" />
</Button>
)}
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => deleteUser.mutate(u.id)}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
))}
{filtered.length === 0 && (
<TableRow><TableCell colSpan={7} className="text-center text-muted-foreground py-8">{t('common.no_data')}</TableCell></TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
);
}
function RolesTab() {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [roleName, setRoleName] = useState('');
const [description, setDescription] = useState('');
const { data: roles = [] } = useRoles();
const createRole = useCreateRole();
const deleteRole = useDeleteRole();
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-sm">{t('users.roles')}</CardTitle>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={<Button size="sm" />}><Plus className="h-4 w-4 mr-1" />{t('users.create_role')}</DialogTrigger>
<DialogContent>
<DialogHeader><DialogTitle>{t('users.create_role')}</DialogTitle></DialogHeader>
<div className="space-y-3">
<div><Label>{t('common.name')}</Label><Input value={roleName} onChange={e => setRoleName(e.target.value)} className="mt-1" /></div>
<div><Label>{t('ipam.description')}</Label><Input value={description} onChange={e => setDescription(e.target.value)} className="mt-1" /></div>
</div>
<DialogFooter>
<DialogClose render={<Button variant="outline" />}>{t('common.cancel')}</DialogClose>
<Button onClick={() => { createRole.mutate({ name: roleName, display_name: roleName, description, permissions: [] }); setOpen(false); setRoleName(''); setDescription(''); }}>{t('common.create')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</CardHeader>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('common.name')}</TableHead>
<TableHead>{t('ipam.description')}</TableHead>
<TableHead>{t('users.permissions_count')}</TableHead>
<TableHead />
</TableRow>
</TableHeader>
<TableBody>
{roles.map(r => (
<TableRow key={r.id ?? r.name}>
<TableCell className="font-medium"><div className="flex items-center gap-2"><Shield className="h-4 w-4" />{r.name}</div></TableCell>
<TableCell className="text-xs text-muted-foreground">{r.description || '—'}</TableCell>
<TableCell>{r.permissions?.length ?? '—'}</TableCell>
<TableCell>
{!r.is_system && (
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => deleteRole.mutate(r.id)}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
);
}
function SessionsTab() {
const { t } = useTranslation();
const { data: sessions = [] } = useSessions();
const terminateSession = useTerminateSession();
return (
<Card>
<CardHeader><CardTitle className="text-sm">{t('users.active_sessions')}</CardTitle></CardHeader>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('settings.username')}</TableHead>
<TableHead>IP</TableHead>
<TableHead>{t('users.user_agent')}</TableHead>
<TableHead>{t('users.created')}</TableHead>
<TableHead>{t('users.expires')}</TableHead>
<TableHead />
</TableRow>
</TableHeader>
<TableBody>
{sessions.map(s => (
<TableRow key={s.id ?? s.session_id}>
<TableCell className="font-medium">{s.username}</TableCell>
<TableCell className="font-mono text-xs">{s.ip_address || '—'}</TableCell>
<TableCell className="text-xs max-w-[200px] truncate">{s.user_agent || '—'}</TableCell>
<TableCell className="text-xs">{s.created_at ? new Date(s.created_at).toLocaleString() : '—'}</TableCell>
<TableCell className="text-xs">{s.expires_at ? new Date(s.expires_at).toLocaleString() : '—'}</TableCell>
<TableCell>
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => terminateSession.mutate(s.id ?? s.session_id)}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</TableCell>
</TableRow>
))}
{sessions.length === 0 && (
<TableRow><TableCell colSpan={6} className="text-center text-muted-foreground py-8">{t('common.no_data')}</TableCell></TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,122 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Plus, Search, Trash2, Users, Server, Pencil } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogClose, DialogFooter } from '@/components/ui/dialog';
import { useWorkspaces, useCreateWorkspace, useUpdateWorkspace, useDeleteWorkspace } from '@/hooks/use-workspaces';
export default function WorkspacesPage() {
const { t } = useTranslation();
const [search, setSearch] = useState('');
const [open, setOpen] = useState(false);
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const { data: workspaces = [] } = useWorkspaces();
const createWorkspace = useCreateWorkspace();
const updateWorkspace = useUpdateWorkspace();
const deleteWorkspace = useDeleteWorkspace();
const [editOpen, setEditOpen] = useState(false);
const [editId, setEditId] = useState<number | null>(null);
const [editName, setEditName] = useState('');
const [editDescription, setEditDescription] = useState('');
const filtered = workspaces.filter(w => {
if (!search) return true;
const s = search.toLowerCase();
return w.name?.toLowerCase().includes(s) || w.description?.toLowerCase().includes(s);
});
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">{t('nav.workspaces')}</h1>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={<Button size="sm" />}><Plus className="h-4 w-4 mr-1" />{t('workspaces.create')}</DialogTrigger>
<DialogContent>
<DialogHeader><DialogTitle>{t('workspaces.create')}</DialogTitle></DialogHeader>
<div className="space-y-3">
<div><Label>{t('common.name')}</Label><Input value={name} onChange={e => setName(e.target.value)} className="mt-1" /></div>
<div><Label>{t('ipam.description')}</Label><Input value={description} onChange={e => setDescription(e.target.value)} className="mt-1" /></div>
</div>
<DialogFooter>
<DialogClose render={<Button variant="outline" />}>{t('common.cancel')}</DialogClose>
<Button onClick={() => { createWorkspace.mutate({ name, description }); setOpen(false); setName(''); setDescription(''); }}>{t('common.create')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
<div className="relative max-w-sm">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input placeholder={t('common.search')} value={search} onChange={e => setSearch(e.target.value)} className="pl-8" />
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{filtered.map(ws => (
<Card key={ws.id}>
<CardHeader className="pb-2">
<div className="flex items-start justify-between">
<CardTitle className="text-base">{ws.name}</CardTitle>
<div className="flex gap-1">
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => {
setEditId(ws.id);
setEditName(ws.name);
setEditDescription(ws.description || '');
setEditOpen(true);
}}>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => deleteWorkspace.mutate(ws.id)}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
{ws.description && <p className="text-xs text-muted-foreground">{ws.description}</p>}
</CardHeader>
<CardContent className="space-y-2">
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<div className="flex items-center gap-1"><Server className="h-3.5 w-3.5" />{ws.servers?.length ?? 0} {t('workspaces.servers')}</div>
<div className="flex items-center gap-1"><Users className="h-3.5 w-3.5" />{ws.users?.length ?? 0} {t('workspaces.users')}</div>
</div>
{ws.servers && ws.servers.length > 0 && (
<div className="flex flex-wrap gap-1">
{ws.servers.map((s: any) => (
<Badge key={s.id ?? s} variant="outline" className="text-xs">{s.name ?? s}</Badge>
))}
</div>
)}
<p className="text-xs text-muted-foreground">{t('workspaces.created')}: {ws.created_at ? new Date(ws.created_at).toLocaleDateString() : '—'}</p>
</CardContent>
</Card>
))}
{filtered.length === 0 && (
<div className="col-span-full text-center text-muted-foreground py-12">{t('common.no_data')}</div>
)}
</div>
{/* Edit Workspace Dialog */}
<Dialog open={editOpen} onOpenChange={setEditOpen}>
<DialogContent>
<DialogHeader><DialogTitle>{t('workspaces.edit')}</DialogTitle></DialogHeader>
<div className="space-y-3">
<div><Label>{t('common.name')}</Label><Input value={editName} onChange={e => setEditName(e.target.value)} className="mt-1" /></div>
<div><Label>{t('ipam.description')}</Label><Input value={editDescription} onChange={e => setEditDescription(e.target.value)} className="mt-1" /></div>
</div>
<DialogFooter>
<DialogClose render={<Button variant="outline" />}>{t('common.cancel')}</DialogClose>
<Button onClick={() => {
if (editId) {
updateWorkspace.mutate({ id: editId, name: editName, description: editDescription });
}
setEditOpen(false);
}}>{t('common.save')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,71 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/lib/api-client';
export const backupKeys = {
storages: (serverId: number) => ['backup-storages', serverId] as const,
list: (serverId: number, node: string, storage: string) => ['backups', serverId, node, storage] as const,
jobs: ['backup-jobs'] as const,
proxmoxJobs: (serverId: number) => ['proxmox-backup-jobs', serverId] as const,
};
export function useBackupStorages(serverId: number) {
return useQuery({
queryKey: backupKeys.storages(serverId),
queryFn: () => apiClient.get<{ storages: unknown[] }>(`/proxmox/api/backups/storages/${serverId}`),
enabled: serverId > 0,
});
}
export function useBackupList(serverId: number, node: string, storage: string) {
return useQuery({
queryKey: backupKeys.list(serverId, node, storage),
queryFn: () => apiClient.get<{ backups: unknown[] }>(`/proxmox/api/backups/list/${serverId}?node=${node}&storage=${storage}`),
enabled: serverId > 0 && !!node && !!storage,
});
}
export function useBackupJobs() {
return useQuery({
queryKey: backupKeys.jobs,
queryFn: () => apiClient.get<{ jobs: unknown[] }>('/proxmox/api/backups/jobs'),
});
}
export function useCreateBackup() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: { server_id: number; node: string; vmid: number; storage: string; mode?: string; compress?: string; notes?: string }) =>
apiClient.post<{ success: boolean; upid: string }>('/proxmox/api/backups/create', data),
onSuccess: () => qc.invalidateQueries({ queryKey: ['backups'] }),
});
}
export function useDeleteBackup() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: { server_id: number; node: string; storage: string; volid: string }) =>
apiClient.delete(`/proxmox/api/backups/${data.server_id}/backup`),
onSuccess: () => qc.invalidateQueries({ queryKey: ['backups'] }),
});
}
export function useRestoreBackup() {
return useMutation({
mutationFn: (data: { server_id: number; node: string; vmid: number; archive: string; storage: string; vm_type: string; new_vmid?: number; start?: boolean }) =>
apiClient.post<{ success: boolean; upid: string }>('/proxmox/api/backups/restore', data),
});
}
export function useToggleBackupJob() {
const qc = useQueryClient();
return useMutation({
mutationFn: (jobId: number) => apiClient.patch<{ success: boolean }>(`/proxmox/api/backups/jobs/${jobId}/toggle`),
onSuccess: () => qc.invalidateQueries({ queryKey: backupKeys.jobs }),
});
}
export function useRunBackupJob() {
return useMutation({
mutationFn: (jobId: number) => apiClient.post<{ success: boolean }>(`/proxmox/api/backups/jobs/${jobId}/run-now`),
});
}

View File

@@ -0,0 +1,229 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/lib/api-client';
import type { VMInstance, Snapshot, VMConfig } from '@/types';
// ==================== Query Keys ====================
export const vmKeys = {
all: ['virtual-machines'] as const,
resources: (serverId?: number) => ['resources', serverId] as const,
resourcesAll: ['resources', 'all'] as const,
detail: (serverId: number, vmid: number) => ['vm', serverId, vmid] as const,
config: (serverId: number, vmid: number) => ['vm', serverId, vmid, 'config'] as const,
status: (serverId: number, vmid: number) => ['vm', serverId, vmid, 'status'] as const,
rrddata: (serverId: number, vmid: number, tf: string) => ['vm', serverId, vmid, 'rrddata', tf] as const,
snapshots: (serverId: number, vmid: number) => ['vm', serverId, vmid, 'snapshots'] as const,
interfaces: (serverId: number, vmid: number) => ['vm', serverId, vmid, 'interfaces'] as const,
};
// ==================== VM List ====================
export function useVirtualMachines() {
return useQuery({
queryKey: vmKeys.all,
queryFn: () => apiClient.get<VMInstance[]>('/proxmox/api/virtual-machines'),
refetchInterval: 30000,
});
}
export function useAllResources() {
return useQuery({
queryKey: vmKeys.resourcesAll,
queryFn: () => apiClient.get<Record<string, unknown>>('/proxmox/api/resources/all'),
refetchInterval: 30000,
});
}
// ==================== VM Detail ====================
interface VMStatusResponse {
status: string;
cpu: number;
mem: number;
maxmem: number;
disk: number;
maxdisk: number;
uptime: number;
netin: number;
netout: number;
name?: string;
pid?: number;
[key: string]: unknown;
}
export function useVMStatus(serverId: number, vmid: number, type: string, node: string, enabled = true) {
const prefix = type === 'lxc' ? 'container' : 'vm';
return useQuery<VMStatusResponse>({
queryKey: vmKeys.status(serverId, vmid),
queryFn: () => apiClient.get(`/proxmox/api/${serverId}/${prefix}/${vmid}/status?node=${node}`),
refetchInterval: 10000,
enabled,
});
}
export function useVMConfig(serverId: number, vmid: number, type: string, node: string, enabled = true) {
const prefix = type === 'lxc' ? 'container' : 'vm';
return useQuery<VMConfig>({
queryKey: vmKeys.config(serverId, vmid),
queryFn: () => apiClient.get(`/proxmox/api/${serverId}/${prefix}/${vmid}/config?node=${node}`),
enabled,
});
}
export function useVMRrddata(serverId: number, vmid: number, type: string, node: string, timeframe = 'hour') {
const prefix = type === 'lxc' ? 'container' : 'vm';
return useQuery({
queryKey: vmKeys.rrddata(serverId, vmid, timeframe),
queryFn: async () => {
const res = await apiClient.get<{ data: unknown[]; timeframe: string }>(`/proxmox/api/${serverId}/${prefix}/${vmid}/rrddata?node=${node}&timeframe=${timeframe}`);
return res.data ?? [];
},
});
}
interface InterfaceInfo {
name: string;
mac?: string;
ips?: Array<{ address: string; prefix: number; type: string }>;
}
export function useVMInterfaces(serverId: number, vmid: number, type: string, node: string, enabled = true) {
const prefix = type === 'lxc' ? 'container' : 'vm';
return useQuery<{ interfaces: InterfaceInfo[] }>({
queryKey: vmKeys.interfaces(serverId, vmid),
queryFn: () => apiClient.get(`/proxmox/api/${serverId}/${prefix}/${vmid}/interfaces?node=${node}`),
enabled,
retry: 1,
});
}
// ==================== Snapshots ====================
export function useSnapshots(serverId: number, vmid: number, type: string, node: string, enabled = true) {
const prefix = type === 'lxc' ? 'container' : 'vm';
return useQuery<{ snapshots: Snapshot[] }>({
queryKey: vmKeys.snapshots(serverId, vmid),
queryFn: () => apiClient.get(`/proxmox/api/${serverId}/${prefix}/${vmid}/snapshots?node=${node}`),
enabled,
});
}
export function useCreateSnapshot(serverId: number, vmid: number, type: string) {
const qc = useQueryClient();
const prefix = type === 'lxc' ? 'container' : 'vm';
return useMutation({
mutationFn: (body: { snapname: string; description?: string; vmstate?: boolean }) =>
apiClient.post(`/proxmox/api/${serverId}/${prefix}/${vmid}/snapshots`, body),
onSuccess: () => {
qc.invalidateQueries({ queryKey: vmKeys.snapshots(serverId, vmid) });
},
});
}
export function useDeleteSnapshot(serverId: number, vmid: number, type: string, node: string) {
const qc = useQueryClient();
const prefix = type === 'lxc' ? 'container' : 'vm';
return useMutation({
mutationFn: (snapname: string) =>
apiClient.delete(`/proxmox/api/${serverId}/${prefix}/${vmid}/snapshots/${snapname}?node=${node}`),
onSuccess: () => {
qc.invalidateQueries({ queryKey: vmKeys.snapshots(serverId, vmid) });
},
});
}
export function useRollbackSnapshot(serverId: number, vmid: number, type: string, node: string) {
const qc = useQueryClient();
const prefix = type === 'lxc' ? 'container' : 'vm';
return useMutation({
mutationFn: (snapname: string) =>
apiClient.post(`/proxmox/api/${serverId}/${prefix}/${vmid}/snapshots/${snapname}/rollback?node=${node}`),
onSuccess: () => {
qc.invalidateQueries({ queryKey: vmKeys.snapshots(serverId, vmid) });
qc.invalidateQueries({ queryKey: vmKeys.status(serverId, vmid) });
},
});
}
// ==================== Power Actions ====================
interface PowerActionResult {
status: string;
action: string;
vmid: number;
node: string;
upid?: string;
}
export function usePowerAction(serverId: number, vmid: number, type: string, node: string) {
const qc = useQueryClient();
const prefix = type === 'lxc' ? 'container' : 'vm';
return useMutation<PowerActionResult, Error, { action: string; force?: boolean }>({
mutationFn: ({ action, force }) => {
const params = new URLSearchParams({ node });
if (force) params.set('force', '1');
return apiClient.post(`/proxmox/api/${serverId}/${prefix}/${vmid}/${action}?${params}`);
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: vmKeys.status(serverId, vmid) });
qc.invalidateQueries({ queryKey: vmKeys.all });
qc.invalidateQueries({ queryKey: vmKeys.resourcesAll });
},
});
}
// ==================== Delete VM ====================
export function useDeleteVM(serverId: number, vmid: number, type: string, node: string) {
const qc = useQueryClient();
const prefix = type === 'lxc' ? 'container' : 'vm';
return useMutation({
mutationFn: () => apiClient.delete(`/proxmox/api/${serverId}/${prefix}/${vmid}?node=${node}`),
onSuccess: () => {
qc.invalidateQueries({ queryKey: vmKeys.all });
qc.invalidateQueries({ queryKey: vmKeys.resourcesAll });
},
});
}
// ==================== Bulk Operations ====================
interface BulkItem {
server_id: number;
vmid: number;
vm_type: string;
name: string;
node: string;
}
export function useBulkOperation() {
const qc = useQueryClient();
return useMutation({
mutationFn: (body: { action: string; items: BulkItem[] }) =>
apiClient.post('/proxmox/api/bulk-operation', body),
onSuccess: () => {
qc.invalidateQueries({ queryKey: vmKeys.all });
qc.invalidateQueries({ queryKey: vmKeys.resourcesAll });
},
});
}
// ==================== Disk Resize ====================
export function useResizeDisk(serverId: number, vmid: number, type: string) {
const qc = useQueryClient();
const prefix = type === 'lxc' ? 'container' : 'vm';
return useMutation({
mutationFn: (body: { disk: string; size: string }) =>
apiClient.post(`/proxmox/api/${serverId}/${prefix}/${vmid}/disk/resize`, body),
onSuccess: () => {
qc.invalidateQueries({ queryKey: vmKeys.config(serverId, vmid) });
qc.invalidateQueries({ queryKey: vmKeys.status(serverId, vmid) });
},
});
}
// ==================== Config Update ====================
export function useUpdateConfig(serverId: number, vmid: number, type: string) {
const qc = useQueryClient();
const prefix = type === 'lxc' ? 'container' : 'vm';
return useMutation({
mutationFn: (body: Record<string, unknown>) =>
apiClient.put(`/proxmox/api/${serverId}/${prefix}/${vmid}/config`, body),
onSuccess: () => {
qc.invalidateQueries({ queryKey: vmKeys.config(serverId, vmid) });
},
});
}

View File

@@ -0,0 +1,131 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/lib/api-client';
import type { IPAMNetwork, IPAMPool, IPAMAllocation } from '@/types';
export const ipamKeys = {
networks: ['ipam-networks'] as const,
network: (id: number) => ['ipam-network', id] as const,
networkStats: (id: number) => ['ipam-network-stats', id] as const,
ipMap: (id: number) => ['ipam-ip-map', id] as const,
pools: (networkId?: number) => ['ipam-pools', networkId] as const,
allocations: (params?: Record<string, unknown>) => ['ipam-allocations', params] as const,
history: (params?: Record<string, unknown>) => ['ipam-history', params] as const,
summary: ['ipam-summary'] as const,
conflicts: ['ipam-conflicts'] as const,
};
export function useIPAMNetworks() {
return useQuery({
queryKey: ipamKeys.networks,
queryFn: () => apiClient.get<IPAMNetwork[]>('/ipam/api/networks'),
});
}
export function useIPAMNetwork(id: number) {
return useQuery({
queryKey: ipamKeys.network(id),
queryFn: () => apiClient.get<IPAMNetwork>(`/ipam/api/networks/${id}`),
enabled: id > 0,
});
}
export function useIPAMNetworkStats(id: number) {
return useQuery({
queryKey: ipamKeys.networkStats(id),
queryFn: () => apiClient.get<{ total_ips: number; allocated_ips: number; reserved_ips: number; available_ips: number; utilization_percent: number }>(`/ipam/api/networks/${id}/stats`),
enabled: id > 0,
});
}
export function useIPAMIPMap(id: number) {
return useQuery({
queryKey: ipamKeys.ipMap(id),
queryFn: () => apiClient.get<{ network_id: number; ip_map: unknown[] }>(`/ipam/api/networks/${id}/ip-map`),
enabled: id > 0,
});
}
export function useIPAMPools(networkId?: number) {
const qs = networkId ? `?network_id=${networkId}` : '';
return useQuery({
queryKey: ipamKeys.pools(networkId),
queryFn: () => apiClient.get<IPAMPool[]>(`/ipam/api/pools${qs}`),
});
}
export function useIPAMAllocations(params?: { network_id?: number; pool_id?: number; status?: string; search?: string; limit?: number; offset?: number }) {
const qs = new URLSearchParams();
if (params?.network_id) qs.set('network_id', String(params.network_id));
if (params?.pool_id) qs.set('pool_id', String(params.pool_id));
if (params?.status) qs.set('status', params.status);
if (params?.search) qs.set('search', params.search);
if (params?.limit) qs.set('limit', String(params.limit));
if (params?.offset) qs.set('offset', String(params.offset));
return useQuery({
queryKey: ipamKeys.allocations(params),
queryFn: () => apiClient.get<IPAMAllocation[]>(`/ipam/api/allocations?${qs}`),
});
}
export function useIPAMHistory(params?: { network_id?: number; ip_address?: string; action?: string; limit?: number }) {
const qs = new URLSearchParams();
if (params?.network_id) qs.set('network_id', String(params.network_id));
if (params?.ip_address) qs.set('ip_address', params.ip_address);
if (params?.action) qs.set('action', params.action);
if (params?.limit) qs.set('limit', String(params.limit));
return useQuery({
queryKey: ipamKeys.history(params),
queryFn: () => apiClient.get<any[]>(`/ipam/api/history?${qs}`),
});
}
export function useIPAMSummary() {
return useQuery({
queryKey: ipamKeys.summary,
queryFn: () => apiClient.get<Record<string, unknown>>('/ipam/api/summary'),
});
}
export function useCreateNetwork() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: Partial<IPAMNetwork>) => apiClient.post<IPAMNetwork>('/ipam/api/networks', data),
onSuccess: () => qc.invalidateQueries({ queryKey: ipamKeys.networks }),
});
}
export function useUpdateNetwork() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, ...data }: Partial<IPAMNetwork> & { id: number }) =>
apiClient.put<IPAMNetwork>(`/ipam/api/networks/${id}`, data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['ipam-network'] });
qc.invalidateQueries({ queryKey: ipamKeys.networks });
},
});
}
export function useDeleteNetwork() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number) => apiClient.delete(`/ipam/api/networks/${id}`),
onSuccess: () => qc.invalidateQueries({ queryKey: ipamKeys.networks }),
});
}
export function useCreateAllocation() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: Partial<IPAMAllocation>) => apiClient.post<IPAMAllocation>('/ipam/api/allocations', data),
onSuccess: () => qc.invalidateQueries({ queryKey: ['ipam-allocations'] }),
});
}
export function useDeleteAllocation() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number) => apiClient.delete(`/ipam/api/allocations/${id}`),
onSuccess: () => qc.invalidateQueries({ queryKey: ['ipam-allocations'] }),
});
}

View File

@@ -0,0 +1,75 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/lib/api-client';
import type { AuditLog } from '@/types';
export const logKeys = {
logs: (params?: Record<string, unknown>) => ['logs', params] as const,
stats: (hours?: number) => ['log-stats', hours] as const,
levels: ['log-levels'] as const,
categories: ['log-categories'] as const,
};
interface LogsResponse {
logs: AuditLog[];
total: number;
page: number;
limit: number;
pages: number;
}
interface LogStats {
period_hours: number;
total: number;
by_level: Record<string, number>;
by_category: Record<string, number>;
errors_count: number;
failed_logins: number;
recent_errors: unknown[];
}
export function useLogs(params?: { level?: string; category?: string; username?: string; search?: string; page?: number; limit?: number; date_from?: string; date_to?: string }) {
const qs = new URLSearchParams();
if (params?.level) qs.set('level', params.level);
if (params?.category) qs.set('category', params.category);
if (params?.username) qs.set('username', params.username);
if (params?.search) qs.set('search', params.search);
if (params?.page) qs.set('page', String(params.page));
if (params?.limit) qs.set('limit', String(params.limit));
if (params?.date_from) qs.set('date_from', params.date_from);
if (params?.date_to) qs.set('date_to', params.date_to);
return useQuery({
queryKey: logKeys.logs(params),
queryFn: () => apiClient.get<LogsResponse>(`/logs/api/logs?${qs}`),
});
}
export function useLogStats(hours = 24) {
return useQuery({
queryKey: logKeys.stats(hours),
queryFn: () => apiClient.get<LogStats>(`/logs/api/stats?hours=${hours}`),
});
}
export function useLogLevels() {
return useQuery({
queryKey: logKeys.levels,
queryFn: () => apiClient.get<{ levels: { value: string; label: string; color: string }[] }>('/logs/api/levels'),
staleTime: Infinity,
});
}
export function useLogCategories() {
return useQuery({
queryKey: logKeys.categories,
queryFn: () => apiClient.get<{ categories: { value: string; label: string }[] }>('/logs/api/categories'),
staleTime: Infinity,
});
}
export function useDeleteOldLogs() {
const qc = useQueryClient();
return useMutation({
mutationFn: (days: number) => apiClient.delete(`/logs/api/logs?days=${days}`),
onSuccess: () => qc.invalidateQueries({ queryKey: ['logs'] }),
});
}

View File

@@ -0,0 +1,72 @@
import { useQuery } from '@tanstack/react-query';
import { apiClient } from '@/lib/api-client';
import type { ProxmoxServer, ProxmoxNode } from '@/types';
export const nodeKeys = {
servers: ['servers'] as const,
server: (id: number) => ['servers', id] as const,
nodes: (serverId: number) => ['nodes', serverId] as const,
nodeStatus: (serverId: number, node: string) => ['node-status', serverId, node] as const,
nodeRrd: (serverId: number, node: string, tf: string) => ['node-rrd', serverId, node, tf] as const,
clusterInfo: (serverId: number) => ['cluster-info', serverId] as const,
topology: ['cluster-topology'] as const,
storages: (serverId: number, node: string) => ['storages', serverId, node] as const,
};
export function useServers() {
return useQuery({
queryKey: nodeKeys.servers,
queryFn: () => apiClient.get<ProxmoxServer[]>('/proxmox/api/servers'),
refetchInterval: 30000,
});
}
export function useServer(serverId: number) {
return useQuery({
queryKey: nodeKeys.server(serverId),
queryFn: () => apiClient.get<ProxmoxServer>(`/proxmox/api/servers/${serverId}`),
enabled: serverId > 0,
});
}
export function useNodes(serverId: number) {
return useQuery({
queryKey: nodeKeys.nodes(serverId),
queryFn: () => apiClient.get<{ nodes: ProxmoxNode[]; cluster_id: string }>(`/proxmox/api/${serverId}/nodes`),
enabled: serverId > 0,
refetchInterval: 30000,
});
}
export function useNodeStatus(serverId: number, node: string) {
return useQuery({
queryKey: nodeKeys.nodeStatus(serverId, node),
queryFn: () => apiClient.get<Record<string, unknown>>(`/proxmox/api/${serverId}/node/status?node=${node}`),
enabled: serverId > 0 && !!node,
refetchInterval: 10000,
});
}
export function useNodeRrddata(serverId: number, node: string, timeframe = 'hour') {
return useQuery({
queryKey: nodeKeys.nodeRrd(serverId, node, timeframe),
queryFn: () => apiClient.get<unknown[]>(`/proxmox/api/${serverId}/node/rrddata?node=${node}&timeframe=${timeframe}`),
enabled: serverId > 0 && !!node,
});
}
export function useClusterTopology() {
return useQuery({
queryKey: nodeKeys.topology,
queryFn: () => apiClient.get<{ clusters: Record<string, unknown[]>; standalone: unknown[] }>('/proxmox/api/cluster/topology'),
refetchInterval: 30000,
});
}
export function useStorages(serverId: number, node: string) {
return useQuery({
queryKey: nodeKeys.storages(serverId, node),
queryFn: () => apiClient.get<unknown[]>(`/proxmox/api/${serverId}/storages?node=${node}`),
enabled: serverId > 0 && !!node,
});
}

View File

@@ -0,0 +1,77 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/lib/api-client';
import type { Notification } from '@/types';
export const notificationKeys = {
list: (params?: Record<string, unknown>) => ['notifications', params] as const,
unreadCount: ['notifications-unread-count'] as const,
preferences: ['notification-preferences'] as const,
};
interface NotificationListResponse {
total: number;
unread_count: number;
notifications: Notification[];
}
export function useNotifications(params?: { unread_only?: boolean; level?: string; limit?: number; offset?: number }) {
const qs = new URLSearchParams();
if (params?.unread_only) qs.set('unread_only', 'true');
if (params?.level) qs.set('level', params.level);
if (params?.limit) qs.set('limit', String(params.limit));
if (params?.offset) qs.set('offset', String(params.offset));
return useQuery({
queryKey: notificationKeys.list(params),
queryFn: () => apiClient.get<NotificationListResponse>(`/api/notifications/?${qs}`),
refetchInterval: 30000,
});
}
export function useUnreadCount() {
return useQuery({
queryKey: notificationKeys.unreadCount,
queryFn: () => apiClient.get<{ count: number }>('/api/notifications/unread-count'),
refetchInterval: 15000,
});
}
export function useMarkRead() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number) => apiClient.patch<Notification>(`/api/notifications/${id}/read`),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['notifications'] });
qc.invalidateQueries({ queryKey: notificationKeys.unreadCount });
},
});
}
export function useMarkAllRead() {
const qc = useQueryClient();
return useMutation({
mutationFn: () => apiClient.post<{ message: string }>('/api/notifications/mark-all-read'),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['notifications'] });
qc.invalidateQueries({ queryKey: notificationKeys.unreadCount });
},
});
}
export function useDeleteNotification() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number) => apiClient.delete(`/api/notifications/${id}`),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['notifications'] });
qc.invalidateQueries({ queryKey: notificationKeys.unreadCount });
},
});
}
export function useDeleteAllRead() {
const qc = useQueryClient();
return useMutation({
mutationFn: () => apiClient.delete('/api/notifications/read/all'),
onSuccess: () => qc.invalidateQueries({ queryKey: ['notifications'] }),
});
}

View File

@@ -0,0 +1,122 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/lib/api-client';
export const settingsKeys = {
profile: ['settings-profile'] as const,
panel: ['settings-panel'] as const,
security: ['settings-security'] as const,
channels: ['notification-channels'] as const,
version: ['app-version'] as const,
};
interface ProfileResponse {
id: number;
username: string;
email: string;
full_name: string;
is_admin: boolean;
is_active: boolean;
ssh_public_key?: string;
created_at: string;
last_login?: string;
}
interface PanelSettings {
panel_name: string;
log_retention_days: number;
language: string;
}
export function useProfile() {
return useQuery({
queryKey: settingsKeys.profile,
queryFn: () => apiClient.get<ProfileResponse>('/settings/api/profile'),
});
}
export function useUpdateProfile() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: { full_name?: string; email?: string; ssh_public_key?: string }) =>
apiClient.put<{ message: string }>('/settings/api/profile', data),
onSuccess: () => qc.invalidateQueries({ queryKey: settingsKeys.profile }),
});
}
export function useChangePassword() {
return useMutation({
mutationFn: (data: { current_password: string; new_password: string; confirm_password: string }) =>
apiClient.post<{ message: string }>('/settings/api/change-password', data),
});
}
export function usePanelSettings() {
return useQuery({
queryKey: settingsKeys.panel,
queryFn: () => apiClient.get<PanelSettings>('/settings/api/panel'),
});
}
export function useUpdatePanelSettings() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: Partial<PanelSettings>) =>
apiClient.put<{ message: string }>('/settings/api/panel', data),
onSuccess: () => qc.invalidateQueries({ queryKey: settingsKeys.panel }),
});
}
export function useSecuritySettings() {
return useQuery({
queryKey: settingsKeys.security,
queryFn: () => apiClient.get<{ single_session_enabled: boolean; max_login_attempts?: number; lockout_duration?: number; session_timeout?: number }>('/settings/api/security-settings'),
});
}
export function useUpdateSecuritySettings() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: { single_session_enabled?: boolean; max_login_attempts?: number; lockout_duration?: number; session_timeout?: number }) =>
apiClient.put<{ message: string }>('/settings/api/security-settings', data),
onSuccess: () => qc.invalidateQueries({ queryKey: settingsKeys.security }),
});
}
export function useNotificationChannels() {
return useQuery({
queryKey: settingsKeys.channels,
queryFn: () => apiClient.get<{ smtp: Record<string, unknown>; telegram: Record<string, unknown> }>('/settings/api/notification-channels'),
});
}
export function useUpdateSMTP() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: Record<string, unknown>) =>
apiClient.put<{ message: string }>('/settings/api/notification-channels/smtp', data),
onSuccess: () => qc.invalidateQueries({ queryKey: settingsKeys.channels }),
});
}
export function useUpdateTelegram() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: { telegram_bot_token?: string }) =>
apiClient.put<{ message: string }>('/settings/api/notification-channels/telegram', data),
onSuccess: () => qc.invalidateQueries({ queryKey: settingsKeys.channels }),
});
}
export function useAppVersion() {
return useQuery({
queryKey: settingsKeys.version,
queryFn: () => apiClient.get<{ version: string }>('/settings/api/version'),
staleTime: Infinity,
});
}
export function useCheckUpdates() {
return useMutation({
mutationFn: () => apiClient.get<Record<string, unknown>>('/settings/api/updates/check'),
});
}

View File

@@ -0,0 +1,65 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/lib/api-client';
export const taskKeys = {
all: (params?: Record<string, unknown>) => ['all-tasks', params] as const,
activeCount: ['tasks-active-count'] as const,
bulk: (limit?: number) => ['bulk-tasks', limit] as const,
};
interface TaskItem {
id?: number;
upid?: string;
type: string;
status: string;
action?: string;
items_count?: number;
completed_count?: number;
failed_count?: number;
created_at?: string;
started_at?: string;
completed_at?: string;
node?: string;
server_id?: number;
server_name?: string;
exitstatus?: string;
starttime?: number;
endtime?: number;
user?: string;
[key: string]: unknown;
}
export function useAllTasks(params?: { limit?: number; status?: string }) {
const qs = new URLSearchParams();
if (params?.limit) qs.set('limit', String(params.limit));
if (params?.status) qs.set('status', params.status);
return useQuery({
queryKey: taskKeys.all(params),
queryFn: () => apiClient.get<{ tasks: TaskItem[] }>(`/proxmox/api/all-tasks?${qs}`),
refetchInterval: 5000,
});
}
export function useActiveTaskCount() {
return useQuery({
queryKey: taskKeys.activeCount,
queryFn: () => apiClient.get<{ count: number }>('/proxmox/api/all-tasks/active-count'),
refetchInterval: 5000,
});
}
export function useCancelTask() {
const qc = useQueryClient();
return useMutation({
mutationFn: (taskId: number) => apiClient.post<{ success: boolean }>(`/proxmox/api/tasks/${taskId}/cancel`),
onSuccess: () => qc.invalidateQueries({ queryKey: ['all-tasks'] }),
});
}
export function useClearCompletedTasks() {
const qc = useQueryClient();
return useMutation({
mutationFn: () => apiClient.delete('/proxmox/api/all-tasks/completed'),
onSuccess: () => qc.invalidateQueries({ queryKey: ['all-tasks'] }),
});
}

View File

@@ -0,0 +1,80 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/lib/api-client';
import type { OSTemplate, OSTemplateGroup } from '@/types';
export const templateKeys = {
groups: ['template-groups'] as const,
templates: (groupId?: number, serverId?: number) => ['templates', groupId, serverId] as const,
template: (id: number) => ['template', id] as const,
discover: (serverId: number) => ['template-discover', serverId] as const,
};
export function useTemplateGroups() {
return useQuery({
queryKey: templateKeys.groups,
queryFn: () => apiClient.get<OSTemplateGroup[]>('/templates/api/groups'),
});
}
export function useTemplates(groupId?: number, serverId?: number) {
const params = new URLSearchParams();
if (groupId) params.set('group_id', String(groupId));
if (serverId) params.set('server_id', String(serverId));
const qs = params.toString();
return useQuery({
queryKey: templateKeys.templates(groupId, serverId),
queryFn: () => apiClient.get<OSTemplate[]>(`/templates/api/templates${qs ? `?${qs}` : ''}`),
});
}
export function useTemplate(templateId: number) {
return useQuery({
queryKey: templateKeys.template(templateId),
queryFn: () => apiClient.get<OSTemplate>(`/templates/api/templates/${templateId}`),
enabled: templateId > 0,
});
}
export function useDiscoverTemplates(serverId: number) {
return useQuery({
queryKey: templateKeys.discover(serverId),
queryFn: () => apiClient.get<{ server_id: number; server_name: string; templates: unknown[] }>(`/templates/api/discover/${serverId}`),
enabled: false, // manual trigger
});
}
export function useAutoImportTemplates() {
const qc = useQueryClient();
return useMutation({
mutationFn: (serverId: number) => apiClient.post<{ success: boolean; count: number }>(`/templates/api/auto-import/${serverId}`),
onSuccess: () => {
qc.invalidateQueries({ queryKey: templateKeys.groups });
qc.invalidateQueries({ queryKey: ['templates'] });
},
});
}
export function useCreateTemplate() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: Partial<OSTemplate>) => apiClient.post<OSTemplate>('/templates/api/templates', data),
onSuccess: () => qc.invalidateQueries({ queryKey: ['templates'] }),
});
}
export function useUpdateTemplate() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, ...data }: Partial<OSTemplate> & { id: number }) =>
apiClient.put<OSTemplate>(`/templates/api/templates/${id}`, data),
onSuccess: () => qc.invalidateQueries({ queryKey: ['templates'] }),
});
}
export function useDeleteTemplate() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number) => apiClient.delete(`/templates/api/templates/${id}`),
onSuccess: () => qc.invalidateQueries({ queryKey: ['templates'] }),
});
}

View File

@@ -0,0 +1,140 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/lib/api-client';
import type { User } from '@/types';
export const userKeys = {
users: ['users'] as const,
user: (id: number) => ['user', id] as const,
roles: ['roles'] as const,
permissions: ['permissions-v2'] as const,
sessions: ['sessions'] as const,
blockedIps: ['blocked-ips'] as const,
securitySettings: ['admin-security-settings'] as const,
securityEvents: ['security-events'] as const,
};
interface Role {
id: number;
name: string;
display_name: string;
description?: string;
permissions: string[];
is_system: boolean;
is_active: boolean;
user_count: number;
}
export function useUsers() {
return useQuery({
queryKey: userKeys.users,
queryFn: () => apiClient.get<User[]>('/admin/api/users'),
});
}
export function useUser(userId: number) {
return useQuery({
queryKey: userKeys.user(userId),
queryFn: () => apiClient.get<User>(`/admin/api/users/${userId}`),
enabled: userId > 0,
});
}
export function useCreateUser() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: { username: string; email: string; password: string; full_name?: string; role_id?: number; is_active?: boolean; is_admin?: boolean }) =>
apiClient.post<User>('/admin/api/users', data),
onSuccess: () => qc.invalidateQueries({ queryKey: userKeys.users }),
});
}
export function useUpdateUser() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, ...data }: { id: number; email?: string; full_name?: string; role_id?: number; is_active?: boolean; is_admin?: boolean }) =>
apiClient.put<User>(`/admin/api/users/${id}`, data),
onSuccess: () => qc.invalidateQueries({ queryKey: userKeys.users }),
});
}
export function useDeleteUser() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number) => apiClient.delete(`/admin/api/users/${id}`),
onSuccess: () => qc.invalidateQueries({ queryKey: userKeys.users }),
});
}
export function useResetPassword() {
const qc = useQueryClient();
return useMutation({
mutationFn: (userId: number) =>
apiClient.post<{ message: string }>(`/admin/api/users/${userId}/reset-password`, {}),
onSuccess: () => qc.invalidateQueries({ queryKey: userKeys.users }),
});
}
export function useUnlockUser() {
const qc = useQueryClient();
return useMutation({
mutationFn: (userId: number) => apiClient.post<{ message: string }>(`/admin/api/users/${userId}/unlock`),
onSuccess: () => qc.invalidateQueries({ queryKey: userKeys.users }),
});
}
export function useRoles() {
return useQuery({
queryKey: userKeys.roles,
queryFn: () => apiClient.get<Role[]>('/admin/api/roles'),
});
}
export function useCreateRole() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: { name: string; display_name: string; description?: string; permissions: string[] }) =>
apiClient.post<Role>('/admin/api/roles', data),
onSuccess: () => qc.invalidateQueries({ queryKey: userKeys.roles }),
});
}
export function useUpdateRole() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, ...data }: { id: number; display_name?: string; description?: string; permissions?: string[]; is_active?: boolean }) =>
apiClient.put<Role>(`/admin/api/roles/${id}`, data),
onSuccess: () => qc.invalidateQueries({ queryKey: userKeys.roles }),
});
}
export function useDeleteRole() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number) => apiClient.delete(`/admin/api/roles/${id}`),
onSuccess: () => qc.invalidateQueries({ queryKey: userKeys.roles }),
});
}
export function usePermissions() {
return useQuery({
queryKey: userKeys.permissions,
queryFn: () => apiClient.get<Record<string, unknown>>('/admin/api/permissions/v2'),
staleTime: Infinity,
});
}
export function useSessions() {
return useQuery({
queryKey: userKeys.sessions,
queryFn: () => apiClient.get<any[]>('/admin/api/sessions'),
refetchInterval: 15000,
});
}
export function useTerminateSession() {
const qc = useQueryClient();
return useMutation({
mutationFn: (sessionId: string) => apiClient.delete(`/admin/api/sessions/${sessionId}`),
onSuccess: () => qc.invalidateQueries({ queryKey: userKeys.sessions }),
});
}

View File

@@ -0,0 +1,75 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/lib/api-client';
import type { Workspace } from '@/types';
export const workspaceKeys = {
all: ['workspaces'] as const,
detail: (id: number) => ['workspace', id] as const,
};
interface WorkspaceDetail extends Omit<Workspace, 'servers' | 'users'> {
servers: { id: number; name: string; ip_address: string; is_online: boolean; cluster_name?: string }[];
users: { id: number; username: string; email: string; full_name?: string }[];
}
export function useWorkspaces() {
return useQuery({
queryKey: workspaceKeys.all,
queryFn: () => apiClient.get<Workspace[]>('/api/workspaces'),
});
}
export function useWorkspace(id: number) {
return useQuery({
queryKey: workspaceKeys.detail(id),
queryFn: () => apiClient.get<WorkspaceDetail>(`/api/workspaces/${id}`),
enabled: id > 0,
});
}
export function useCreateWorkspace() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: { name: string; description?: string; color?: string; server_ids?: number[]; user_ids?: number[] }) =>
apiClient.post<WorkspaceDetail>('/api/workspaces', data),
onSuccess: () => qc.invalidateQueries({ queryKey: workspaceKeys.all }),
});
}
export function useUpdateWorkspace() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, ...data }: { id: number; name?: string; description?: string; color?: string }) =>
apiClient.put<WorkspaceDetail>(`/api/workspaces/${id}`, data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: workspaceKeys.all });
qc.invalidateQueries({ queryKey: ['workspace'] });
},
});
}
export function useDeleteWorkspace() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number) => apiClient.delete(`/api/workspaces/${id}`),
onSuccess: () => qc.invalidateQueries({ queryKey: workspaceKeys.all }),
});
}
export function useUpdateWorkspaceServers() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, ids }: { id: number; ids: number[] }) =>
apiClient.post<WorkspaceDetail>(`/api/workspaces/${id}/servers`, { ids }),
onSuccess: () => qc.invalidateQueries({ queryKey: ['workspace'] }),
});
}
export function useUpdateWorkspaceUsers() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, ids }: { id: number; ids: number[] }) =>
apiClient.post<WorkspaceDetail>(`/api/workspaces/${id}/users`, { ids }),
onSuccess: () => qc.invalidateQueries({ queryKey: ['workspace'] }),
});
}

140
frontend/src/index.css Normal file
View File

@@ -0,0 +1,140 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--font-sans: "DM Sans", system-ui, sans-serif;
--font-mono: "JetBrains Mono", ui-monospace, monospace;
--font-heading: var(--font-sans);
--color-surface-0: #09090B;
--color-surface-1: #111113;
--color-surface-2: #1A1A1F;
--color-text-primary: #F0F0F3;
--color-text-secondary: #A0A0AB;
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-foreground: var(--foreground);
--color-background: var(--background);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
--sidebar-width: 232px;
}
:root {
--radius: 0.4375rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.592 0.19 264);
--primary-foreground: oklch(1 0 0);
--secondary: oklch(0.965 0.002 264);
--secondary-foreground: oklch(0.205 0.015 264);
--muted: oklch(0.965 0.002 264);
--muted-foreground: oklch(0.556 0.015 264);
--accent: oklch(0.965 0.002 264);
--accent-foreground: oklch(0.205 0.015 264);
--destructive: oklch(0.577 0.245 27.33);
--destructive-foreground: oklch(1 0 0);
--border: oklch(0.922 0.004 264);
--input: oklch(0.922 0.004 264);
--ring: oklch(0.592 0.19 264);
--chart-1: oklch(0.592 0.19 264);
--chart-2: oklch(0.723 0.191 149);
--chart-3: oklch(0.768 0.165 75);
--chart-4: oklch(0.577 0.245 27.33);
--chart-5: oklch(0.586 0.24 293);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.592 0.19 264);
--sidebar-primary-foreground: oklch(1 0 0);
--sidebar-accent: oklch(0.965 0.002 264);
--sidebar-accent-foreground: oklch(0.205 0.015 264);
--sidebar-border: oklch(0.922 0.004 264);
--sidebar-ring: oklch(0.592 0.19 264);
}
.dark {
--background: oklch(0.141 0.005 286);
--foreground: oklch(0.94 0.005 264);
--card: oklch(0.18 0.006 286);
--card-foreground: oklch(0.94 0.005 264);
--popover: oklch(0.18 0.006 286);
--popover-foreground: oklch(0.94 0.005 264);
--primary: oklch(0.592 0.19 264);
--primary-foreground: oklch(1 0 0);
--secondary: oklch(0.24 0.008 264);
--secondary-foreground: oklch(0.94 0.005 264);
--muted: oklch(0.24 0.008 264);
--muted-foreground: oklch(0.64 0.015 264);
--accent: oklch(0.24 0.008 264);
--accent-foreground: oklch(0.94 0.005 264);
--destructive: oklch(0.577 0.245 27.33);
--destructive-foreground: oklch(1 0 0);
--border: oklch(0.27 0.008 264);
--input: oklch(0.27 0.008 264);
--ring: oklch(0.592 0.19 264);
--chart-1: oklch(0.592 0.19 264);
--chart-2: oklch(0.723 0.191 149);
--chart-3: oklch(0.768 0.165 75);
--chart-4: oklch(0.577 0.245 27.33);
--chart-5: oklch(0.586 0.24 293);
--sidebar: oklch(0.17 0.006 286);
--sidebar-foreground: oklch(0.94 0.005 264);
--sidebar-primary: oklch(0.592 0.19 264);
--sidebar-primary-foreground: oklch(1 0 0);
--sidebar-accent: oklch(0.24 0.008 264);
--sidebar-accent-foreground: oklch(0.94 0.005 264);
--sidebar-border: oklch(0.27 0.008 264);
--sidebar-ring: oklch(0.592 0.19 264);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground font-sans antialiased;
}
}

View File

@@ -0,0 +1,285 @@
import { Link, useLocation, Outlet, useNavigate } from 'react-router';
import {
LayoutDashboard,
Server,
Monitor,
Network,
HardDrive,
Archive,
Globe,
Settings,
Users,
FileText,
ClipboardList,
FolderKanban,
Sun,
Moon,
Menu,
LogOut,
Languages,
ChevronDown,
Building2,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Separator } from '@/components/ui/separator';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useAuthStore } from '@/stores/auth-store';
import { useThemeStore } from '@/stores/theme-store';
import { useWorkspaceStore } from '@/stores/workspace-store';
import { useWorkspaces } from '@/hooks/use-workspaces';
import { useTranslation } from 'react-i18next';
import { useQueryClient } from '@tanstack/react-query';
import NotificationsDropdown from '@/components/shared/NotificationsDropdown';
interface NavItem {
label: string;
icon: React.ComponentType<{ className?: string }>;
path: string;
}
interface NavGroup {
title: string;
items: NavItem[];
}
const navGroups: NavGroup[] = [
{
title: 'Infrastructure',
items: [
{ label: 'Dashboard', icon: LayoutDashboard, path: '/dashboard' },
{ label: 'Instances', icon: Monitor, path: '/instances' },
{ label: 'Nodes', icon: Server, path: '/nodes' },
{ label: 'Templates', icon: HardDrive, path: '/templates' },
{ label: 'Backups', icon: Archive, path: '/backups' },
{ label: 'Tasks', icon: ClipboardList, path: '/tasks' },
],
},
{
title: 'Network',
items: [
{ label: 'IPAM', icon: Globe, path: '/ipam' },
{ label: 'Networks', icon: Network, path: '/networks' },
],
},
{
title: 'Management',
items: [
{ label: 'Users', icon: Users, path: '/users' },
{ label: 'Workspaces', icon: FolderKanban, path: '/workspaces' },
{ label: 'Logs', icon: FileText, path: '/logs' },
{ label: 'Settings', icon: Settings, path: '/settings' },
],
},
];
function SidebarContent() {
const location = useLocation();
const { t } = useTranslation();
return (
<div className="flex h-full flex-col">
{/* Logo */}
<div className="flex h-14 items-center gap-2 px-4">
<img src="/favicon.svg" alt="PVEmanager" className="h-8 w-8" />
<span className="text-base font-semibold tracking-tight">PVEmanager</span>
</div>
<Separator />
{/* Navigation */}
<nav className="flex-1 space-y-4 overflow-y-auto px-3 py-4">
{navGroups.map((group) => (
<div key={group.title}>
<p className="mb-1.5 px-2 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
{t(`nav.${group.title.toLowerCase()}`, group.title)}
</p>
<div className="space-y-0.5">
{group.items.map((item) => {
const isActive = location.pathname === item.path ||
(item.path !== '/dashboard' && location.pathname.startsWith(item.path));
const Icon = item.icon;
return (
<Link
key={item.path}
to={item.path}
className={`flex items-center gap-2.5 rounded-md px-2.5 py-1.5 text-[13px] font-medium transition-colors ${
isActive
? 'bg-primary/10 text-primary'
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
}`}
>
<Icon className="h-4 w-4 shrink-0" />
{t(`nav.${item.label.toLowerCase()}`, item.label)}
</Link>
);
})}
</div>
</div>
))}
</nav>
<Separator />
{/* User section */}
<UserSection />
</div>
);
}
function UserSection() {
const { user, logout } = useAuthStore();
const navigate = useNavigate();
const { t } = useTranslation();
const handleLogout = async () => {
await logout();
navigate('/login');
};
return (
<div className="p-3">
<DropdownMenu>
<DropdownMenuTrigger render={<button className="flex w-full items-center gap-2.5 rounded-md px-2.5 py-2 text-left hover:bg-accent transition-colors" />}>
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-primary/20 text-xs font-semibold text-primary">
{user?.username?.[0]?.toUpperCase() ?? '?'}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-[13px] font-medium">{user?.username ?? 'User'}</p>
<p className="truncate text-[11px] text-muted-foreground">
{user?.is_admin ? 'Admin' : 'User'}
</p>
</div>
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem onClick={() => navigate('/settings')}>
<Settings className="mr-2 h-4 w-4" />
{t('nav.settings')}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleLogout} className="text-destructive">
<LogOut className="mr-2 h-4 w-4" />
{t('login.logout', 'Logout')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
function WorkspaceSwitcher() {
const { t } = useTranslation();
const { data: workspaces = [] } = useWorkspaces();
const { activeWorkspaceId, setActiveWorkspace } = useWorkspaceStore();
const qc = useQueryClient();
const active = workspaces.find((w) => w.id === activeWorkspaceId);
const handleSwitch = (id: number | null) => {
setActiveWorkspace(id);
// Invalidate all queries to refetch with new workspace
qc.invalidateQueries();
};
if (workspaces.length === 0) return null;
return (
<DropdownMenu>
<DropdownMenuTrigger render={<Button variant="outline" size="sm" className="gap-1.5 max-w-[200px]" />}>
<Building2 className="h-3.5 w-3.5 shrink-0" />
<span className="truncate text-xs">{active?.name || t('workspaces.all', 'All workspaces')}</span>
<ChevronDown className="h-3 w-3 shrink-0 opacity-50" />
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-[200px]">
<DropdownMenuItem onClick={() => handleSwitch(null)}>
<Building2 className="mr-2 h-3.5 w-3.5" />
{t('workspaces.all', 'All workspaces')}
</DropdownMenuItem>
<DropdownMenuSeparator />
{workspaces.map((w) => (
<DropdownMenuItem key={w.id} onClick={() => handleSwitch(w.id)}>
<FolderKanban className="mr-2 h-3.5 w-3.5" />
<span className="truncate">{w.name}</span>
{w.id === activeWorkspaceId && (
<span className="ml-auto text-primary">✓</span>
)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
function TopBar() {
const { theme, toggleTheme } = useThemeStore();
const { i18n } = useTranslation();
const toggleLanguage = () => {
const next = i18n.language === 'ru' ? 'en' : 'ru';
i18n.changeLanguage(next);
};
return (
<header className="flex h-14 items-center justify-between border-b px-4 lg:px-6">
{/* Mobile menu */}
<Sheet>
<SheetTrigger render={<Button variant="ghost" size="icon" className="lg:hidden" />}>
<Menu className="h-5 w-5" />
</SheetTrigger>
<SheetContent side="left" className="w-[var(--sidebar-width)] p-0">
<SidebarContent />
</SheetContent>
</Sheet>
<div className="hidden lg:block">
<WorkspaceSwitcher />
</div>
{/* Right side */}
<div className="flex items-center gap-1">
<Tooltip>
<TooltipTrigger render={<Button variant="ghost" size="icon" onClick={toggleLanguage} />}>
<Languages className="h-4 w-4" />
</TooltipTrigger>
<TooltipContent>{i18n.language === 'ru' ? 'English' : 'Русский'}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger render={<Button variant="ghost" size="icon" onClick={toggleTheme} />}>
{theme === 'dark' ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
</TooltipTrigger>
<TooltipContent>{theme === 'dark' ? 'Light mode' : 'Dark mode'}</TooltipContent>
</Tooltip>
<NotificationsDropdown />
</div>
</header>
);
}
export default function AppLayout() {
return (
<div className="flex h-screen overflow-hidden">
{/* Desktop sidebar */}
<aside className="hidden w-[var(--sidebar-width)] shrink-0 border-r bg-card lg:block">
<SidebarContent />
</aside>
{/* Main area */}
<div className="flex flex-1 flex-col overflow-hidden">
<TopBar />
<main className="flex-1 overflow-y-auto p-4 lg:p-6">
<Outlet />
</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,20 @@
import { Outlet } from 'react-router';
import { Monitor } from 'lucide-react';
export default function AuthLayout() {
return (
<div className="flex min-h-screen items-center justify-center bg-surface-0 p-4">
<div className="w-full max-w-sm">
{/* Logo */}
<div className="mb-8 flex flex-col items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary">
<Monitor className="h-6 w-6 text-primary-foreground" />
</div>
<h1 className="text-xl font-semibold">PVEmanager</h1>
</div>
<Outlet />
</div>
</div>
);
}

View File

@@ -0,0 +1,9 @@
import { Outlet } from 'react-router';
export default function ConsoleLayout() {
return (
<div className="h-screen w-screen overflow-hidden bg-black">
<Outlet />
</div>
);
}

View File

@@ -0,0 +1,154 @@
import type { ApiError } from '@/types';
const API_BASE = '';
function getActiveWorkspaceId(): number | null {
const stored = localStorage.getItem('pve-active-workspace');
return stored ? Number(stored) : null;
}
class ApiClient {
private token: string | null = null;
private refreshTimer: ReturnType<typeof setTimeout> | null = null;
constructor() {
this.token = localStorage.getItem('access_token');
if (this.token) {
this.scheduleRefresh();
}
}
setToken(token: string | null) {
this.token = token;
if (token) {
localStorage.setItem('access_token', token);
this.scheduleRefresh();
} else {
localStorage.removeItem('access_token');
if (this.refreshTimer) {
clearTimeout(this.refreshTimer);
this.refreshTimer = null;
}
}
}
getToken(): string | null {
return this.token;
}
private getTokenExpiry(): number | null {
if (!this.token) return null;
try {
const payload = JSON.parse(atob(this.token.split('.')[1]));
return payload.exp ? payload.exp * 1000 : null;
} catch {
return null;
}
}
private scheduleRefresh() {
if (this.refreshTimer) clearTimeout(this.refreshTimer);
const expiry = this.getTokenExpiry();
if (!expiry) return;
const refreshIn = expiry - Date.now() - 2 * 60 * 1000; // 2 min before expiry
if (refreshIn <= 0) {
this.refreshToken();
return;
}
this.refreshTimer = setTimeout(() => this.refreshToken(), refreshIn);
}
private async refreshToken() {
try {
const res = await fetch(`${API_BASE}/api/auth/refresh`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(this.token ? { Authorization: `Bearer ${this.token}` } : {}),
},
});
if (res.ok) {
const data = await res.json();
this.setToken(data.access_token);
} else {
this.setToken(null);
window.location.href = '/login';
}
} catch {
// Network error, try again later
}
}
async request<T>(
path: string,
options: RequestInit = {}
): Promise<T> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(options.headers as Record<string, string> || {}),
};
if (this.token) {
headers['Authorization'] = `Bearer ${this.token}`;
}
const wsId = getActiveWorkspaceId();
if (wsId) {
headers['X-Active-Workspace'] = String(wsId);
}
const res = await fetch(`${API_BASE}${path}`, {
...options,
headers,
});
if (res.status === 401) {
this.setToken(null);
window.location.href = '/login';
throw new Error('Unauthorized');
}
if (!res.ok) {
const error: ApiError = await res.json().catch(() => ({
detail: `HTTP ${res.status}: ${res.statusText}`,
}));
throw new Error(error.detail || `HTTP ${res.status}`);
}
if (res.status === 204) return undefined as T;
return res.json();
}
get<T>(path: string) {
return this.request<T>(path);
}
post<T>(path: string, body?: unknown) {
return this.request<T>(path, {
method: 'POST',
body: body ? JSON.stringify(body) : undefined,
});
}
put<T>(path: string, body?: unknown) {
return this.request<T>(path, {
method: 'PUT',
body: body ? JSON.stringify(body) : undefined,
});
}
patch<T>(path: string, body?: unknown) {
return this.request<T>(path, {
method: 'PATCH',
body: body ? JSON.stringify(body) : undefined,
});
}
delete<T>(path: string) {
return this.request<T>(path, { method: 'DELETE' });
}
}
export const apiClient = new ApiClient();

View File

@@ -0,0 +1,29 @@
export function formatBytes(bytes: number, decimals = 1): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${(bytes / Math.pow(k, i)).toFixed(decimals)} ${sizes[i]}`;
}
export function formatUptime(seconds: number): string {
if (!seconds || seconds <= 0) return '-';
const d = Math.floor(seconds / 86400);
const h = Math.floor((seconds % 86400) / 3600);
const m = Math.floor((seconds % 3600) / 60);
if (d > 0) return `${d}d ${h}h`;
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
}
export function formatPercent(value: number, decimals = 1): string {
return `${value.toFixed(decimals)}%`;
}
export function formatCPU(cpuFraction: number, maxcpu: number): string {
return `${(cpuFraction * 100).toFixed(1)}% of ${maxcpu}`;
}
export function vmTypeLabel(type: string): string {
return type === 'qemu' ? 'VM' : type === 'lxc' ? 'LXC' : type.toUpperCase();
}

26
frontend/src/lib/i18n.ts Normal file
View File

@@ -0,0 +1,26 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import en from '@/locales/en.json';
import ru from '@/locales/ru.json';
i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
resources: {
en: { translation: en },
ru: { translation: ru },
},
fallbackLng: 'en',
interpolation: {
escapeValue: false,
},
detection: {
order: ['localStorage', 'navigator'],
lookupLocalStorage: 'pve-lang',
caches: ['localStorage'],
},
});
export default i18n;

View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View File

@@ -0,0 +1,155 @@
type MessageHandler = (data: unknown) => void;
interface WSOptions {
url: string;
onMessage?: MessageHandler;
onOpen?: () => void;
onClose?: () => void;
onError?: (event: Event) => void;
reconnect?: boolean;
reconnectInterval?: number;
maxReconnectAttempts?: number;
}
export class WebSocketManager {
private ws: WebSocket | null = null;
private options: WSOptions;
private reconnectAttempts = 0;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private pingTimer: ReturnType<typeof setInterval> | null = null;
private subscribers = new Map<string, Set<MessageHandler>>();
private _isConnected = false;
constructor(options: WSOptions) {
this.options = {
reconnect: true,
reconnectInterval: 3000,
maxReconnectAttempts: 10,
...options,
};
}
get isConnected() {
return this._isConnected;
}
connect() {
if (this.ws?.readyState === WebSocket.OPEN) return;
const token = localStorage.getItem('access_token');
const separator = this.options.url.includes('?') ? '&' : '?';
const url = token
? `${this.options.url}${separator}token=${encodeURIComponent(token)}`
: this.options.url;
this.ws = new WebSocket(url);
this.ws.onopen = () => {
this._isConnected = true;
this.reconnectAttempts = 0;
this.startPing();
this.options.onOpen?.();
};
this.ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
this.options.onMessage?.(data);
// Route to channel subscribers
if (data.channel) {
const handlers = this.subscribers.get(data.channel);
handlers?.forEach((handler) => handler(data));
}
} catch {
// Non-JSON message
this.options.onMessage?.(event.data);
}
};
this.ws.onclose = () => {
this._isConnected = false;
this.stopPing();
this.options.onClose?.();
this.tryReconnect();
};
this.ws.onerror = (event) => {
this.options.onError?.(event);
};
}
disconnect() {
this.options.reconnect = false;
this.stopPing();
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
this.ws?.close();
this.ws = null;
this._isConnected = false;
}
send(data: unknown) {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(typeof data === 'string' ? data : JSON.stringify(data));
}
}
subscribe(channel: string, handler: MessageHandler) {
if (!this.subscribers.has(channel)) {
this.subscribers.set(channel, new Set());
}
this.subscribers.get(channel)!.add(handler);
// Send subscribe message
this.send({ type: 'subscribe', channel });
// Return unsubscribe function
return () => {
this.subscribers.get(channel)?.delete(handler);
};
}
private tryReconnect() {
if (
!this.options.reconnect ||
this.reconnectAttempts >= (this.options.maxReconnectAttempts ?? 10)
) {
return;
}
this.reconnectAttempts++;
this.reconnectTimer = setTimeout(() => {
this.connect();
}, this.options.reconnectInterval);
}
private startPing() {
this.stopPing();
this.pingTimer = setInterval(() => {
this.send({ type: 'ping' });
}, 30000);
}
private stopPing() {
if (this.pingTimer) {
clearInterval(this.pingTimer);
this.pingTimer = null;
}
}
}
// Singleton for tasks WebSocket
let tasksWs: WebSocketManager | null = null;
export function getTasksWebSocket(): WebSocketManager {
if (!tasksWs) {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
tasksWs = new WebSocketManager({
url: `${protocol}//${window.location.host}/ws/tasks`,
});
}
return tasksWs;
}

View File

@@ -0,0 +1,269 @@
{
"nav": {
"infrastructure": "Infrastructure",
"network": "Network",
"management": "Management",
"dashboard": "Dashboard",
"instances": "Instances",
"nodes": "Nodes",
"templates": "Templates",
"backups": "Backups",
"tasks": "Tasks",
"ipam": "IPAM",
"networks": "Networks",
"users": "Users",
"workspaces": "Workspaces",
"logs": "Logs",
"settings": "Settings"
},
"login": {
"title": "Sign in to your account",
"username": "Username",
"password": "Password",
"username_placeholder": "Enter username",
"password_placeholder": "Enter password",
"sign_in": "Sign in",
"signing_in": "Signing in...",
"logout": "Logout"
},
"dashboard": {
"title": "Dashboard",
"nodes": "Nodes",
"virtual_machines": "Virtual Machines",
"containers": "Containers",
"alerts": "Alerts",
"memory": "Memory",
"storage": "Storage",
"instances": "Instances",
"recent_activity": "Recent Activity"
},
"common": {
"loading": "Loading...",
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
"edit": "Edit",
"create": "Create",
"search": "Search...",
"actions": "Actions",
"status": "Status",
"name": "Name",
"type": "Type",
"node": "Node",
"running": "Running",
"stopped": "Stopped",
"online": "Online",
"offline": "Offline",
"confirm_delete": "Are you sure you want to delete this?",
"no_data": "No data available",
"all": "All",
"date": "Date",
"user": "User",
"prev": "Previous",
"next": "Next",
"refresh": "Refresh",
"back": "Back",
"close": "Close",
"confirm": "Confirm",
"console": "Console",
"start": "Start",
"stop": "Stop",
"restart": "Restart",
"snapshots": "Snapshots",
"suspend": "Suspend"
},
"instances": {
"create": "Create Instance",
"search_placeholder": "Search by name, IP, or VMID...",
"all_statuses": "All statuses",
"all_nodes": "All nodes",
"no_instances": "No instances found",
"try_other_search": "Try a different search query",
"create_first": "Create your first instance",
"showing": "Showing",
"of": "of",
"overview": "Overview",
"graphs": "Graphs",
"networking": "Networking",
"destroy": "Destroy"
},
"wizard": {
"title": "Create Instance",
"step_server": "Server",
"step_template": "Template",
"step_config": "Configuration",
"step_confirm": "Confirm",
"select_server": "Select server",
"select_template": "Select template",
"hostname": "Hostname",
"cpu": "CPU Cores",
"memory": "Memory (MB)",
"disk": "Disk (GB)",
"network": "Network",
"password": "Password",
"ssh_keys": "SSH Keys",
"deploy": "Deploy",
"deploying": "Deploying...",
"os_group": "OS Group",
"ipam_network": "IPAM Network",
"auto_allocate_ip": "Auto-allocate IP"
},
"nodes": {
"title": "Servers",
"vms": "VMs",
"containers": "Containers",
"cpu": "CPU",
"memory": "Memory",
"disk": "Disk",
"uptime": "Uptime",
"cluster_nodes": "Cluster Nodes",
"vm_list": "Virtual Machines on Server"
},
"templates": {
"title": "OS Templates",
"search": "Search templates...",
"all_groups": "All groups",
"all_servers": "All servers",
"auto_import": "Auto Import",
"importing": "Importing...",
"no_templates": "No templates found",
"os": "Operating System",
"version": "Version"
},
"backups": {
"title": "Backups",
"files": "Backup Files",
"jobs": "Scheduled Jobs",
"storage": "Storage",
"server": "Server",
"size": "Size",
"date": "Date",
"restore": "Restore",
"download": "Download",
"schedule": "Schedule",
"enabled": "Enabled",
"disabled": "Disabled",
"run_now": "Run Now",
"no_backups": "No backups found"
},
"ipam": {
"dashboard": "IPAM Dashboard",
"networks": "Networks",
"allocations": "Allocations",
"history": "History",
"total_ips": "Total IPs",
"allocated": "Allocated",
"reserved": "Reserved",
"available": "Available",
"utilization": "Utilization",
"network": "Network",
"network_info": "Network Information",
"pools": "Pools",
"range": "Range",
"usage": "Usage",
"resource": "Resource",
"description": "Description",
"action": "Action",
"details": "Details",
"server": "Server",
"bridge": "Bridge",
"create_network": "Create Network",
"cidr": "CIDR",
"gateway": "Gateway",
"vms_linked": "VMs Linked",
"network_overview": "Network Overview"
},
"logs": {
"level": "Level",
"category": "Category",
"message": "Message",
"delete_old": "Delete old logs"
},
"settings": {
"profile": "Profile",
"panel": "Panel",
"security": "Security",
"notifications": "Notifications",
"about": "About",
"profile_info": "Profile Information",
"username": "Username",
"display_name": "Display Name",
"change_password": "Change Password",
"current_password": "Current Password",
"new_password": "New Password",
"confirm_password": "Confirm Password",
"password_mismatch": "Passwords do not match",
"panel_settings": "Panel Settings",
"panel_name": "Panel Name",
"language": "Language",
"security_settings": "Security Settings",
"max_attempts": "Max Login Attempts",
"lockout_duration": "Lockout Duration",
"session_timeout": "Session Timeout",
"seconds": "seconds",
"version": "Version",
"check_updates": "Check for Updates",
"update_available": "Update available",
"up_to_date": "You are up to date"
},
"users": {
"users": "Users",
"roles": "Roles",
"sessions": "Sessions",
"create": "Create User",
"create_role": "Create Role",
"role": "Role",
"last_login": "Last Login",
"active": "Active",
"inactive": "Inactive",
"locked": "Locked",
"reset_password": "Reset Password",
"unlock": "Unlock",
"permissions_count": "Permissions",
"active_sessions": "Active Sessions",
"user_agent": "User Agent",
"created": "Created",
"expires": "Expires"
},
"workspaces": {
"create": "Create Workspace",
"edit": "Edit Workspace",
"servers": "Servers",
"users": "Users",
"created": "Created",
"all": "All workspaces",
"select": "Select workspace"
},
"tasks": {
"clear_completed": "Clear Completed",
"running": "Running",
"queued": "Queued",
"completed": "Completed",
"failed": "Failed",
"tasks_count": "tasks",
"description": "Description",
"server": "Server",
"node": "Node",
"started": "Started",
"progress": "Progress"
},
"notifications": {
"title": "Notifications",
"mark_all_read": "Mark all read",
"empty": "No notifications"
},
"graphs": {
"cpu_usage": "CPU Usage",
"memory_usage": "Memory Usage",
"network_in": "Network In",
"network_out": "Network Out",
"disk_read": "Disk Read",
"disk_write": "Disk Write"
},
"console": {
"connecting": "Connecting to console...",
"reconnect": "Reconnect",
"connected": "Connected",
"error": "Error"
}
}

View File

@@ -0,0 +1,269 @@
{
"nav": {
"infrastructure": "Инфраструктура",
"network": "Сеть",
"management": "Управление",
"dashboard": "Дашборд",
"instances": "Инстансы",
"nodes": "Узлы",
"templates": "Шаблоны",
"backups": "Бэкапы",
"tasks": "Задачи",
"ipam": "IPAM",
"networks": "Сети",
"users": "Пользователи",
"workspaces": "Рабочие области",
"logs": "Логи",
"settings": "Настройки"
},
"login": {
"title": "Вход в аккаунт",
"username": "Имя пользователя",
"password": "Пароль",
"username_placeholder": "Введите имя пользователя",
"password_placeholder": "Введите пароль",
"sign_in": "Войти",
"signing_in": "Входим...",
"logout": "Выход"
},
"dashboard": {
"title": "Дашборд",
"nodes": "Узлы",
"virtual_machines": "Виртуальные машины",
"containers": "Контейнеры",
"alerts": "Уведомления",
"memory": "Память",
"storage": "Хранилище",
"instances": "Инстансы",
"recent_activity": "Последняя активность"
},
"common": {
"loading": "Загрузка...",
"save": "Сохранить",
"cancel": "Отмена",
"delete": "Удалить",
"edit": "Редактировать",
"create": "Создать",
"search": "Поиск...",
"actions": "Действия",
"status": "Статус",
"name": "Имя",
"type": "Тип",
"node": "Узел",
"running": "Работает",
"stopped": "Остановлен",
"online": "Онлайн",
"offline": "Офлайн",
"confirm_delete": "Вы уверены, что хотите удалить?",
"no_data": "Нет данных",
"all": "Все",
"date": "Дата",
"user": "Пользователь",
"prev": "Назад",
"next": "Далее",
"refresh": "Обновить",
"back": "Назад",
"close": "Закрыть",
"confirm": "Подтвердить",
"console": "Консоль",
"start": "Запустить",
"stop": "Остановить",
"restart": "Перезагрузить",
"snapshots": "Снимки",
"suspend": "Приостановить"
},
"instances": {
"create": "Создать инстанс",
"search_placeholder": "Поиск по имени, IP или VMID...",
"all_statuses": "Все статусы",
"all_nodes": "Все узлы",
"no_instances": "Инстансы не найдены",
"try_other_search": "Попробуйте другой поисковый запрос",
"create_first": "Создайте свой первый инстанс",
"showing": "Показано",
"of": "из",
"overview": "Обзор",
"graphs": "Графики",
"networking": "Сеть",
"destroy": "Уничтожить"
},
"wizard": {
"title": "Создание инстанса",
"step_server": "Сервер",
"step_template": "Шаблон",
"step_config": "Настройка",
"step_confirm": "Подтверждение",
"select_server": "Выберите сервер",
"select_template": "Выберите шаблон",
"hostname": "Имя хоста",
"cpu": "Ядра CPU",
"memory": "Память (МБ)",
"disk": "Диск (ГБ)",
"network": "Сеть",
"password": "Пароль",
"ssh_keys": "SSH ключи",
"deploy": "Развернуть",
"deploying": "Развертывание...",
"os_group": "Группа ОС",
"ipam_network": "Сеть IPAM",
"auto_allocate_ip": "Авто-выделение IP"
},
"nodes": {
"title": "Серверы",
"vms": "ВМ",
"containers": "Контейнеры",
"cpu": "CPU",
"memory": "Память",
"disk": "Диск",
"uptime": "Аптайм",
"cluster_nodes": "Узлы кластера",
"vm_list": "Виртуальные машины на сервере"
},
"templates": {
"title": "Шаблоны ОС",
"search": "Поиск шаблонов...",
"all_groups": "Все группы",
"all_servers": "Все серверы",
"auto_import": "Авто-импорт",
"importing": "Импорт...",
"no_templates": "Шаблоны не найдены",
"os": "Операционная система",
"version": "Версия"
},
"backups": {
"title": "Бэкапы",
"files": "Файлы бэкапов",
"jobs": "Задания по расписанию",
"storage": "Хранилище",
"server": "Сервер",
"size": "Размер",
"date": "Дата",
"restore": "Восстановить",
"download": "Скачать",
"schedule": "Расписание",
"enabled": "Включено",
"disabled": "Выключено",
"run_now": "Запустить сейчас",
"no_backups": "Бэкапы не найдены"
},
"ipam": {
"dashboard": "IPAM Дашборд",
"networks": "Сети",
"allocations": "Распределения",
"history": "История",
"total_ips": "Всего IP",
"allocated": "Выделено",
"reserved": "Зарезервировано",
"available": "Доступно",
"utilization": "Использование",
"network": "Сеть",
"network_info": "Информация о сети",
"pools": "Пулы",
"range": "Диапазон",
"usage": "Использование",
"resource": "Ресурс",
"description": "Описание",
"action": "Действие",
"details": "Детали",
"server": "Сервер",
"bridge": "Мост",
"create_network": "Создать сеть",
"cidr": "CIDR",
"gateway": "Шлюз",
"vms_linked": "Привязано ВМ",
"network_overview": "Обзор сетей"
},
"logs": {
"level": "Уровень",
"category": "Категория",
"message": "Сообщение",
"delete_old": "Удалить старые"
},
"settings": {
"profile": "Профиль",
"panel": "Панель",
"security": "Безопасность",
"notifications": "Уведомления",
"about": "О системе",
"profile_info": "Информация профиля",
"username": "Имя пользователя",
"display_name": "Отображаемое имя",
"change_password": "Сменить пароль",
"current_password": "Текущий пароль",
"new_password": "Новый пароль",
"confirm_password": "Подтвердите пароль",
"password_mismatch": "Пароли не совпадают",
"panel_settings": "Настройки панели",
"panel_name": "Название панели",
"language": "Язык",
"security_settings": "Настройки безопасности",
"max_attempts": "Макс. попыток входа",
"lockout_duration": "Время блокировки",
"session_timeout": "Таймаут сессии",
"seconds": "секунд",
"version": "Версия",
"check_updates": "Проверить обновления",
"update_available": "Доступно обновление",
"up_to_date": "Установлена последняя версия"
},
"users": {
"users": "Пользователи",
"roles": "Роли",
"sessions": "Сессии",
"create": "Создать пользователя",
"create_role": "Создать роль",
"role": "Роль",
"last_login": "Последний вход",
"active": "Активен",
"inactive": "Неактивен",
"locked": "Заблокирован",
"reset_password": "Сбросить пароль",
"unlock": "Разблокировать",
"permissions_count": "Разрешения",
"active_sessions": "Активные сессии",
"user_agent": "User Agent",
"created": "Создано",
"expires": "Истекает"
},
"workspaces": {
"create": "Создать рабочую область",
"edit": "Редактировать рабочую область",
"servers": "Серверы",
"users": "Пользователи",
"created": "Создано",
"all": "Все рабочие области",
"select": "Выбрать рабочую область"
},
"tasks": {
"clear_completed": "Очистить завершённые",
"running": "Выполняется",
"queued": "В очереди",
"completed": "Завершено",
"failed": "Ошибка",
"tasks_count": "задач",
"description": "Описание",
"server": "Сервер",
"node": "Узел",
"started": "Запущено",
"progress": "Прогресс"
},
"notifications": {
"title": "Уведомления",
"mark_all_read": "Прочитать все",
"empty": "Нет уведомлений"
},
"graphs": {
"cpu_usage": "Загрузка CPU",
"memory_usage": "Использование памяти",
"network_in": "Входящий трафик",
"network_out": "Исходящий трафик",
"disk_read": "Чтение диска",
"disk_write": "Запись диска"
},
"console": {
"connecting": "Подключение к консоли...",
"reconnect": "Переподключить",
"connected": "Подключено",
"error": "Ошибка"
}
}

11
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,11 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './lib/i18n'
import './index.css'
import App from './App'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

87
frontend/src/router.tsx Normal file
View File

@@ -0,0 +1,87 @@
import { lazy, Suspense } from 'react';
import { createBrowserRouter, Navigate } from 'react-router';
import AppLayout from '@/layouts/AppLayout';
import AuthLayout from '@/layouts/AuthLayout';
import ConsoleLayout from '@/layouts/ConsoleLayout';
import { ProtectedRoute, PublicRoute } from '@/components/shared/route-guards';
import LoginPage from '@/features/auth/LoginPage';
import DashboardPage from '@/features/dashboard/DashboardPage';
import InstancesPage from '@/features/instances/InstancesPage';
import InstanceDetailPage from '@/features/instances/InstanceDetailPage';
import ConsolePage from '@/features/console/ConsolePage';
// Lazy-loaded pages
const CreateInstanceWizard = lazy(() => import('@/features/instances/CreateInstanceWizard'));
const NodesPage = lazy(() => import('@/features/nodes/NodesPage'));
const NodeDetailPage = lazy(() => import('@/features/nodes/NodeDetailPage'));
const TemplatesPage = lazy(() => import('@/features/templates/TemplatesPage'));
const BackupsPage = lazy(() => import('@/features/backups/BackupsPage'));
const TasksPage = lazy(() => import('@/features/tasks/TasksPage'));
const IPAMDashboardPage = lazy(() => import('@/features/ipam/IPAMDashboardPage'));
const IPAMNetworksPage = lazy(() => import('@/features/ipam/IPAMNetworksPage'));
const IPAMNetworkDetailPage = lazy(() => import('@/features/ipam/IPAMNetworkDetailPage'));
const IPAMAllocationsPage = lazy(() => import('@/features/ipam/IPAMAllocationsPage'));
const IPAMHistoryPage = lazy(() => import('@/features/ipam/IPAMHistoryPage'));
const UsersPage = lazy(() => import('@/features/users/UsersPage'));
const WorkspacesPage = lazy(() => import('@/features/workspaces/WorkspacesPage'));
const LogsPage = lazy(() => import('@/features/logs/LogsPage'));
const SettingsPage = lazy(() => import('@/features/settings/SettingsPage'));
function LazyFallback() {
return <div className="flex items-center justify-center py-20 text-muted-foreground text-sm">Loading…</div>;
}
function SuspenseWrap({ children }: { children: React.ReactNode }) {
return <Suspense fallback={<LazyFallback />}>{children}</Suspense>;
}
export const router = createBrowserRouter([
{
path: '/login',
element: <PublicRoute />,
children: [
{
element: <AuthLayout />,
children: [{ index: true, element: <LoginPage /> }],
},
],
},
{
element: <ProtectedRoute />,
children: [
// Console — separate layout (no sidebar)
{
path: '/console/:serverId/:vmid',
element: <ConsoleLayout />,
children: [{ index: true, element: <ConsolePage /> }],
},
// Main app layout
{
element: <AppLayout />,
children: [
{ path: '/', element: <Navigate to="/dashboard" replace /> },
{ path: '/dashboard', element: <DashboardPage /> },
{ path: '/instances', element: <InstancesPage /> },
{ path: '/instances/create', element: <SuspenseWrap><CreateInstanceWizard /></SuspenseWrap> },
{ path: '/instances/:serverId/:vmid', element: <InstanceDetailPage /> },
{ path: '/nodes', element: <SuspenseWrap><NodesPage /></SuspenseWrap> },
{ path: '/nodes/:serverId', element: <SuspenseWrap><NodeDetailPage /></SuspenseWrap> },
{ path: '/templates', element: <SuspenseWrap><TemplatesPage /></SuspenseWrap> },
{ path: '/backups', element: <SuspenseWrap><BackupsPage /></SuspenseWrap> },
{ path: '/tasks', element: <SuspenseWrap><TasksPage /></SuspenseWrap> },
{ path: '/ipam', element: <SuspenseWrap><IPAMDashboardPage /></SuspenseWrap> },
{ path: '/ipam/networks', element: <SuspenseWrap><IPAMNetworksPage /></SuspenseWrap> },
{ path: '/ipam/network/:id', element: <SuspenseWrap><IPAMNetworkDetailPage /></SuspenseWrap> },
{ path: '/ipam/allocations', element: <SuspenseWrap><IPAMAllocationsPage /></SuspenseWrap> },
{ path: '/ipam/history', element: <SuspenseWrap><IPAMHistoryPage /></SuspenseWrap> },
{ path: '/networks', element: <SuspenseWrap><IPAMNetworksPage /></SuspenseWrap> },
{ path: '/users', element: <SuspenseWrap><UsersPage /></SuspenseWrap> },
{ path: '/workspaces', element: <SuspenseWrap><WorkspacesPage /></SuspenseWrap> },
{ path: '/logs', element: <SuspenseWrap><LogsPage /></SuspenseWrap> },
{ path: '/settings', element: <SuspenseWrap><SettingsPage /></SuspenseWrap> },
],
},
],
},
{ path: '*', element: <Navigate to="/dashboard" replace /> },
]);

View File

@@ -0,0 +1,70 @@
import { create } from 'zustand';
import { apiClient } from '@/lib/api-client';
import type { User, LoginRequest, AuthResponse } from '@/types';
interface AuthState {
user: User | null;
token: string | null;
isAuthenticated: boolean;
isLoading: boolean;
error: string | null;
login: (credentials: LoginRequest) => Promise<void>;
logout: () => Promise<void>;
loadUser: () => Promise<void>;
clearError: () => void;
}
export const useAuthStore = create<AuthState>((set) => ({
user: null,
token: localStorage.getItem('access_token'),
isAuthenticated: !!localStorage.getItem('access_token'),
isLoading: false,
error: null,
login: async (credentials: LoginRequest) => {
set({ isLoading: true, error: null });
try {
const data = await apiClient.post<AuthResponse>('/api/auth/login', credentials);
apiClient.setToken(data.access_token);
set({ token: data.access_token, isAuthenticated: true, isLoading: false });
// Load user info
const user = await apiClient.get<User>('/api/auth/me');
set({ user });
} catch (err) {
const message = err instanceof Error ? err.message : 'Login failed';
set({ error: message, isLoading: false, isAuthenticated: false });
throw err;
}
},
logout: async () => {
try {
await apiClient.post('/api/auth/logout');
} catch {
// Ignore logout errors
}
apiClient.setToken(null);
set({ user: null, token: null, isAuthenticated: false });
},
loadUser: async () => {
const token = localStorage.getItem('access_token');
if (!token) {
set({ isAuthenticated: false, isLoading: false });
return;
}
set({ isLoading: true });
try {
apiClient.setToken(token);
const user = await apiClient.get<User>('/api/auth/me');
set({ user, isAuthenticated: true, isLoading: false });
} catch {
apiClient.setToken(null);
set({ user: null, token: null, isAuthenticated: false, isLoading: false });
}
},
clearError: () => set({ error: null }),
}));

View File

@@ -0,0 +1,45 @@
import { create } from 'zustand';
type Theme = 'dark' | 'light';
interface ThemeState {
theme: Theme;
setTheme: (theme: Theme) => void;
toggleTheme: () => void;
}
function getInitialTheme(): Theme {
const stored = localStorage.getItem('pve-theme');
if (stored === 'light' || stored === 'dark') return stored;
return 'dark'; // Dark by default per TZ
}
function applyTheme(theme: Theme) {
const root = document.documentElement;
if (theme === 'dark') {
root.classList.add('dark');
} else {
root.classList.remove('dark');
}
localStorage.setItem('pve-theme', theme);
}
// Apply initial theme immediately
applyTheme(getInitialTheme());
export const useThemeStore = create<ThemeState>((set) => ({
theme: getInitialTheme(),
setTheme: (theme: Theme) => {
applyTheme(theme);
set({ theme });
},
toggleTheme: () => {
set((state) => {
const next = state.theme === 'dark' ? 'light' : 'dark';
applyTheme(next);
return { theme: next };
});
},
}));

View File

@@ -0,0 +1,21 @@
import { create } from 'zustand';
interface WorkspaceState {
activeWorkspaceId: number | null;
setActiveWorkspace: (id: number | null) => void;
}
export const useWorkspaceStore = create<WorkspaceState>((set) => ({
activeWorkspaceId: (() => {
const stored = localStorage.getItem('pve-active-workspace');
return stored ? Number(stored) : null;
})(),
setActiveWorkspace: (id) => {
if (id !== null) {
localStorage.setItem('pve-active-workspace', String(id));
} else {
localStorage.removeItem('pve-active-workspace');
}
set({ activeWorkspaceId: id });
},
}));

363
frontend/src/types/index.ts Normal file
View File

@@ -0,0 +1,363 @@
// ==================== User Types ====================
export interface User {
id: number;
username: string;
email: string;
full_name?: string;
is_active: boolean;
is_admin: boolean;
created_at: string;
last_login?: string;
role_id?: number;
role_name?: string;
is_locked?: boolean;
permissions?: string[];
}
export interface LoginRequest {
username: string;
password: string;
}
export interface AuthResponse {
access_token: string;
token_type: string;
session_id?: string;
}
// ==================== Proxmox Server Types ====================
export interface ProxmoxServer {
id: number;
name: string;
hostname: string;
ip_address: string;
port: number;
api_user: string;
verify_ssl: boolean;
description?: string;
created_at: string;
updated_at: string;
last_check?: string;
is_online?: boolean;
last_error?: string;
}
export interface ProxmoxServerCreate {
name: string;
hostname: string;
ip_address: string;
port?: number;
api_user?: string;
api_token_name?: string;
api_token_value?: string;
use_password?: boolean;
password?: string;
verify_ssl?: boolean;
description?: string;
}
// ==================== VM / Container Types ====================
export type VMType = 'qemu' | 'lxc';
export type VMStatus = 'running' | 'stopped' | 'paused' | 'suspended' | 'unknown';
export interface VMInstance {
vmid: number;
name?: string;
status: VMStatus;
type: VMType;
node: string;
server_id?: number;
server_name?: string;
cpu?: number;
mem?: number;
maxmem?: number;
disk?: number;
maxdisk?: number;
uptime?: number;
netin?: number;
netout?: number;
ip_address?: string;
tags?: string;
template?: boolean;
}
export interface VMConfig {
cores?: number;
memory?: number;
disk?: string;
name?: string;
ostype?: string;
net0?: string;
boot?: string;
agent?: number;
onboot?: number;
[key: string]: unknown;
}
export interface VMDeployRequest {
template_id: number;
name: string;
vmid?: number;
target_node?: string;
cores?: number;
memory?: number;
disk?: number;
target_storage?: string;
start_after_create?: boolean;
onboot?: boolean;
enable_ha?: boolean;
network_bridge?: string;
ip_address?: string;
gateway?: string;
ipam_network_id?: number;
ipam_pool_id?: number;
cloud_init_user?: string;
cloud_init_password?: string;
ssh_keys?: string;
}
// ==================== Node Types ====================
export interface ProxmoxNode {
node: string;
status: 'online' | 'offline';
cpu?: number;
maxcpu?: number;
mem?: number;
maxmem?: number;
disk?: number;
maxdisk?: number;
uptime?: number;
server_id: number;
server_name?: string;
vm_count?: number;
ct_count?: number;
}
// ==================== IPAM Types ====================
export interface IPAMNetwork {
id: number;
name: string;
description?: string;
network: string;
gateway?: string;
vlan_id?: number;
dns_primary?: string;
dns_secondary?: string;
dns_domain?: string;
proxmox_server_id?: number;
proxmox_node?: string;
proxmox_bridge?: string;
is_active: boolean;
created_at: string;
updated_at: string;
total_ips?: number;
used_ips?: number;
available_ips?: number;
utilization_percent?: number;
server_name?: string;
}
export interface IPAMPool {
id: number;
network_id: number;
name: string;
pool_type: 'static' | 'dhcp' | 'reserved';
range_start: string;
range_end: string;
auto_assign: boolean;
description?: string;
is_active: boolean;
created_at: string;
updated_at: string;
total_ips?: number;
used_ips?: number;
available_ips?: number;
}
export interface IPAMAllocation {
id: number;
network_id: number;
pool_id?: number;
ip_address: string;
mac_address?: string;
resource_type?: string;
resource_id?: number;
resource_name?: string;
network_name?: string;
proxmox_server_id?: number;
proxmox_vmid?: number;
proxmox_node?: string;
status: 'allocated' | 'reserved' | 'available' | 'conflict';
description?: string;
created_at: string;
updated_at: string;
}
// ==================== Template Types ====================
export interface OSTemplateGroup {
id: number;
name: string;
icon?: string;
description?: string;
sort_order: number;
is_active: boolean;
created_at: string;
updated_at: string;
}
export interface OSTemplate {
id: number;
group_id: number;
server_id: number;
name: string;
vmid: number;
node: string;
default_cores: number;
default_memory: number;
default_disk: number;
min_cores: number;
min_memory: number;
min_disk: number;
description?: string;
is_active: boolean;
sort_order: number;
created_at: string;
updated_at: string;
group_name?: string;
group_icon?: string;
server_name?: string;
}
// ==================== Workspace Types ====================
export interface Workspace {
id: number;
name: string;
description?: string;
is_active: boolean;
created_at: string;
updated_at: string;
servers?: WorkspaceServer[];
users?: WorkspaceUser[];
}
export interface WorkspaceServer {
server_id: number;
server_name: string;
}
export interface WorkspaceUser {
user_id: number;
username: string;
}
// ==================== Notification Types ====================
export interface Notification {
id: number;
user_id: number;
title: string;
message: string;
type: 'info' | 'warning' | 'error' | 'success';
is_read: boolean;
category?: string;
resource_type?: string;
resource_id?: string;
created_at: string;
}
// ==================== Log Types ====================
export interface AuditLog {
id: number;
timestamp: string;
level: string;
category: string;
message: string;
user_id?: number;
username?: string;
ip_address?: string;
details?: Record<string, unknown>;
}
// ==================== Task Types ====================
export interface ProxmoxTask {
upid: string;
node: string;
type: string;
status?: string;
exitstatus?: string;
starttime: number;
endtime?: number;
user: string;
server_id?: number;
server_name?: string;
}
// ==================== Backup Types ====================
export interface BackupJob {
id: number;
server_id: number;
vmid?: number;
schedule?: string;
storage: string;
mode?: string;
compress?: string;
enabled: boolean;
created_at: string;
}
// ==================== Snapshot Types ====================
export interface Snapshot {
name: string;
description?: string;
snaptime?: number;
parent?: string;
vmstate?: boolean;
}
// ==================== Dashboard Types ====================
export interface DashboardStats {
nodes_total: number;
nodes_online: number;
vms_total: number;
vms_running: number;
containers_total: number;
containers_running: number;
alerts_count: number;
}
export interface ResourceMetrics {
cpu_percent: number;
memory_used: number;
memory_total: number;
memory_percent: number;
storage_used: number;
storage_total: number;
storage_percent: number;
}
// ==================== Common Types ====================
export interface PaginatedResponse<T> {
items: T[];
total: number;
page: number;
per_page: number;
pages: number;
}
export interface ApiError {
detail: string;
status_code?: number;
}

View File

@@ -0,0 +1,31 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"ignoreDeprecations": "6.0",
"target": "es2023",
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "esnext",
"types": ["vite/client"],
"skipLibCheck": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"strict": true
},
"include": ["src"]
}

14
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,14 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
],
"compilerOptions": {
"ignoreDeprecations": "6.0",
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}

View File

@@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"module": "esnext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}

31
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,31 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import path from 'path'
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
},
'/proxmox': {
target: 'http://localhost:8000',
changeOrigin: true,
ws: true,
},
'/ws': {
target: 'http://localhost:8000',
changeOrigin: true,
ws: true,
},
},
},
})