Compare commits
2 Commits
4546a798d0
...
297082dc72
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
297082dc72 | ||
|
|
1d08c815a7 |
322
.claude/PVEmanager_Frontend_Redesign_TZ.md
Normal file
@@ -0,0 +1,322 @@
|
||||
# ТЕХНИЧЕСКОЕ ЗАДАНИЕ
|
||||
## Редизайн фронтенда PVEmanager
|
||||
|
||||
*Переход на компонентную архитектуру в стиле облачных провайдеров*
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Проект** | PVEmanager |
|
||||
| **Версия ТЗ** | 1.0 |
|
||||
| **Дата** | 07.04.2026 |
|
||||
| **Автор** | DevOps / CTO |
|
||||
| **Статус** | Черновик |
|
||||
|
||||
> Eskiz (AS35682) / Innasoft Digital Service LLC
|
||||
|
||||
---
|
||||
|
||||
# 1. Введение
|
||||
|
||||
## 1.1 О проекте
|
||||
|
||||
PVEmanager — self-hosted веб-панель для управления несколькими серверами Proxmox VE. Полный жизненный цикл VM/LXC, RBAC, VNC/xterm.js-консоль, IPAM, SDN, мониторинг в реальном времени, уведомления (Email, Telegram), аудит-лог.
|
||||
|
||||
## 1.2 Текущий стек
|
||||
|
||||
| Компонент | Технология | Проблема |
|
||||
|---|---|---|
|
||||
| Бэкенд | Python 3.12, FastAPI, SQLAlchemy | Остаётся без изменений |
|
||||
| Шаблоны | Jinja2 (20 файлов, 26 159 строк) | Монолитные файлы до 4 700 строк |
|
||||
| JavaScript | Vanilla JS (inline в HTML) | 1 500+ ручных DOM-манипуляций |
|
||||
| CSS-фреймворк | Bootstrap 5 + theme.css (3 487 строк) | Большая часть переопределена кастомным CSS |
|
||||
| Графики | Chart.js | Ограниченная интерактивность |
|
||||
| Консоль | noVNC, xterm.js | Остаётся |
|
||||
| БД | PostgreSQL 16 | Остаётся |
|
||||
|
||||
## 1.3 Цели редизайна
|
||||
|
||||
1. Перевести фронтенд на компонентную SPA-архитектуру (React + TypeScript)
|
||||
2. Достичь визуального качества уровня DigitalOcean / Hetzner Cloud / Vultr
|
||||
3. Улучшить поддерживаемость: от 26K строк Jinja2 к реиспользуемым компонентам
|
||||
4. Сохранить 100% функциональности текущего фронтенда
|
||||
5. Унифицировать стек с Eskiz Panel (React + FastAPI)
|
||||
|
||||
## 1.4 Эталонные интерфейсы
|
||||
|
||||
Дизайн должен соответствовать UX-паттернам следующих панелей облачных провайдеров:
|
||||
|
||||
- **DigitalOcean Cloud Console** — чистый layout, группировка навигации, проектная структура
|
||||
- **Hetzner Cloud** — минимализм, табы на детальной странице, тёмная тема
|
||||
- **Vultr** — inline-метрики, компактные карточки серверов
|
||||
- **ConvoyPanel** — open-source Proxmox-панель на React, референс по компонентной архитектуре
|
||||
|
||||
---
|
||||
|
||||
# 2. Целевой технологический стек
|
||||
|
||||
| Слой | Технология | Назначение |
|
||||
|---|---|---|
|
||||
| Фреймворк | React 19 + TypeScript | SPA, компонентная архитектура |
|
||||
| Сборка | Vite 6 | HMR, быстрая сборка |
|
||||
| UI-компоненты | shadcn/ui (Radix UI) | Акcессибилити, контроль над кодом |
|
||||
| CSS | Tailwind CSS 4 | Утилитарный CSS, dark/light темы |
|
||||
| Роутинг | React Router 7 / TanStack Router | Клиентская маршрутизация |
|
||||
| Состояние | Zustand | Легковесный state management |
|
||||
| Запросы | TanStack Query (React Query) | Кэш, рефетч, оптимистические обновления |
|
||||
| Таблицы | TanStack Table | Сортировка, фильтрация, пагинация, выделение |
|
||||
| Графики | Recharts / Tremor | Метрики, area-графики, sparkline |
|
||||
| Иконки | Lucide React | Единый стиль, 1 400+ иконок |
|
||||
| WebSocket | Нативный WebSocket API | Метрики реального времени, задачи |
|
||||
| VNC | noVNC (ES module) | Консоль VM |
|
||||
| Терминал | xterm.js + xterm-addon-fit | Консоль LXC |
|
||||
| i18n | react-i18next | Русский / English |
|
||||
|
||||
---
|
||||
|
||||
# 3. Архитектура
|
||||
|
||||
## 3.1 Структура проекта
|
||||
|
||||
```
|
||||
frontend/
|
||||
├── src/
|
||||
│ ├── components/ # Общие UI-компоненты (Button, Modal, Table, …)
|
||||
│ ├── components/ui/ # shadcn/ui примитивы
|
||||
│ ├── features/ # Функциональные модули
|
||||
│ │ ├── dashboard/ # Дашборд
|
||||
│ │ ├── instances/ # VM + LXC список и детали
|
||||
│ │ ├── nodes/ # Узлы Proxmox
|
||||
│ │ ├── ipam/ # IP-менеджмент
|
||||
│ │ ├── templates/ # OS-шаблоны
|
||||
│ │ ├── backups/ # Бэкапы
|
||||
│ │ ├── console/ # noVNC + xterm.js (отдельная вкладка браузера)
|
||||
│ │ ├── settings/ # Настройки
|
||||
│ │ ├── users/ # Пользователи и RBAC
|
||||
│ │ └── logs/ # Аудит-лог
|
||||
│ ├── hooks/ # useAuth, useWebSocket, useMetrics, …
|
||||
│ ├── lib/ # API-клиент, утилиты, константы
|
||||
│ ├── layouts/ # AppLayout, AuthLayout
|
||||
│ ├── stores/ # Zustand-сторы
|
||||
│ ├── locales/ # ru.json, en.json
|
||||
│ └── types/ # TypeScript-типы (VM, Node, User, …)
|
||||
├── index.html
|
||||
├── vite.config.ts
|
||||
├── tailwind.config.ts
|
||||
└── tsconfig.json
|
||||
```
|
||||
|
||||
## 3.2 API-слой
|
||||
|
||||
FastAPI остаётся без изменений. Удаляются только Jinja2-роуты (эндпоинты с `TemplateResponse`). JSON API-эндпоинты сохраняются как есть. WebSocket-эндпоинты (`/ws/tasks`, `/ws/metrics`) сохраняются.
|
||||
|
||||
## 3.3 Аутентификация
|
||||
|
||||
Текущая схема JWT + session token сохраняется. React-клиент хранит токен в localStorage (как сейчас), проактивный refresh за 2 минуты до истечения. Protected routes через React Router guard.
|
||||
|
||||
---
|
||||
|
||||
# 4. Дизайн-система
|
||||
|
||||
## 4.1 Цветовая палитра
|
||||
|
||||
Тёмная тема по умолчанию, светлая по переключению. Flat-цвета без градиентов на кнопках и карточках.
|
||||
|
||||
| Токен | Dark | Light | Назначение |
|
||||
|---|---|---|---|
|
||||
| `surface-0` | `#09090B` | `#FFFFFF` | Фон приложения |
|
||||
| `surface-1` | `#111113` | `#FAFAFA` | Карточки, сайдбар |
|
||||
| `surface-2` | `#1A1A1F` | `#F4F4F5` | Hover, выделение строк |
|
||||
| `border` | `#222228` | `#E4E4E7` | Границы |
|
||||
| `text-primary` | `#F0F0F3` | `#09090B` | Основной текст |
|
||||
| `text-secondary` | `#A0A0AB` | `#71717A` | Вторичный текст |
|
||||
| `blue` (primary) | `#3B82F6` | `#2563EB` | Акцент, CTA, ссылки |
|
||||
| `green` | `#22C55E` | `#16A34A` | Статус online/running |
|
||||
| `red` | `#EF4444` | `#DC2626` | Статус offline, ошибки, danger |
|
||||
| `amber` | `#F59E0B` | `#D97706` | Предупреждения, средняя нагрузка |
|
||||
|
||||
## 4.2 Типографика
|
||||
|
||||
Основной шрифт: **DM Sans** (Google Fonts). Моноширинный: **JetBrains Mono** (для IP-адресов, ID, консоли).
|
||||
|
||||
| Элемент | Размер | Вес |
|
||||
|---|---|---|
|
||||
| Заголовок страницы | 22px | 650 (semibold) |
|
||||
| Заголовок секции | 13–14px | 600 |
|
||||
| Основной текст | 13px | 450 |
|
||||
| Метки (labels) | 11px | 600, uppercase, letter-spacing 0.06em |
|
||||
| Стат-цифры | 28px | 700, tabular-nums |
|
||||
| Моно (IP, ID) | 12px | JetBrains Mono 400 |
|
||||
|
||||
## 4.3 Компоненты
|
||||
|
||||
Все компоненты из shadcn/ui с кастомизацией под дизайн-систему:
|
||||
|
||||
| Компонент | Источник | Кастомизация |
|
||||
|---|---|---|
|
||||
| Button | shadcn/ui | Flat-стиль, без градиентов, border-radius 7px |
|
||||
| Dialog / Modal | shadcn/ui (Radix) | Заменяет 65 модалок Bootstrap |
|
||||
| DropdownMenu | shadcn/ui (Radix) | Контекстные меню VM (три точки) |
|
||||
| Select / Combobox | shadcn/ui | Фильтры по нодам, статусам |
|
||||
| Tabs | shadcn/ui (Radix) | All / VM / LXC, детальная страница VM |
|
||||
| Tooltip | shadcn/ui (Radix) | Подсказки на иконках |
|
||||
| Toast | sonner | Уведомления о действиях |
|
||||
| StatusDot | Кастом | Индикатор online/running/stopped |
|
||||
| MetricRing | Кастом (SVG) | Кольцевой индикатор CPU на нодах |
|
||||
| Sparkline | Кастом (SVG) | Мини-графики в карточках ресурсов |
|
||||
| Tag | Кастом | Цветные теги VM (production, dev, …) |
|
||||
| ConsoleView | Кастом | Обёртка noVNC/xterm.js. Открывается в отдельной вкладке (route `/console/:serverId/:vmid`) |
|
||||
|
||||
---
|
||||
|
||||
# 5. Спецификация страниц
|
||||
|
||||
## 5.1 Layout
|
||||
|
||||
Фиксированный sidebar (232px) с группировкой навигации: **Infrastructure** / **Network** / **Management**. Сверху — лого + workspace-селектор. Снизу — аватар пользователя + роль. Top bar: статус-индикатор системы + колокольчик уведомлений. Мобильный: sidebar складывается в hamburger.
|
||||
|
||||
## 5.2 Dashboard
|
||||
|
||||
- 4 stat-карточки: Nodes, VM, Containers, Alerts. Каждая: метка, цифра, подпись, иконка
|
||||
- 3 ресурсных карточки: CPU / Memory / Storage. Sparkline-график + progress bar + % значение
|
||||
- Таблица Instances (6 строк): name, node, cpu, ram, status. Ссылка «View all»
|
||||
- Панель Nodes: кольцевой индикатор CPU, имя, VM/CT count, uptime, статус
|
||||
- Лента Recent Activity: время, пользователь, действие (цветной badge), ресурс
|
||||
- Автообновление через WebSocket (метрики) и polling (список VM, 30с)
|
||||
|
||||
## 5.3 Instances (список VM/LXC)
|
||||
|
||||
- Табы: All (N) / VM (N) / LXC (N) с каунтерами
|
||||
- Поиск по имени и IP. Фильтры: статус, нода, теги
|
||||
- Таблица: checkbox, name (#id), status (точка), node, type (VM/LXC badge), IP, CPU (inline progress bar), Memory, Disk, Tags, меню (три точки)
|
||||
- Bulk actions: Start / Stop / Delete (появляются при выделении)
|
||||
- Контекстное меню: Console, Start/Restart, Stop, Snapshot
|
||||
- Пагинация в футере таблицы
|
||||
- Кнопка «Create Instance» в шапке страницы
|
||||
|
||||
## 5.4 Instance Detail (детальная страница VM/LXC)
|
||||
|
||||
Ключевое изменение: вместо одной длинной страницы — горизонтальные табы (как у Hetzner):
|
||||
|
||||
| Таб | Содержимое |
|
||||
|---|---|
|
||||
| **Overview** | Инфо-карточка (IP, OS, vCPU, RAM, Disk, нода), 4 metric-карточки (CPU, RAM, Disk, Uptime), сетевой трафик |
|
||||
| **Graphs** | Area-графики CPU/RAM/Network/Disk I/O с переключением периода (1h/6h/24h/7d/30d) |
|
||||
| **Console** | Открывается в отдельной вкладке браузера (`window.open`). Полноэкранный noVNC (VM) / xterm.js (LXC) с тулбаром (Send Ctrl+Alt+Del, Clipboard, Fullscreen). Без sidebar и top bar — только консоль. |
|
||||
| **Snapshots** | Список снапшотов с действиями: create, rollback, delete |
|
||||
| **Backups** | История бэкапов, запуск ручного бэкапа |
|
||||
| **Networking** | IP-адреса, сетевые интерфейсы, firewall-правила |
|
||||
| **Commands** | Быстрые команды + произвольный bash-скрипт (QEMU Guest Agent) |
|
||||
| **Settings** | Resize (CPU/RAM/Disk), перенос владельца, теги |
|
||||
| **Destroy** | Удаление с подтверждением (ввод имени VM) |
|
||||
|
||||
Шапка страницы: имя VM, статус, кнопки действий (Power On/Off, Restart, Console → новая вкладка) в одну линию.
|
||||
|
||||
## 5.5 Остальные страницы
|
||||
|
||||
| Страница | Ключевые изменения |
|
||||
|---|---|
|
||||
| **Nodes** | Карточки с MetricRing + клик → детальная страница ноды |
|
||||
| **Templates** | Grid-сетка карточек OS с иконками дистрибутивов |
|
||||
| **Backups** | Таблица + статусы + расписание |
|
||||
| **IPAM** | Три-уровневая: сети → подсети → адреса. Визуализация занятости |
|
||||
| **Logs** | Лог-таблица с фильтрами, expandable rows |
|
||||
| **Settings** | Табы: General / Notifications / Security / About |
|
||||
| **Users** | Таблица + модальное создание/редактирование |
|
||||
| **Login** | Центрированная форма, лого, ошибки валидации |
|
||||
|
||||
---
|
||||
|
||||
# 6. План работ и фазы
|
||||
|
||||
| Фаза | Срок | Содержание | Результат |
|
||||
|---|---|---|---|
|
||||
| **1. Каркас** | 2–3 нед. | Vite + React + TS + shadcn/ui + Tailwind. Layout (сайдбар, top bar). Auth (логин, guards). Dashboard. | Работающий dashboard, логин |
|
||||
| **2. Instances** | 3–4 нед. | Список VM/LXC. Detail с табами. Create VM wizard. Console (noVNC + xterm.js). | Основной функционал VM |
|
||||
| **3. Доп. страницы** | 2–3 нед. | Nodes, Templates, Backups, IPAM, Logs, Settings, Users, Workspaces. | Полный паритет с текущим UI |
|
||||
| **4. Полировка** | 1–2 нед. | i18n (ru/en), мобильная адаптация, empty states, анимации, тестирование. | Продакшн-готовность |
|
||||
|
||||
**Итого: 8–12 недель при работе одного разработчика.**
|
||||
|
||||
---
|
||||
|
||||
# 7. Изменения на бэкенде
|
||||
|
||||
## 7.1 Удалить
|
||||
|
||||
- Jinja2-роуты (`TemplateResponse`) — все эндпоинты с `include_in_schema=False`
|
||||
- Папку `templates/` (все 20 HTML-файлов)
|
||||
- Папку `static/` (CSS, JS, img) — заменяется Vite-сборкой
|
||||
- `template_helpers.py`, `language_middleware.py`, `i18n.py`
|
||||
- Зависимость `Jinja2` из `requirements.txt`
|
||||
|
||||
## 7.2 Сохранить без изменений
|
||||
|
||||
- Все JSON API-эндпоинты (19 файлов роутеров, 12 147 строк)
|
||||
- WebSocket-эндпоинты (`/ws/tasks`, метрики)
|
||||
- RBAC, auth, models, schemas, services, workers
|
||||
- `proxmox_client.py`, `ssh_client.py`
|
||||
- Docker-конфигурация (добавить сервис frontend)
|
||||
|
||||
## 7.3 Добавить
|
||||
|
||||
- CORS: разрешить оригин фронтенда (`localhost:5173` для dev, продакшн-домен)
|
||||
- Nginx: проксирование `/api/*` → FastAPI, остальное → SPA (`index.html`)
|
||||
- Docker: новый сервис frontend с Nginx или multi-stage build
|
||||
|
||||
---
|
||||
|
||||
# 8. Деплой и инфраструктура
|
||||
|
||||
Фронтенд собирается в статику (`vite build`) и отдаётся через Nginx. Два варианта:
|
||||
|
||||
- **Вариант A (рекомендуется):** Отдельный Docker-контейнер frontend: `node:22-alpine` для build stage, `nginx:alpine` для serve.
|
||||
- **Вариант B:** Сборка в CI/CD, копирование `dist/` в существующий Nginx-контейнер.
|
||||
|
||||
Nginx-конфиг для SPA:
|
||||
|
||||
```nginx
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8000/;
|
||||
}
|
||||
|
||||
location /ws/ {
|
||||
proxy_pass http://backend:8000/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 9. Критерии приёмки
|
||||
|
||||
1. Все существующие функции работают идентично текущему UI
|
||||
2. Визуальное соответствие прототипам (Dashboard + VM List)
|
||||
3. Dark / Light темы работают корректно
|
||||
4. i18n: полный перевод RU/EN
|
||||
5. Мобильная адаптация на экранах от 320px
|
||||
6. Lighthouse Performance ≥ 85
|
||||
7. VNC/xterm.js консоль работает без регрессий (открывается в отдельной вкладке)
|
||||
8. WebSocket-метрики обновляются в реальном времени
|
||||
9. Нет зависимости от Bootstrap
|
||||
10. Docker-сборка работает через `docker compose up -d`
|
||||
|
||||
---
|
||||
|
||||
# 10. Риски
|
||||
|
||||
| Риск | Вероятность | Митигация |
|
||||
|---|---|---|
|
||||
| Регрессия функционала | Средняя | Пофазный перенос, параллельная работа старого UI |
|
||||
| noVNC/xterm.js совместимость | Низкая | Используются как ES-модули, React-обёртки существуют |
|
||||
| Увеличение сроков | Средняя | Начать с критических страниц (Dashboard, Instances) |
|
||||
| Потеря i18n-переводов | Низкая | Миграция `ru.json`/`en.json` в react-i18next |
|
||||
|
||||
---
|
||||
|
||||
*Конец документа*
|
||||
@@ -1,7 +1,6 @@
|
||||
from datetime import datetime, timedelta
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Request, Form
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi.responses import RedirectResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from loguru import logger
|
||||
|
||||
@@ -19,23 +18,9 @@ from ..auth import (
|
||||
)
|
||||
from ..config import settings, utcnow
|
||||
from ..logging_service import LoggingService
|
||||
from ..template_helpers import add_i18n_context
|
||||
from ..services.security_service import SecurityService
|
||||
|
||||
router = APIRouter()
|
||||
templates = Jinja2Templates(directory="app/templates")
|
||||
|
||||
|
||||
@router.get("/login", response_class=HTMLResponse, include_in_schema=False)
|
||||
def login_page(request: Request):
|
||||
"""Login page"""
|
||||
context = {
|
||||
"request": request,
|
||||
"panel_name": settings.PANEL_NAME,
|
||||
"version": settings.VERSION,
|
||||
}
|
||||
context = add_i18n_context(request, context)
|
||||
return templates.TemplateResponse("login.html", context)
|
||||
|
||||
|
||||
@router.post("/api/auth/login", response_model=Token)
|
||||
|
||||
@@ -1,46 +1,27 @@
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func, distinct
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from ..db import get_db
|
||||
from ..models import ProxmoxServer, AuditLog, User
|
||||
from ..template_helpers import add_i18n_context
|
||||
from ..auth import PermissionChecker
|
||||
|
||||
router = APIRouter()
|
||||
templates = Jinja2Templates(directory="app/templates")
|
||||
|
||||
|
||||
@router.get("/", response_class=HTMLResponse, include_in_schema=False)
|
||||
@router.get("/dashboard", response_class=HTMLResponse, include_in_schema=False)
|
||||
def dashboard(request: Request, db: Session = Depends(get_db)):
|
||||
@router.get("/api/dashboard/stats")
|
||||
def dashboard_stats(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("dashboard.view"))
|
||||
):
|
||||
"""Dashboard statistics API"""
|
||||
from .workspaces import get_workspace_server_ids
|
||||
|
||||
# Try to get current user for workspace filtering (best-effort)
|
||||
try:
|
||||
token = request.cookies.get("access_token") or (
|
||||
request.headers.get("Authorization", "").removeprefix("Bearer ").strip() or None
|
||||
)
|
||||
current_user_obj = None
|
||||
if token:
|
||||
from ..auth import decode_access_token
|
||||
from ..models import User as UserModel
|
||||
try:
|
||||
payload = decode_access_token(token)
|
||||
username = payload.get("sub")
|
||||
if username:
|
||||
current_user_obj = db.query(UserModel).filter(
|
||||
UserModel.username == username, UserModel.is_active == True
|
||||
).first()
|
||||
except Exception:
|
||||
pass
|
||||
server_ids = get_workspace_server_ids(request, db, current_user)
|
||||
|
||||
server_ids = get_workspace_server_ids(request, db, current_user_obj) if current_user_obj else None
|
||||
except Exception:
|
||||
server_ids = None
|
||||
from sqlalchemy import func, distinct
|
||||
|
||||
base_q = db.query(ProxmoxServer)
|
||||
if server_ids is not None:
|
||||
@@ -50,22 +31,14 @@ def dashboard(request: Request, db: Session = Depends(get_db)):
|
||||
online_servers = base_q.filter(ProxmoxServer.is_online == True).count()
|
||||
offline_servers = base_q.filter(ProxmoxServer.is_online == False).count()
|
||||
|
||||
# Count distinct clusters within the filtered servers
|
||||
cluster_q = db.query(func.count(distinct(ProxmoxServer.cluster_name))).filter(
|
||||
ProxmoxServer.cluster_name.isnot(None),
|
||||
ProxmoxServer.cluster_name != ''
|
||||
)
|
||||
standalone_q = db.query(ProxmoxServer).filter(
|
||||
(ProxmoxServer.cluster_name == None) | (ProxmoxServer.cluster_name == '')
|
||||
)
|
||||
if server_ids is not None:
|
||||
cluster_q = cluster_q.filter(ProxmoxServer.id.in_(server_ids))
|
||||
standalone_q = standalone_q.filter(ProxmoxServer.id.in_(server_ids))
|
||||
named_clusters = cluster_q.scalar() or 0
|
||||
standalone_count = standalone_q.count()
|
||||
total_clusters = named_clusters
|
||||
total_clusters = cluster_q.scalar() or 0
|
||||
|
||||
# Alerts in last 24 hours
|
||||
since_24h = datetime.utcnow() - timedelta(hours=24)
|
||||
total_alerts = db.query(AuditLog).filter(
|
||||
AuditLog.level.in_(['error', 'critical']),
|
||||
@@ -76,7 +49,6 @@ def dashboard(request: Request, db: Session = Depends(get_db)):
|
||||
AuditLog.created_at >= since_24h
|
||||
).count()
|
||||
|
||||
# Recent audit events (last 5)
|
||||
recent_audit = db.query(AuditLog).order_by(
|
||||
AuditLog.created_at.desc()
|
||||
).limit(5).all()
|
||||
@@ -85,29 +57,29 @@ def dashboard(request: Request, db: Session = Depends(get_db)):
|
||||
if server_ids is not None:
|
||||
recent_servers_q = recent_servers_q.filter(ProxmoxServer.id.in_(server_ids))
|
||||
recent_servers = recent_servers_q.order_by(ProxmoxServer.id.desc()).limit(6).all()
|
||||
|
||||
stats = {
|
||||
"total_servers": total_servers,
|
||||
"online_servers": online_servers,
|
||||
"offline_servers": offline_servers,
|
||||
"total_clusters": total_clusters,
|
||||
"total_alerts": total_alerts,
|
||||
"critical_alerts": critical_alerts,
|
||||
|
||||
return {
|
||||
"stats": {
|
||||
"total_servers": total_servers,
|
||||
"online_servers": online_servers,
|
||||
"offline_servers": offline_servers,
|
||||
"total_clusters": total_clusters,
|
||||
"total_alerts": total_alerts,
|
||||
"critical_alerts": critical_alerts,
|
||||
},
|
||||
"recent_servers": [
|
||||
{"id": s.id, "name": s.name, "ip_address": s.ip_address, "is_online": s.is_online}
|
||||
for s in recent_servers
|
||||
],
|
||||
"recent_audit": [
|
||||
{
|
||||
"id": a.id, "level": a.level, "category": a.category,
|
||||
"action": a.action, "message": a.message, "username": a.username,
|
||||
"created_at": a.created_at.isoformat() if a.created_at else None,
|
||||
}
|
||||
for a in recent_audit
|
||||
],
|
||||
}
|
||||
|
||||
lang = request.cookies.get("language", "en")
|
||||
from ..i18n import t
|
||||
|
||||
context = {
|
||||
"request": request,
|
||||
"stats": stats,
|
||||
"recent_servers": recent_servers,
|
||||
"recent_audit": recent_audit,
|
||||
"page_title": t('nav_dashboard', lang),
|
||||
}
|
||||
context = add_i18n_context(request, context)
|
||||
|
||||
return templates.TemplateResponse("dashboard.html", context)
|
||||
|
||||
|
||||
@router.get("/api/dashboard/alerts")
|
||||
@@ -139,16 +111,3 @@ def get_dashboard_alerts(
|
||||
],
|
||||
"total": len(alerts)
|
||||
})
|
||||
|
||||
|
||||
@router.get("/containers", response_class=HTMLResponse, include_in_schema=False)
|
||||
def containers(request: Request):
|
||||
lang = request.cookies.get("language", "en")
|
||||
from ..i18n import t
|
||||
|
||||
context = {
|
||||
"request": request,
|
||||
"page_title": t('nav_docker', lang),
|
||||
}
|
||||
context = add_i18n_context(request, context)
|
||||
return templates.TemplateResponse("containers.html", context)
|
||||
|
||||
@@ -8,8 +8,6 @@ import ipaddress as ipaddress_module
|
||||
from typing import List, Optional
|
||||
from pydantic import BaseModel
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..db import get_db
|
||||
@@ -23,84 +21,9 @@ from ..schemas import (
|
||||
)
|
||||
from ..ipam_service import IPAMService
|
||||
from ..auth import get_current_user, PermissionChecker
|
||||
from ..template_helpers import add_i18n_context
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
templates = Jinja2Templates(directory="app/templates")
|
||||
|
||||
|
||||
# ==================== HTML Pages ====================
|
||||
|
||||
@router.get("/", response_class=HTMLResponse, include_in_schema=False)
|
||||
def ipam_dashboard(request: Request):
|
||||
"""IPAM Dashboard page"""
|
||||
from ..i18n import t
|
||||
lang = request.cookies.get("language", "en")
|
||||
|
||||
context = {
|
||||
"request": request,
|
||||
"page_title": t('nav_ipam', lang),
|
||||
}
|
||||
context = add_i18n_context(request, context)
|
||||
return templates.TemplateResponse("ipam_dashboard.html", context)
|
||||
|
||||
|
||||
@router.get("/networks", response_class=HTMLResponse, include_in_schema=False)
|
||||
def ipam_networks_page(request: Request):
|
||||
"""IPAM Networks management page"""
|
||||
from ..i18n import t
|
||||
lang = request.cookies.get("language", "en")
|
||||
|
||||
context = {
|
||||
"request": request,
|
||||
"page_title": t('nav_ipam', lang),
|
||||
}
|
||||
context = add_i18n_context(request, context)
|
||||
return templates.TemplateResponse("ipam_networks.html", context)
|
||||
|
||||
|
||||
@router.get("/network/{network_id}", response_class=HTMLResponse, include_in_schema=False)
|
||||
def ipam_network_detail(request: Request, network_id: int):
|
||||
"""IPAM Network detail page with IP grid"""
|
||||
from ..i18n import t
|
||||
lang = request.cookies.get("language", "en")
|
||||
|
||||
context = {
|
||||
"request": request,
|
||||
"network_id": network_id,
|
||||
"page_title": t('nav_ipam', lang),
|
||||
}
|
||||
context = add_i18n_context(request, context)
|
||||
return templates.TemplateResponse("ipam_network_detail.html", context)
|
||||
|
||||
|
||||
@router.get("/allocations", response_class=HTMLResponse, include_in_schema=False)
|
||||
def ipam_allocations_page(request: Request):
|
||||
"""IPAM Allocations page"""
|
||||
from ..i18n import t
|
||||
lang = request.cookies.get("language", "en")
|
||||
|
||||
context = {
|
||||
"request": request,
|
||||
"page_title": t('nav_ipam', lang),
|
||||
}
|
||||
context = add_i18n_context(request, context)
|
||||
return templates.TemplateResponse("ipam_allocations.html", context)
|
||||
|
||||
|
||||
@router.get("/history", response_class=HTMLResponse, include_in_schema=False)
|
||||
def ipam_history_page(request: Request):
|
||||
"""IPAM History page"""
|
||||
from ..i18n import t
|
||||
lang = request.cookies.get("language", "en")
|
||||
|
||||
context = {
|
||||
"request": request,
|
||||
"page_title": t('nav_ipam', lang),
|
||||
}
|
||||
context = add_i18n_context(request, context)
|
||||
return templates.TemplateResponse("ipam_history.html", context)
|
||||
|
||||
|
||||
# ==================== Network API ====================
|
||||
|
||||
@@ -6,8 +6,6 @@ View and manage audit logs
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, List
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy.orm import Session
|
||||
from pydantic import BaseModel
|
||||
|
||||
@@ -15,11 +13,9 @@ from ..db import get_db
|
||||
from ..auth import get_current_user, PermissionChecker
|
||||
from ..models import User, AuditLog
|
||||
from ..logging_service import LoggingService
|
||||
from ..template_helpers import add_i18n_context
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
templates = Jinja2Templates(directory="app/templates")
|
||||
|
||||
|
||||
# ==================== Schemas ====================
|
||||
@@ -78,22 +74,6 @@ class LogStatsResponse(BaseModel):
|
||||
recent_errors: List[dict]
|
||||
|
||||
|
||||
# ==================== HTML Endpoints ====================
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
async def logs_page(request: Request):
|
||||
"""Render logs page"""
|
||||
from ..i18n import t
|
||||
lang = request.cookies.get("language", "ru")
|
||||
context = {
|
||||
"request": request,
|
||||
"title": t("nav_logs", lang),
|
||||
"page_title": f"📋 {t('system_logs', lang)}"
|
||||
}
|
||||
context = add_i18n_context(request, context)
|
||||
return templates.TemplateResponse("logs.html", context)
|
||||
|
||||
|
||||
# ==================== API Endpoints ====================
|
||||
|
||||
@router.get("/api/logs", response_model=LogsListResponse)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from fastapi import APIRouter, Depends, Request, HTTPException, Query, Form, status, WebSocket, WebSocketDisconnect
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func
|
||||
from loguru import logger
|
||||
@@ -16,11 +15,8 @@ from ...schemas import ProxmoxServerCreate, ProxmoxServerUpdate, ProxmoxServerRe
|
||||
from ...proxmox_client import ProxmoxClient, get_proxmox_resources
|
||||
from ...auth import get_current_user, PermissionChecker, require_permission, check_permission
|
||||
from ...logging_service import LoggingService
|
||||
from ...template_helpers import add_i18n_context
|
||||
from ...ipam_service import IPAMService
|
||||
|
||||
templates = Jinja2Templates(directory="app/templates")
|
||||
|
||||
|
||||
# ==================== Helper Functions for User Isolation ====================
|
||||
|
||||
|
||||
@@ -9,8 +9,7 @@ from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from loguru import logger
|
||||
|
||||
@@ -18,59 +17,11 @@ from ...db import get_db
|
||||
from ...models import BackupJob, ProxmoxServer, User
|
||||
from ...auth import PermissionChecker
|
||||
from ...logging_service import LoggingService
|
||||
from ...template_helpers import add_i18n_context
|
||||
from ._helpers import _get_proxmox_client, templates
|
||||
from ._helpers import _get_proxmox_client
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ==================== UI Page ====================
|
||||
|
||||
@router.get("/api/backups/page", response_class=HTMLResponse, include_in_schema=False)
|
||||
async def backups_page(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
from ...i18n import t
|
||||
from ...api.workspaces import get_workspace_server_ids
|
||||
from ...auth import get_current_user_optional
|
||||
|
||||
lang = request.cookies.get("language", "ru")
|
||||
|
||||
# Workspace filtering (best-effort, page may be loaded unauthenticated)
|
||||
server_ids = None
|
||||
try:
|
||||
token = request.cookies.get("access_token") or (
|
||||
request.headers.get("Authorization", "").removeprefix("Bearer ").strip() or None
|
||||
)
|
||||
if token:
|
||||
from ...auth import decode_access_token
|
||||
from ...models import User as UserModel
|
||||
payload = decode_access_token(token)
|
||||
username = payload.get("sub")
|
||||
if username:
|
||||
user_obj = db.query(UserModel).filter(
|
||||
UserModel.username == username, UserModel.is_active == True
|
||||
).first()
|
||||
if user_obj:
|
||||
server_ids = get_workspace_server_ids(request, db, user_obj)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
servers_q = db.query(ProxmoxServer).filter(ProxmoxServer.is_online.is_(True))
|
||||
if server_ids is not None:
|
||||
servers_q = servers_q.filter(ProxmoxServer.id.in_(server_ids))
|
||||
servers = servers_q.all()
|
||||
|
||||
context = {
|
||||
"request": request,
|
||||
"page_title": t("nav_backups", lang),
|
||||
"servers": [{"id": s.id, "name": s.name} for s in servers],
|
||||
}
|
||||
context = add_i18n_context(request, context)
|
||||
return templates.TemplateResponse("backups.html", context)
|
||||
|
||||
|
||||
# ==================== Storage Management ====================
|
||||
|
||||
@router.get("/api/backups/storages/{server_id}")
|
||||
|
||||
@@ -1,34 +1,16 @@
|
||||
from fastapi import APIRouter, Depends, Request, HTTPException
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from loguru import logger
|
||||
|
||||
from ...db import get_db
|
||||
from ...models import ProxmoxServer, User, IPAMNetwork
|
||||
from ...auth import PermissionChecker
|
||||
from ...template_helpers import add_i18n_context
|
||||
from ._helpers import _get_proxmox_client, templates
|
||||
from ._helpers import _get_proxmox_client
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ==================== HTML Page ====================
|
||||
|
||||
@router.get("/networks", response_class=HTMLResponse, include_in_schema=False)
|
||||
def networks_page(request: Request, db: Session = Depends(get_db)):
|
||||
"""Страница управления сетями PVE (SDN + интерфейсы нод)"""
|
||||
from ...i18n import t
|
||||
lang = request.cookies.get("language", "en")
|
||||
proxmox_servers = db.query(ProxmoxServer).all()
|
||||
context = {
|
||||
"request": request,
|
||||
"proxmox_servers": proxmox_servers,
|
||||
"page_title": t("nav_networks", lang),
|
||||
}
|
||||
context = add_i18n_context(request, context)
|
||||
return templates.TemplateResponse("networks.html", context)
|
||||
|
||||
|
||||
# ==================== Node list ====================
|
||||
|
||||
@router.get("/api/servers/{server_id}/nodes")
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from fastapi import APIRouter, Depends, Request, HTTPException, Query, Form, status, WebSocket, WebSocketDisconnect
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func
|
||||
from loguru import logger
|
||||
@@ -16,72 +15,14 @@ from ...schemas import ProxmoxServerCreate, ProxmoxServerUpdate, ProxmoxServerRe
|
||||
from ...proxmox_client import ProxmoxClient, get_proxmox_resources
|
||||
from ...auth import get_current_user, PermissionChecker, require_permission, check_permission
|
||||
from ...logging_service import LoggingService
|
||||
from ...template_helpers import add_i18n_context
|
||||
from ...ipam_service import IPAMService
|
||||
from ._helpers import (check_vm_access, require_vm_access, _get_proxmox_client,
|
||||
get_next_vmid, archive_and_delete_snapshots,
|
||||
save_vm_instance, get_vm_instance, soft_delete_vm_instance,
|
||||
templates)
|
||||
save_vm_instance, get_vm_instance, soft_delete_vm_instance)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ==================== HTML Pages ====================
|
||||
|
||||
@router.get("/vms", response_class=HTMLResponse, include_in_schema=False)
|
||||
def vms_page(request: Request, db: Session = Depends(get_db)):
|
||||
"""Страница управления Proxmox серверами, VM и LXC"""
|
||||
from ...i18n import t
|
||||
lang = request.cookies.get("language", "en")
|
||||
|
||||
# All servers are needed for cluster modals (admin operations).
|
||||
# The main servers table is rendered dynamically via JS with workspace filtering.
|
||||
proxmox_servers = db.query(ProxmoxServer).all()
|
||||
|
||||
context = {
|
||||
"request": request,
|
||||
"proxmox_servers": proxmox_servers,
|
||||
"page_title": t('nav_proxmox', lang),
|
||||
}
|
||||
context = add_i18n_context(request, context)
|
||||
return templates.TemplateResponse("proxmox_vms.html", context)
|
||||
|
||||
|
||||
@router.get("/server/{server_id}", response_class=HTMLResponse, include_in_schema=False)
|
||||
def server_detail_page(request: Request, server_id: int, db: Session = Depends(get_db)):
|
||||
"""Страница детального просмотра VM/LXC конкретного Proxmox сервера"""
|
||||
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
|
||||
if not server:
|
||||
raise HTTPException(status_code=404, detail="Proxmox server not found")
|
||||
|
||||
context = {
|
||||
"request": request,
|
||||
"server": server,
|
||||
"page_title": server.name,
|
||||
}
|
||||
context = add_i18n_context(request, context)
|
||||
return templates.TemplateResponse("proxmox_server_detail.html", context)
|
||||
|
||||
|
||||
@router.get("/server/{server_id}/instance/{vmid}", response_class=HTMLResponse, include_in_schema=False)
|
||||
def instance_detail_page(request: Request, server_id: int, vmid: int, type: str = "qemu", node: str = "", db: Session = Depends(get_db)):
|
||||
"""Страница детального просмотра конкретной VM или LXC"""
|
||||
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
|
||||
if not server:
|
||||
raise HTTPException(status_code=404, detail="Proxmox server not found")
|
||||
|
||||
context = {
|
||||
"request": request,
|
||||
"server": server,
|
||||
"vmid": vmid,
|
||||
"type": type,
|
||||
"node": node,
|
||||
"page_title": f"VM {vmid}",
|
||||
}
|
||||
context = add_i18n_context(request, context)
|
||||
return templates.TemplateResponse("instance_detail.html", context)
|
||||
|
||||
|
||||
# ==================== Proxmox Server CRUD ====================
|
||||
|
||||
@router.get("/api/servers", response_model=List[ProxmoxServerResponse])
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from fastapi import APIRouter, Depends, Request, HTTPException, Query, Form, status, WebSocket, WebSocketDisconnect
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func
|
||||
from loguru import logger
|
||||
@@ -16,12 +15,10 @@ from ...schemas import ProxmoxServerCreate, ProxmoxServerUpdate, ProxmoxServerRe
|
||||
from ...proxmox_client import ProxmoxClient, get_proxmox_resources
|
||||
from ...auth import get_current_user, PermissionChecker, require_permission, check_permission
|
||||
from ...logging_service import LoggingService
|
||||
from ...template_helpers import add_i18n_context
|
||||
from ...ipam_service import IPAMService
|
||||
from ._helpers import (check_vm_access, require_vm_access, _get_proxmox_client,
|
||||
get_next_vmid, archive_and_delete_snapshots,
|
||||
save_vm_instance, get_vm_instance, soft_delete_vm_instance,
|
||||
templates)
|
||||
save_vm_instance, get_vm_instance, soft_delete_vm_instance)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from fastapi import APIRouter, Depends, Request, HTTPException, Query, Form, status, WebSocket, WebSocketDisconnect
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func
|
||||
from loguru import logger
|
||||
@@ -16,14 +15,12 @@ from ...schemas import ProxmoxServerCreate, ProxmoxServerUpdate, ProxmoxServerRe
|
||||
from ...proxmox_client import ProxmoxClient, get_proxmox_resources
|
||||
from ...auth import get_current_user, PermissionChecker, require_permission, check_permission
|
||||
from ...logging_service import LoggingService
|
||||
from ...template_helpers import add_i18n_context
|
||||
from ...ipam_service import IPAMService
|
||||
from ...models import TaskQueue
|
||||
from ...services.task_queue_service import TaskQueueService, process_task_queue
|
||||
from ._helpers import (check_vm_access, require_vm_access, _get_proxmox_client,
|
||||
get_next_vmid, archive_and_delete_snapshots,
|
||||
save_vm_instance, get_vm_instance, soft_delete_vm_instance,
|
||||
templates)
|
||||
save_vm_instance, get_vm_instance, soft_delete_vm_instance)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from fastapi import APIRouter, Depends, Request, HTTPException, Query, Form, status, WebSocket, WebSocketDisconnect
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func
|
||||
from loguru import logger
|
||||
@@ -16,12 +15,10 @@ from ...schemas import ProxmoxServerCreate, ProxmoxServerUpdate, ProxmoxServerRe
|
||||
from ...proxmox_client import ProxmoxClient, get_proxmox_resources
|
||||
from ...auth import get_current_user, PermissionChecker, require_permission, check_permission
|
||||
from ...logging_service import LoggingService
|
||||
from ...template_helpers import add_i18n_context
|
||||
from ...ipam_service import IPAMService
|
||||
from ._helpers import (check_vm_access, require_vm_access, _get_proxmox_client,
|
||||
get_next_vmid, archive_and_delete_snapshots,
|
||||
save_vm_instance, get_vm_instance, soft_delete_vm_instance,
|
||||
templates)
|
||||
save_vm_instance, get_vm_instance, soft_delete_vm_instance)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@@ -5,8 +5,7 @@ User account settings and panel configuration
|
||||
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Request, Query
|
||||
from fastapi.responses import HTMLResponse, Response
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi.responses import Response
|
||||
from sqlalchemy.orm import Session
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
from typing import Optional
|
||||
@@ -15,7 +14,6 @@ from ..db import get_db
|
||||
from ..models import User, PanelSettings
|
||||
from ..auth import get_current_user, get_current_user_optional, get_password_hash, verify_password, PermissionChecker
|
||||
from ..logging_service import LoggingService
|
||||
from ..template_helpers import add_i18n_context
|
||||
from ..i18n import I18nService, t
|
||||
from ..services.logo_service import LogoService
|
||||
from ..services.update_service import (
|
||||
@@ -33,7 +31,6 @@ def get_local_time() -> str:
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
templates = Jinja2Templates(directory="app/templates")
|
||||
|
||||
|
||||
# ==================== Helper Functions ====================
|
||||
@@ -103,29 +100,6 @@ async def get_protected_logo():
|
||||
)
|
||||
|
||||
|
||||
# ==================== HTML Pages ====================
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
async def settings_page(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: Optional[User] = Depends(get_current_user_optional)
|
||||
):
|
||||
"""Render settings page"""
|
||||
from ..template_helpers import get_language
|
||||
lang = get_language(request)
|
||||
context = {
|
||||
"request": request,
|
||||
"title": t("nav_settings", lang),
|
||||
"page_title": f"⚙️ {t('nav_settings', lang)}",
|
||||
"current_user": current_user
|
||||
}
|
||||
# Add i18n context
|
||||
context = add_i18n_context(request, context)
|
||||
|
||||
return templates.TemplateResponse("settings.html", context)
|
||||
|
||||
|
||||
# ==================== User Account Settings ====================
|
||||
|
||||
@router.get("/api/profile")
|
||||
|
||||
@@ -4,8 +4,7 @@ Allows creating VM templates, groups, and deploying VMs from templates
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, Request, HTTPException, status
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from loguru import logger
|
||||
from typing import List
|
||||
@@ -22,33 +21,8 @@ from ..api.proxmox import get_next_vmid, save_vm_instance, get_vm_instance
|
||||
from ..auth import get_current_user, PermissionChecker
|
||||
from ..ipam_service import IPAMService
|
||||
from ..logging_service import LoggingService
|
||||
from ..template_helpers import add_i18n_context
|
||||
|
||||
router = APIRouter()
|
||||
templates = Jinja2Templates(directory="app/templates")
|
||||
|
||||
|
||||
# ==================== HTML Pages ====================
|
||||
|
||||
@router.get("/", response_class=HTMLResponse, include_in_schema=False)
|
||||
def templates_page(request: Request, db: Session = Depends(get_db)):
|
||||
"""Страница управления OS шаблонами"""
|
||||
from ..i18n import t
|
||||
lang = request.cookies.get("language", "en")
|
||||
|
||||
proxmox_servers = db.query(ProxmoxServer).all()
|
||||
template_groups = db.query(OSTemplateGroup).order_by(OSTemplateGroup.sort_order).all()
|
||||
os_templates = db.query(OSTemplate).order_by(OSTemplate.sort_order).all()
|
||||
|
||||
context = {
|
||||
"request": request,
|
||||
"proxmox_servers": proxmox_servers,
|
||||
"template_groups": template_groups,
|
||||
"os_templates": os_templates,
|
||||
"page_title": t('nav_templates', lang),
|
||||
}
|
||||
context = add_i18n_context(request, context)
|
||||
return templates.TemplateResponse("os_templates.html", context)
|
||||
|
||||
|
||||
# ==================== Template Groups CRUD ====================
|
||||
|
||||
@@ -5,8 +5,6 @@ Users and Roles Management API
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy.orm import Session
|
||||
from pydantic import BaseModel, Field, EmailStr
|
||||
from loguru import logger
|
||||
@@ -23,27 +21,8 @@ from ..auth import (
|
||||
from ..config import utcnow
|
||||
from ..services.security_service import SecurityService
|
||||
from ..logging_service import LoggingService
|
||||
from ..template_helpers import add_i18n_context
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
templates = Jinja2Templates(directory="app/templates")
|
||||
|
||||
|
||||
# ==================== HTML Pages ====================
|
||||
|
||||
@router.get("/users", response_class=HTMLResponse, include_in_schema=False)
|
||||
async def users_page(request: Request, db: Session = Depends(get_db)):
|
||||
"""Users management page (admin only) - auth checked on frontend"""
|
||||
from ..i18n import t
|
||||
lang = request.cookies.get("language", "en")
|
||||
|
||||
context = {
|
||||
"request": request,
|
||||
"page_title": t('nav_users', lang),
|
||||
}
|
||||
context = add_i18n_context(request, context)
|
||||
return templates.TemplateResponse("users.html", context)
|
||||
|
||||
|
||||
# ==================== Schemas ====================
|
||||
@@ -136,24 +115,6 @@ class SecuritySettingsUpdate(BaseModel):
|
||||
password_require_special: Optional[bool] = None
|
||||
|
||||
|
||||
# ==================== HTML Pages ====================
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
async def users_page(
|
||||
request: Request,
|
||||
):
|
||||
"""Render users management page"""
|
||||
from ..i18n import t
|
||||
lang = request.cookies.get("language", "en")
|
||||
|
||||
context = {
|
||||
"request": request,
|
||||
"page_title": t('nav_users', lang),
|
||||
}
|
||||
context = add_i18n_context(request, context)
|
||||
return templates.TemplateResponse("users.html", context)
|
||||
|
||||
|
||||
# ==================== Role API ====================
|
||||
|
||||
@router.get("/api/roles", response_model=List[RoleResponse])
|
||||
|
||||
@@ -11,8 +11,6 @@ All authenticated: list (filtered to their workspaces), get detail.
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy.orm import Session
|
||||
from loguru import logger
|
||||
|
||||
@@ -20,11 +18,9 @@ from ..db import get_db
|
||||
from ..models import Workspace, WorkspaceServer, WorkspaceUser, ProxmoxServer, User
|
||||
from ..schemas import WorkspaceCreate, WorkspaceUpdate, WorkspaceResponse, WorkspaceDetail
|
||||
from ..auth import get_current_user, PermissionChecker
|
||||
from ..template_helpers import add_i18n_context
|
||||
from ..logging_service import LoggingService
|
||||
|
||||
router = APIRouter()
|
||||
templates = Jinja2Templates(directory="app/templates")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -150,24 +146,6 @@ def _build_detail(ws: Workspace) -> WorkspaceDetail:
|
||||
)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# HTML Page
|
||||
# ===========================================================================
|
||||
|
||||
@router.get("/workspaces", response_class=HTMLResponse, include_in_schema=False)
|
||||
def workspaces_page(
|
||||
request: Request,
|
||||
):
|
||||
"""Admin workspace management page"""
|
||||
context = {
|
||||
"request": request,
|
||||
"page_title": "Workspaces",
|
||||
}
|
||||
from ..template_helpers import add_i18n_context
|
||||
context = add_i18n_context(request, context)
|
||||
return templates.TemplateResponse("workspaces.html", context)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# REST API
|
||||
# ===========================================================================
|
||||
|
||||
@@ -5,10 +5,8 @@ from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, Request, HTTPException, WebSocket, WebSocketDisconnect, Query, Depends
|
||||
from fastapi.responses import RedirectResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.exception_handlers import http_exception_handler
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from loguru import logger
|
||||
|
||||
from .config import settings
|
||||
@@ -28,7 +26,6 @@ from .api import workspaces as workspaces_router
|
||||
from .logging_middleware import RequestLoggingMiddleware
|
||||
from .language_middleware import LanguageMiddleware
|
||||
from .i18n import I18nService
|
||||
from .template_helpers import add_i18n_context
|
||||
|
||||
|
||||
# Configure logging
|
||||
@@ -225,9 +222,6 @@ def create_app() -> FastAPI:
|
||||
# Add language detection middleware
|
||||
app.add_middleware(LanguageMiddleware)
|
||||
|
||||
# Templates
|
||||
templates = Jinja2Templates(directory="app/templates")
|
||||
|
||||
# Security headers middleware
|
||||
@app.middleware("http")
|
||||
async def add_security_headers(request: Request, call_next):
|
||||
@@ -261,13 +255,6 @@ def create_app() -> FastAPI:
|
||||
@app.exception_handler(HTTPException)
|
||||
async def custom_http_exception_handler(request: Request, exc: HTTPException):
|
||||
logger.warning(f"HTTP exception: {exc.status_code} - {exc.detail}")
|
||||
# Redirect browser HTML page requests to /login on auth errors
|
||||
if exc.status_code in (401, 403):
|
||||
accept = request.headers.get("accept", "")
|
||||
path = request.url.path
|
||||
if "text/html" in accept and not path.startswith(("/api/", "/static/", "/settings/api/")):
|
||||
from urllib.parse import quote
|
||||
return RedirectResponse(url=f"/login?returnUrl={quote(path)}", status_code=302)
|
||||
return await http_exception_handler(request, exc)
|
||||
|
||||
# Health check endpoint
|
||||
@@ -281,66 +268,17 @@ def create_app() -> FastAPI:
|
||||
"database": "connected" if db_healthy else "disconnected"
|
||||
}
|
||||
|
||||
# Root redirect
|
||||
# Root — API info
|
||||
@app.get("/", include_in_schema=False)
|
||||
async def root():
|
||||
return RedirectResponse(url="/dashboard", status_code=302)
|
||||
return {"name": settings.PANEL_NAME, "version": settings.VERSION, "docs": "/docs"}
|
||||
|
||||
# Backup Storage page
|
||||
@app.get("/backups", include_in_schema=False)
|
||||
async def backups_page(request: Request):
|
||||
"""Backup storage management page"""
|
||||
return RedirectResponse(url="/proxmox/api/backups/page", status_code=302)
|
||||
|
||||
# Legacy redirect for old /servers URL
|
||||
@app.get("/servers", include_in_schema=False)
|
||||
async def servers_redirect():
|
||||
return RedirectResponse(url="/proxmox/vms", status_code=301)
|
||||
|
||||
# Direct access to /vms - now shows Proxmox servers
|
||||
@app.get("/vms", include_in_schema=False)
|
||||
async def vms_redirect():
|
||||
return RedirectResponse(url="/proxmox/vms", status_code=301)
|
||||
|
||||
# Virtual Machines list page
|
||||
@app.get("/virtual-machines", include_in_schema=False)
|
||||
async def virtual_machines_page(request: Request):
|
||||
"""Virtual Machines list page"""
|
||||
from .i18n import t
|
||||
lang = request.cookies.get("language", "ru")
|
||||
|
||||
context = {
|
||||
"request": request,
|
||||
"page_title": t('nav_vms', lang),
|
||||
}
|
||||
context = add_i18n_context(request, context)
|
||||
return templates.TemplateResponse("virtual_machines.html", context)
|
||||
|
||||
# Virtual Machine detail page
|
||||
@app.get("/virtual-machines/{server_id}/{vmid}", include_in_schema=False)
|
||||
async def virtual_machine_detail_page(request: Request, server_id: int, vmid: int, type: str = "qemu", node: str = ""):
|
||||
"""Virtual Machine detail page"""
|
||||
return RedirectResponse(url=f"/proxmox/server/{server_id}/instance/{vmid}?type={type}&node={node}", status_code=302)
|
||||
|
||||
# API for virtual machines list (proxy to proxmox router)
|
||||
@app.get("/api/virtual-machines")
|
||||
async def api_virtual_machines(request: Request):
|
||||
"""Proxy to proxmox virtual-machines API"""
|
||||
return RedirectResponse(url="/proxmox/api/virtual-machines", status_code=307)
|
||||
|
||||
# Task Manager page
|
||||
@app.get("/tasks", include_in_schema=False)
|
||||
async def tasks_page(request: Request):
|
||||
"""Task Manager page"""
|
||||
from .i18n import t
|
||||
lang = request.cookies.get("language", "ru")
|
||||
context = {
|
||||
"request": request,
|
||||
"page_title": t("nav_tasks", lang) if callable(t) else "Менеджер задач",
|
||||
}
|
||||
context = add_i18n_context(request, context)
|
||||
return templates.TemplateResponse("tasks.html", context)
|
||||
|
||||
# WebSocket endpoint for real-time task updates
|
||||
@app.websocket("/ws/tasks")
|
||||
async def websocket_tasks(websocket: WebSocket, token: str = Query(None)):
|
||||
@@ -463,11 +401,6 @@ def create_app() -> FastAPI:
|
||||
app.include_router(users_router.router, prefix="/admin", tags=["users", "admin"])
|
||||
app.include_router(workspaces_router.router, tags=["workspaces"])
|
||||
|
||||
# Mount static files for noVNC
|
||||
static_dir = Path(__file__).parent / "static"
|
||||
if static_dir.exists():
|
||||
app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
|
||||
|
||||
return app
|
||||
|
||||
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
/**
|
||||
* Client-side translations
|
||||
* Synchronized with backend i18n.py
|
||||
*/
|
||||
|
||||
const translations = {
|
||||
ru: {
|
||||
// Common
|
||||
loading: "Загрузка...",
|
||||
save: "Сохранить",
|
||||
cancel: "Отмена",
|
||||
delete: "Удалить",
|
||||
edit: "Редактировать",
|
||||
add: "Добавить",
|
||||
search: "Поиск",
|
||||
filter: "Фильтр",
|
||||
actions: "Действия",
|
||||
status: "Статус",
|
||||
name: "Название",
|
||||
description: "Описание",
|
||||
success: "Успешно",
|
||||
error: "Ошибка",
|
||||
warning: "Предупреждение",
|
||||
confirm: "Подтвердить",
|
||||
yes: "Да",
|
||||
no: "Нет",
|
||||
|
||||
// Messages
|
||||
profile_updated: "Профиль обновлён",
|
||||
password_changed: "Пароль успешно изменён",
|
||||
settings_saved: "Настройки сохранены",
|
||||
language_changed: "Язык изменён. Страница будет перезагружена...",
|
||||
error_occurred: "Произошла ошибка",
|
||||
confirm_delete: "Вы уверены, что хотите удалить?",
|
||||
|
||||
// Updates
|
||||
update_check_disabled: "Проверка обновлений отключена",
|
||||
checking_updates: "Проверка обновлений...",
|
||||
check_updates: "Проверить обновления",
|
||||
update_available: "Доступно обновление",
|
||||
no_updates: "Установлена последняя версия",
|
||||
update_warning: "Панель будет недоступна во время обновления",
|
||||
updating_system: "Обновление системы...",
|
||||
|
||||
// SSH Keys
|
||||
ssh_key_saved: "SSH ключ успешно сохранён",
|
||||
error_saving_ssh_key: "Ошибка сохранения SSH ключа",
|
||||
invalid_ssh_key_format: "Неверный формат SSH ключа",
|
||||
|
||||
// Dashboard
|
||||
nodes_label: "Ноды",
|
||||
clusters_label: "Кластеры",
|
||||
alerts_label: "Оповещения",
|
||||
vms: "ВМ",
|
||||
running: "Запущено",
|
||||
online: "Онлайн",
|
||||
paused: "Пауза",
|
||||
stopped: "Остановлен",
|
||||
loading_dots: "Загрузка…",
|
||||
no_vms_found: "Нет виртуальных машин",
|
||||
no_nodes: "Нет доступных нод",
|
||||
all_healthy: "Все работают",
|
||||
no_alerts: "Нет оповещений",
|
||||
},
|
||||
|
||||
en: {
|
||||
// Common
|
||||
loading: "Loading...",
|
||||
save: "Save",
|
||||
cancel: "Cancel",
|
||||
delete: "Delete",
|
||||
edit: "Edit",
|
||||
add: "Add",
|
||||
search: "Search",
|
||||
filter: "Filter",
|
||||
actions: "Actions",
|
||||
status: "Status",
|
||||
name: "Name",
|
||||
description: "Description",
|
||||
success: "Success",
|
||||
error: "Error",
|
||||
warning: "Warning",
|
||||
confirm: "Confirm",
|
||||
yes: "Yes",
|
||||
no: "No",
|
||||
|
||||
// Messages
|
||||
profile_updated: "Profile updated",
|
||||
password_changed: "Password changed successfully",
|
||||
settings_saved: "Settings saved",
|
||||
language_changed: "Language changed. Page will reload...",
|
||||
error_occurred: "An error occurred",
|
||||
confirm_delete: "Are you sure you want to delete?",
|
||||
|
||||
// Updates
|
||||
update_check_disabled: "Update check is disabled",
|
||||
checking_updates: "Checking for updates...",
|
||||
check_updates: "Check for Updates",
|
||||
update_available: "Update available",
|
||||
no_updates: "You have the latest version",
|
||||
update_warning: "Panel will be unavailable during update",
|
||||
updating_system: "Updating system...",
|
||||
|
||||
// SSH Keys
|
||||
ssh_key_saved: "SSH key saved successfully",
|
||||
error_saving_ssh_key: "Error saving SSH key",
|
||||
invalid_ssh_key_format: "Invalid SSH key format",
|
||||
|
||||
// Dashboard
|
||||
nodes_label: "Nodes",
|
||||
clusters_label: "Clusters",
|
||||
alerts_label: "Alerts",
|
||||
vms: "VMs",
|
||||
running: "Running",
|
||||
online: "Online",
|
||||
paused: "Paused",
|
||||
stopped: "Stopped",
|
||||
loading_dots: "Loading…",
|
||||
no_vms_found: "No VMs found",
|
||||
no_nodes: "No nodes available",
|
||||
all_healthy: "All healthy",
|
||||
no_alerts: "No alerts",
|
||||
}
|
||||
};
|
||||
|
||||
// Get current language from localStorage or default
|
||||
function getCurrentLanguage() {
|
||||
return localStorage.getItem('panel_language') || 'ru';
|
||||
}
|
||||
|
||||
// Set language
|
||||
function setLanguage(lang) {
|
||||
localStorage.setItem('panel_language', lang);
|
||||
}
|
||||
|
||||
// Get translation
|
||||
function t(key, lang = null) {
|
||||
if (!lang) {
|
||||
lang = getCurrentLanguage();
|
||||
}
|
||||
|
||||
if (translations[lang] && translations[lang][key]) {
|
||||
return translations[lang][key];
|
||||
}
|
||||
|
||||
// Fallback to Russian
|
||||
if (translations.ru[key]) {
|
||||
return translations.ru[key];
|
||||
}
|
||||
|
||||
return key;
|
||||
}
|
||||
|
||||
// Export for use in other scripts
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = { translations, t, getCurrentLanguage, setLanguage };
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
/**
|
||||
* Theme Manager - Handles light/dark mode switching and sidebar toggle
|
||||
*/
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
const THEME_KEY = 'panel-theme';
|
||||
const SIDEBAR_KEY = 'panel-sidebar';
|
||||
const DARK_THEME = 'dark';
|
||||
const LIGHT_THEME = 'light';
|
||||
|
||||
// Get saved theme or default to light
|
||||
function getPreferredTheme() {
|
||||
const savedTheme = localStorage.getItem(THEME_KEY);
|
||||
if (savedTheme) {
|
||||
return savedTheme;
|
||||
}
|
||||
|
||||
// Default to light theme (cloud panel style)
|
||||
return LIGHT_THEME;
|
||||
}
|
||||
|
||||
// Apply theme to document
|
||||
function applyTheme(theme) {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
localStorage.setItem(THEME_KEY, theme);
|
||||
|
||||
// Update toggle button icon
|
||||
updateToggleIcon(theme);
|
||||
}
|
||||
|
||||
// Update toggle button icon
|
||||
function updateToggleIcon(theme) {
|
||||
const toggle = document.getElementById('themeToggle');
|
||||
if (!toggle) return;
|
||||
|
||||
const sunIcon = toggle.querySelector('.icon-sun');
|
||||
const moonIcon = toggle.querySelector('.icon-moon');
|
||||
|
||||
if (sunIcon && moonIcon) {
|
||||
if (theme === DARK_THEME) {
|
||||
sunIcon.style.display = 'block';
|
||||
moonIcon.style.display = 'none';
|
||||
} else {
|
||||
sunIcon.style.display = 'none';
|
||||
moonIcon.style.display = 'block';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle theme
|
||||
function toggleTheme() {
|
||||
const currentTheme = document.documentElement.getAttribute('data-theme') || DARK_THEME;
|
||||
const newTheme = currentTheme === DARK_THEME ? LIGHT_THEME : DARK_THEME;
|
||||
applyTheme(newTheme);
|
||||
}
|
||||
|
||||
// Toggle sidebar
|
||||
function toggleSidebar() {
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
const overlay = document.getElementById('sidebarOverlay');
|
||||
|
||||
if (sidebar) {
|
||||
sidebar.classList.toggle('open');
|
||||
|
||||
if (overlay) {
|
||||
overlay.classList.toggle('active', sidebar.classList.contains('open'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Close sidebar on escape key
|
||||
function handleKeydown(e) {
|
||||
if (e.key === 'Escape') {
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
const overlay = document.getElementById('sidebarOverlay');
|
||||
|
||||
if (sidebar && sidebar.classList.contains('open')) {
|
||||
sidebar.classList.remove('open');
|
||||
if (overlay) {
|
||||
overlay.classList.remove('active');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize on DOM load
|
||||
function init() {
|
||||
// Apply saved or preferred theme immediately
|
||||
const theme = getPreferredTheme();
|
||||
applyTheme(theme);
|
||||
|
||||
// Set up toggle button
|
||||
const toggle = document.getElementById('themeToggle');
|
||||
if (toggle) {
|
||||
toggle.addEventListener('click', toggleTheme);
|
||||
}
|
||||
|
||||
// Listen for escape key
|
||||
document.addEventListener('keydown', handleKeydown);
|
||||
|
||||
// Listen for system theme changes
|
||||
if (window.matchMedia) {
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
|
||||
// Only auto-switch if user hasn't manually selected a theme
|
||||
if (!localStorage.getItem(THEME_KEY)) {
|
||||
applyTheme(e.matches ? DARK_THEME : LIGHT_THEME);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Close sidebar when clicking on a link (mobile)
|
||||
const sidebarLinks = document.querySelectorAll('.sidebar-nav a');
|
||||
sidebarLinks.forEach(link => {
|
||||
link.addEventListener('click', () => {
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
const overlay = document.getElementById('sidebarOverlay');
|
||||
|
||||
if (window.innerWidth <= 768 && sidebar) {
|
||||
sidebar.classList.remove('open');
|
||||
if (overlay) {
|
||||
overlay.classList.remove('active');
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Apply theme before DOM is ready to prevent flash
|
||||
const savedTheme = getPreferredTheme();
|
||||
document.documentElement.setAttribute('data-theme', savedTheme);
|
||||
|
||||
// Initialize when DOM is ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
|
||||
// Expose functions globally
|
||||
window.toggleTheme = toggleTheme;
|
||||
window.toggleSidebar = toggleSidebar;
|
||||
})();
|
||||
@@ -1,55 +0,0 @@
|
||||
"""
|
||||
Template helpers
|
||||
Jinja2 context processors and filters
|
||||
"""
|
||||
|
||||
import os
|
||||
from fastapi import Request
|
||||
from .i18n import I18nService
|
||||
from .config import settings
|
||||
|
||||
|
||||
# Path to custom logo
|
||||
CUSTOM_LOGO_PATH = os.path.join(os.path.dirname(__file__), "static", "img", "logo.png")
|
||||
|
||||
|
||||
def get_language(request: Request) -> str:
|
||||
"""Get current language from request state"""
|
||||
return getattr(request.state, 'lang', 'ru')
|
||||
|
||||
|
||||
def custom_logo_exists() -> bool:
|
||||
"""Check if custom logo file exists"""
|
||||
return os.path.isfile(CUSTOM_LOGO_PATH)
|
||||
|
||||
|
||||
def add_i18n_context(request: Request, context: dict) -> dict:
|
||||
"""
|
||||
Add i18n variables to template context
|
||||
|
||||
Args:
|
||||
request: FastAPI request
|
||||
context: Existing template context
|
||||
|
||||
Returns:
|
||||
Updated context with i18n variables
|
||||
"""
|
||||
lang = get_language(request)
|
||||
|
||||
# Add language info
|
||||
context['lang'] = lang
|
||||
context['translations'] = I18nService.get_all(lang)
|
||||
context['t'] = lambda key, **kwargs: I18nService.get(key, lang, **kwargs)
|
||||
context['_'] = context['t'] # Alias for templates using _()
|
||||
|
||||
# Add timezone
|
||||
context['timezone'] = settings.TZ
|
||||
|
||||
# Add panel name and custom logo check
|
||||
context['panel_name'] = settings.PANEL_NAME
|
||||
context['custom_logo_exists'] = custom_logo_exists()
|
||||
|
||||
# Add version
|
||||
context['version'] = settings.VERSION
|
||||
|
||||
return context
|
||||
@@ -1,13 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% set page_title = t('docker_management') %}
|
||||
{% block content %}
|
||||
<div class="card">
|
||||
<h3><i class="fa-brands fa-docker"></i> {{ t('docker_containers') }}</h3>
|
||||
<p class="text-muted" style="margin-top: 1rem;">
|
||||
{{ t('section_in_development') }}
|
||||
</p>
|
||||
<div style="margin-top: 1.5rem;">
|
||||
<a href="/servers" class="btn btn-primary">← {{ t('to_servers') }}</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,700 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% set page_title = t('nav_dashboard') %}
|
||||
|
||||
{% block page_subtitle %}
|
||||
<div class="page-subtitle" id="dashSubtitle">
|
||||
{%- if stats.online_servers > 0 -%}
|
||||
{{ stats.online_servers }} {{ t('online') | lower }}
|
||||
{%- else -%}
|
||||
{{ t('proxmox_servers') }}: {{ stats.total_servers }}
|
||||
{%- endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- ======== 5 STAT CARDS ======== -->
|
||||
<div class="stats-grid-5">
|
||||
<div class="stat-card-v2">
|
||||
<div class="stat-card-lbl">{{ t('nodes_label') }}</div>
|
||||
<div class="stat-card-val">{{ stats.total_servers }}</div>
|
||||
<div class="stat-card-sub" id="statNodesSub">
|
||||
{%- if stats.online_servers == stats.total_servers and stats.total_servers > 0 -%}
|
||||
{{ t('all_online') }}
|
||||
{%- elif stats.online_servers > 0 -%}
|
||||
{{ stats.online_servers }} {{ t('online') | lower }}
|
||||
{%- else -%}
|
||||
—
|
||||
{%- endif -%}
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card-v2">
|
||||
<div class="stat-card-lbl">{{ t('vms') }}</div>
|
||||
<div class="stat-card-val skeleton" id="statVmCount">—</div>
|
||||
<div class="stat-card-sub" id="statVmSub">{{ t('loading') }}…</div>
|
||||
</div>
|
||||
<div class="stat-card-v2">
|
||||
<div class="stat-card-lbl">{{ t('containers') }}</div>
|
||||
<div class="stat-card-val skeleton" id="statCtCount">—</div>
|
||||
<div class="stat-card-sub" id="statCtSub">{{ t('loading') }}…</div>
|
||||
</div>
|
||||
<div class="stat-card-v2">
|
||||
<div class="stat-card-lbl">{{ t('clusters_label') }}</div>
|
||||
<div class="stat-card-val">{{ stats.total_clusters }}</div>
|
||||
<div class="stat-card-sub" id="statClusterSub">
|
||||
{%- if stats.total_alerts == 0 -%}{{ t('all_healthy') }}{%- else -%}—{%- endif -%}
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card-v2 {% if stats.critical_alerts > 0 %}card-alerts{% endif %}">
|
||||
<div class="stat-card-lbl">{{ t('alerts_label') }}</div>
|
||||
<div class="stat-card-val">{{ stats.total_alerts }}</div>
|
||||
<div class="stat-card-sub">
|
||||
{%- if stats.critical_alerts > 0 -%}
|
||||
<span style="color: var(--danger);">{{ stats.critical_alerts }} {{ t('critical') | lower }}</span>
|
||||
{%- elif stats.total_alerts > 0 -%}
|
||||
{{ t('last_24h') }}
|
||||
{%- else -%}
|
||||
{{ t('no_alerts') }}
|
||||
{%- endif -%}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ======== RESOURCE CARDS (CPU / RAM / STORAGE) ======== -->
|
||||
<div style="position:relative;">
|
||||
<div class="resource-cards-row" id="resourceCardsRow">
|
||||
<!-- refresh spinner (shown while fetching) -->
|
||||
<span id="dashRefreshSpin" title="{{ t('loading') }}" style="
|
||||
position:absolute; top:-1.6rem; right:0;
|
||||
font-size:.75rem; color:var(--text-muted); opacity:0;
|
||||
transition:opacity .2s; pointer-events:none;
|
||||
"><i class="fa-solid fa-arrows-rotate fa-spin" style="margin-right:.3rem;"></i><span id="dashLastUpdated"></span></span>
|
||||
<div class="resource-card">
|
||||
<div class="resource-card-header">
|
||||
<span class="resource-card-title">{{ t('cpu_usage') }}</span>
|
||||
<span class="resource-pct-badge skeleton" id="cpuPct">—</span>
|
||||
</div>
|
||||
<div class="resource-mini-chart">
|
||||
<canvas id="cpuMiniChart"></canvas>
|
||||
</div>
|
||||
<div class="resource-progress-bar">
|
||||
<div class="resource-progress-fill cpu-fill" id="cpuBar" style="width: 0%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="resource-card">
|
||||
<div class="resource-card-header">
|
||||
<span class="resource-card-title">{{ t('ram_usage') }}</span>
|
||||
<span class="resource-pct-badge skeleton" id="ramPct">—</span>
|
||||
</div>
|
||||
<div class="resource-mini-chart">
|
||||
<canvas id="ramMiniChart"></canvas>
|
||||
</div>
|
||||
<div class="resource-progress-bar">
|
||||
<div class="resource-progress-fill ram-fill" id="ramBar" style="width: 0%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="resource-card">
|
||||
<div class="resource-card-header">
|
||||
<span class="resource-card-title">{{ t('storage') }}</span>
|
||||
<span class="resource-pct-badge skeleton" id="diskPct">—</span>
|
||||
</div>
|
||||
<div class="resource-mini-chart">
|
||||
<canvas id="diskMiniChart"></canvas>
|
||||
</div>
|
||||
<div class="resource-progress-bar">
|
||||
<div class="resource-progress-fill disk-fill" id="diskBar" style="width: 0%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ======== VM TABLE + NODES PANEL ======== -->
|
||||
<div class="dash-main-grid">
|
||||
<!-- VM Table -->
|
||||
<div class="dash-section">
|
||||
<div class="dash-section-header">
|
||||
<span class="dash-section-title">{{ t('virtual_machines') }}</span>
|
||||
<div style="display:flex; gap:0.75rem; align-items:center;">
|
||||
<span class="dash-section-meta" id="vmTableMeta">—</span>
|
||||
<a href="/virtual-machines" class="dash-section-link">{{ t('view_all') }}</a>
|
||||
</div>
|
||||
</div>
|
||||
<div style="overflow-x: auto;">
|
||||
<table class="vm-table-compact">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ t('name') }}</th>
|
||||
<th>{{ t('node') }}</th>
|
||||
<th>{{ t('cpu') }}</th>
|
||||
<th>{{ t('ram') }}</th>
|
||||
<th>{{ t('status') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="vmTableBody">
|
||||
<tr><td colspan="5" style="text-align:center; color:var(--text-muted); padding:2rem 1.25rem;">
|
||||
<i class="fa-solid fa-circle-notch fa-spin" style="margin-right:.5rem;"></i> {{ t('loading') }}…
|
||||
</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Nodes Panel -->
|
||||
<div class="dash-section">
|
||||
<div class="dash-section-header">
|
||||
<span class="dash-section-title">{{ t('nodes_label') }}</span>
|
||||
<span class="dash-section-meta" id="nodesOnlineMeta">—</span>
|
||||
</div>
|
||||
<div class="nodes-panel" id="nodesPanel">
|
||||
<div style="text-align:center; color:var(--text-muted); padding:2rem 1.25rem;">
|
||||
<i class="fa-solid fa-circle-notch fa-spin" style="margin-right:.5rem;"></i> {{ t('loading') }}…
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ======== RECENT AUDIT EVENTS ======== -->
|
||||
<div class="dash-section" style="margin-bottom: var(--spacing-lg);">
|
||||
<div class="dash-section-header">
|
||||
<span class="dash-section-title">{{ t('recent_audit_events') }}</span>
|
||||
<a href="/logs" class="dash-section-link">{{ t('view_all') }}</a>
|
||||
</div>
|
||||
<div class="audit-events-list" id="auditEventsList">
|
||||
{% if recent_audit %}
|
||||
{% for entry in recent_audit %}
|
||||
<div class="audit-row">
|
||||
<span class="audit-time">
|
||||
{{ entry.created_at.strftime('%H:%M:%S') if entry.created_at else '—' }}
|
||||
</span>
|
||||
<span class="audit-user">{{ entry.username or '—' }}</span>
|
||||
<span>
|
||||
<span class="audit-action-tag {{ entry.level | lower if entry.level else 'default' }}">
|
||||
{{ entry.action or '—' }}
|
||||
</span>
|
||||
</span>
|
||||
<span class="audit-resource">{{ entry.resource_name or '—' }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div style="text-align:center; color:var(--text-muted); padding:1.5rem 1.25rem; font-size:0.875rem;">
|
||||
{{ t('no_audit_events') }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ======== Dashboard JS ========
|
||||
|
||||
// Colour the % badge based on value
|
||||
function setPctBadgeColor(el, pct) {
|
||||
el.classList.remove('pct-ok', 'pct-warn', 'pct-high');
|
||||
if (pct < 60) el.classList.add('pct-ok');
|
||||
else if (pct < 85) el.classList.add('pct-warn');
|
||||
else el.classList.add('pct-high');
|
||||
}
|
||||
|
||||
// Draw a mini line chart on a canvas — updates in-place on subsequent calls
|
||||
function drawMiniLineChart(canvasId, dataPoints, color, bgColor) {
|
||||
if (typeof Chart === 'undefined') return;
|
||||
const canvas = document.getElementById(canvasId);
|
||||
if (!canvas) return;
|
||||
if (canvas._chartInstance) {
|
||||
// Update data in-place (no flicker, smooth transition)
|
||||
canvas._chartInstance.data.labels = dataPoints.map((_, i) => i);
|
||||
canvas._chartInstance.data.datasets[0].data = dataPoints;
|
||||
canvas._chartInstance.update('active');
|
||||
return;
|
||||
}
|
||||
const ctx = canvas.getContext('2d');
|
||||
canvas._chartInstance = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: dataPoints.map((_, i) => i),
|
||||
datasets: [{
|
||||
data: dataPoints,
|
||||
borderColor: color,
|
||||
backgroundColor: bgColor,
|
||||
borderWidth: 2,
|
||||
tension: 0.4,
|
||||
fill: true,
|
||||
pointRadius: 0,
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: { duration: 400 },
|
||||
plugins: { legend: { display: false }, tooltip: { enabled: false } },
|
||||
scales: {
|
||||
x: { display: false },
|
||||
y: { display: false, beginAtZero: true, max: 100 }
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Draw a mini bar chart (for storage) — updates in-place on subsequent calls
|
||||
function drawMiniBarChart(canvasId, dataPoints, color) {
|
||||
if (typeof Chart === 'undefined') return;
|
||||
const canvas = document.getElementById(canvasId);
|
||||
if (!canvas) return;
|
||||
if (canvas._chartInstance) {
|
||||
canvas._chartInstance.data.labels = dataPoints.map((_, i) => i);
|
||||
canvas._chartInstance.data.datasets[0].data = dataPoints;
|
||||
canvas._chartInstance.update('active');
|
||||
return;
|
||||
}
|
||||
const ctx = canvas.getContext('2d');
|
||||
canvas._chartInstance = new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: dataPoints.map((_, i) => i),
|
||||
datasets: [{
|
||||
data: dataPoints,
|
||||
backgroundColor: color,
|
||||
borderRadius: 3,
|
||||
borderSkipped: false,
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: { duration: 400 },
|
||||
plugins: { legend: { display: false }, tooltip: { enabled: false } },
|
||||
scales: {
|
||||
x: { display: false },
|
||||
y: { display: false, beginAtZero: true, max: 100 }
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Refresh spinner helpers
|
||||
function showRefreshSpin(visible) {
|
||||
const el = document.getElementById('dashRefreshSpin');
|
||||
if (el) el.style.opacity = visible ? '1' : '0';
|
||||
}
|
||||
function markLastUpdated() {
|
||||
const el = document.getElementById('dashLastUpdated');
|
||||
if (!el) return;
|
||||
const now = new Date();
|
||||
el.textContent = now.toLocaleTimeString(
|
||||
'{{ "ru-RU" if lang == "ru" else "en-US" }}',
|
||||
{ hour: '2-digit', minute: '2-digit', second: '2-digit' }
|
||||
);
|
||||
}
|
||||
|
||||
// Inner function: fetch node stats for already-known online servers
|
||||
async function _refreshNodeStats(onlineServers) {
|
||||
try {
|
||||
|
||||
// ---------- Step 1: fetch node lists for all servers IN PARALLEL ----------
|
||||
const nodeListResults = await Promise.all(
|
||||
onlineServers.map(async server => {
|
||||
// server here may be from /resources/all (has .id) or /servers (has .id + .is_online)
|
||||
try {
|
||||
const r = await fetchWithAuth(`/proxmox/api/${server.id}/nodes`, { hideOnForbidden: true });
|
||||
if (!r || !r.ok) return null;
|
||||
const d = await r.json();
|
||||
return { server, nodes: d.nodes || [], clusterId: d.cluster_id || `standalone-${server.id}` };
|
||||
} catch (e_) { return null; }
|
||||
})
|
||||
);
|
||||
|
||||
// de-duplicate across clusters
|
||||
const seenNodes = new Set();
|
||||
const uniqueNodes = [];
|
||||
for (const result of nodeListResults) {
|
||||
if (!result) continue;
|
||||
for (const nodeInfo of result.nodes) {
|
||||
const key = `${result.clusterId}::${nodeInfo.node}`;
|
||||
if (seenNodes.has(key)) continue;
|
||||
seenNodes.add(key);
|
||||
uniqueNodes.push({ server: result.server, nodeName: nodeInfo.node, maxVms: nodeInfo.maxvms || 0 });
|
||||
}
|
||||
}
|
||||
if (uniqueNodes.length === 0) return;
|
||||
|
||||
// ---------- Step 2: fetch status AND rrd for ALL nodes IN PARALLEL ----------
|
||||
const [statusResults, rrdResults] = await Promise.all([
|
||||
Promise.all(uniqueNodes.map(async ({ server, nodeName, maxVms }) => {
|
||||
try {
|
||||
const r = await fetchWithAuth(`/proxmox/api/${server.id}/node/status?node=${nodeName}`, { hideOnForbidden: true });
|
||||
if (!r || !r.ok) return { nodeName, serverName: server.name, cpuPct: 0, ramPct: 0, diskPct: 0, vmCount: maxVms };
|
||||
const st = await r.json();
|
||||
return {
|
||||
nodeName, serverName: server.name, vmCount: maxVms,
|
||||
cpuPct: Math.round((st.cpu || 0) * 100),
|
||||
ramPct: st.memory && st.memory.total > 0 ? Math.round((st.memory.used / st.memory.total) * 100) : 0,
|
||||
diskPct: st.rootfs && st.rootfs.total > 0 ? Math.round((st.rootfs.used / st.rootfs.total) * 100) : 0,
|
||||
};
|
||||
} catch (e_) { return { nodeName, serverName: server.name, cpuPct: 0, ramPct: 0, diskPct: 0, vmCount: maxVms }; }
|
||||
})),
|
||||
Promise.all(uniqueNodes.map(async ({ server, nodeName }) => {
|
||||
try {
|
||||
const r = await fetchWithAuth(`/proxmox/api/${server.id}/node/rrddata?node=${nodeName}&timeframe=hour`, { hideOnForbidden: true });
|
||||
if (!r || !r.ok) return null;
|
||||
const d = await r.json();
|
||||
return d.data || [];
|
||||
} catch (e_) { return null; }
|
||||
}))
|
||||
]);
|
||||
|
||||
const allNodeData = statusResults.filter(Boolean);
|
||||
|
||||
// Aggregate RRD histories
|
||||
const cpuHistoryAll = [], ramHistoryAll = [], diskHistoryAll = [];
|
||||
for (const data of rrdResults) {
|
||||
if (!data || data.length === 0) continue;
|
||||
const nc = [], nr = [], nd = [];
|
||||
for (const pt of data) {
|
||||
nc.push((pt.cpu || 0) * 100);
|
||||
nr.push(pt.memtotal > 0 ? ((pt.memused || 0) / pt.memtotal * 100) : (pt.mem || 0) * 100);
|
||||
nd.push(pt.roottotal > 0 ? ((pt.rootused || 0) / pt.roottotal * 100) : 0);
|
||||
}
|
||||
if (nc.length > 0) { cpuHistoryAll.push(nc); ramHistoryAll.push(nr); diskHistoryAll.push(nd); }
|
||||
}
|
||||
|
||||
// ---------- Render resource cards ----------
|
||||
if (allNodeData.length > 0) {
|
||||
const avgCpu = Math.round(allNodeData.reduce((s, n) => s + n.cpuPct, 0) / allNodeData.length);
|
||||
const avgRam = Math.round(allNodeData.reduce((s, n) => s + n.ramPct, 0) / allNodeData.length);
|
||||
const avgDisk = Math.round(allNodeData.reduce((s, n) => s + n.diskPct, 0) / allNodeData.length);
|
||||
const cpuPctEl = document.getElementById('cpuPct');
|
||||
const ramPctEl = document.getElementById('ramPct');
|
||||
const diskPctEl = document.getElementById('diskPct');
|
||||
if (cpuPctEl) { cpuPctEl.classList.remove('skeleton'); cpuPctEl.textContent = avgCpu + '%'; setPctBadgeColor(cpuPctEl, avgCpu); document.getElementById('cpuBar').style.width = avgCpu + '%'; }
|
||||
if (ramPctEl) { ramPctEl.classList.remove('skeleton'); ramPctEl.textContent = avgRam + '%'; setPctBadgeColor(ramPctEl, avgRam); document.getElementById('ramBar').style.width = avgRam + '%'; }
|
||||
if (diskPctEl) { diskPctEl.classList.remove('skeleton'); diskPctEl.textContent = avgDisk + '%'; setPctBadgeColor(diskPctEl, avgDisk); document.getElementById('diskBar').style.width = avgDisk + '%'; }
|
||||
}
|
||||
|
||||
// Mini charts
|
||||
if (cpuHistoryAll.length > 0) {
|
||||
const len = Math.max(...cpuHistoryAll.map(a => a.length));
|
||||
const avgH = arrays => Array.from({ length: len }, (_, i) => {
|
||||
const vals = arrays.map(a => i < a.length ? a[i] : (a[a.length - 1] || 0));
|
||||
return vals.reduce((s, v) => s + v, 0) / vals.length;
|
||||
});
|
||||
const cpuSlice = avgH(cpuHistoryAll).slice(-24);
|
||||
const ramSlice = avgH(ramHistoryAll).slice(-24);
|
||||
const diskSlice = avgH(diskHistoryAll).slice(-24);
|
||||
requestAnimationFrame(() => {
|
||||
drawMiniLineChart('cpuMiniChart', cpuSlice, '#3b82f6', 'rgba(59,130,246,0.15)');
|
||||
drawMiniLineChart('ramMiniChart', ramSlice, '#8b5cf6', 'rgba(139,92,246,0.15)');
|
||||
if (diskSlice.some(v => v > 0)) {
|
||||
drawMiniBarChart('diskMiniChart', diskSlice, 'rgba(16,185,129,0.7)');
|
||||
} else {
|
||||
const avgD = allNodeData.length > 0 ? Math.round(allNodeData.reduce((s, n) => s + n.diskPct, 0) / allNodeData.length) : 0;
|
||||
drawMiniBarChart('diskMiniChart', Array(24).fill(0).map((_, i) => (i / 23) * avgD * 0.9 + avgD * 0.1), 'rgba(16,185,129,0.7)');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- Render Nodes panel ----------
|
||||
const panel = document.getElementById('nodesPanel');
|
||||
const onlineMeta = document.getElementById('nodesOnlineMeta');
|
||||
if (onlineMeta) onlineMeta.textContent = allNodeData.length + '/' + allNodeData.length + ' ' + t('online').toLowerCase();
|
||||
if (panel) {
|
||||
if (allNodeData.length === 0) {
|
||||
panel.innerHTML = `<div style="text-align:center;color:var(--text-muted);padding:2rem 1.25rem;font-size:.875rem;">${t('no_nodes')}</div>`;
|
||||
} else {
|
||||
panel.innerHTML = allNodeData.map(node => `
|
||||
<div class="node-item">
|
||||
<div class="node-item-header">
|
||||
<div class="node-item-name">
|
||||
<span class="node-dot-online"></span>
|
||||
<span class="node-name-text">${escapeHtml(node.serverName || node.nodeName)}</span>
|
||||
</div>
|
||||
<span class="node-vm-count">${node.vmCount > 0 ? node.vmCount + ' ' + t('vms') : ''}</span>
|
||||
</div>
|
||||
<div class="node-bar-row">
|
||||
<span class="node-bar-lbl">CPU</span>
|
||||
<div class="node-bar-wrap"><div class="node-bar-fill cpu" style="width:${node.cpuPct}%"></div></div>
|
||||
<span class="node-bar-pct">${node.cpuPct}%</span>
|
||||
</div>
|
||||
<div class="node-bar-row">
|
||||
<span class="node-bar-lbl">RAM</span>
|
||||
<div class="node-bar-wrap"><div class="node-bar-fill ram" style="width:${node.ramPct}%"></div></div>
|
||||
<span class="node-bar-pct">${node.ramPct}%</span>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// silent
|
||||
}
|
||||
}
|
||||
|
||||
// HTML escape helper
|
||||
function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
const d = document.createElement('div');
|
||||
d.textContent = str;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
// Two-speed refresh:
|
||||
// • WS push (dashboard_metrics channel) — every 10 s from server
|
||||
// • _refreshNodeStats — max once per 60 s (slow, many requests)
|
||||
let _lastNodeRefresh = 0;
|
||||
const NODE_REFRESH_MIN_MS = 300000; // HTTP fallback every 5 min (WS provides node data normally)
|
||||
|
||||
async function refreshVMs() {
|
||||
showRefreshSpin(true);
|
||||
try {
|
||||
const res = await fetchWithAuth('/proxmox/api/resources/all', { hideOnForbidden: true });
|
||||
if (!res || !res.ok || res.forbidden) { showRefreshSpin(false); return; }
|
||||
const data = await res.json();
|
||||
renderDashboardData(data);
|
||||
} catch (e) {
|
||||
// silent
|
||||
} finally {
|
||||
showRefreshSpin(false);
|
||||
}
|
||||
}
|
||||
|
||||
function renderDashboardData(data) {
|
||||
showRefreshSpin(true);
|
||||
try {
|
||||
|
||||
let totalVms = 0, runningVms = 0, totalCt = 0, runningCt = 0;
|
||||
const allVms = [];
|
||||
|
||||
for (const server of (data.servers || [])) {
|
||||
const vms = server.vms || [];
|
||||
const cts = server.containers || [];
|
||||
totalVms += vms.length;
|
||||
runningVms += vms.filter(v => v.status === 'running').length;
|
||||
totalCt += cts.length;
|
||||
runningCt += cts.filter(c => c.status === 'running').length;
|
||||
for (const vm of vms) allVms.push({ ...vm, serverName: server.name });
|
||||
}
|
||||
|
||||
const vmCount = document.getElementById('statVmCount');
|
||||
const vmSub = document.getElementById('statVmSub');
|
||||
const ctCount = document.getElementById('statCtCount');
|
||||
const ctSub = document.getElementById('statCtSub');
|
||||
if (vmCount) { vmCount.classList.remove('skeleton'); vmCount.textContent = totalVms; }
|
||||
if (vmSub) vmSub.textContent = runningVms + ' ' + t('running').toLowerCase();
|
||||
if (ctCount) { ctCount.classList.remove('skeleton'); ctCount.textContent = totalCt; }
|
||||
if (ctSub) ctSub.textContent = runningCt + ' ' + t('running').toLowerCase();
|
||||
|
||||
const tbody = document.getElementById('vmTableBody');
|
||||
const meta = document.getElementById('vmTableMeta');
|
||||
if (meta) meta.textContent = totalVms + ' ' + t('total');
|
||||
if (tbody) {
|
||||
allVms.sort((a, b) => {
|
||||
const rank = s => s === 'running' ? 0 : s === 'paused' ? 1 : 2;
|
||||
return rank(a.status) - rank(b.status);
|
||||
});
|
||||
const shown = allVms.slice(0, 8);
|
||||
if (shown.length === 0) {
|
||||
tbody.innerHTML = `<tr><td colspan="5"><div style="text-align:center;padding:3rem 1.25rem;color:var(--text-muted);"><i class="fa-solid fa-server" style="font-size:2rem;margin-bottom:.75rem;display:block;opacity:.3;"></i><div style="font-size:.875rem;">${t('no_vms_found')}</div></div></td></tr>`;
|
||||
} else {
|
||||
tbody.innerHTML = shown.map(vm => {
|
||||
const cpuPct = vm.cpu != null ? (vm.cpu * 100).toFixed(0) + '%' : '—';
|
||||
const ramGb = vm.mem != null ? (vm.mem / (1024**3)).toFixed(1) + 'G' : '—';
|
||||
const status = (vm.status || 'unknown').toLowerCase();
|
||||
const statusCls = status === 'running' ? 'running' : status === 'paused' ? 'warning' : 'stopped';
|
||||
const statusLabel = status === 'running' ? t('running') : status === 'paused' ? t('paused') : t('stopped');
|
||||
return `<tr>
|
||||
<td><span class="vm-name-cell">${escapeHtml(vm.name || 'vm-' + vm.vmid)}</span></td>
|
||||
<td><span class="vm-node-cell">${escapeHtml(vm.node || '—')}</span></td>
|
||||
<td><span class="vm-metric-cell">${cpuPct}</span></td>
|
||||
<td><span class="vm-metric-cell">${ramGb}</span></td>
|
||||
<td><span class="vm-status-badge ${statusCls}"><span class="status-dot-sm"></span>${statusLabel}</span></td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
}
|
||||
|
||||
// Use node data from WS payload (cpu_pct/ram_pct/disk_pct/rrd_*) if available
|
||||
const allNodes = [];
|
||||
for (const server of (data.servers || [])) {
|
||||
if (Array.isArray(server.nodes)) {
|
||||
for (const node of server.nodes) allNodes.push({ ...node, serverName: server.name });
|
||||
}
|
||||
}
|
||||
if (allNodes.length > 0) {
|
||||
const avgCpu = Math.round(allNodes.reduce((s, n) => s + (n.cpu_pct || 0), 0) / allNodes.length);
|
||||
const avgRam = Math.round(allNodes.reduce((s, n) => s + (n.ram_pct || 0), 0) / allNodes.length);
|
||||
const avgDisk = Math.round(allNodes.reduce((s, n) => s + (n.disk_pct || 0), 0) / allNodes.length);
|
||||
const cpuPctEl = document.getElementById('cpuPct');
|
||||
const ramPctEl = document.getElementById('ramPct');
|
||||
const diskPctEl = document.getElementById('diskPct');
|
||||
if (cpuPctEl) { cpuPctEl.classList.remove('skeleton'); cpuPctEl.textContent = avgCpu + '%'; setPctBadgeColor(cpuPctEl, avgCpu); document.getElementById('cpuBar').style.width = avgCpu + '%'; }
|
||||
if (ramPctEl) { ramPctEl.classList.remove('skeleton'); ramPctEl.textContent = avgRam + '%'; setPctBadgeColor(ramPctEl, avgRam); document.getElementById('ramBar').style.width = avgRam + '%'; }
|
||||
if (diskPctEl) { diskPctEl.classList.remove('skeleton'); diskPctEl.textContent = avgDisk + '%'; setPctBadgeColor(diskPctEl, avgDisk); document.getElementById('diskBar').style.width = avgDisk + '%'; }
|
||||
|
||||
// Mini charts from WS-provided RRD slices
|
||||
const cpuHistoryAll = [], ramHistoryAll = [], diskHistoryAll = [];
|
||||
for (const node of allNodes) {
|
||||
if (Array.isArray(node.rrd_cpu) && node.rrd_cpu.length > 0) {
|
||||
cpuHistoryAll.push(node.rrd_cpu);
|
||||
ramHistoryAll.push(node.rrd_ram || []);
|
||||
diskHistoryAll.push(node.rrd_disk || []);
|
||||
}
|
||||
}
|
||||
if (cpuHistoryAll.length > 0) {
|
||||
const len = Math.max(...cpuHistoryAll.map(a => a.length));
|
||||
const avgH = arrays => Array.from({ length: len }, (_, i) => {
|
||||
const vals = arrays.map(a => i < a.length ? a[i] : (a[a.length - 1] || 0));
|
||||
return vals.reduce((s, v) => s + v, 0) / vals.length;
|
||||
});
|
||||
const cpuSlice = avgH(cpuHistoryAll);
|
||||
const ramSlice = avgH(ramHistoryAll);
|
||||
const diskSlice = avgH(diskHistoryAll);
|
||||
requestAnimationFrame(() => {
|
||||
drawMiniLineChart('cpuMiniChart', cpuSlice, '#3b82f6', 'rgba(59,130,246,0.15)');
|
||||
drawMiniLineChart('ramMiniChart', ramSlice, '#8b5cf6', 'rgba(139,92,246,0.15)');
|
||||
if (diskSlice.some(v => v > 0)) {
|
||||
drawMiniBarChart('diskMiniChart', diskSlice, 'rgba(16,185,129,0.7)');
|
||||
} else {
|
||||
drawMiniBarChart('diskMiniChart', Array(24).fill(0).map((_, i) => (i / 23) * avgDisk * 0.9 + avgDisk * 0.1), 'rgba(16,185,129,0.7)');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Nodes panel
|
||||
const panel = document.getElementById('nodesPanel');
|
||||
const onlineMeta = document.getElementById('nodesOnlineMeta');
|
||||
if (onlineMeta) onlineMeta.textContent = allNodes.length + '/' + allNodes.length + ' ' + t('online').toLowerCase();
|
||||
if (panel) {
|
||||
panel.innerHTML = allNodes.map(node => `
|
||||
<div class="node-item">
|
||||
<div class="node-item-header">
|
||||
<div class="node-item-name">
|
||||
<span class="node-dot-online"></span>
|
||||
<span class="node-name-text">${escapeHtml(node.serverName || node.name)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="node-bar-row">
|
||||
<span class="node-bar-lbl">CPU</span>
|
||||
<div class="node-bar-wrap"><div class="node-bar-fill cpu" style="width:${node.cpu_pct}%"></div></div>
|
||||
<span class="node-bar-pct">${node.cpu_pct}%</span>
|
||||
</div>
|
||||
<div class="node-bar-row">
|
||||
<span class="node-bar-lbl">RAM</span>
|
||||
<div class="node-bar-wrap"><div class="node-bar-fill ram" style="width:${node.ram_pct}%"></div></div>
|
||||
<span class="node-bar-pct">${node.ram_pct}%</span>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
_lastNodeRefresh = Date.now();
|
||||
} else {
|
||||
// Fallback: HTTP poll if WS has no node data yet
|
||||
const now = Date.now();
|
||||
if (now - _lastNodeRefresh >= NODE_REFRESH_MIN_MS) {
|
||||
_lastNodeRefresh = now;
|
||||
const onlineServers = (data.servers || []).filter(s => !s.error && s.id);
|
||||
if (onlineServers.length > 0) {
|
||||
_refreshNodeStats(onlineServers).then(() => { markLastUpdated(); });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
markLastUpdated();
|
||||
} catch (e) {
|
||||
// silent
|
||||
} finally {
|
||||
showRefreshSpin(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Init — loads initial data then subscribes to WebSocket push updates
|
||||
document.addEventListener('DOMContentLoaded', async function() {
|
||||
// Initial full load (VMs + nodes)
|
||||
showRefreshSpin(true);
|
||||
try {
|
||||
const res = await fetchWithAuth('/proxmox/api/resources/all', { hideOnForbidden: true });
|
||||
if (res && res.ok && !res.forbidden) {
|
||||
const data = await res.json();
|
||||
const onlineServers = (data.servers || []).filter(s => !s.error && s.id);
|
||||
if (onlineServers.length > 0) {
|
||||
_lastNodeRefresh = Date.now();
|
||||
await _refreshNodeStats(onlineServers);
|
||||
}
|
||||
}
|
||||
} catch (e) {/* ignore */}
|
||||
await refreshVMs();
|
||||
|
||||
// Subscribe to WebSocket push updates.
|
||||
// subscribeWsChannel queues the subscription and sends it on WS open/reconnect.
|
||||
if (typeof subscribeWsChannel === 'function') {
|
||||
subscribeWsChannel('dashboard_metrics');
|
||||
}
|
||||
if (typeof registerWsHandler === 'function') {
|
||||
registerWsHandler('metrics_update', (msg) => {
|
||||
if (msg.channel === 'dashboard_metrics') {
|
||||
renderDashboardData(msg.data);
|
||||
}
|
||||
});
|
||||
}
|
||||
window.addEventListener('beforeunload', () => {
|
||||
if (typeof unsubscribeWsChannel === 'function') unsubscribeWsChannel('dashboard_metrics');
|
||||
});
|
||||
});
|
||||
|
||||
// ======== Alerts Modal ========
|
||||
function closeAlertsModal() {
|
||||
document.getElementById('alertsModal').style.display = 'none';
|
||||
}
|
||||
|
||||
async function openAlertsModal() {
|
||||
const modal = document.getElementById('alertsModal');
|
||||
modal.style.display = 'flex';
|
||||
const body = document.getElementById('alertsModalBody');
|
||||
body.innerHTML = '<div style="text-align:center;color:var(--text-muted);padding:2rem;"><i class="fa-solid fa-circle-notch fa-spin"></i></div>';
|
||||
try {
|
||||
const res = await fetchWithAuth('/api/dashboard/alerts');
|
||||
if (!res || !res.ok) throw new Error('failed');
|
||||
const data = await res.json();
|
||||
if (!data.alerts || data.alerts.length === 0) {
|
||||
body.innerHTML = `<div style="text-align:center;color:var(--text-muted);padding:2rem;">${t('no_alerts')}</div>`;
|
||||
return;
|
||||
}
|
||||
const locale = '{{ "ru-RU" if lang == "ru" else "en-US" }}';
|
||||
body.innerHTML = data.alerts.map(a => {
|
||||
const time = a.created_at
|
||||
? new Date(a.created_at).toLocaleString(locale, {month:'short', day:'numeric', hour:'2-digit', minute:'2-digit'})
|
||||
: '—';
|
||||
const lvlCls = a.level === 'critical' ? 'critical' : 'error';
|
||||
return `<div class="audit-row" style="align-items:flex-start;padding:.65rem 0;border-bottom:1px solid var(--border, rgba(255,255,255,.06));">
|
||||
<span class="audit-time" style="flex-shrink:0;">${time}</span>
|
||||
<span style="flex-shrink:0;"><span class="audit-action-tag ${lvlCls}">${escapeHtml(a.level)}</span></span>
|
||||
<span style="flex:1;font-size:.875rem;color:var(--text-primary);word-break:break-word;">${escapeHtml(a.message)}</span>
|
||||
${a.username ? `<span class="audit-user" style="flex-shrink:0;">${escapeHtml(a.username)}</span>` : '<span></span>'}
|
||||
</div>`;
|
||||
}).join('');
|
||||
} catch (e) {
|
||||
body.innerHTML = '<div style="text-align:center;color:var(--danger);padding:2rem;"><i class="fa-solid fa-circle-exclamation" style="margin-right:.5rem;"></i>Ошибка загрузки</div>';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- ======== Alerts Modal ======== -->
|
||||
<div id="alertsModal" style="display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.82);z-index:2000;align-items:center;justify-content:center;">
|
||||
<div class="card" style="max-width:680px;width:95%;max-height:82vh;display:flex;flex-direction:column;">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem;">
|
||||
<h3 style="margin:0;color:var(--text-primary);">
|
||||
<i class="fa-solid fa-triangle-exclamation" style="color:var(--warning,#f59e0b);margin-right:.5rem;"></i>
|
||||
{{ t('alerts_label') }} — {{ t('last_24h') }}
|
||||
</h3>
|
||||
<button onclick="closeAlertsModal()" aria-label="Закрыть" class="modal-close-btn">×</button>
|
||||
</div>
|
||||
<div id="alertsModalBody" style="overflow-y:auto;flex:1;min-height:80px;"></div>
|
||||
<div style="margin-top:.75rem;padding-top:.75rem;border-top:1px solid var(--border, rgba(255,255,255,.06));text-align:right;">
|
||||
<a href="/logs?level=error" class="btn btn-secondary" style="text-decoration:none;">
|
||||
<i class="fa-solid fa-arrow-right" style="margin-right:.5rem;"></i>{{ t('view_all') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
document.getElementById('alertsModal').addEventListener('click', function(e) {
|
||||
if (e.target === this) closeAlertsModal();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,281 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% set page_title = t('nav_dashboard') %}
|
||||
{% block content %}
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">{{ t('proxmox_servers') }}</div>
|
||||
<div class="stat-value">{{ stats.total_servers }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">{{ t('online') }}</div>
|
||||
<div class="stat-value text-success">{{ stats.online_servers }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">{{ t('offline') }}</div>
|
||||
<div class="stat-value text-danger">{{ stats.offline_servers }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Графики Proxmox нод -->
|
||||
<div class="card" id="proxmox-charts-section" style="display: none;">
|
||||
<h3><i class="fa-solid fa-chart-line"></i> {{ t('proxmox_nodes_load') }}</h3>
|
||||
<div class="dashboard-charts-grid" id="nodes-charts-grid"></div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.dashboard-charts-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.dashboard-charts-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: 1fr !important;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.dashboard-charts-grid .card h4 {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// Загрузка статистики и графиков Proxmox нод
|
||||
async function loadProxmoxNodesStats() {
|
||||
try {
|
||||
const response = await fetchWithAuth('/proxmox/api/servers');
|
||||
if (!response) return;
|
||||
|
||||
const servers = await response.json();
|
||||
const onlineServers = servers.filter(s => s.is_online);
|
||||
|
||||
if (onlineServers.length === 0) return;
|
||||
|
||||
const chartsSection = document.getElementById('proxmox-charts-section');
|
||||
const chartsGrid = document.getElementById('nodes-charts-grid');
|
||||
|
||||
chartsGrid.innerHTML = '';
|
||||
|
||||
// Собрать все данные сначала, потом отрисовать
|
||||
const chartsToRender = [];
|
||||
// Дедупликация по ключу "cluster_id::node".
|
||||
// Члены одного кластера возвращают одинаковый cluster_id (имя кластера),
|
||||
// поэтому общие ноды показываются один раз.
|
||||
// Независимые серверы с одинаковым именем ноды получают разные cluster_id
|
||||
// ("standalone-{server_id}"), поэтому отображаются отдельно.
|
||||
const seenNodes = new Set();
|
||||
|
||||
// Загрузить данные для каждого сервера
|
||||
for (const server of onlineServers) {
|
||||
try {
|
||||
// Получить список нод
|
||||
const nodesResponse = await fetchWithAuth(`/proxmox/api/${server.id}/nodes`);
|
||||
if (!nodesResponse || !nodesResponse.ok) {
|
||||
console.warn(`Failed to load nodes for ${server.name}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const nodesData = await nodesResponse.json();
|
||||
const nodes = nodesData.nodes || [];
|
||||
const clusterId = nodesData.cluster_id || `standalone-${server.id}`;
|
||||
|
||||
if (nodes.length === 0) {
|
||||
console.warn(`No nodes found for ${server.name}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Обработать ВСЕ ноды этого сервера
|
||||
for (const nodeInfo of nodes) {
|
||||
const node = nodeInfo.node;
|
||||
|
||||
// Дедупликация по кластеру: члены одного кластера имеют
|
||||
// одинаковый clusterId и одинаковые имена нод → пропускаем дубли.
|
||||
// Независимые серверы с одинаковым именем ноды имеют разные
|
||||
// clusterId → отображаются отдельно.
|
||||
const dedupeKey = `${clusterId}::${node}`;
|
||||
if (seenNodes.has(dedupeKey)) {
|
||||
continue;
|
||||
}
|
||||
seenNodes.add(dedupeKey);
|
||||
|
||||
// Получить данные для графиков
|
||||
const rrdResponse = await fetchWithAuth(`/proxmox/api/${server.id}/node/rrddata?node=${node}&timeframe=hour`);
|
||||
if (!rrdResponse || !rrdResponse.ok) {
|
||||
console.warn(`Failed to load RRD data for ${server.name}/${node}, skipping chart`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const rrdData = await rrdResponse.json();
|
||||
const data = rrdData.data || [];
|
||||
|
||||
if (data.length === 0) {
|
||||
console.warn(`No RRD data for ${server.name}/${node}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Сохранить данные для отрисовки
|
||||
chartsToRender.push({
|
||||
canvasId: `chart-${server.id}-${node}`,
|
||||
serverName: server.name,
|
||||
nodeName: node,
|
||||
data: data
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Error loading stats for server ${server.id}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Теперь создать все canvas элементы
|
||||
if (chartsToRender.length > 0) {
|
||||
let html = '';
|
||||
for (const chart of chartsToRender) {
|
||||
// Извлекаем базовое имя сервера (убираем " - nodeX" если есть)
|
||||
let baseServerName = chart.serverName.trim();
|
||||
// Если имя сервера заканчивается на " - <nodeName>", убираем это
|
||||
const nodePattern = new RegExp(`\\s*-\\s*(pve\\d+|node\\d+|${chart.nodeName})$`, 'i');
|
||||
baseServerName = baseServerName.replace(nodePattern, '').trim();
|
||||
|
||||
// Формируем отображаемое имя
|
||||
// Если базовое имя пустое или равно имени ноды — просто показываем имя ноды
|
||||
let displayName;
|
||||
if (!baseServerName || baseServerName.toLowerCase() === chart.nodeName.toLowerCase()) {
|
||||
displayName = chart.nodeName;
|
||||
} else {
|
||||
displayName = `${baseServerName} - ${chart.nodeName}`;
|
||||
}
|
||||
html += `
|
||||
<div class="card">
|
||||
<h4 style="margin: 0 0 1rem 0;">${displayName}</h4>
|
||||
<canvas id="${chart.canvasId}" style="max-height: 250px;"></canvas>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
chartsGrid.innerHTML = html;
|
||||
chartsSection.style.display = 'block';
|
||||
|
||||
// Отрисовать все графики после того как DOM обновился
|
||||
requestAnimationFrame(() => {
|
||||
for (const chart of chartsToRender) {
|
||||
drawNodeChart(chart.canvasId, chart.data);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading Proxmox stats:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Отрисовка графика ноды
|
||||
function drawNodeChart(canvasId, data) {
|
||||
if (typeof Chart === 'undefined') {
|
||||
console.error('Chart.js not loaded');
|
||||
return;
|
||||
}
|
||||
|
||||
const ctx = document.getElementById(canvasId);
|
||||
if (!ctx) {
|
||||
console.error('Canvas not found:', canvasId);
|
||||
return;
|
||||
}
|
||||
|
||||
const locale = "{{ 'ru-RU' if lang == 'ru' else 'en-US' }}";
|
||||
const formatTime = (timestamp) => {
|
||||
const date = new Date(timestamp * 1000);
|
||||
return date.toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' });
|
||||
};
|
||||
|
||||
new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: data.map(d => formatTime(d.time)),
|
||||
datasets: [
|
||||
{
|
||||
label: '{{ t('cpu') }} %',
|
||||
data: data.map(d => ((d.cpu || 0) * 100).toFixed(2)),
|
||||
borderColor: '#60a5fa',
|
||||
backgroundColor: 'rgba(96,165,250,0.1)',
|
||||
borderWidth: 2,
|
||||
tension: 0.4,
|
||||
fill: true,
|
||||
pointRadius: 0,
|
||||
yAxisID: 'y'
|
||||
},
|
||||
{
|
||||
label: '{{ t('memory') }} {{ t('gb') }}',
|
||||
data: data.map(d => ((d.memused || 0) / (1024*1024*1024)).toFixed(2)),
|
||||
borderColor: '#f59e0b',
|
||||
backgroundColor: 'rgba(245,158,11,0.1)',
|
||||
borderWidth: 2,
|
||||
tension: 0.4,
|
||||
fill: true,
|
||||
pointRadius: 0,
|
||||
yAxisID: 'y1'
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: true,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'top',
|
||||
labels: { color: '#9ca3af', usePointStyle: true, padding: 10 }
|
||||
},
|
||||
tooltip: { enabled: true, mode: 'index', intersect: false }
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
type: 'category',
|
||||
grid: { color: 'rgba(255,255,255,0.1)' },
|
||||
ticks: { color: '#9ca3af', maxTicksLimit: 10 }
|
||||
},
|
||||
y: {
|
||||
type: 'linear',
|
||||
display: true,
|
||||
position: 'left',
|
||||
beginAtZero: true,
|
||||
grid: { color: 'rgba(255,255,255,0.1)' },
|
||||
ticks: { color: '#9ca3af' },
|
||||
title: { display: true, text: '{{ t('cpu') }} %', color: '#60a5fa' }
|
||||
},
|
||||
y1: {
|
||||
type: 'linear',
|
||||
display: true,
|
||||
position: 'right',
|
||||
beginAtZero: true,
|
||||
grid: { drawOnChartArea: false },
|
||||
ticks: { color: '#9ca3af' },
|
||||
title: { display: true, text: '{{ t('memory') }} {{ t('gb') }}', color: '#f59e0b' }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Инициализация при полной загрузке DOM
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Загрузить графики нод
|
||||
loadProxmoxNodesStats();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,351 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}IPAM - {{ t('ip_allocations') }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem;">
|
||||
<div>
|
||||
<h1 style="font-size: 1.75rem; font-weight: 600; margin: 0;"><i class="fa-solid fa-thumbtack"></i> {{ t('ip_allocations') }}</h1>
|
||||
<p style="color: var(--text-secondary); margin-top: 0.25rem;">{{ t('all_allocated_ips') }}</p>
|
||||
</div>
|
||||
<div style="display: flex; gap: 0.75rem;">
|
||||
<a href="/ipam" class="btn btn-secondary">← {{ t('back') }}</a>
|
||||
<button onclick="showAllocateModal()" class="btn btn-success" data-permission="ipam.manage"><i class="fa-solid fa-plus"></i> {{ t('allocate_ip') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="card" style="margin-bottom: 1rem;">
|
||||
<div style="display: flex; gap: 1rem; flex-wrap: wrap; align-items: center;">
|
||||
<div>
|
||||
<label style="color: var(--text-secondary); font-size: 0.875rem;">{{ t('network_label') }}:</label>
|
||||
<select id="filterNetwork" onchange="loadAllocations()" class="form-select" style="padding: 0.5rem; min-width: 150px;">
|
||||
<option value="">{{ t('all_networks') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style="color: var(--text-secondary); font-size: 0.875rem;">{{ t('status') }}:</label>
|
||||
<select id="filterStatus" onchange="loadAllocations()" class="form-select" style="padding: 0.5rem; min-width: 150px;">
|
||||
<option value="">{{ t('all_status') }}</option>
|
||||
<option value="allocated">{{ t('allocated') }}</option>
|
||||
<option value="reserved">{{ t('reserved') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style="color: var(--text-secondary); font-size: 0.875rem;">{{ t('search') }}:</label>
|
||||
<input type="text" id="filterSearch" placeholder="{{ t('search_placeholder') }}" oninput="filterTable()" style="padding: 0.5rem; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-tertiary); color: var(--text-primary);">
|
||||
</div>
|
||||
<button onclick="loadAllocations()" class="btn btn-sm" style="background: var(--bg-tertiary); color: var(--text-primary); margin-left: auto;"><i class="fa-solid fa-rotate"></i> {{ t('refresh') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Allocations List -->
|
||||
<div class="card">
|
||||
<div id="allocationsContainer">
|
||||
<div style="text-align: center; padding: 2rem; color: var(--text-muted);">
|
||||
<i class="fa-solid fa-spinner fa-spin"></i> {{ t('loading') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Allocate IP Modal -->
|
||||
<div id="allocateModal" style="display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.85); z-index: 2000; align-items: center; justify-content: center; padding: 1rem;">
|
||||
<div class="card" style="max-width: 500px; width: 95%; max-height: 90vh; overflow-y: auto;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
|
||||
<h3 style="margin: 0;"><i class="fa-solid fa-plus"></i> {{ t('allocate_ip') }}</h3>
|
||||
<button onclick="closeAllocateModal()" style="background: none; border: none; color: var(--text-muted); font-size: 1.5rem; cursor: pointer;">×</button>
|
||||
</div>
|
||||
|
||||
<form id="allocateForm" onsubmit="allocateIP(event)">
|
||||
<div style="display: grid; gap: 1rem;">
|
||||
<div class="form-group">
|
||||
<label style="display: block; margin-bottom: 0.5rem; color: var(--text-secondary);">{{ t('network_label') }} {{ t('required') }}</label>
|
||||
<select id="allocNetwork" required style="width: 100%; padding: 0.75rem; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-tertiary); color: var(--text-primary);">
|
||||
<option value="">{{ t('select_network') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label style="display: block; margin-bottom: 0.5rem; color: var(--text-secondary);">{{ t('ip_address_auto') }}</label>
|
||||
<input type="text" id="allocIP" placeholder="{{ t('auto_allocation') }}" style="width: 100%; padding: 0.75rem; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-tertiary); color: var(--text-primary);">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label style="display: block; margin-bottom: 0.5rem; color: var(--text-secondary);">{{ t('hostname') }}</label>
|
||||
<input type="text" id="allocHostname" placeholder="server-01" style="width: 100%; padding: 0.75rem; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-tertiary); color: var(--text-primary);">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label style="display: block; margin-bottom: 0.5rem; color: var(--text-secondary);">{{ t('description') }}</label>
|
||||
<input type="text" id="allocDescription" placeholder="Web server" style="width: 100%; padding: 0.75rem; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-tertiary); color: var(--text-primary);">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label style="display: flex; align-items: center; gap: 0.5rem; color: var(--text-secondary);">
|
||||
<input type="checkbox" id="allocReserved" style="width: auto;">
|
||||
{{ t('reserve_checkbox') }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 1rem; justify-content: flex-end; margin-top: 1.5rem; padding-top: 1rem; border-top: 1px solid var(--border-default);">
|
||||
<button type="button" onclick="closeAllocateModal()" class="btn btn-secondary">{{ t('cancel') }}</button>
|
||||
<button type="submit" class="btn btn-success"><i class="fa-solid fa-floppy-disk"></i> {{ t('allocate') }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let allAllocations = [];
|
||||
|
||||
// URL params helper functions
|
||||
function getUrlParams() {
|
||||
return new URLSearchParams(window.location.search);
|
||||
}
|
||||
|
||||
function updateUrlParams(params) {
|
||||
const url = new URL(window.location);
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value) {
|
||||
url.searchParams.set(key, value);
|
||||
} else {
|
||||
url.searchParams.delete(key);
|
||||
}
|
||||
});
|
||||
window.history.replaceState({}, '', url);
|
||||
}
|
||||
|
||||
function restoreFiltersFromUrl() {
|
||||
const params = getUrlParams();
|
||||
|
||||
const network = params.get('network') || '';
|
||||
const networkSelect = document.getElementById('filterNetwork');
|
||||
if (networkSelect && network) {
|
||||
// Will be set after networks are loaded
|
||||
networkSelect.dataset.pendingValue = network;
|
||||
}
|
||||
|
||||
const status = params.get('status') || '';
|
||||
document.getElementById('filterStatus').value = status;
|
||||
|
||||
const search = params.get('search') || '';
|
||||
document.getElementById('filterSearch').value = search;
|
||||
}
|
||||
|
||||
async function loadNetworks() {
|
||||
try {
|
||||
const response = await fetchWithAuth('/ipam/api/networks');
|
||||
if (response.ok) {
|
||||
const networks = await response.json();
|
||||
|
||||
const filterSelect = document.getElementById('filterNetwork');
|
||||
const allocSelect = document.getElementById('allocNetwork');
|
||||
|
||||
filterSelect.innerHTML = `<option value="">${t('all_networks')}</option>` +
|
||||
networks.map(n => `<option value="${n.id}">${n.name} (${n.network})</option>`).join('');
|
||||
|
||||
// Restore pending value from URL
|
||||
if (filterSelect.dataset.pendingValue) {
|
||||
filterSelect.value = filterSelect.dataset.pendingValue;
|
||||
delete filterSelect.dataset.pendingValue;
|
||||
}
|
||||
|
||||
allocSelect.innerHTML = `<option value="">${t('select_network')}</option>` +
|
||||
networks.map(n => `<option value="${n.id}">${n.name} (${n.network})</option>`).join('');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading networks:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAllocations() {
|
||||
try {
|
||||
const networkId = document.getElementById('filterNetwork').value;
|
||||
const status = document.getElementById('filterStatus').value;
|
||||
|
||||
// Save filters to URL
|
||||
updateUrlParams({ network: networkId, status: status });
|
||||
|
||||
let url = '/ipam/api/allocations?';
|
||||
if (networkId) url += `network_id=${networkId}&`;
|
||||
if (status) url += `status=${status}&`;
|
||||
|
||||
const response = await fetchWithAuth(url);
|
||||
const container = document.getElementById('allocationsContainer');
|
||||
|
||||
if (response.ok) {
|
||||
allAllocations = await response.json();
|
||||
// Apply search filter if exists
|
||||
filterTable(false);
|
||||
} else {
|
||||
container.innerHTML = `<div style="text-align: center; padding: 2rem; color: var(--danger);"><i class="fa-solid fa-circle-xmark"></i> ${t('error_loading')}</div>`;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading allocations:', error);
|
||||
document.getElementById('allocationsContainer').innerHTML = `<div style="text-align: center; padding: 2rem; color: var(--danger);"><i class="fa-solid fa-circle-xmark"></i> ${t('error_loading')}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function filterTable(updateUrl = true) {
|
||||
const search = document.getElementById('filterSearch').value.toLowerCase();
|
||||
if (updateUrl) {
|
||||
updateUrlParams({ search: search });
|
||||
}
|
||||
const filtered = allAllocations.filter(a =>
|
||||
a.ip_address.toLowerCase().includes(search) ||
|
||||
(a.hostname || '').toLowerCase().includes(search) ||
|
||||
(a.description || '').toLowerCase().includes(search) ||
|
||||
(a.vmid || '').toString().includes(search)
|
||||
);
|
||||
renderAllocations(filtered);
|
||||
}
|
||||
|
||||
function renderAllocations(allocations) {
|
||||
const container = document.getElementById('allocationsContainer');
|
||||
|
||||
if (allocations.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div style="text-align: center; padding: 3rem;">
|
||||
<div style="font-size: 3rem; margin-bottom: 1rem;"><i class="fa-solid fa-thumbtack"></i></div>
|
||||
<h3 style="color: var(--text-primary); margin-bottom: 0.5rem;">${t('allocations_not_found')}</h3>
|
||||
<p style="color: var(--text-secondary);">${t('no_allocated_ips')}</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = `
|
||||
<table style="width: 100%; border-collapse: collapse;">
|
||||
<thead>
|
||||
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||
<th style="text-align: left; padding: 0.75rem; color: var(--text-secondary); font-weight: 500;">${t('ip_address')}</th>
|
||||
<th style="text-align: left; padding: 0.75rem; color: var(--text-secondary); font-weight: 500;">${t('network_label')}</th>
|
||||
<th style="text-align: left; padding: 0.75rem; color: var(--text-secondary); font-weight: 500;">${t('hostname')}</th>
|
||||
<th style="text-align: left; padding: 0.75rem; color: var(--text-secondary); font-weight: 500;">${t('resource')}</th>
|
||||
<th style="text-align: left; padding: 0.75rem; color: var(--text-secondary); font-weight: 500;">${t('status')}</th>
|
||||
<th style="text-align: left; padding: 0.75rem; color: var(--text-secondary); font-weight: 500;">${t('created')}</th>
|
||||
<th style="text-align: right; padding: 0.75rem; color: var(--text-secondary); font-weight: 500;">${t('actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${allocations.map(a => `
|
||||
<tr style="border-bottom: 1px solid var(--border-default);" onmouseover="this.style.background='var(--bg-surface-hover)'" onmouseout="this.style.background='transparent'">
|
||||
<td style="padding: 0.75rem; font-family: monospace; font-weight: 500; color: var(--text-primary);">${a.ip_address}</td>
|
||||
<td style="padding: 0.75rem; color: var(--text-secondary);">${a.network_name || '-'}</td>
|
||||
<td style="padding: 0.75rem; color: var(--text-primary);">${a.hostname || '-'}</td>
|
||||
<td style="padding: 0.75rem; color: var(--text-primary);">
|
||||
${a.resource_type ? `<span style="background: var(--bg-surface-hover); padding: 0.25rem 0.5rem; border-radius: 4px; font-size: 0.75rem; color: var(--text-primary);">${a.resource_type}</span>` : ''}
|
||||
${a.vmid ? `<span style="font-family: monospace; margin-left: 0.25rem; color: var(--text-secondary);">#${a.vmid}</span>` : ''}
|
||||
</td>
|
||||
<td style="padding: 0.75rem;">
|
||||
${a.status === 'reserved' ?
|
||||
`<span class="badge badge-info"><i class="fa-solid fa-lock"></i> ${t('reserved')}</span>` :
|
||||
`<span class="badge badge-success">✓ ${t('allocated')}</span>`}
|
||||
</td>
|
||||
<td style="padding: 0.75rem; color: var(--text-muted); font-size: 0.875rem;">${a.created_at ? new Date(a.created_at).toLocaleDateString('ru-RU') : '-'}</td>
|
||||
<td style="padding: 0.75rem; text-align: right;">
|
||||
<button onclick="releaseIP(${a.id}, '${a.ip_address}')" class="btn btn-sm btn-danger"><i class="fa-solid fa-trash"></i> ${t('release')}</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
}
|
||||
|
||||
function showAllocateModal() {
|
||||
document.getElementById('allocateModal').style.display = 'flex';
|
||||
}
|
||||
|
||||
function closeAllocateModal() {
|
||||
document.getElementById('allocateModal').style.display = 'none';
|
||||
document.getElementById('allocateForm').reset();
|
||||
}
|
||||
|
||||
async function allocateIP(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const networkId = document.getElementById('allocNetwork').value;
|
||||
const ip = document.getElementById('allocIP').value.trim();
|
||||
const hostname = document.getElementById('allocHostname').value.trim();
|
||||
const description = document.getElementById('allocDescription').value.trim();
|
||||
const isReserved = document.getElementById('allocReserved').checked;
|
||||
|
||||
try {
|
||||
let response;
|
||||
|
||||
if (ip) {
|
||||
// Manual allocation
|
||||
response = await fetchWithAuth('/ipam/api/allocations', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
network_id: parseInt(networkId),
|
||||
ip_address: ip,
|
||||
hostname: hostname || null,
|
||||
description: description || null,
|
||||
status: isReserved ? 'reserved' : 'allocated'
|
||||
})
|
||||
});
|
||||
} else {
|
||||
// Auto allocation
|
||||
response = await fetchWithAuth('/ipam/api/allocations/auto', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
network_id: parseInt(networkId),
|
||||
hostname: hostname || null,
|
||||
description: description || null
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
closeAllocateModal();
|
||||
loadAllocations();
|
||||
showToast('success', t('success'), t('ip_allocated_successfully') || 'IP allocated successfully');
|
||||
} else {
|
||||
const error = await response.json();
|
||||
showToast('error', t('error'), error.detail || t('error_allocating_ip'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error allocating IP:', error);
|
||||
showToast('error', t('error'), t('error_allocating_ip'));
|
||||
}
|
||||
}
|
||||
|
||||
async function releaseIP(id, ip) {
|
||||
const confirmed = await showConfirm(
|
||||
t('release_ip'),
|
||||
`${t('release_ip_confirm')} ${ip}?`,
|
||||
{ type: 'warning', confirmText: t('release') }
|
||||
);
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuth(`/ipam/api/allocations/${id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
loadAllocations();
|
||||
showToast('success', t('success'), t('ip_released_successfully') || 'IP released successfully');
|
||||
} else {
|
||||
const error = await response.json();
|
||||
showToast('error', t('error'), error.detail || t('error_releasing_ip'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error releasing IP:', error);
|
||||
showToast('error', t('error'), t('error_releasing_ip'));
|
||||
}
|
||||
}
|
||||
|
||||
// Initial load after DOM ready
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
restoreFiltersFromUrl();
|
||||
setTimeout(() => {
|
||||
loadNetworks();
|
||||
loadAllocations();
|
||||
}, 100);
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,718 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}IPAM - {{ t('ip_address_management') }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header-block">
|
||||
<div class="page-header-left">
|
||||
<nav class="breadcrumb-nav">{{ t('dashboard') }} / IPAM</nav>
|
||||
<h1 class="page-header-title"><i class="fa-solid fa-globe"></i> IPAM</h1>
|
||||
<p class="page-header-sub">{{ t('ip_address_management') }}</p>
|
||||
</div>
|
||||
<div class="page-header-right" style="flex-wrap: wrap;">
|
||||
<a href="/ipam/networks" class="btn btn-secondary"><i class="fa-solid fa-list"></i> {{ t('networks') }}</a>
|
||||
<a href="/ipam/allocations" class="btn btn-secondary"><i class="fa-solid fa-thumbtack"></i> {{ t('allocations') }}</a>
|
||||
<button onclick="showMaintenanceModal()" class="btn btn-warning" data-permission="ipam.manage"><i class="fa-solid fa-wrench"></i> {{ t('maintenance') }}</button>
|
||||
<button onclick="showAddNetworkModal()" class="btn btn-success" data-permission="ipam.manage"><i class="fa-solid fa-plus"></i> {{ t('add_network') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Summary Cards -->
|
||||
<div class="stats-grid" style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; margin-bottom: 2rem;">
|
||||
<div class="card stat-card">
|
||||
<div style="display: flex; justify-content: space-between; align-items: flex-start;">
|
||||
<div>
|
||||
<div style="font-size: 2rem; font-weight: 600; color: var(--text-primary);" id="networksCount">-</div>
|
||||
<div style="color: var(--text-secondary); font-size: 0.875rem;">{{ t('networks') }}</div>
|
||||
</div>
|
||||
<div style="font-size: 2rem; opacity: 0.5;"><i class="fa-solid fa-globe"></i></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card stat-card">
|
||||
<div style="display: flex; justify-content: space-between; align-items: flex-start;">
|
||||
<div>
|
||||
<div style="font-size: 2rem; font-weight: 600; color: var(--text-primary);" id="allocatedCount">-</div>
|
||||
<div style="color: var(--text-secondary); font-size: 0.875rem;">{{ t('allocated_ips') }}</div>
|
||||
</div>
|
||||
<div style="font-size: 2rem; opacity: 0.5;"><i class="fa-solid fa-thumbtack"></i></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card stat-card">
|
||||
<div style="display: flex; justify-content: space-between; align-items: flex-start;">
|
||||
<div>
|
||||
<div style="font-size: 2rem; font-weight: 600; color: var(--text-primary);" id="vmCount">-</div>
|
||||
<div style="color: var(--text-secondary); font-size: 0.875rem;">VM</div>
|
||||
</div>
|
||||
<div style="font-size: 2rem; opacity: 0.5;"><i class="fa-solid fa-desktop"></i></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card stat-card">
|
||||
<div style="display: flex; justify-content: space-between; align-items: flex-start;">
|
||||
<div>
|
||||
<div style="font-size: 2rem; font-weight: 600; color: var(--text-primary);" id="reservedCount">-</div>
|
||||
<div style="color: var(--text-secondary); font-size: 0.875rem;">{{ t('reserved') }}</div>
|
||||
</div>
|
||||
<div style="font-size: 2rem; opacity: 0.5;"><i class="fa-solid fa-lock"></i></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Networks List -->
|
||||
<div class="card">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
|
||||
<h3 style="margin: 0;"><i class="fa-solid fa-list"></i> {{ t('networks') }}</h3>
|
||||
<button onclick="loadData()" class="btn btn-sm" style="background: var(--bg-tertiary); color: var(--text-primary);"><i class="fa-solid fa-rotate"></i> {{ t('refresh') }}</button>
|
||||
</div>
|
||||
|
||||
<div id="networksContainer">
|
||||
<div style="text-align: center; padding: 2rem; color: var(--text-muted);">
|
||||
<i class="fa-solid fa-spinner fa-spin"></i> {{ t('loading_ellipsis') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Activity -->
|
||||
<div class="card">
|
||||
<h3 style="margin: 0 0 1rem 0;"><i class="fa-solid fa-file-lines"></i> {{ t('recent_activity') }}</h3>
|
||||
<div id="recentActivity">
|
||||
<div style="text-align: center; padding: 1rem; color: var(--text-muted);">
|
||||
<i class="fa-solid fa-spinner fa-spin"></i> {{ t('loading_ellipsis') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Network Modal -->
|
||||
<div id="addNetworkModal" style="display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.85); z-index: 2000; align-items: center; justify-content: center; padding: 1rem;">
|
||||
<div class="card" style="max-width: 600px; width: 95%; max-height: 90vh; overflow-y: auto;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
|
||||
<h3 style="margin: 0;"><i class="fa-solid fa-plus"></i> {{ t('add_network') }}</h3>
|
||||
<button onclick="closeAddNetworkModal()" style="background: none; border: none; color: var(--text-muted); font-size: 1.5rem; cursor: pointer;">×</button>
|
||||
</div>
|
||||
|
||||
<form id="addNetworkForm" onsubmit="createNetwork(event)">
|
||||
<div style="display: grid; gap: 1rem;">
|
||||
<div class="form-group">
|
||||
<label style="display: block; margin-bottom: 0.5rem; color: var(--text-secondary);">{{ t('title') }} *</label>
|
||||
<input type="text" id="networkName" required placeholder="Production Network" style="width: 100%; padding: 0.75rem; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-tertiary); color: var(--text-primary);">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label style="display: block; margin-bottom: 0.5rem; color: var(--text-secondary);">{{ t('network_cidr') }} *</label>
|
||||
<input type="text" id="networkCIDR" required placeholder="10.10.10.0/24" pattern="^(\d{1,3}\.){3}\d{1,3}/\d{1,2}$" style="width: 100%; padding: 0.75rem; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-tertiary); color: var(--text-primary);">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label style="display: block; margin-bottom: 0.5rem; color: var(--text-secondary);">{{ t('gateway') }}</label>
|
||||
<input type="text" id="networkGateway" placeholder="10.10.10.1" style="width: 100%; padding: 0.75rem; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-tertiary); color: var(--text-primary);">
|
||||
</div>
|
||||
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem;">
|
||||
<div class="form-group">
|
||||
<label style="display: block; margin-bottom: 0.5rem; color: var(--text-secondary);">{{ t('vlan_id') }}</label>
|
||||
<input type="number" id="networkVLAN" min="1" max="4094" placeholder="100" style="width: 100%; padding: 0.75rem; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-tertiary); color: var(--text-primary);">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label style="display: block; margin-bottom: 0.5rem; color: var(--text-secondary);">{{ t('proxmox_bridge') }}</label>
|
||||
<input type="text" id="networkBridge" placeholder="vmbr0" style="width: 100%; padding: 0.75rem; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-tertiary); color: var(--text-primary);">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label style="display: block; margin-bottom: 0.5rem; color: var(--text-secondary);">{{ t('proxmox_server') }}</label>
|
||||
<select id="networkServer" onchange="onNetworkServerChange()" style="width: 100%; padding: 0.75rem; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-tertiary); color: var(--text-primary);">
|
||||
<option value="">{{ t('all_servers') }} ({{ t('global_network') }})</option>
|
||||
</select>
|
||||
<small style="color: var(--text-muted);">{{ t('network_server_hint') }}</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="networkNodeRow" style="display: none;">
|
||||
<label style="display: block; margin-bottom: 0.5rem; color: var(--text-secondary);">{{ t('proxmox_node') }}</label>
|
||||
<select id="networkNode" style="width: 100%; padding: 0.75rem; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-tertiary); color: var(--text-primary);">
|
||||
<option value="">{{ t('all_nodes') }}</option>
|
||||
</select>
|
||||
<small style="color: var(--text-muted);">{{ t('proxmox_node_hint') }}</small>
|
||||
</div>
|
||||
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem;">
|
||||
<div class="form-group">
|
||||
<label style="display: block; margin-bottom: 0.5rem; color: var(--text-secondary);">{{ t('dns_primary') }}</label>
|
||||
<input type="text" id="networkDNS1" placeholder="8.8.8.8" style="width: 100%; padding: 0.75rem; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-tertiary); color: var(--text-primary);">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label style="display: block; margin-bottom: 0.5rem; color: var(--text-secondary);">{{ t('dns_secondary') }}</label>
|
||||
<input type="text" id="networkDNS2" placeholder="8.8.4.4" style="width: 100%; padding: 0.75rem; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-tertiary); color: var(--text-primary);">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label style="display: block; margin-bottom: 0.5rem; color: var(--text-secondary);">{{ t('dns_domain') }}</label>
|
||||
<input type="text" id="networkDomain" placeholder="example.local" style="width: 100%; padding: 0.75rem; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-tertiary); color: var(--text-primary);">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label style="display: block; margin-bottom: 0.5rem; color: var(--text-secondary);">{{ t('description') }}</label>
|
||||
<textarea id="networkDescription" rows="2" placeholder="{{ t('network_description_placeholder') }}" style="width: 100%; padding: 0.75rem; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-tertiary); color: var(--text-primary); resize: vertical;"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 1rem; justify-content: flex-end; margin-top: 1.5rem; padding-top: 1rem; border-top: 1px solid var(--border-default);">
|
||||
<button type="button" onclick="closeAddNetworkModal()" class="btn btn-secondary">{{ t('cancel') }}</button>
|
||||
<button type="submit" class="btn btn-success"><i class="fa-solid fa-floppy-disk"></i> {{ t('create') }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Use global fetchWithAuth from base.html
|
||||
|
||||
async function loadData() {
|
||||
await Promise.all([
|
||||
loadSummary(),
|
||||
loadNetworks()
|
||||
]);
|
||||
}
|
||||
|
||||
async function loadSummary() {
|
||||
try {
|
||||
const response = await fetchWithAuth('/ipam/api/summary');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
|
||||
document.getElementById('networksCount').textContent = data.networks_count || 0;
|
||||
document.getElementById('allocatedCount').textContent = data.total_allocated || 0;
|
||||
document.getElementById('vmCount').textContent = (data.vm_count || 0) + (data.lxc_count || 0);
|
||||
document.getElementById('reservedCount').textContent = data.total_reserved || 0;
|
||||
|
||||
// Recent activity
|
||||
const activityDiv = document.getElementById('recentActivity');
|
||||
if (data.recent_allocations && data.recent_allocations.length > 0) {
|
||||
activityDiv.innerHTML = data.recent_allocations.map(item => `
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; padding: 0.75rem 0; border-bottom: 1px solid var(--border-color);">
|
||||
<div>
|
||||
<span style="font-family: monospace; background: var(--bg-tertiary); padding: 0.25rem 0.5rem; border-radius: 4px; color: var(--text-primary);">${item.ip}</span>
|
||||
<span style="margin-left: 0.5rem; color: var(--text-secondary);">${item.resource || 'N/A'}</span>
|
||||
</div>
|
||||
<div style="color: var(--text-muted); font-size: 0.875rem;">
|
||||
${item.by || 'system'} • ${item.at ? new Date(item.at).toLocaleString('ru-RU') : ''}
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
} else {
|
||||
activityDiv.innerHTML = '<div style="text-align: center; padding: 1rem; color: var(--text-muted);">' + t('no_recent_activity') + '</div>';
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading summary:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadNetworks() {
|
||||
try {
|
||||
const response = await fetchWithAuth('/ipam/api/networks');
|
||||
const container = document.getElementById('networksContainer');
|
||||
|
||||
if (response.ok) {
|
||||
const networks = await response.json();
|
||||
|
||||
if (networks.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div style="text-align: center; padding: 3rem;">
|
||||
<div style="font-size: 3rem; margin-bottom: 1rem;"><i class="fa-solid fa-globe"></i></div>
|
||||
<h3 style="color: var(--text-primary); margin-bottom: 0.5rem;">${t('networks_not_found')}</h3>
|
||||
<p style="color: var(--text-secondary); margin-bottom: 1.5rem;">${t('add_first_network')}</p>
|
||||
<button onclick="showAddNetworkModal()" class="btn btn-success"><i class="fa-solid fa-plus"></i> ${t('add_network')}</button>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = `
|
||||
<table style="width: 100%; border-collapse: collapse;">
|
||||
<thead>
|
||||
<tr style="border-bottom: 1px solid var(--border-default);">
|
||||
<th style="text-align: left; padding: 0.75rem; color: var(--text-secondary); font-weight: 500;">${t('network_label')}</th>
|
||||
<th style="text-align: left; padding: 0.75rem; color: var(--text-secondary); font-weight: 500;">${t('cidr')}</th>
|
||||
<th style="text-align: left; padding: 0.75rem; color: var(--text-secondary); font-weight: 500;">${t('server')}</th>
|
||||
<th style="text-align: left; padding: 0.75rem; color: var(--text-secondary); font-weight: 500;">${t('vlan')}</th>
|
||||
<th style="text-align: left; padding: 0.75rem; color: var(--text-secondary); font-weight: 500;">${t('usage')}</th>
|
||||
<th style="text-align: right; padding: 0.75rem; color: var(--text-secondary); font-weight: 500;">${t('actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${networks.map(net => {
|
||||
const serverName = net.server_name || (net.proxmox_server_id ? `ID: ${net.proxmox_server_id}` : t('global'));
|
||||
return `
|
||||
<tr style="border-bottom: 1px solid var(--border-default);" onmouseover="this.style.background='var(--bg-surface-hover)'" onmouseout="this.style.background='transparent'">
|
||||
<td style="padding: 0.75rem;">
|
||||
<a href="/ipam/network/${net.id}" style="color: var(--primary); text-decoration: none; font-weight: 500;">
|
||||
${net.name}
|
||||
</a>
|
||||
${net.gateway ? `<div style="font-size: 0.75rem; color: var(--text-muted);">GW: ${net.gateway}</div>` : ''}
|
||||
</td>
|
||||
<td style="padding: 0.75rem; font-family: monospace; color: var(--text-primary);">${net.network}</td>
|
||||
<td style="padding: 0.75rem;">
|
||||
${net.proxmox_server_id ?
|
||||
`<span class="badge" style="background: var(--primary-bg); color: var(--primary);"><i class="fa-solid fa-server"></i> ${serverName}</span>` :
|
||||
`<span style="color: var(--text-muted);">${t('global')}</span>`
|
||||
}
|
||||
</td>
|
||||
<td style="padding: 0.75rem; color: var(--text-primary);">${net.vlan_id || '-'}</td>
|
||||
<td style="padding: 0.75rem;">
|
||||
<div style="display: flex; align-items: center; gap: 0.5rem;">
|
||||
<div style="flex: 1; height: 8px; background: var(--bg-tertiary); border-radius: 4px; overflow: hidden; max-width: 100px;">
|
||||
<div style="height: 100%; background: ${getUtilizationColor(net.utilization_percent)}; width: ${net.utilization_percent || 0}%;"></div>
|
||||
</div>
|
||||
<span style="font-size: 0.875rem; color: var(--text-secondary);">${net.used_ips || 0}/${net.total_ips || 0}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td style="padding: 0.75rem; text-align: right;">
|
||||
<a href="/ipam/network/${net.id}" class="btn btn-sm" style="background: var(--bg-tertiary); color: var(--text-primary);"><i class="fa-solid fa-eye"></i></a>
|
||||
<button onclick="deleteNetwork(${net.id}, '${net.name}')" class="btn btn-sm btn-delete" style="margin-left: 0.25rem;"><i class="fa-solid fa-trash"></i></button>
|
||||
</td>
|
||||
</tr>
|
||||
`}).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
} else {
|
||||
container.innerHTML = '<div style="text-align: center; padding: 2rem; color: var(--danger);"><i class="fa-solid fa-circle-xmark"></i> ' + t('error_loading_networks') + '</div>';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading networks:', error);
|
||||
document.getElementById('networksContainer').innerHTML = '<div style="text-align: center; padding: 2rem; color: var(--danger);"><i class="fa-solid fa-circle-xmark"></i> ' + t('error_loading') + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function getUtilizationColor(percent) {
|
||||
if (percent >= 90) return 'var(--danger)';
|
||||
if (percent >= 70) return 'var(--warning)';
|
||||
if (percent >= 50) return 'var(--warning-light, #eab308)';
|
||||
return 'var(--success)';
|
||||
}
|
||||
|
||||
let ipamServers = [];
|
||||
|
||||
async function loadServersForIPAM() {
|
||||
try {
|
||||
const response = await fetchWithAuth('/proxmox/api/servers');
|
||||
if (response.ok) {
|
||||
ipamServers = await response.json();
|
||||
const select = document.getElementById('networkServer');
|
||||
select.innerHTML = `<option value="">{{ t('all_servers') }} ({{ t('global_network') }})</option>`;
|
||||
ipamServers.forEach(s => {
|
||||
select.innerHTML += `<option value="${s.id}">${s.name} (${s.ip_address})</option>`;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error loading servers:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function onNetworkServerChange() {
|
||||
const serverId = document.getElementById('networkServer').value;
|
||||
const nodeRow = document.getElementById('networkNodeRow');
|
||||
const nodeSelect = document.getElementById('networkNode');
|
||||
|
||||
if (!serverId) {
|
||||
nodeRow.style.display = 'none';
|
||||
nodeSelect.innerHTML = `<option value="">{{ t('all_nodes') }}</option>`;
|
||||
return;
|
||||
}
|
||||
|
||||
nodeRow.style.display = 'block';
|
||||
nodeSelect.innerHTML = `<option value="">{{ t('loading_nodes') }}</option>`;
|
||||
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/proxmox/api/servers/${serverId}/cluster-info`);
|
||||
const info = resp.ok ? await resp.json() : {};
|
||||
const nodes = info.nodes || [];
|
||||
nodeSelect.innerHTML = `<option value="">{{ t('all_nodes') }}</option>`;
|
||||
nodes.forEach(n => {
|
||||
nodeSelect.innerHTML += `<option value="${n}">${n}</option>`;
|
||||
});
|
||||
// If single-node server, pre-select it
|
||||
if (nodes.length === 1) {
|
||||
nodeSelect.value = nodes[0];
|
||||
}
|
||||
} catch(e) {
|
||||
nodeSelect.innerHTML = `<option value="">{{ t('all_nodes') }}</option>`;
|
||||
}
|
||||
}
|
||||
|
||||
function showAddNetworkModal() {
|
||||
document.getElementById('addNetworkModal').style.display = 'flex';
|
||||
loadServersForIPAM();
|
||||
}
|
||||
|
||||
function closeAddNetworkModal() {
|
||||
document.getElementById('addNetworkModal').style.display = 'none';
|
||||
document.getElementById('addNetworkForm').reset();
|
||||
document.getElementById('networkNodeRow').style.display = 'none';
|
||||
}
|
||||
|
||||
async function createNetwork(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const serverId = document.getElementById('networkServer').value;
|
||||
const nodeVal = document.getElementById('networkNode').value;
|
||||
|
||||
const data = {
|
||||
name: document.getElementById('networkName').value,
|
||||
network: document.getElementById('networkCIDR').value,
|
||||
gateway: document.getElementById('networkGateway').value || null,
|
||||
vlan_id: document.getElementById('networkVLAN').value ? parseInt(document.getElementById('networkVLAN').value) : null,
|
||||
proxmox_bridge: document.getElementById('networkBridge').value || null,
|
||||
proxmox_server_id: serverId ? parseInt(serverId) : null,
|
||||
proxmox_node: nodeVal || null,
|
||||
dns_primary: document.getElementById('networkDNS1').value || null,
|
||||
dns_secondary: document.getElementById('networkDNS2').value || null,
|
||||
dns_domain: document.getElementById('networkDomain').value || null,
|
||||
description: document.getElementById('networkDescription').value || null
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuth('/ipam/api/networks', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
closeAddNetworkModal();
|
||||
loadData();
|
||||
showToast('success', t('success'), t('network_created_successfully') || 'Network created successfully');
|
||||
} else {
|
||||
const error = await response.json();
|
||||
showToast('error', t('error'), error.detail || t('failed_to_create_network'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating network:', error);
|
||||
showToast('error', t('error'), t('network_creation_error'));
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteNetwork(id, name) {
|
||||
const confirmed = await showDeleteConfirm(
|
||||
t('delete_network'),
|
||||
t('delete_network_confirm').replace('{name}', name)
|
||||
);
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuth(`/ipam/api/networks/${id}?force=true`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
loadData();
|
||||
showToast('success', t('success'), t('network_deleted_successfully') || 'Network deleted successfully');
|
||||
} else {
|
||||
const error = await response.json();
|
||||
showToast('error', t('error'), error.detail || t('failed_to_delete_network'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting network:', error);
|
||||
showToast('error', t('error'), t('network_deletion_error'));
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Maintenance Functions ====================
|
||||
|
||||
function showMaintenanceModal() {
|
||||
document.getElementById('maintenanceModal').style.display = 'flex';
|
||||
loadMaintenanceData();
|
||||
}
|
||||
|
||||
function closeMaintenanceModal() {
|
||||
document.getElementById('maintenanceModal').style.display = 'none';
|
||||
}
|
||||
|
||||
async function loadMaintenanceData() {
|
||||
// Load orphans
|
||||
try {
|
||||
const orphansResp = await fetchWithAuth('/ipam/api/orphans');
|
||||
if (orphansResp.ok) {
|
||||
const data = await orphansResp.json();
|
||||
renderOrphans(data.orphans);
|
||||
document.getElementById('orphansCount').textContent = data.count;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error loading orphans:', e);
|
||||
}
|
||||
|
||||
// Load unlinked
|
||||
try {
|
||||
const unlinkedResp = await fetchWithAuth('/ipam/api/unlinked');
|
||||
if (unlinkedResp.ok) {
|
||||
const data = await unlinkedResp.json();
|
||||
renderUnlinked(data.unlinked);
|
||||
document.getElementById('unlinkedCount').textContent = data.count;
|
||||
document.getElementById('linkableCount').textContent = data.linkable_count;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error loading unlinked:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function renderOrphans(orphans) {
|
||||
const container = document.getElementById('orphansList');
|
||||
if (!orphans.length) {
|
||||
container.innerHTML = '<div style="text-align: center; color: var(--text-muted); padding: 1rem;"><i class="fa-solid fa-check-circle" style="color: var(--success);"></i> {{ t("no_orphans_found") }}</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '<div style="max-height: 200px; overflow-y: auto;">';
|
||||
for (const o of orphans) {
|
||||
html += `
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; padding: 0.5rem; border-bottom: 1px solid var(--border-color);">
|
||||
<div>
|
||||
<span style="font-family: monospace; color: var(--warning);">${o.ip_address}</span>
|
||||
<span style="color: var(--text-secondary); margin-left: 0.5rem;">${o.resource_name || '-'}</span>
|
||||
<small style="color: var(--text-muted); margin-left: 0.5rem;">(${o.reason})</small>
|
||||
</div>
|
||||
<span class="badge" style="background: var(--bg-tertiary);">${o.resource_type}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
html += '</div>';
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
function renderUnlinked(unlinked) {
|
||||
const container = document.getElementById('unlinkedList');
|
||||
if (!unlinked.length) {
|
||||
container.innerHTML = '<div style="text-align: center; color: var(--text-muted); padding: 1rem;"><i class="fa-solid fa-check-circle" style="color: var(--success);"></i> {{ t("all_allocations_linked") }}</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '<div style="max-height: 200px; overflow-y: auto;">';
|
||||
for (const u of unlinked) {
|
||||
const canLink = u.can_link;
|
||||
html += `
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; padding: 0.5rem; border-bottom: 1px solid var(--border-color);">
|
||||
<div>
|
||||
<span style="font-family: monospace; color: var(--text-primary);">${u.ip_address}</span>
|
||||
<span style="color: var(--text-secondary); margin-left: 0.5rem;">${u.resource_name}</span>
|
||||
</div>
|
||||
<div>
|
||||
${canLink ?
|
||||
`<span class="badge" style="background: var(--success);">→ VMID ${u.suggested_vmid}</span>` :
|
||||
`<span class="badge" style="background: var(--danger);">{{ t("no_match") }}</span>`
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
html += '</div>';
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
async function cleanupOrphans() {
|
||||
const confirmed = await showDeleteConfirm(
|
||||
'{{ t("cleanup_orphans") }}',
|
||||
'{{ t("confirm_cleanup_orphans") }}'
|
||||
);
|
||||
if (!confirmed) return;
|
||||
|
||||
const btn = document.querySelector('#maintenanceModal .btn-danger');
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> {{ t("cleaning") }}...';
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuth('/ipam/api/cleanup-orphans', { method: 'POST' });
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
showToast('success', '{{ t("success") }}', `{{ t("released") }}: ${data.released_count} IP`);
|
||||
loadMaintenanceData();
|
||||
loadData(); // Refresh main data
|
||||
} else {
|
||||
const error = await response.json();
|
||||
showToast('error', '{{ t("error") }}', error.detail || '{{ t("cleanup_failed") }}');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error cleaning orphans:', e);
|
||||
showToast('error', '{{ t("error") }}', '{{ t("cleanup_failed") }}');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="fa-solid fa-trash"></i> {{ t("cleanup_orphans") }}';
|
||||
}
|
||||
}
|
||||
|
||||
async function linkAllocations() {
|
||||
await showLinkModal();
|
||||
}
|
||||
|
||||
// ---- Link Allocations Modal ----
|
||||
let _linkServers = [];
|
||||
|
||||
async function showLinkModal() {
|
||||
document.getElementById('linkAllocationsModal').style.display = 'flex';
|
||||
const serverSel = document.getElementById('linkServerSelect');
|
||||
const networkSel = document.getElementById('linkNetworkSelect');
|
||||
serverSel.innerHTML = `<option value="">{{ t('loading_ellipsis') }}</option>`;
|
||||
networkSel.innerHTML = `<option value="">{{ t('select_network_first_server') }}</option>`;
|
||||
networkSel.disabled = true;
|
||||
document.getElementById('submitLinkBtn').disabled = true;
|
||||
|
||||
try {
|
||||
const resp = await fetchWithAuth('/proxmox/api/servers');
|
||||
_linkServers = resp.ok ? await resp.json() : [];
|
||||
} catch(e) { _linkServers = []; }
|
||||
|
||||
serverSel.innerHTML = `<option value="">{{ t('select_server_for_link') }}</option>`;
|
||||
_linkServers.forEach(s => {
|
||||
serverSel.innerHTML += `<option value="${s.id}">${s.name} (${s.ip_address})</option>`;
|
||||
});
|
||||
}
|
||||
|
||||
function closeLinkModal() {
|
||||
document.getElementById('linkAllocationsModal').style.display = 'none';
|
||||
}
|
||||
|
||||
async function onLinkServerChange() {
|
||||
const serverId = document.getElementById('linkServerSelect').value;
|
||||
const networkSel = document.getElementById('linkNetworkSelect');
|
||||
const submitBtn = document.getElementById('submitLinkBtn');
|
||||
|
||||
networkSel.disabled = true;
|
||||
submitBtn.disabled = true;
|
||||
|
||||
if (!serverId) {
|
||||
networkSel.innerHTML = `<option value="">{{ t('select_network_first_server') }}</option>`;
|
||||
return;
|
||||
}
|
||||
|
||||
networkSel.innerHTML = `<option value="">{{ t('loading_ellipsis') }}</option>`;
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/ipam/api/networks?proxmox_server_id=${serverId}`);
|
||||
const data = resp.ok ? await resp.json() : [];
|
||||
if (!data.length) {
|
||||
networkSel.innerHTML = `<option value="">{{ t('no_networks_for_server') }}</option>`;
|
||||
return;
|
||||
}
|
||||
networkSel.innerHTML = `<option value="">{{ t('select_network') }}</option>`;
|
||||
data.forEach(n => {
|
||||
networkSel.innerHTML += `<option value="${n.id}">${n.name} (${n.network})</option>`;
|
||||
});
|
||||
networkSel.disabled = false;
|
||||
} catch(e) {
|
||||
networkSel.innerHTML = `<option value="">{{ t('error') }}</option>`;
|
||||
}
|
||||
}
|
||||
|
||||
function onLinkNetworkChange() {
|
||||
const networkSel = document.getElementById('linkNetworkSelect');
|
||||
document.getElementById('submitLinkBtn').disabled = !networkSel.value;
|
||||
}
|
||||
|
||||
async function submitLinkAllocations() {
|
||||
const serverId = document.getElementById('linkServerSelect').value;
|
||||
const networkId = document.getElementById('linkNetworkSelect').value;
|
||||
if (!serverId || !networkId) return;
|
||||
|
||||
const submitBtn = document.getElementById('submitLinkBtn');
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> {{ t("linking") }}...';
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuth('/ipam/api/link-allocations', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ server_id: parseInt(serverId), network_id: parseInt(networkId) })
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
closeLinkModal();
|
||||
showToast('success', '{{ t("success") }}', `{{ t("linked") }}: ${data.linked_count}, {{ t("not_found") }}: ${data.not_found_count}`);
|
||||
loadMaintenanceData();
|
||||
loadData();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
showToast('error', '{{ t("error") }}', error.detail || '{{ t("linking_failed") }}');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error linking allocations:', e);
|
||||
showToast('error', '{{ t("error") }}', '{{ t("linking_failed") }}');
|
||||
} finally {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.innerHTML = '<i class="fa-solid fa-link"></i> {{ t("link_to_vms") }}';
|
||||
}
|
||||
}
|
||||
|
||||
// Initial load after DOM ready
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
setTimeout(loadData, 100);
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Link Allocations Modal -->
|
||||
<div id="linkAllocationsModal" style="display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.85); z-index: 3000; align-items: center; justify-content: center; padding: 1rem;">
|
||||
<div class="card" style="max-width: 480px; width: 95%;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
|
||||
<h3 style="margin: 0;"><i class="fa-solid fa-link"></i> {{ t('link_allocations_title') }}</h3>
|
||||
<button onclick="closeLinkModal()" style="background: none; border: none; color: var(--text-muted); font-size: 1.5rem; cursor: pointer;">×</button>
|
||||
</div>
|
||||
<p style="color: var(--text-secondary); font-size: 0.875rem; margin-bottom: 1.25rem;">{{ t('link_scope_hint') }}</p>
|
||||
<div style="display: grid; gap: 1rem; margin-bottom: 1.5rem;">
|
||||
<div>
|
||||
<label style="display: block; margin-bottom: 0.4rem; font-size: 0.85rem; color: var(--text-secondary);">{{ t('proxmox_server') }} *</label>
|
||||
<select id="linkServerSelect" onchange="onLinkServerChange()" style="width: 100%; padding: 0.65rem 0.75rem; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-tertiary); color: var(--text-primary);">
|
||||
<option value="">{{ t('select_server_for_link') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style="display: block; margin-bottom: 0.4rem; font-size: 0.85rem; color: var(--text-secondary);">{{ t('network') }} *</label>
|
||||
<select id="linkNetworkSelect" onchange="onLinkNetworkChange()" disabled style="width: 100%; padding: 0.65rem 0.75rem; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-tertiary); color: var(--text-primary);">
|
||||
<option value="">{{ t('select_network_first_server') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: flex-end; gap: 0.75rem;">
|
||||
<button onclick="closeLinkModal()" class="btn btn-secondary">{{ t('cancel') }}</button>
|
||||
<button onclick="submitLinkAllocations()" id="submitLinkBtn" disabled class="btn btn-primary">
|
||||
<i class="fa-solid fa-link"></i> {{ t('link_to_vms') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Maintenance Modal -->
|
||||
<div id="maintenanceModal" style="display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.85); z-index: 2000; align-items: center; justify-content: center; padding: 1rem;">
|
||||
<div class="card" style="max-width: 800px; width: 95%; max-height: 90vh; overflow-y: auto;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
|
||||
<h3 style="margin: 0;"><i class="fa-solid fa-wrench"></i> {{ t('ipam_maintenance') }}</h3>
|
||||
<button onclick="closeMaintenanceModal()" style="background: none; border: none; color: var(--text-muted); font-size: 1.5rem; cursor: pointer;">×</button>
|
||||
</div>
|
||||
|
||||
<!-- Orphan Allocations -->
|
||||
<div style="margin-bottom: 1.5rem;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.75rem;">
|
||||
<h4 style="margin: 0; color: var(--warning);"><i class="fa-solid fa-ghost"></i> {{ t('orphan_allocations') }} (<span id="orphansCount">0</span>)</h4>
|
||||
<button onclick="cleanupOrphans()" class="btn btn-danger btn-sm"><i class="fa-solid fa-trash"></i> {{ t('cleanup_orphans') }}</button>
|
||||
</div>
|
||||
<p style="color: var(--text-secondary); font-size: 0.875rem; margin-bottom: 0.5rem;">{{ t('orphan_allocations_desc') }}</p>
|
||||
<div id="orphansList" style="background: var(--bg-tertiary); border-radius: 6px; padding: 0.5rem;">
|
||||
<div style="text-align: center; color: var(--text-muted);"><i class="fa-solid fa-spinner fa-spin"></i></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Unlinked Allocations -->
|
||||
<div>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.75rem;">
|
||||
<h4 style="margin: 0; color: var(--info);"><i class="fa-solid fa-unlink"></i> {{ t('unlinked_allocations') }} (<span id="unlinkedCount">0</span>, {{ t('linkable') }}: <span id="linkableCount">0</span>)</h4>
|
||||
<button onclick="linkAllocations()" class="btn btn-primary btn-sm"><i class="fa-solid fa-link"></i> {{ t('link_to_vms') }}</button>
|
||||
</div>
|
||||
<p style="color: var(--text-secondary); font-size: 0.875rem; margin-bottom: 0.5rem;">{{ t('unlinked_allocations_desc') }}</p>
|
||||
<div id="unlinkedList" style="background: var(--bg-tertiary); border-radius: 6px; padding: 0.5rem;">
|
||||
<div style="text-align: center; color: var(--text-muted);"><i class="fa-solid fa-spinner fa-spin"></i></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,183 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ t('ipam_history') }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem;">
|
||||
<div>
|
||||
<h1 style="font-size: 1.75rem; font-weight: 600; margin: 0;"><i class="fa-solid fa-file-lines"></i> {{ t('ipam_history') }}</h1>
|
||||
<p style="color: var(--text-secondary); margin-top: 0.25rem;">{{ t('all_changes_log') }}</p>
|
||||
</div>
|
||||
<div style="display: flex; gap: 0.75rem;">
|
||||
<a href="/ipam" class="btn" style="background: var(--bg-tertiary); color: var(--text-primary);">← {{ t('back') }}</a>
|
||||
<button onclick="loadHistory()" class="btn" style="background: var(--bg-tertiary); color: var(--text-primary);"><i class="fa-solid fa-rotate"></i> {{ t('refresh') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="card" style="margin-bottom: 1rem;">
|
||||
<div style="display: flex; gap: 1rem; flex-wrap: wrap; align-items: center;">
|
||||
<div>
|
||||
<label style="color: var(--text-secondary); font-size: 0.875rem;">{{ t('action_filter') }}</label>
|
||||
<select id="filterAction" onchange="loadHistory()" style="padding: 0.5rem; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-tertiary); color: var(--text-primary);">
|
||||
<option value="">{{ t('all') }}</option>
|
||||
<option value="allocate">{{ t('allocation') }}</option>
|
||||
<option value="release">{{ t('release') }}</option>
|
||||
<option value="reserve">{{ t('reservation') }}</option>
|
||||
<option value="update">{{ t('update') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style="color: var(--text-secondary); font-size: 0.875rem;">{{ t('period_filter') }}</label>
|
||||
<select id="filterPeriod" onchange="loadHistory()" style="padding: 0.5rem; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-tertiary); color: var(--text-primary);">
|
||||
<option value="7">{{ t('days_7') }}</option>
|
||||
<option value="30" selected>{{ t('days_30') }}</option>
|
||||
<option value="90">{{ t('days_90') }}</option>
|
||||
<option value="">{{ t('all_time') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- History List -->
|
||||
<div class="card">
|
||||
<div id="historyContainer">
|
||||
<div style="text-align: center; padding: 2rem; color: var(--text-muted);">
|
||||
<i class="fa-solid fa-spinner fa-spin"></i> {{ t('loading_ellipsis') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// URL params helper functions
|
||||
function getUrlParams() {
|
||||
return new URLSearchParams(window.location.search);
|
||||
}
|
||||
|
||||
function updateUrlParams(params) {
|
||||
const url = new URL(window.location);
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value) {
|
||||
url.searchParams.set(key, value);
|
||||
} else {
|
||||
url.searchParams.delete(key);
|
||||
}
|
||||
});
|
||||
window.history.replaceState({}, '', url);
|
||||
}
|
||||
|
||||
function restoreFiltersFromUrl() {
|
||||
const params = getUrlParams();
|
||||
|
||||
const action = params.get('action') || '';
|
||||
document.getElementById('filterAction').value = action;
|
||||
|
||||
const period = params.get('period') || '30';
|
||||
document.getElementById('filterPeriod').value = period;
|
||||
}
|
||||
|
||||
async function loadHistory() {
|
||||
try {
|
||||
const action = document.getElementById('filterAction').value;
|
||||
const period = document.getElementById('filterPeriod').value;
|
||||
|
||||
// Save filters to URL
|
||||
updateUrlParams({ action: action, period: period });
|
||||
|
||||
let url = '/ipam/api/history?limit=100';
|
||||
if (action) url += `&action=${action}`;
|
||||
if (period) {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() - parseInt(period));
|
||||
url += `&from_date=${date.toISOString()}`;
|
||||
}
|
||||
|
||||
const response = await fetchWithAuth(url);
|
||||
const container = document.getElementById('historyContainer');
|
||||
|
||||
if (response.ok) {
|
||||
const history = await response.json();
|
||||
renderHistory(history);
|
||||
} else {
|
||||
container.innerHTML = '<div style="text-align: center; padding: 2rem; color: var(--danger);"><i class="fa-solid fa-circle-xmark"></i> ' + t('error_loading') + '</div>';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading history:', error);
|
||||
document.getElementById('historyContainer').innerHTML = '<div style="text-align: center; padding: 2rem; color: var(--danger);"><i class="fa-solid fa-circle-xmark"></i> ' + t('error_loading') + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderHistory(history) {
|
||||
const container = document.getElementById('historyContainer');
|
||||
|
||||
if (history.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div style="text-align: center; padding: 3rem;">
|
||||
<div style="font-size: 3rem; margin-bottom: 1rem;"><i class="fa-solid fa-scroll"></i></div>
|
||||
<h3 style="color: var(--text-primary); margin-bottom: 0.5rem;">${t('history_empty')}</h3>
|
||||
<p style="color: var(--text-secondary);">${t('no_records_for_period')}</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
const actionIcons = {
|
||||
'allocate': '<i class="fa-solid fa-plus"></i>',
|
||||
'release': '<i class="fa-solid fa-minus"></i>',
|
||||
'reserve': '<i class="fa-solid fa-lock"></i>',
|
||||
'update': '<i class="fa-solid fa-pen"></i>',
|
||||
'create': '<i class="fa-solid fa-plus-circle"></i>',
|
||||
'delete': '<i class="fa-solid fa-trash"></i>'
|
||||
};
|
||||
|
||||
const actionColors = {
|
||||
'allocate': '#22c55e',
|
||||
'release': '#ef4444',
|
||||
'reserve': '#0ea5e9',
|
||||
'update': '#3b82f6',
|
||||
'create': '#22c55e',
|
||||
'delete': '#ef4444'
|
||||
};
|
||||
|
||||
container.innerHTML = `
|
||||
<div style="display: flex; flex-direction: column; gap: 0.5rem;">
|
||||
${history.map(h => `
|
||||
<div style="display: flex; align-items: flex-start; gap: 1rem; padding: 1rem; border-radius: 8px; background: var(--bg-secondary); border-left: 3px solid ${actionColors[h.action] || 'var(--border-color)'};">
|
||||
<div style="font-size: 1.5rem;">${actionIcons[h.action] || '<i class="fa-solid fa-file-lines"></i>'}</div>
|
||||
<div style="flex: 1;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: flex-start;">
|
||||
<div>
|
||||
<span style="font-family: monospace; background: var(--bg-tertiary); padding: 0.25rem 0.5rem; border-radius: 4px; font-weight: 500; color: var(--text-primary);">${h.ip_address}</span>
|
||||
<span style="color: var(--text-secondary); margin-left: 0.5rem;">${h.network_name || ''}</span>
|
||||
</div>
|
||||
<span style="color: var(--text-muted); font-size: 0.875rem;">${new Date(h.created_at).toLocaleString('ru-RU')}</span>
|
||||
</div>
|
||||
<div style="margin-top: 0.5rem; color: var(--text-secondary);">
|
||||
${h.details || getActionDescription(h.action)}
|
||||
</div>
|
||||
${h.performed_by ? `<div style="margin-top: 0.25rem; color: var(--text-muted); font-size: 0.75rem;"><i class="fa-solid fa-user"></i> ${h.performed_by}</div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function getActionDescription(action) {
|
||||
const descriptions = {
|
||||
'allocate': t('ip_allocated'),
|
||||
'release': t('ip_released'),
|
||||
'reserve': t('ip_reserved'),
|
||||
'update': t('record_updated'),
|
||||
'create': t('record_created'),
|
||||
'delete': t('record_deleted')
|
||||
};
|
||||
return descriptions[action] || action;
|
||||
}
|
||||
|
||||
// Initial load after DOM ready
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
restoreFiltersFromUrl();
|
||||
setTimeout(loadHistory, 100);
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,655 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}IPAM - {{ t('network_detail') }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem;">
|
||||
<div>
|
||||
<div style="display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.25rem;">
|
||||
<a href="/ipam" style="color: var(--text-muted); text-decoration: none;">IPAM</a>
|
||||
<span style="color: var(--text-muted);">/</span>
|
||||
<span id="networkBreadcrumb">{{ t('network_label') }}</span>
|
||||
</div>
|
||||
<h1 style="font-size: 1.75rem; font-weight: 600; margin: 0;" id="networkTitle"><i class="fa-solid fa-globe"></i> {{ t('loading') }}</h1>
|
||||
</div>
|
||||
<div style="display: flex; gap: 0.75rem;">
|
||||
<button onclick="showAddPoolModal()" class="btn" style="background: var(--bg-tertiary); color: var(--text-primary);" data-permission="ipam.manage"><i class="fa-solid fa-cube"></i> {{ t('add_pool') }}</button>
|
||||
<button onclick="showAllocateModal()" class="btn" style="background: var(--success); color: var(--text-on-success);" data-permission="ipam.manage"><i class="fa-solid fa-plus"></i> {{ t('allocate_ip') }}</button>
|
||||
<a href="/ipam" class="btn" style="background: var(--bg-tertiary); color: var(--text-primary);">← {{ t('back') }}</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Network Stats -->
|
||||
<div class="stats-grid" style="display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 1rem; margin-bottom: 2rem;">
|
||||
<div class="card" style="padding: 1rem;">
|
||||
<div style="font-size: 1.5rem; font-weight: 600; color: var(--text-primary);" id="statTotal">-</div>
|
||||
<div style="color: var(--text-secondary); font-size: 0.875rem;">{{ t('total_ips') }}</div>
|
||||
</div>
|
||||
<div class="card" style="padding: 1rem;">
|
||||
<div style="font-size: 1.5rem; font-weight: 600; color: var(--success);" id="statAvailable">-</div>
|
||||
<div style="color: var(--text-secondary); font-size: 0.875rem;">{{ t('available_ips') }}</div>
|
||||
</div>
|
||||
<div class="card" style="padding: 1rem;">
|
||||
<div style="font-size: 1.5rem; font-weight: 600; color: var(--info);" id="statAllocated">-</div>
|
||||
<div style="color: var(--text-secondary); font-size: 0.875rem;">{{ t('allocated_ips') }}</div>
|
||||
</div>
|
||||
<div class="card" style="padding: 1rem;">
|
||||
<div style="font-size: 1.5rem; font-weight: 600; color: var(--warning);" id="statReserved">-</div>
|
||||
<div style="color: var(--text-secondary); font-size: 0.875rem;">{{ t('reserved_ips') }}</div>
|
||||
</div>
|
||||
<div class="card" style="padding: 1rem;">
|
||||
<div style="font-size: 1.5rem; font-weight: 600; color: var(--text-primary);" id="statUtilization">-</div>
|
||||
<div style="color: var(--text-secondary); font-size: 0.875rem;">{{ t('utilization') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pools -->
|
||||
<div class="card" style="margin-bottom: 1.5rem;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
|
||||
<h3 style="margin: 0;"><i class="fa-solid fa-cube"></i> {{ t('ip_pools') }}</h3>
|
||||
</div>
|
||||
<div id="poolsContainer">
|
||||
<div style="text-align: center; padding: 1rem; color: var(--text-muted);"><i class="fa-solid fa-spinner fa-spin"></i> {{ t('loading') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- IP Grid -->
|
||||
<div class="card">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
|
||||
<h3 style="margin: 0;"><i class="fa-solid fa-map"></i> {{ t('ip_map') }}</h3>
|
||||
<div style="display: flex; gap: 1rem; align-items: center;">
|
||||
<div style="display: flex; gap: 0.5rem; align-items: center; font-size: 0.8rem; color: var(--text-primary);">
|
||||
<span style="display: inline-block; width: 16px; height: 16px; background: var(--success); border-radius: 3px;"></span> {{ t('available') }}
|
||||
<span style="display: inline-block; width: 16px; height: 16px; background: var(--info); border-radius: 3px; margin-left: 0.5rem;"></span> {{ t('allocated') }}
|
||||
<span style="display: inline-block; width: 16px; height: 16px; background: var(--warning); border-radius: 3px; margin-left: 0.5rem;"></span> {{ t('reserved') }}
|
||||
<span style="display: inline-block; width: 16px; height: 16px; background: var(--purple); border-radius: 3px; margin-left: 0.5rem;"></span> {{ t('gateway') }}
|
||||
</div>
|
||||
<button onclick="loadIPMap()" class="btn btn-sm" style="background: var(--bg-tertiary); color: var(--text-primary);"><i class="fa-solid fa-rotate"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="ipGridContainer" style="max-height: 500px; overflow-y: auto;">
|
||||
<div style="text-align: center; padding: 2rem; color: var(--text-muted);"><i class="fa-solid fa-spinner fa-spin"></i> {{ t('loading_data') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Allocations Table -->
|
||||
<div class="card" style="margin-top: 1.5rem;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
|
||||
<h3 style="margin: 0;"><i class="fa-solid fa-list"></i> {{ t('allocated_addresses') }}</h3>
|
||||
</div>
|
||||
<div id="allocationsContainer">
|
||||
<div style="text-align: center; padding: 1rem; color: var(--text-muted);"><i class="fa-solid fa-spinner fa-spin"></i> {{ t('loading') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Allocate IP Modal -->
|
||||
<div id="allocateModal" style="display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.85); z-index: 2000; align-items: center; justify-content: center; padding: 1rem;">
|
||||
<div class="card" style="max-width: 500px; width: 95%; max-height: 90vh; overflow-y: auto;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
|
||||
<h3 style="margin: 0;"><i class="fa-solid fa-plus"></i> {{ t('allocate_ip') }}</h3>
|
||||
<button onclick="closeAllocateModal()" style="background: none; border: none; color: var(--text-muted); font-size: 1.5rem; cursor: pointer;">×</button>
|
||||
</div>
|
||||
|
||||
<form id="allocateForm" onsubmit="allocateIP(event)">
|
||||
<div style="display: grid; gap: 1rem;">
|
||||
<div class="form-group">
|
||||
<label style="display: block; margin-bottom: 0.5rem; color: var(--text-secondary);">{{ t('ip_address') }}</label>
|
||||
<div style="display: flex; gap: 0.5rem;">
|
||||
<input type="text" id="allocIP" placeholder="{{ t('auto_button') }}" style="flex: 1; padding: 0.75rem; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-tertiary); color: var(--text-primary);">
|
||||
<button type="button" onclick="getNextAvailable()" class="btn" style="background: var(--bg-tertiary); color: var(--text-primary);"><i class="fa-solid fa-dice"></i> {{ t('auto_button') }}</button>
|
||||
</div>
|
||||
<small style="color: var(--text-muted);">{{ t('leave_empty_auto') }}</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label style="display: block; margin-bottom: 0.5rem; color: var(--text-secondary);">{{ t('resource_type') }}</label>
|
||||
<select id="allocResourceType" style="width: 100%; padding: 0.75rem; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-tertiary); color: var(--text-primary);">
|
||||
<option value="">{{ t('not_specified') }}</option>
|
||||
<option value="vm"><i class="fa-solid fa-desktop"></i> {{ t('vm') }}</option>
|
||||
<option value="lxc"><i class="fa-solid fa-cube"></i> {{ t('lxc_container') }}</option>
|
||||
<option value="physical"><i class="fa-solid fa-server"></i> {{ t('physical_server') }}</option>
|
||||
<option value="service"><i class="fa-solid fa-gear"></i> {{ t('service') }}</option>
|
||||
<option value="reserved"><i class="fa-solid fa-lock"></i> {{ t('reserved') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label style="display: block; margin-bottom: 0.5rem; color: var(--text-secondary);">{{ t('resource_name') }}</label>
|
||||
<input type="text" id="allocResourceName" placeholder="web-server-01" style="width: 100%; padding: 0.75rem; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-tertiary); color: var(--text-primary);">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label style="display: block; margin-bottom: 0.5rem; color: var(--text-secondary);">{{ t('hostname') }}</label>
|
||||
<input type="text" id="allocHostname" placeholder="web-server-01" style="width: 100%; padding: 0.75rem; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-tertiary); color: var(--text-primary);">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label style="display: block; margin-bottom: 0.5rem; color: var(--text-secondary);">{{ t('mac_address') }}</label>
|
||||
<input type="text" id="allocMAC" placeholder="AA:BB:CC:DD:EE:FF" style="width: 100%; padding: 0.75rem; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-tertiary); color: var(--text-primary);">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label style="display: block; margin-bottom: 0.5rem; color: var(--text-secondary);">{{ t('notes') }}</label>
|
||||
<textarea id="allocNotes" rows="2" placeholder="{{ t('description_placeholder') }}" style="width: 100%; padding: 0.75rem; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-tertiary); color: var(--text-primary); resize: vertical;"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 1rem; justify-content: flex-end; margin-top: 1.5rem; padding-top: 1rem; border-top: 1px solid var(--border-color);">
|
||||
<button type="button" onclick="closeAllocateModal()" class="btn" style="background: var(--bg-tertiary); color: var(--text-primary);">{{ t('cancel') }}</button>
|
||||
<button type="submit" class="btn btn-success"><i class="fa-solid fa-floppy-disk"></i> {{ t('allocate') }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Pool Modal -->
|
||||
<div id="addPoolModal" style="display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.85); z-index: 2000; align-items: center; justify-content: center; padding: 1rem;">
|
||||
<div class="card" style="max-width: 500px; width: 95%;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
|
||||
<h3 style="margin: 0;"><i class="fa-solid fa-cube"></i> {{ t('add_pool') }}</h3>
|
||||
<button onclick="closeAddPoolModal()" style="background: none; border: none; color: var(--text-muted); font-size: 1.5rem; cursor: pointer;">×</button>
|
||||
</div>
|
||||
|
||||
<form id="addPoolForm" onsubmit="createPool(event)">
|
||||
<div style="display: grid; gap: 1rem;">
|
||||
<div class="form-group">
|
||||
<label style="display: block; margin-bottom: 0.5rem; color: var(--text-secondary);">{{ t('pool_name') }} {{ t('required') }}</label>
|
||||
<input type="text" id="poolName" required placeholder="Static IPs" style="width: 100%; padding: 0.75rem; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-tertiary); color: var(--text-primary);">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label style="display: block; margin-bottom: 0.5rem; color: var(--text-secondary);">{{ t('pool_type') }}</label>
|
||||
<select id="poolType" style="width: 100%; padding: 0.75rem; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-tertiary); color: var(--text-primary);">
|
||||
<option value="static">{{ t('static') }}</option>
|
||||
<option value="dhcp">{{ t('dhcp') }}</option>
|
||||
<option value="reserved">{{ t('reserved') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem;">
|
||||
<div class="form-group">
|
||||
<label style="display: block; margin-bottom: 0.5rem; color: var(--text-secondary);">{{ t('range_start') }} {{ t('required') }}</label>
|
||||
<input type="text" id="poolStart" required placeholder="10.10.10.100" style="width: 100%; padding: 0.75rem; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-tertiary); color: var(--text-primary);">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label style="display: block; margin-bottom: 0.5rem; color: var(--text-secondary);">{{ t('range_end') }} {{ t('required') }}</label>
|
||||
<input type="text" id="poolEnd" required placeholder="10.10.10.200" style="width: 100%; padding: 0.75rem; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-tertiary); color: var(--text-primary);">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label style="display: flex; align-items: center; gap: 0.5rem; color: var(--text-secondary);">
|
||||
<input type="checkbox" id="poolAutoAssign" checked style="width: auto;">
|
||||
{{ t('auto_assign_pool') }}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label style="display: block; margin-bottom: 0.5rem; color: var(--text-secondary);">{{ t('description') }}</label>
|
||||
<textarea id="poolDescription" rows="2" placeholder="{{ t('pool_description') }}" style="width: 100%; padding: 0.75rem; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-tertiary); color: var(--text-primary); resize: vertical;"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 1rem; justify-content: flex-end; margin-top: 1.5rem; padding-top: 1rem; border-top: 1px solid var(--border-color);">
|
||||
<button type="button" onclick="closeAddPoolModal()" class="btn" style="background: var(--bg-tertiary); color: var(--text-primary);">{{ t('cancel') }}</button>
|
||||
<button type="submit" class="btn btn-success"><i class="fa-solid fa-floppy-disk"></i> {{ t('create') }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- IP Detail Modal -->
|
||||
<div id="ipDetailModal" style="display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.85); z-index: 2000; align-items: center; justify-content: center; padding: 1rem;">
|
||||
<div class="card" style="max-width: 500px; width: 95%;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
|
||||
<h3 style="margin: 0;" id="ipDetailTitle"><i class="fa-solid fa-thumbtack"></i> IP Details</h3>
|
||||
<button onclick="closeIPDetailModal()" style="background: none; border: none; color: var(--text-muted); font-size: 1.5rem; cursor: pointer;">×</button>
|
||||
</div>
|
||||
<div id="ipDetailContent"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Use global fetchWithAuth from base.html
|
||||
const networkId = {{ network_id }};
|
||||
|
||||
async function loadData() {
|
||||
await Promise.all([
|
||||
loadNetworkInfo(),
|
||||
loadPools(),
|
||||
loadIPMap(),
|
||||
loadAllocations()
|
||||
]);
|
||||
}
|
||||
|
||||
async function loadNetworkInfo() {
|
||||
try {
|
||||
const response = await fetchWithAuth(`/ipam/api/networks/${networkId}/stats`);
|
||||
if (response.ok) {
|
||||
const stats = await response.json();
|
||||
|
||||
document.getElementById('networkTitle').innerHTML = `<i class="fa-solid fa-globe"></i> ${stats.network_name}`;
|
||||
document.getElementById('networkBreadcrumb').textContent = stats.network_name;
|
||||
|
||||
document.getElementById('statTotal').textContent = stats.total_ips;
|
||||
document.getElementById('statAvailable').textContent = stats.available_ips;
|
||||
document.getElementById('statAllocated').textContent = stats.allocated_ips;
|
||||
document.getElementById('statReserved').textContent = stats.reserved_ips;
|
||||
document.getElementById('statUtilization').textContent = stats.utilization_percent + '%';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading network info:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPools() {
|
||||
try {
|
||||
const response = await fetchWithAuth(`/ipam/api/pools?network_id=${networkId}`);
|
||||
const container = document.getElementById('poolsContainer');
|
||||
|
||||
if (response.ok) {
|
||||
const pools = await response.json();
|
||||
|
||||
if (pools.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div style="text-align: center; padding: 1rem; color: var(--text-muted);">
|
||||
${t('no_pools')} <button onclick="showAddPoolModal()" style="background: none; border: none; color: var(--accent); cursor: pointer; text-decoration: underline;">${t('add_pool')}</button>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = `
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 1rem;">
|
||||
${pools.map(pool => `
|
||||
<div style="background: var(--bg-tertiary); border-radius: 6px; padding: 1rem; border: 1px solid var(--border-color);">
|
||||
<div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 0.5rem;">
|
||||
<div>
|
||||
<div style="font-weight: 500; color: var(--text-primary);">${pool.name}</div>
|
||||
<div style="font-size: 0.8rem; color: var(--text-muted);">${pool.pool_type}</div>
|
||||
</div>
|
||||
<button onclick="deletePool(${pool.id}, '${pool.name}')" style="background: none; border: none; color: var(--text-muted); cursor: pointer;"><i class="fa-solid fa-trash"></i></button>
|
||||
</div>
|
||||
<div style="font-family: monospace; font-size: 0.875rem; color: var(--text-secondary); margin-bottom: 0.5rem;">
|
||||
${pool.range_start} - ${pool.range_end}
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; gap: 0.5rem;">
|
||||
<div style="flex: 1; height: 6px; background: var(--bg-secondary); border-radius: 3px; overflow: hidden;">
|
||||
<div style="height: 100%; background: var(--info); width: ${pool.total_ips > 0 ? (pool.used_ips / pool.total_ips * 100) : 0}%;"></div>
|
||||
</div>
|
||||
<span style="font-size: 0.75rem; color: var(--text-muted);">${pool.used_ips}/${pool.total_ips}</span>
|
||||
</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading pools:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadIPMap() {
|
||||
try {
|
||||
const response = await fetchWithAuth(`/ipam/api/networks/${networkId}/ip-map`);
|
||||
const container = document.getElementById('ipGridContainer');
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const ipMap = data.ip_map;
|
||||
|
||||
if (ipMap.length === 0) {
|
||||
container.innerHTML = `<div style="text-align: center; padding: 2rem; color: var(--text-muted);">${t('no_data')}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Limit to first 254 IPs for /24 or show warning for larger networks
|
||||
const displayIPs = ipMap.slice(0, 254);
|
||||
const hasMore = ipMap.length > 254;
|
||||
|
||||
container.innerHTML = `
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(70px, 1fr)); gap: 4px;">
|
||||
${displayIPs.map(ip => {
|
||||
const lastOctet = ip.ip_address.split('.').pop();
|
||||
const color = getIPColor(ip.status);
|
||||
const icon = getIPIcon(ip);
|
||||
|
||||
return `
|
||||
<div onclick="showIPDetail('${ip.ip_address}')"
|
||||
style="background: ${color}; padding: 0.5rem; border-radius: 4px; text-align: center; cursor: pointer; transition: filter 0.15s;"
|
||||
onmouseover="this.style.filter='brightness(1.1)'"
|
||||
onmouseout="this.style.filter='brightness(1)'"
|
||||
title="${ip.ip_address}${ip.resource_name ? ' - ' + ip.resource_name : ''}">
|
||||
<div style="font-size: 0.75rem; font-weight: 500;">.${lastOctet}</div>
|
||||
<div style="font-size: 0.7rem;">${icon}</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('')}
|
||||
</div>
|
||||
${hasMore ? `<div style="text-align: center; padding: 1rem; color: var(--text-muted);">${t('showing_first_ips')} ${ipMap.length}</div>` : ''}
|
||||
`;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading IP map:', error);
|
||||
document.getElementById('ipGridContainer').innerHTML = `<div style="text-align: center; padding: 2rem; color: var(--danger);"><i class="fa-solid fa-circle-xmark"></i> ${t('error_loading_map')}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function getIPColor(status) {
|
||||
switch(status) {
|
||||
case 'available': return 'var(--success-light)';
|
||||
case 'allocated': return 'var(--info-light)';
|
||||
case 'reserved': return 'var(--warning-light)';
|
||||
case 'gateway': return 'var(--purple-light)';
|
||||
case 'conflict': return 'var(--danger-light)';
|
||||
default: return 'var(--bg-tertiary)';
|
||||
}
|
||||
}
|
||||
|
||||
function getIPIcon(ip) {
|
||||
if (ip.is_gateway) return '<i class="fa-solid fa-globe"></i>';
|
||||
if (!ip.resource_type) return '';
|
||||
switch(ip.resource_type) {
|
||||
case 'vm': return '<i class="fa-solid fa-desktop"></i>';
|
||||
case 'lxc': return '<i class="fa-solid fa-cube"></i>';
|
||||
case 'physical': return '<i class="fa-solid fa-server"></i>';
|
||||
case 'service': return '<i class="fa-solid fa-gear"></i>';
|
||||
case 'reserved': return '<i class="fa-solid fa-lock"></i>';
|
||||
default: return '<i class="fa-solid fa-thumbtack"></i>';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAllocations() {
|
||||
try {
|
||||
const response = await fetchWithAuth(`/ipam/api/allocations?network_id=${networkId}&limit=50`);
|
||||
const container = document.getElementById('allocationsContainer');
|
||||
|
||||
if (response.ok) {
|
||||
const allocations = await response.json();
|
||||
|
||||
if (allocations.length === 0) {
|
||||
container.innerHTML = `<div style="text-align: center; padding: 1rem; color: var(--text-muted);">${t('no_allocated_addresses')}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = `
|
||||
<table style="width: 100%; border-collapse: collapse;">
|
||||
<thead>
|
||||
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||
<th style="text-align: left; padding: 0.5rem; color: var(--text-secondary); font-weight: 500; font-size: 0.875rem;">${t('ip_address')}</th>
|
||||
<th style="text-align: left; padding: 0.5rem; color: var(--text-secondary); font-weight: 500; font-size: 0.875rem;">${t('resource')}</th>
|
||||
<th style="text-align: left; padding: 0.5rem; color: var(--text-secondary); font-weight: 500; font-size: 0.875rem;">${t('type')}</th>
|
||||
<th style="text-align: left; padding: 0.5rem; color: var(--text-secondary); font-weight: 500; font-size: 0.875rem;">${t('hostname')}</th>
|
||||
<th style="text-align: left; padding: 0.5rem; color: var(--text-secondary); font-weight: 500; font-size: 0.875rem;">${t('status')}</th>
|
||||
<th style="text-align: right; padding: 0.5rem;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${allocations.map(alloc => `
|
||||
<tr style="border-bottom: 1px solid var(--border-color);" onmouseover="this.style.background='var(--bg-hover)'" onmouseout="this.style.background='transparent'">
|
||||
<td style="padding: 0.5rem; font-family: monospace; color: var(--text-primary);">${alloc.ip_address}</td>
|
||||
<td style="padding: 0.5rem; color: var(--text-primary);">${alloc.resource_name || '-'}</td>
|
||||
<td style="padding: 0.5rem; color: var(--text-secondary);">${alloc.resource_type || '-'}</td>
|
||||
<td style="padding: 0.5rem; color: var(--text-secondary);">${alloc.hostname || '-'}</td>
|
||||
<td style="padding: 0.5rem;">
|
||||
<span style="padding: 0.125rem 0.5rem; border-radius: 4px; font-size: 0.75rem; background: ${getStatusColor(alloc.status)}; color: ${getStatusTextColor(alloc.status)}; font-weight: 600;">
|
||||
${alloc.status}
|
||||
</span>
|
||||
</td>
|
||||
<td style="padding: 0.5rem; text-align: right;">
|
||||
<button onclick="releaseIP(${alloc.id}, '${alloc.ip_address}')" class="btn btn-sm btn-danger"><i class="fa-solid fa-trash"></i></button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading allocations:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusColor(status) {
|
||||
switch(status) {
|
||||
case 'allocated': return 'var(--info-light)';
|
||||
case 'reserved': return 'var(--warning-light)';
|
||||
case 'conflict': return 'var(--danger-light)';
|
||||
default: return 'var(--bg-tertiary)';
|
||||
}
|
||||
}
|
||||
function getStatusTextColor(status) {
|
||||
switch(status) {
|
||||
case 'allocated': return 'var(--info)';
|
||||
case 'reserved': return 'var(--warning)';
|
||||
case 'conflict': return 'var(--danger)';
|
||||
default: return 'var(--text-secondary)';
|
||||
}
|
||||
}
|
||||
|
||||
// Modal functions
|
||||
function showAllocateModal() {
|
||||
document.getElementById('allocateModal').style.display = 'flex';
|
||||
}
|
||||
|
||||
function closeAllocateModal() {
|
||||
document.getElementById('allocateModal').style.display = 'none';
|
||||
document.getElementById('allocateForm').reset();
|
||||
}
|
||||
|
||||
function showAddPoolModal() {
|
||||
document.getElementById('addPoolModal').style.display = 'flex';
|
||||
}
|
||||
|
||||
function closeAddPoolModal() {
|
||||
document.getElementById('addPoolModal').style.display = 'none';
|
||||
document.getElementById('addPoolForm').reset();
|
||||
}
|
||||
|
||||
function closeIPDetailModal() {
|
||||
document.getElementById('ipDetailModal').style.display = 'none';
|
||||
}
|
||||
|
||||
async function getNextAvailable() {
|
||||
try {
|
||||
const response = await fetchWithAuth(`/ipam/api/allocations/next-available/${networkId}`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
document.getElementById('allocIP').value = data.next_available_ip;
|
||||
} else {
|
||||
await showWarning(t('no_available_ips'), t('warning'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error getting next IP:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function allocateIP(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const ip = document.getElementById('allocIP').value.trim();
|
||||
|
||||
const data = {
|
||||
network_id: networkId,
|
||||
resource_type: document.getElementById('allocResourceType').value || null,
|
||||
resource_name: document.getElementById('allocResourceName').value || null,
|
||||
hostname: document.getElementById('allocHostname').value || null,
|
||||
mac_address: document.getElementById('allocMAC').value || null,
|
||||
notes: document.getElementById('allocNotes').value || null,
|
||||
status: document.getElementById('allocResourceType').value === 'reserved' ? 'reserved' : 'allocated'
|
||||
};
|
||||
|
||||
try {
|
||||
let response;
|
||||
if (ip) {
|
||||
data.ip_address = ip;
|
||||
response = await fetchWithAuth('/ipam/api/allocations', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
} else {
|
||||
response = await fetchWithAuth('/ipam/api/allocations/auto', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
closeAllocateModal();
|
||||
loadData();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
await showError(error.detail || t('error_allocating_ip'), t('error'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error allocating IP:', error);
|
||||
await showError(t('error_allocating_ip'), t('error'));
|
||||
}
|
||||
}
|
||||
|
||||
async function createPool(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const data = {
|
||||
network_id: networkId,
|
||||
name: document.getElementById('poolName').value,
|
||||
pool_type: document.getElementById('poolType').value,
|
||||
range_start: document.getElementById('poolStart').value,
|
||||
range_end: document.getElementById('poolEnd').value,
|
||||
auto_assign: document.getElementById('poolAutoAssign').checked,
|
||||
description: document.getElementById('poolDescription').value || null
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuth('/ipam/api/pools', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
closeAddPoolModal();
|
||||
loadPools();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
await showError(error.detail || t('error_creating_pool'), t('error'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating pool:', error);
|
||||
await showError(t('error_creating_pool'), t('error'));
|
||||
}
|
||||
}
|
||||
|
||||
async function deletePool(id, name) {
|
||||
const confirmed = await showDeleteConfirm(`${t('delete_pool_confirm')} "${name}"?`);
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuth(`/ipam/api/pools/${id}`, { method: 'DELETE' });
|
||||
if (response.ok) {
|
||||
loadPools();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
await showError(error.detail || t('error_deleting_pool'), t('error'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting pool:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function releaseIP(id, ip) {
|
||||
const confirmed = await showConfirm(`${t('release_ip_confirm')} ${ip}?`, t('confirm'), { type: 'warning', icon: '<i class="fa-solid fa-lock-open"></i>' });
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuth(`/ipam/api/allocations/${id}`, { method: 'DELETE' });
|
||||
if (response.ok) {
|
||||
loadData();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
await showError(error.detail || t('error_releasing_ip'), t('error'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error releasing IP:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function showIPDetail(ip) {
|
||||
const modal = document.getElementById('ipDetailModal');
|
||||
const content = document.getElementById('ipDetailContent');
|
||||
|
||||
document.getElementById('ipDetailTitle').innerHTML = `<i class="fa-solid fa-thumbtack"></i> ${ip}`;
|
||||
content.innerHTML = `<div style="text-align: center; padding: 1rem; color: var(--text-muted);"><i class="fa-solid fa-spinner fa-spin"></i> ${t('loading')}</div>`;
|
||||
modal.style.display = 'flex';
|
||||
|
||||
try {
|
||||
// Try to find allocation for this IP
|
||||
const response = await fetchWithAuth(`/ipam/api/allocations?search=${ip}&limit=1`);
|
||||
if (response.ok) {
|
||||
const allocations = await response.json();
|
||||
const alloc = allocations.find(a => a.ip_address === ip);
|
||||
|
||||
if (alloc) {
|
||||
content.innerHTML = `
|
||||
<div style="display: grid; gap: 0.75rem;">
|
||||
<div style="display: grid; grid-template-columns: 1fr 2fr; gap: 0.5rem;">
|
||||
<div style="color: var(--text-muted);">${t('ip_address')}:</div>
|
||||
<div style="font-family: monospace; color: var(--text-primary);">${alloc.ip_address}</div>
|
||||
|
||||
<div style="color: var(--text-muted);">${t('status')}:</div>
|
||||
<div><span style="padding: 0.125rem 0.5rem; border-radius: 4px; font-size: 0.875rem; background: ${getStatusColor(alloc.status)}; color: ${getStatusTextColor(alloc.status)}; font-weight: 600;">${alloc.status}</span></div>
|
||||
|
||||
<div style="color: var(--text-muted);">${t('resource_type')}:</div>
|
||||
<div style="color: var(--text-primary);">${alloc.resource_type || '-'}</div>
|
||||
|
||||
<div style="color: var(--text-muted);">${t('name')}:</div>
|
||||
<div style="color: var(--text-primary);">${alloc.resource_name || '-'}</div>
|
||||
|
||||
<div style="color: var(--text-muted);">${t('hostname')}:</div>
|
||||
<div style="color: var(--text-primary);">${alloc.hostname || '-'}</div>
|
||||
|
||||
<div style="color: var(--text-muted);">${t('mac_address')}:</div>
|
||||
<div style="font-family: monospace; color: var(--text-primary);">${alloc.mac_address || '-'}</div>
|
||||
|
||||
<div style="color: var(--text-muted);">${t('proxmox_vmid')}:</div>
|
||||
<div style="color: var(--text-primary);">${alloc.proxmox_vmid || '-'}</div>
|
||||
|
||||
<div style="color: var(--text-muted);">${t('allocated_by')}:</div>
|
||||
<div style="color: var(--text-primary);">${alloc.allocated_by || 'system'} • ${new Date(alloc.allocated_at).toLocaleString('ru-RU')}</div>
|
||||
|
||||
<div style="color: var(--text-muted);">${t('notes')}:</div>
|
||||
<div style="color: var(--text-primary);">${alloc.notes || '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: flex; gap: 0.5rem; justify-content: flex-end; margin-top: 1.5rem; padding-top: 1rem; border-top: 1px solid var(--border-color);">
|
||||
<button onclick="releaseIP(${alloc.id}, '${alloc.ip_address}'); closeIPDetailModal();" class="btn btn-danger"><i class="fa-solid fa-trash"></i> ${t('release')}</button>
|
||||
<button onclick="closeIPDetailModal()" class="btn" style="background: var(--bg-tertiary); color: var(--text-primary);">${t('close')}</button>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
content.innerHTML = `
|
||||
<div style="text-align: center; padding: 1rem;">
|
||||
<div style="font-size: 2rem; margin-bottom: 0.5rem;"><i class="fa-solid fa-circle-check"></i></div>
|
||||
<div style="color: var(--text-secondary);">${t('ip_available')}</div>
|
||||
</div>
|
||||
<div style="display: flex; gap: 0.5rem; justify-content: center; margin-top: 1rem;">
|
||||
<button onclick="document.getElementById('allocIP').value='${ip}'; closeIPDetailModal(); showAllocateModal();" class="btn btn-success"><i class="fa-solid fa-plus"></i> ${t('allocate')}</button>
|
||||
<button onclick="closeIPDetailModal()" class="btn" style="background: var(--bg-tertiary); color: var(--text-primary);">${t('close')}</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading IP detail:', error);
|
||||
content.innerHTML = `<div style="text-align: center; padding: 1rem; color: var(--danger);"><i class="fa-solid fa-circle-xmark"></i> ${t('error_loading')}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Initial load after DOM ready
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
setTimeout(loadData, 100);
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,286 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}IPAM - {{ t('network_label') }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem;">
|
||||
<div>
|
||||
<h1 style="font-size: 1.75rem; font-weight: 600; margin: 0;"><i class="fa-solid fa-globe"></i> {{ t('ipam_networks') }}</h1>
|
||||
<p style="color: var(--text-secondary); margin-top: 0.25rem;">{{ t('ip_networks_management') }}</p>
|
||||
</div>
|
||||
<div style="display: flex; gap: 0.75rem;">
|
||||
<a href="/ipam" class="btn" style="background: var(--bg-tertiary); color: var(--text-primary);">← {{ t('back') }}</a>
|
||||
<button onclick="showAddNetworkModal()" class="btn btn-success" data-permission="ipam.manage"><i class="fa-solid fa-plus"></i> {{ t('add_network') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Networks List -->
|
||||
<div class="card">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
|
||||
<h3 style="margin: 0;"><i class="fa-solid fa-list"></i> {{ t('all_networks') }}</h3>
|
||||
<button onclick="loadNetworks()" class="btn btn-sm" style="background: var(--bg-tertiary); color: var(--text-primary);"><i class="fa-solid fa-rotate"></i> {{ t('refresh') }}</button>
|
||||
</div>
|
||||
|
||||
<div id="networksContainer">
|
||||
<div style="text-align: center; padding: 2rem; color: var(--text-muted);">
|
||||
<i class="fa-solid fa-spinner fa-spin"></i> {{ t('loading') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Network Modal -->
|
||||
<div id="addNetworkModal" style="display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.85); z-index: 2000; align-items: center; justify-content: center; padding: 1rem;">
|
||||
<div class="card" style="max-width: 600px; width: 95%; max-height: 90vh; overflow-y: auto;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
|
||||
<h3 style="margin: 0;"><i class="fa-solid fa-plus"></i> {{ t('add_network') }}</h3>
|
||||
<button onclick="closeAddNetworkModal()" style="background: none; border: none; color: var(--text-muted); font-size: 1.5rem; cursor: pointer;">×</button>
|
||||
</div>
|
||||
|
||||
<form id="addNetworkForm" onsubmit="createNetwork(event)">
|
||||
<div style="display: grid; gap: 1rem;">
|
||||
<div class="form-group">
|
||||
<label style="display: block; margin-bottom: 0.5rem; color: var(--text-secondary);">{{ t('title') }} {{ t('required') }}</label>
|
||||
<input type="text" id="networkName" required placeholder="Production Network" style="width: 100%; padding: 0.75rem; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-tertiary); color: var(--text-primary);">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label style="display: block; margin-bottom: 0.5rem; color: var(--text-secondary);">{{ t('network_cidr') }} {{ t('required') }}</label>
|
||||
<input type="text" id="networkCIDR" required placeholder="10.10.10.0/24" pattern="^(\d{1,3}\.){3}\d{1,3}/\d{1,2}$" style="width: 100%; padding: 0.75rem; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-tertiary); color: var(--text-primary);">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label style="display: block; margin-bottom: 0.5rem; color: var(--text-secondary);">{{ t('gateway') }}</label>
|
||||
<input type="text" id="networkGateway" placeholder="10.10.10.1" style="width: 100%; padding: 0.75rem; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-tertiary); color: var(--text-primary);">
|
||||
</div>
|
||||
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem;">
|
||||
<div class="form-group">
|
||||
<label style="display: block; margin-bottom: 0.5rem; color: var(--text-secondary);">{{ t('vlan_id') }}</label>
|
||||
<input type="number" id="networkVLAN" min="1" max="4094" placeholder="100" style="width: 100%; padding: 0.75rem; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-tertiary); color: var(--text-primary);">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label style="display: block; margin-bottom: 0.5rem; color: var(--text-secondary);">{{ t('proxmox_bridge') }}</label>
|
||||
<input type="text" id="networkBridge" placeholder="vmbr0" style="width: 100%; padding: 0.75rem; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-tertiary); color: var(--text-primary);">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label style="display: block; margin-bottom: 0.5rem; color: var(--text-secondary);">{{ t('proxmox_server') }}</label>
|
||||
<select id="networkServer" onchange="onNetworkServerChange()" style="width: 100%; padding: 0.75rem; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-tertiary); color: var(--text-primary);">
|
||||
<option value="">{{ t('all_servers') }} ({{ t('global_network') }})</option>
|
||||
</select>
|
||||
<small style="color: var(--text-muted);">{{ t('network_server_hint') }}</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="networkNodeRow" style="display: none;">
|
||||
<label style="display: block; margin-bottom: 0.5rem; color: var(--text-secondary);">{{ t('proxmox_node') }}</label>
|
||||
<select id="networkNode" style="width: 100%; padding: 0.75rem; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-tertiary); color: var(--text-primary);">
|
||||
<option value="">{{ t('all_nodes') }}</option>
|
||||
</select>
|
||||
<small style="color: var(--text-muted);">{{ t('proxmox_node_hint') }}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 1rem; justify-content: flex-end; margin-top: 1.5rem; padding-top: 1rem; border-top: 1px solid var(--border-color);">
|
||||
<button type="button" onclick="closeAddNetworkModal()" class="btn" style="background: var(--bg-tertiary); color: var(--text-primary);">{{ t('cancel') }}</button>
|
||||
<button type="submit" class="btn btn-success"><i class="fa-solid fa-floppy-disk"></i> {{ t('create') }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
async function loadNetworks() {
|
||||
try {
|
||||
const response = await fetchWithAuth('/ipam/api/networks');
|
||||
const container = document.getElementById('networksContainer');
|
||||
|
||||
if (response.ok) {
|
||||
const networks = await response.json();
|
||||
|
||||
if (networks.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div style="text-align: center; padding: 3rem;">
|
||||
<div style="font-size: 3rem; margin-bottom: 1rem;"><i class="fa-solid fa-globe"></i></div>
|
||||
<h3 style="color: var(--text-primary); margin-bottom: 0.5rem;">${t('networks_not_found')}</h3>
|
||||
<p style="color: var(--text-secondary); margin-bottom: 1.5rem;">${t('add_first_network')}</p>
|
||||
<button onclick="showAddNetworkModal()" class="btn btn-success"><i class="fa-solid fa-plus"></i> ${t('add_network')}</button>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = `
|
||||
<table style="width: 100%; border-collapse: collapse;">
|
||||
<thead>
|
||||
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||
<th style="text-align: left; padding: 0.75rem; color: var(--text-secondary); font-weight: 500;">${t('network_label')}</th>
|
||||
<th style="text-align: left; padding: 0.75rem; color: var(--text-secondary); font-weight: 500;">${t('cidr')}</th>
|
||||
<th style="text-align: left; padding: 0.75rem; color: var(--text-secondary); font-weight: 500;">${t('gateway')}</th>
|
||||
<th style="text-align: left; padding: 0.75rem; color: var(--text-secondary); font-weight: 500;">${t('vlan')}</th>
|
||||
<th style="text-align: left; padding: 0.75rem; color: var(--text-secondary); font-weight: 500;">${t('usage')}</th>
|
||||
<th style="text-align: right; padding: 0.75rem; color: var(--text-secondary); font-weight: 500;">${t('actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${networks.map(net => `
|
||||
<tr style="border-bottom: 1px solid var(--border-color);" onmouseover="this.style.background='var(--bg-hover)'" onmouseout="this.style.background='transparent'">
|
||||
<td style="padding: 0.75rem;">
|
||||
<a href="/ipam/network/${net.id}" style="color: var(--accent); text-decoration: none; font-weight: 500;">
|
||||
${net.name}
|
||||
</a>
|
||||
${net.description ? `<div style="font-size: 0.75rem; color: var(--text-muted);">${net.description}</div>` : ''}
|
||||
</td>
|
||||
<td style="padding: 0.75rem; font-family: monospace; color: var(--text-primary);">${net.network}</td>
|
||||
<td style="padding: 0.75rem; font-family: monospace; color: var(--text-secondary);">${net.gateway || '-'}</td>
|
||||
<td style="padding: 0.75rem; color: var(--text-primary);">${net.vlan_id || '-'}</td>
|
||||
<td style="padding: 0.75rem;">
|
||||
<div style="display: flex; align-items: center; gap: 0.5rem;">
|
||||
<div style="flex: 1; height: 8px; background: var(--bg-tertiary); border-radius: 4px; overflow: hidden; max-width: 100px;">
|
||||
<div style="height: 100%; background: ${getUtilizationColor(net.utilization_percent)}; width: ${net.utilization_percent || 0}%;"></div>
|
||||
</div>
|
||||
<span style="font-size: 0.875rem; color: var(--text-secondary);">${net.used_ips || 0}/${net.total_ips || 0}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td style="padding: 0.75rem; text-align: right;">
|
||||
<a href="/ipam/network/${net.id}" class="btn btn-sm" style="background: var(--bg-tertiary); color: var(--text-primary);"><i class="fa-solid fa-eye"></i> ${t('open')}</a>
|
||||
<button onclick="deleteNetwork(${net.id}, '${net.name}')" class="btn btn-sm btn-danger" style="margin-left: 0.25rem;"><i class="fa-solid fa-trash"></i></button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
} else {
|
||||
container.innerHTML = `<div style="text-align: center; padding: 2rem; color: var(--danger);"><i class="fa-solid fa-circle-xmark"></i> ${t('error_loading_networks')}</div>`;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading networks:', error);
|
||||
document.getElementById('networksContainer').innerHTML = `<div style="text-align: center; padding: 2rem; color: var(--danger);"><i class="fa-solid fa-circle-xmark"></i> ${t('error_loading')}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function getUtilizationColor(percent) {
|
||||
if (percent >= 90) return 'var(--danger)';
|
||||
if (percent >= 70) return 'var(--warning)';
|
||||
if (percent >= 50) return 'var(--warning)';
|
||||
return 'var(--success)';
|
||||
}
|
||||
|
||||
function showAddNetworkModal() {
|
||||
document.getElementById('addNetworkModal').style.display = 'flex';
|
||||
loadServersForIPAM();
|
||||
}
|
||||
|
||||
function closeAddNetworkModal() {
|
||||
document.getElementById('addNetworkModal').style.display = 'none';
|
||||
document.getElementById('addNetworkForm').reset();
|
||||
document.getElementById('networkNodeRow').style.display = 'none';
|
||||
}
|
||||
|
||||
let ipamServers = [];
|
||||
|
||||
async function loadServersForIPAM() {
|
||||
try {
|
||||
const response = await fetchWithAuth('/proxmox/api/servers');
|
||||
if (response.ok) {
|
||||
ipamServers = await response.json();
|
||||
const select = document.getElementById('networkServer');
|
||||
select.innerHTML = `<option value="">{{ t('all_servers') }} ({{ t('global_network') }})</option>`;
|
||||
ipamServers.forEach(s => {
|
||||
select.innerHTML += `<option value="${s.id}">${s.name} (${s.ip_address})</option>`;
|
||||
});
|
||||
}
|
||||
} catch(e) { console.error('Error loading servers:', e); }
|
||||
}
|
||||
|
||||
async function onNetworkServerChange() {
|
||||
const serverId = document.getElementById('networkServer').value;
|
||||
const nodeRow = document.getElementById('networkNodeRow');
|
||||
const nodeSelect = document.getElementById('networkNode');
|
||||
if (!serverId) {
|
||||
nodeRow.style.display = 'none';
|
||||
nodeSelect.innerHTML = `<option value="">{{ t('all_nodes') }}</option>`;
|
||||
return;
|
||||
}
|
||||
nodeRow.style.display = 'block';
|
||||
nodeSelect.innerHTML = `<option value="">{{ t('loading_nodes') }}</option>`;
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/proxmox/api/servers/${serverId}/cluster-info`);
|
||||
const info = resp.ok ? await resp.json() : {};
|
||||
const nodes = info.nodes || [];
|
||||
nodeSelect.innerHTML = `<option value="">{{ t('all_nodes') }}</option>`;
|
||||
nodes.forEach(n => { nodeSelect.innerHTML += `<option value="${n}">${n}</option>`; });
|
||||
if (nodes.length === 1) nodeSelect.value = nodes[0];
|
||||
} catch(e) {
|
||||
nodeSelect.innerHTML = `<option value="">{{ t('all_nodes') }}</option>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function createNetwork(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const data = {
|
||||
name: document.getElementById('networkName').value,
|
||||
network: document.getElementById('networkCIDR').value,
|
||||
gateway: document.getElementById('networkGateway').value || null,
|
||||
vlan_id: document.getElementById('networkVLAN').value ? parseInt(document.getElementById('networkVLAN').value) : null,
|
||||
proxmox_bridge: document.getElementById('networkBridge').value || null,
|
||||
proxmox_server_id: document.getElementById('networkServer').value ? parseInt(document.getElementById('networkServer').value) : null,
|
||||
proxmox_node: document.getElementById('networkNode').value || null,
|
||||
description: document.getElementById('networkDescription').value || null
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuth('/ipam/api/networks', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
closeAddNetworkModal();
|
||||
loadNetworks();
|
||||
showToast('success', t('success'), t('network_created_successfully') || 'Network created successfully');
|
||||
} else {
|
||||
const error = await response.json();
|
||||
showToast('error', t('error'), error.detail || t('error_creating_network'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating network:', error);
|
||||
showToast('error', t('error'), t('error_creating_network'));
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteNetwork(id, name) {
|
||||
const confirmed = await showDeleteConfirm(
|
||||
t('delete_network'),
|
||||
`${t('delete_network_confirm')} "${name}"? ${t('all_related_data_lost')}`
|
||||
);
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuth(`/ipam/api/networks/${id}?force=true`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
loadNetworks();
|
||||
showToast('success', t('success'), t('network_deleted_successfully') || 'Network deleted successfully');
|
||||
} else {
|
||||
const error = await response.json();
|
||||
showToast('error', t('error'), error.detail || t('error_deleting_network'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting network:', error);
|
||||
showToast('error', t('error'), t('error_deleting_network'));
|
||||
}
|
||||
}
|
||||
|
||||
// Initial load after DOM ready
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
setTimeout(loadNetworks, 100);
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,314 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="{{ lang }}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>{{ t('login') }} - {{ t('app_name') }}</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=5, viewport-fit=cover">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="theme-color" content="#f4f5f7">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap">
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
body {
|
||||
font-family: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: #f4f5f7;
|
||||
color: #1a1d23;
|
||||
min-height: 100vh;
|
||||
min-height: -webkit-fill-available;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);
|
||||
}
|
||||
html {
|
||||
height: -webkit-fill-available;
|
||||
}
|
||||
.login-container {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
.login-card {
|
||||
background: #ffffff;
|
||||
border-radius: 6px;
|
||||
padding: 2.25rem 2rem;
|
||||
border: 1px solid #dde1e9;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
|
||||
}
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.login-header h1 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: #1a1d23;
|
||||
}
|
||||
.login-header p {
|
||||
margin: 0;
|
||||
color: #6b7280;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.form-group {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.375rem;
|
||||
color: #374151;
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.form-group input {
|
||||
width: 100%;
|
||||
padding: 0.625rem 0.875rem;
|
||||
background: #ffffff;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 4px;
|
||||
color: #1a1d23;
|
||||
font-size: 16px;
|
||||
font-family: inherit;
|
||||
transition: all 0.15s;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
}
|
||||
.form-group input::placeholder {
|
||||
color: #9ca3af;
|
||||
}
|
||||
.form-group input:focus {
|
||||
outline: none;
|
||||
border-color: #0ea5e9;
|
||||
box-shadow: 0 0 0 3px rgba(14, 165, 233, 0.15);
|
||||
}
|
||||
.btn {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
background: #0ea5e9;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
min-height: 44px;
|
||||
touch-action: manipulation;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.btn:hover {
|
||||
background: #0284c7;
|
||||
}
|
||||
.btn:active {
|
||||
background: #0369a1;
|
||||
}
|
||||
.btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.error-message {
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fca5a5;
|
||||
color: #b91c1c;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.875rem;
|
||||
display: none;
|
||||
}
|
||||
.error-message.show {
|
||||
display: block;
|
||||
}
|
||||
.loading {
|
||||
display: inline-block;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
border: 2px solid rgba(255,255,255,0.4);
|
||||
border-radius: 50%;
|
||||
border-top-color: #fff;
|
||||
animation: spin 0.6s linear infinite;
|
||||
vertical-align: middle;
|
||||
}
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
.footer {
|
||||
text-align: center;
|
||||
margin-top: 1.5rem;
|
||||
color: #9ca3af;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="login-container">
|
||||
<div class="login-card">
|
||||
<div class="login-header">
|
||||
{% if custom_logo_exists %}
|
||||
<img src="/settings/api/logo.png" alt="{{ panel_name }}" style="height: 128px; width: auto; object-fit: contain; margin-bottom: 0.5rem;">
|
||||
{% endif %}
|
||||
<p>{{ t('login_to_continue') }}</p>
|
||||
</div> <div id="errorMessage" class="error-message"></div>
|
||||
|
||||
<form id="loginForm">
|
||||
<div class="form-group">
|
||||
<label for="username">{{ t('username') }}</label>
|
||||
<input
|
||||
type="text"
|
||||
id="username"
|
||||
name="username"
|
||||
required
|
||||
autocomplete="username"
|
||||
placeholder="{{ t('enter_username') }}"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">{{ t('password') }}</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
name="password"
|
||||
required
|
||||
autocomplete="current-password"
|
||||
placeholder="{{ t('enter_password') }}"
|
||||
>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn" id="loginBtn">
|
||||
{{ t('login') }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p>{{ panel_name }} v{{ version }} © 2025</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const loginForm = document.getElementById('loginForm');
|
||||
const loginBtn = document.getElementById('loginBtn');
|
||||
const errorMessage = document.getElementById('errorMessage');
|
||||
|
||||
loginForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const username = document.getElementById('username').value;
|
||||
const password = document.getElementById('password').value;
|
||||
|
||||
// Disable button and show loading
|
||||
loginBtn.disabled = true;
|
||||
loginBtn.innerHTML = '<span class="loading"></span> ' + '{{ t('logging_in') }}';
|
||||
errorMessage.classList.remove('show');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
// Save token to localStorage
|
||||
localStorage.setItem('access_token', data.access_token);
|
||||
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
if (urlParams.get('returnUrl')) {
|
||||
window.location.href = decodeURIComponent(urlParams.get('returnUrl'));
|
||||
} else {
|
||||
// Fetch profile to decide landing page based on actual permissions
|
||||
try {
|
||||
const meResp = await fetch('/api/auth/me', { headers: { 'Authorization': `Bearer ${data.access_token}` } });
|
||||
const me = meResp.ok ? await meResp.json() : {};
|
||||
const perms = me.permissions || {};
|
||||
const isAdmin = me.is_admin === true || me.role?.name === 'admin';
|
||||
if (isAdmin || perms['dashboard:view'] || perms['dashboard.view']) {
|
||||
window.location.href = '/dashboard';
|
||||
} else if (perms['vm:view'] || perms['vms.view']) {
|
||||
window.location.href = '/virtual-machines';
|
||||
} else if (perms['server:view'] || perms['proxmox.view']) {
|
||||
window.location.href = '/vms';
|
||||
} else if (perms['backup:view'] || perms['backups.view']) {
|
||||
window.location.href = '/backups';
|
||||
} else if (perms['ipam:view'] || perms['ipam.view']) {
|
||||
window.location.href = '/ipam';
|
||||
} else if (perms['log:view'] || perms['logs.view']) {
|
||||
window.location.href = '/logs';
|
||||
} else {
|
||||
window.location.href = '/settings';
|
||||
}
|
||||
} catch { window.location.href = '/settings'; }
|
||||
}
|
||||
} else {
|
||||
// Show error
|
||||
errorMessage.textContent = data.detail || '{{ t('login_error_check_credentials') }}';
|
||||
errorMessage.classList.add('show');
|
||||
|
||||
loginBtn.disabled = false;
|
||||
loginBtn.textContent = '{{ t('login') }}';
|
||||
}
|
||||
} catch (error) {
|
||||
errorMessage.textContent = '{{ t('server_connection_error') }}';
|
||||
errorMessage.classList.add('show');
|
||||
|
||||
loginBtn.disabled = false;
|
||||
loginBtn.textContent = '{{ t('login') }}';
|
||||
}
|
||||
});
|
||||
|
||||
// Check if already logged in
|
||||
const token = localStorage.getItem('access_token');
|
||||
if (token) {
|
||||
// Verify token
|
||||
fetch('/api/auth/me', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
}).then(async response => {
|
||||
if (response.ok) {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
if (urlParams.get('returnUrl')) {
|
||||
window.location.href = decodeURIComponent(urlParams.get('returnUrl'));
|
||||
} else {
|
||||
try {
|
||||
const meResp = await fetch('/api/auth/me', { headers: { 'Authorization': `Bearer ${token}` } });
|
||||
const me = meResp.ok ? await meResp.json() : {};
|
||||
const perms = me.permissions || {};
|
||||
const isAdmin = me.is_admin === true || me.role?.name === 'admin';
|
||||
if (isAdmin || perms['dashboard:view'] || perms['dashboard.view']) {
|
||||
window.location.href = '/dashboard';
|
||||
} else if (perms['vm:view'] || perms['vms.view']) {
|
||||
window.location.href = '/virtual-machines';
|
||||
} else if (perms['server:view'] || perms['proxmox.view']) {
|
||||
window.location.href = '/vms';
|
||||
} else if (perms['backup:view'] || perms['backups.view']) {
|
||||
window.location.href = '/backups';
|
||||
} else if (perms['ipam:view'] || perms['ipam.view']) {
|
||||
window.location.href = '/ipam';
|
||||
} else if (perms['log:view'] || perms['logs.view']) {
|
||||
window.location.href = '/logs';
|
||||
} else {
|
||||
window.location.href = '/settings';
|
||||
}
|
||||
} catch { window.location.href = '/settings'; }
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,947 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% set page_title = t('nav_logs') %}
|
||||
|
||||
{% block content %}
|
||||
<style>
|
||||
.logs-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.filters-panel {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 1.25rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.filters-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.filter-group label {
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.375rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.filter-group select,
|
||||
.filter-group input {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.filter-group select:focus,
|
||||
.filter-group input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.filter-group input[type="date"] {
|
||||
color-scheme: dark light;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .filter-group input[type="date"] {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .filter-group input[type="date"] {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
.filter-group input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.filter-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.stats-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.stat-box {
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-box-value {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.stat-box-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.stat-box.errors .stat-box-value {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.stat-box.warnings .stat-box-value {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.logs-table-container {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.logs-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.logs-table th {
|
||||
background: var(--bg-tertiary);
|
||||
padding: 0.875rem;
|
||||
text-align: left;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.logs-table td {
|
||||
padding: 0.75rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.logs-table tr:hover {
|
||||
background: var(--bg-tertiary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.log-level {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.log-level.debug { background: var(--text-muted); color: var(--text-secondary); }
|
||||
.log-level.info { background: var(--info-light); color: var(--info); }
|
||||
.log-level.warning { background: var(--warning-light); color: var(--warning); }
|
||||
.log-level.error { background: var(--danger-light); color: var(--danger); }
|
||||
.log-level.critical { background: var(--danger); color: var(--danger-text-light); }
|
||||
|
||||
.log-category {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.6875rem;
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.log-message {
|
||||
max-width: 400px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.log-time {
|
||||
font-family: 'SF Mono', monospace;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.log-success {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.log-failed {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem;
|
||||
background: var(--bg-tertiary);
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.pagination button {
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.pagination button:hover:not(:disabled) {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.pagination button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.pagination-info {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.875rem;
|
||||
margin: 0 1rem;
|
||||
}
|
||||
|
||||
/* Modal for log details */
|
||||
.modal-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
z-index: 1000;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.modal-overlay.active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
width: 90%;
|
||||
max-width: 700px;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
display: grid;
|
||||
grid-template-columns: 140px 1fr;
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.detail-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
color: var(--text-primary);
|
||||
font-size: 0.875rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.detail-json {
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 4px;
|
||||
padding: 0.75rem;
|
||||
font-family: 'SF Mono', monospace;
|
||||
font-size: 0.8125rem;
|
||||
overflow-x: auto;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.no-logs {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Мобильная адаптация */
|
||||
@media (max-width: 768px) {
|
||||
.logs-header {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.logs-header > div:last-child {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.filters-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.logs-table th,
|
||||
.logs-table td {
|
||||
padding: 0.5rem 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
/* Скрываем менее важные колонки */
|
||||
.logs-table th:nth-child(3),
|
||||
.logs-table td:nth-child(3),
|
||||
.logs-table th:nth-child(4),
|
||||
.logs-table td:nth-child(4),
|
||||
.logs-table th:nth-child(6),
|
||||
.logs-table td:nth-child(6) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.log-modal-content {
|
||||
padding: 1rem;
|
||||
margin: 0.5rem;
|
||||
max-height: 90vh;
|
||||
}
|
||||
|
||||
.detail-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.filters-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.logs-table th:nth-child(5),
|
||||
.logs-table td:nth-child(5) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="page-header-block">
|
||||
<div class="page-header-left">
|
||||
<nav class="breadcrumb-nav">{{ t('dashboard') }} / {{ t('nav_logs') }}</nav>
|
||||
<h1 class="page-header-title"><i class="fa-solid fa-clipboard-list"></i> {{ t('event_log') }}</h1>
|
||||
<p class="page-header-sub">{{ t('view_and_filter_system_logs') }}</p>
|
||||
</div>
|
||||
<div class="page-header-right">
|
||||
<button class="btn btn-secondary" onclick="refreshLogs()"><i class="fa-solid fa-rotate"></i> {{ t('refresh') }}</button>
|
||||
<button class="btn btn-secondary" onclick="exportLogs()"><i class="fa-solid fa-calendar"></i> {{ t('export') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats Row -->
|
||||
<div class="stats-row" id="statsRow">
|
||||
<div class="stat-box">
|
||||
<div class="stat-box-value" id="statTotal">-</div>
|
||||
<div class="stat-box-label">{{ t('total_24h') }}</div>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<div class="stat-box-value" id="statAuth">-</div>
|
||||
<div class="stat-box-label"><i class="fa-solid fa-lock"></i> {{ t('auth') }}</div>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<div class="stat-box-value" id="statProxmox">-</div>
|
||||
<div class="stat-box-label"><i class="fa-solid fa-desktop"></i> Proxmox</div>
|
||||
</div>
|
||||
<div class="stat-box errors">
|
||||
<div class="stat-box-value" id="statErrors">-</div>
|
||||
<div class="stat-box-label"><i class="fa-solid fa-circle-xmark"></i> {{ t('errors') }}</div>
|
||||
</div>
|
||||
<div class="stat-box warnings">
|
||||
<div class="stat-box-value" id="statWarnings">-</div>
|
||||
<div class="stat-box-label"><i class="fa-solid fa-triangle-exclamation"></i> {{ t('warnings') }}</div>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<div class="stat-box-value" id="statFailedLogins">-</div>
|
||||
<div class="stat-box-label"><i class="fa-solid fa-ban"></i> {{ t('failed_logins') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters Panel -->
|
||||
<div class="filters-panel">
|
||||
<div class="filters-grid">
|
||||
<div class="filter-group">
|
||||
<label>{{ t('level') }}</label>
|
||||
<select id="filterLevel">
|
||||
<option value="">{{ t('all_levels') }}</option>
|
||||
<option value="debug">Debug</option>
|
||||
<option value="info">Info</option>
|
||||
<option value="warning">Warning</option>
|
||||
<option value="error">Error</option>
|
||||
<option value="critical">Critical</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>{{ t('category') }}</label>
|
||||
<select id="filterCategory">
|
||||
<option value="">{{ t('all_categories') }}</option>
|
||||
<option value="auth"><i class="fa-solid fa-lock"></i> {{ t('auth') }}</option>
|
||||
<option value="proxmox"><i class="fa-solid fa-desktop"></i> Proxmox</option>
|
||||
<option value="ipam"><i class="fa-solid fa-globe"></i> IPAM</option>
|
||||
<option value="system"><i class="fa-solid fa-gear"></i> {{ t('system') }}</option>
|
||||
<option value="api"><i class="fa-solid fa-plug"></i> API</option>
|
||||
<option value="docker"><i class="fa-brands fa-docker"></i> Docker</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>{{ t('user') }}</label>
|
||||
<input type="text" id="filterUsername" placeholder="{{ t('username') }}">
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>{{ t('date_from') }}</label>
|
||||
<input type="date" id="filterDateFrom">
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>{{ t('date_to') }}</label>
|
||||
<input type="date" id="filterDateTo">
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>IP {{ t('address') }}</label>
|
||||
<input type="text" id="filterIP" placeholder="192.168.1.1">
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>{{ t('search') }}</label>
|
||||
<input type="text" id="filterSearch" placeholder="{{ t('search_in_messages') }}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="filter-actions">
|
||||
<button class="btn btn-secondary" onclick="clearFilters()">{{ t('reset') }}</button>
|
||||
<button class="btn btn-primary" onclick="applyFilters()">{{ t('apply') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Logs Table -->
|
||||
<div class="logs-table-container">
|
||||
<table class="logs-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 140px;">{{ t('time') }}</th>
|
||||
<th style="width: 80px;">{{ t('level') }}</th>
|
||||
<th style="width: 100px;">{{ t('category') }}</th>
|
||||
<th style="width: 120px;">{{ t('action') }}</th>
|
||||
<th>{{ t('message') }}</th>
|
||||
<th style="width: 100px;">{{ t('user') }}</th>
|
||||
<th style="width: 110px;">IP</th>
|
||||
<th style="width: 60px;">{{ t('status') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="logsTableBody">
|
||||
<tr>
|
||||
<td colspan="7" class="loading">{{ t('loading_logs') }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="pagination">
|
||||
<button id="btnPrevPage" onclick="prevPage()" disabled>← {{ t('back') }}</button>
|
||||
<span class="pagination-info" id="paginationInfo">{{ t('page') }} 1 {{ t('of') }} 1</span>
|
||||
<button id="btnNextPage" onclick="nextPage()" disabled>{{ t('forward') }} →</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Log Detail Modal -->
|
||||
<div class="modal-overlay" id="logDetailModal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3><i class="fa-solid fa-list"></i> {{ t('log_details') }}</h3>
|
||||
<button class="modal-close" onclick="closeModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body" id="logDetailBody">
|
||||
<!-- Content loaded dynamically -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// State
|
||||
let currentPage = 1;
|
||||
let totalPages = 1;
|
||||
let currentFilters = {};
|
||||
let logsData = [];
|
||||
|
||||
// URL params helper functions
|
||||
function getUrlParams() {
|
||||
return new URLSearchParams(window.location.search);
|
||||
}
|
||||
|
||||
function updateUrlParams(params) {
|
||||
const url = new URL(window.location);
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value) {
|
||||
url.searchParams.set(key, value);
|
||||
} else {
|
||||
url.searchParams.delete(key);
|
||||
}
|
||||
});
|
||||
window.history.replaceState({}, '', url);
|
||||
}
|
||||
|
||||
function restoreFiltersFromUrl() {
|
||||
const params = getUrlParams();
|
||||
|
||||
const level = params.get('level') || '';
|
||||
document.getElementById('filterLevel').value = level;
|
||||
|
||||
const category = params.get('category') || '';
|
||||
document.getElementById('filterCategory').value = category;
|
||||
|
||||
const username = params.get('username') || '';
|
||||
document.getElementById('filterUsername').value = username;
|
||||
|
||||
const dateFrom = params.get('dateFrom') || '';
|
||||
document.getElementById('filterDateFrom').value = dateFrom;
|
||||
|
||||
const dateTo = params.get('dateTo') || '';
|
||||
document.getElementById('filterDateTo').value = dateTo;
|
||||
|
||||
const ip = params.get('ip') || '';
|
||||
document.getElementById('filterIP').value = ip;
|
||||
|
||||
const search = params.get('search') || '';
|
||||
document.getElementById('filterSearch').value = search;
|
||||
|
||||
const page = params.get('page');
|
||||
if (page && parseInt(page) > 0) {
|
||||
currentPage = parseInt(page);
|
||||
}
|
||||
|
||||
currentFilters = { level, category, username, dateFrom, dateTo, ip, search };
|
||||
}
|
||||
|
||||
// Initialize on page load
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
restoreFiltersFromUrl();
|
||||
setTimeout(() => {
|
||||
loadStats();
|
||||
loadLogs();
|
||||
}, 100);
|
||||
|
||||
// Enter key on search and IP fields
|
||||
document.getElementById('filterSearch').addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') applyFilters();
|
||||
});
|
||||
document.getElementById('filterIP').addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') applyFilters();
|
||||
});
|
||||
});
|
||||
|
||||
// Load statistics
|
||||
async function loadStats() {
|
||||
try {
|
||||
const token = localStorage.getItem('access_token');
|
||||
const response = await fetch('/logs/api/stats?hours=24', {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const stats = await response.json();
|
||||
document.getElementById('statTotal').textContent = stats.total;
|
||||
document.getElementById('statAuth').textContent = stats.by_category.auth || 0;
|
||||
document.getElementById('statProxmox').textContent = stats.by_category.proxmox || 0;
|
||||
document.getElementById('statErrors').textContent = stats.errors_count || 0;
|
||||
document.getElementById('statWarnings').textContent = stats.by_level.warning || 0;
|
||||
document.getElementById('statFailedLogins').textContent = stats.failed_logins || 0;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load stats:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Load logs with filters
|
||||
async function loadLogs() {
|
||||
const tbody = document.getElementById('logsTableBody');
|
||||
tbody.innerHTML = '<tr><td colspan="8" class="loading">' + t('loading_logs') + '</td></tr>';
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('access_token');
|
||||
|
||||
// Build query string
|
||||
const params = new URLSearchParams();
|
||||
params.append('page', currentPage);
|
||||
params.append('limit', 50);
|
||||
|
||||
if (currentFilters.level) params.append('level', currentFilters.level);
|
||||
if (currentFilters.category) params.append('category', currentFilters.category);
|
||||
if (currentFilters.username) params.append('username', currentFilters.username);
|
||||
if (currentFilters.dateFrom) params.append('date_from', currentFilters.dateFrom);
|
||||
if (currentFilters.dateTo) params.append('date_to', currentFilters.dateTo);
|
||||
if (currentFilters.search) params.append('search', currentFilters.search);
|
||||
|
||||
const response = await fetch(`/logs/api/logs?${params.toString()}`, {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
logsData = data.logs;
|
||||
totalPages = data.pages;
|
||||
|
||||
renderLogs(data.logs);
|
||||
updatePagination(data.page, data.pages, data.total);
|
||||
} catch (error) {
|
||||
console.error('Failed to load logs:', error);
|
||||
tbody.innerHTML = '<tr><td colspan="8" class="no-logs">' + t('log_load_error') + '</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
// Render logs table
|
||||
function renderLogs(logs) {
|
||||
const tbody = document.getElementById('logsTableBody');
|
||||
|
||||
if (!logs || logs.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="8" class="no-logs">' + t('no_logs_for_filters') + '</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = logs.map((log, index) => `
|
||||
<tr onclick="showLogDetail(${index})" style="cursor: pointer;">
|
||||
<td class="log-time">${formatDateTime(log.created_at)}</td>
|
||||
<td><span class="log-level ${log.level}">${log.level}</span></td>
|
||||
<td><span class="log-category">${getCategoryIcon(log.category)} ${log.category}</span></td>
|
||||
<td>${log.action}</td>
|
||||
<td class="log-message" title="${escapeHtml(log.message)}">${escapeHtml(log.message)}</td>
|
||||
<td>${log.username || '-'}</td>
|
||||
<td style="font-family: monospace; font-size: 0.8rem; color: var(--text-primary);">${log.ip_address || '-'}</td>
|
||||
<td class="${log.success ? 'log-success' : 'log-failed'}">${log.success ? '✓' : '✗'}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// Update pagination
|
||||
function updatePagination(page, pages, total) {
|
||||
document.getElementById('paginationInfo').textContent = t('page') + ' ' + page + ' ' + t('of') + ' ' + pages + ' (' + total + ' ' + t('records') + ')';
|
||||
document.getElementById('btnPrevPage').disabled = page <= 1;
|
||||
document.getElementById('btnNextPage').disabled = page >= pages;
|
||||
}
|
||||
|
||||
// Navigation
|
||||
function prevPage() {
|
||||
if (currentPage > 1) {
|
||||
currentPage--;
|
||||
updateUrlParams({ page: String(currentPage) });
|
||||
loadLogs();
|
||||
}
|
||||
}
|
||||
|
||||
function nextPage() {
|
||||
if (currentPage < totalPages) {
|
||||
currentPage++;
|
||||
updateUrlParams({ page: String(currentPage) });
|
||||
loadLogs();
|
||||
}
|
||||
}
|
||||
|
||||
// Filters
|
||||
function applyFilters() {
|
||||
currentFilters = {
|
||||
level: document.getElementById('filterLevel').value,
|
||||
category: document.getElementById('filterCategory').value,
|
||||
username: document.getElementById('filterUsername').value,
|
||||
dateFrom: document.getElementById('filterDateFrom').value,
|
||||
dateTo: document.getElementById('filterDateTo').value,
|
||||
ip: document.getElementById('filterIP').value,
|
||||
search: document.getElementById('filterSearch').value
|
||||
};
|
||||
currentPage = 1;
|
||||
updateUrlParams({
|
||||
...currentFilters,
|
||||
page: '1'
|
||||
});
|
||||
loadLogs();
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
document.getElementById('filterLevel').value = '';
|
||||
document.getElementById('filterCategory').value = '';
|
||||
document.getElementById('filterUsername').value = '';
|
||||
document.getElementById('filterDateFrom').value = '';
|
||||
document.getElementById('filterDateTo').value = '';
|
||||
document.getElementById('filterIP').value = '';
|
||||
document.getElementById('filterSearch').value = '';
|
||||
currentFilters = {};
|
||||
currentPage = 1;
|
||||
// Clear all URL params
|
||||
updateUrlParams({
|
||||
level: '',
|
||||
category: '',
|
||||
username: '',
|
||||
dateFrom: '',
|
||||
dateTo: '',
|
||||
ip: '',
|
||||
search: '',
|
||||
page: ''
|
||||
});
|
||||
loadLogs();
|
||||
}
|
||||
|
||||
// Refresh
|
||||
function refreshLogs() {
|
||||
loadStats();
|
||||
loadLogs();
|
||||
}
|
||||
|
||||
// Show log detail modal
|
||||
function showLogDetail(index) {
|
||||
const log = logsData[index];
|
||||
if (!log) return;
|
||||
|
||||
const body = document.getElementById('logDetailBody');
|
||||
body.innerHTML = `
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">ID</div>
|
||||
<div class="detail-value">${log.id}</div>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">${t('time')}</div>
|
||||
<div class="detail-value">${formatDateTime(log.created_at)}</div>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">${t('level')}</div>
|
||||
<div class="detail-value"><span class="log-level ${log.level}">${log.level.toUpperCase()}</span></div>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">${t('category')}</div>
|
||||
<div class="detail-value">${getCategoryIcon(log.category)} ${log.category}</div>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">${t('action')}</div>
|
||||
<div class="detail-value">${log.action}</div>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">${t('message')}</div>
|
||||
<div class="detail-value">${escapeHtml(log.message)}</div>
|
||||
</div>
|
||||
${log.request_id ? `
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">Request ID</div>
|
||||
<div class="detail-value"><code style="font-family: monospace; background: var(--bg-tertiary); padding: 2px 6px; border-radius: 4px;">${log.request_id}</code></div>
|
||||
</div>
|
||||
` : ''}
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">${t('user')}</div>
|
||||
<div class="detail-value">${log.username || '-'}${log.user_id ? ` (ID: ${log.user_id})` : ''}</div>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">IP ${t('address')}</div>
|
||||
<div class="detail-value">${log.ip_address || '-'}${log.geo_location ? ` <span style="color: var(--text-muted);">(${log.geo_location})</span>` : ''}</div>
|
||||
</div>
|
||||
${log.user_agent ? `
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">User Agent</div>
|
||||
<div class="detail-value" style="font-size: 0.8rem; word-break: break-all;">${escapeHtml(log.user_agent)}</div>
|
||||
</div>
|
||||
` : ''}
|
||||
${log.server_name || log.node_name ? `
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">Proxmox Server</div>
|
||||
<div class="detail-value">${log.server_name || '-'}${log.node_name ? ` / Node: ${log.node_name}` : ''}</div>
|
||||
</div>
|
||||
` : ''}
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">${t('resource')}</div>
|
||||
<div class="detail-value">${log.resource_type ? `${log.resource_type} #${log.resource_id}${log.resource_name ? ` (${log.resource_name})` : ''}` : '-'}</div>
|
||||
</div>
|
||||
${log.request_method ? `
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">HTTP ${t('request')}</div>
|
||||
<div class="detail-value">${log.request_method} ${log.request_path}${log.query_params ? `?${log.query_params}` : ''} → ${log.response_status} (${log.duration_ms}ms)</div>
|
||||
</div>
|
||||
` : ''}
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">${t('status')}</div>
|
||||
<div class="detail-value" class="${log.success ? 'log-success' : 'log-failed'}">${log.success ? '✓ ' + t('success') : '✗ ' + t('error')}</div>
|
||||
</div>
|
||||
${log.error_message ? `
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">${t('error')}</div>
|
||||
<div class="detail-value" style="color: var(--danger);">${escapeHtml(log.error_message)}</div>
|
||||
</div>
|
||||
` : ''}
|
||||
${log.error_traceback ? `
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">Stack Trace</div>
|
||||
<div class="detail-value">
|
||||
<pre style="background: var(--bg-tertiary); padding: 0.5rem; border-radius: 4px; font-size: 0.75rem; overflow-x: auto; max-height: 200px; white-space: pre-wrap;">${escapeHtml(log.error_traceback)}</pre>
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
${log.request_body ? `
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">Request Body</div>
|
||||
<div class="detail-value">
|
||||
<div class="detail-json">${JSON.stringify(log.request_body, null, 2)}</div>
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
${log.response_body ? `
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">Response Body</div>
|
||||
<div class="detail-value">
|
||||
<div class="detail-json">${JSON.stringify(log.response_body, null, 2)}</div>
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
${log.details ? `
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">${t('details')} (JSON)</div>
|
||||
<div class="detail-value">
|
||||
<div class="detail-json">${JSON.stringify(log.details, null, 2)}</div>
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
`;
|
||||
|
||||
document.getElementById('logDetailModal').classList.add('active');
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
document.getElementById('logDetailModal').classList.remove('active');
|
||||
}
|
||||
|
||||
// Close modal on escape key
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') closeModal();
|
||||
});
|
||||
|
||||
// Close modal on overlay click
|
||||
document.getElementById('logDetailModal').addEventListener('click', (e) => {
|
||||
if (e.target.classList.contains('modal-overlay')) closeModal();
|
||||
});
|
||||
|
||||
// Export logs
|
||||
function exportLogs() {
|
||||
if (!logsData || logsData.length === 0) {
|
||||
showToast('warning', t('warning') || 'Warning', t('no_data_to_export'));
|
||||
return;
|
||||
}
|
||||
|
||||
const csv = [
|
||||
['ID', t('time'), t('level'), t('category'), t('action'), t('message'), t('user'), 'IP', t('status')].join(','),
|
||||
...logsData.map(log => [
|
||||
log.id,
|
||||
log.created_at,
|
||||
log.level,
|
||||
log.category,
|
||||
log.action,
|
||||
`"${(log.message || '').replace(/"/g, '""')}"`,
|
||||
log.username || '',
|
||||
log.ip_address || '',
|
||||
log.success ? 'OK' : 'ERROR'
|
||||
].join(','))
|
||||
].join('\n');
|
||||
|
||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `logs_${new Date().toISOString().slice(0, 10)}.csv`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
// Helpers
|
||||
function formatDateTime(dateStr) {
|
||||
if (!dateStr) return '-';
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleString('ru-RU', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
function getCategoryIcon(category) {
|
||||
const icons = {
|
||||
'auth': '<i class="fa-solid fa-lock"></i>',
|
||||
'proxmox': '<i class="fa-solid fa-desktop"></i>',
|
||||
'ipam': '<i class="fa-solid fa-globe"></i>',
|
||||
'system': '<i class="fa-solid fa-gear"></i>',
|
||||
'api': '<i class="fa-solid fa-plug"></i>',
|
||||
'docker': '<i class="fa-brands fa-docker"></i>'
|
||||
};
|
||||
return icons[category] || '<i class="fa-solid fa-list"></i>';
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,514 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header-block">
|
||||
<div class="page-header-left">
|
||||
<h1 class="page-header-title"><i class="fa-solid fa-list-check"></i> {{ t('task_manager') }}</h1>
|
||||
<p class="page-header-sub">{{ t('task_manager_subtitle') }}</p>
|
||||
</div>
|
||||
<div class="page-header-right">
|
||||
<div id="wsStatus" style="display:flex; align-items:center; gap:.4rem; font-size:.78rem; color:var(--text-muted);">
|
||||
<span class="task-dot" id="wsDot" style="background:#6b7280;"></span>
|
||||
<span id="wsStatusText">{{ t('connecting') }}</span>
|
||||
</div>
|
||||
<button class="btn btn-secondary" onclick="TaskManager.refresh()" title="{{ t('refresh') }}">
|
||||
<i class="fa-solid fa-rotate-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab bar -->
|
||||
<div class="tasks-tab-bar">
|
||||
<button class="tasks-tab active" data-filter="all" onclick="TaskManager.setFilter('all', this)">
|
||||
{{ t('all') }} <span class="tasks-tab-count" id="cnt-all">0</span>
|
||||
</button>
|
||||
<button class="tasks-tab" data-filter="running" onclick="TaskManager.setFilter('running', this)">
|
||||
<span class="task-dot running" style="margin-right:.3rem;"></span>
|
||||
{{ t('status_running') }} <span class="tasks-tab-count" id="cnt-running">0</span>
|
||||
</button>
|
||||
<button class="tasks-tab" data-filter="pending" onclick="TaskManager.setFilter('pending', this)">
|
||||
<span class="task-dot pending" style="margin-right:.3rem;"></span>
|
||||
{{ t('status_pending') }} <span class="tasks-tab-count" id="cnt-pending">0</span>
|
||||
</button>
|
||||
<button class="tasks-tab" data-filter="completed" onclick="TaskManager.setFilter('completed', this)">
|
||||
<span class="task-dot completed" style="margin-right:.3rem;"></span>
|
||||
{{ t('status_completed') }} <span class="tasks-tab-count" id="cnt-completed">0</span>
|
||||
</button>
|
||||
<button class="tasks-tab" data-filter="failed" onclick="TaskManager.setFilter('failed', this)">
|
||||
<span class="task-dot failed" style="margin-right:.3rem;"></span>
|
||||
{{ t('status_failed') }} <span class="tasks-tab-count" id="cnt-failed">0</span>
|
||||
</button>
|
||||
<button class="tasks-tab" data-filter="cancelled" onclick="TaskManager.setFilter('cancelled', this)">
|
||||
<span class="task-dot cancelled" style="margin-right:.3rem;"></span>
|
||||
{{ t('status_cancelled') }} <span class="tasks-tab-count" id="cnt-cancelled">0</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Task list -->
|
||||
<div id="taskList">
|
||||
<div class="task-empty-state" id="taskEmpty" style="text-align:center; padding:4rem 2rem; color:var(--text-muted); display:none;">
|
||||
<i class="fa-solid fa-inbox" style="font-size:2.5rem; margin-bottom:1rem; display:block; opacity:.35;"></i>
|
||||
<p style="font-size:.95rem;">{{ t('tasks_not_found') }}</p>
|
||||
</div>
|
||||
<div id="taskCards"></div>
|
||||
</div>
|
||||
|
||||
<!-- Trace Modal -->
|
||||
<div class="modal fade" id="traceModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||
<div class="modal-content" style="background:var(--glass-bg); border:1px solid var(--glass-border); border-radius:16px;">
|
||||
<div class="modal-header" style="border-bottom:1px solid var(--border-default); padding:1.25rem 1.5rem;">
|
||||
<div>
|
||||
<h5 class="modal-title" id="traceModalTitle" style="font-weight:700; color:var(--text-primary); margin:0;"></h5>
|
||||
<div id="traceModalMeta" style="font-size:.78rem; color:var(--text-muted); margin-top:.25rem;"></div>
|
||||
</div>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="{{ t('close') }}"></button>
|
||||
</div>
|
||||
<div class="modal-body" style="padding:0;">
|
||||
<!-- Status bar -->
|
||||
<div id="traceStatusBar" style="padding:.75rem 1.5rem; border-bottom:1px solid var(--border-muted); display:flex; align-items:center; gap:.75rem;">
|
||||
<span id="traceStatusBadge"></span>
|
||||
<div class="task-progress-track" style="flex:1;">
|
||||
<div class="task-progress-fill" id="traceProgressFill" style="width:0%;"></div>
|
||||
</div>
|
||||
<span id="traceProgressPct" style="font-size:.78rem; color:var(--text-muted); min-width:35px; text-align:right;">0%</span>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<ul class="nav nav-tabs" style="padding:0 1.5rem; border-bottom:1px solid var(--border-muted); margin:0;">
|
||||
<li class="nav-item">
|
||||
<button class="nav-link active" id="tabResultsBtn" onclick="TaskManager.switchTraceTab('results')"
|
||||
style="color:var(--text-muted); background:none; border:none; border-bottom:2px solid transparent; padding:.75rem 1rem; font-size:.85rem;">
|
||||
<i class="fa-solid fa-table-list" style="margin-right:.4rem;"></i> {{ t('results') }}
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<button class="nav-link" id="tabLogBtn" onclick="TaskManager.switchTraceTab('log')"
|
||||
style="color:var(--text-muted); background:none; border:none; border-bottom:2px solid transparent; padding:.75rem 1rem; font-size:.85rem;">
|
||||
<i class="fa-solid fa-terminal" style="margin-right:.4rem;"></i> {{ t('log') }}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- Results tab -->
|
||||
<div id="tabResults" style="padding:1.25rem 1.5rem;">
|
||||
<div id="traceResultsContent">
|
||||
<div style="text-align:center; padding:2rem; color:var(--text-muted);">{{ t('no_data') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Log tab -->
|
||||
<div id="tabLog" style="padding:1.25rem 1.5rem; display:none;">
|
||||
<div class="task-trace-log" id="traceLogContent">{{ t('loading') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer" style="border-top:1px solid var(--border-default); padding:1rem 1.5rem;">
|
||||
<button id="traceCancelBtn" class="btn btn-sm btn-danger" onclick="TaskManager.cancelTask()" style="display:none;">
|
||||
<i class="fa-solid fa-ban"></i> {{ t('cancel_task') }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm" data-bs-dismiss="modal"
|
||||
style="background:var(--bg-surface); border:1px solid var(--border-default); color:var(--text-secondary);">
|
||||
{{ t('close') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const TaskManager = (() => {
|
||||
let ws = null;
|
||||
let tasks = {}; // id -> task object
|
||||
let currentFilter = 'all';
|
||||
let openTaskId = null;
|
||||
let openTaskKind = null; // 'bulk' | 'proxmox'
|
||||
let traceTab = 'results';
|
||||
let reconnectTimer = null;
|
||||
let heartbeatTimer = null;
|
||||
let reconnectDelay = 2000;
|
||||
|
||||
// ── Status helpers ─────────────────────────────────────────────
|
||||
const STATUS_LABEL = {
|
||||
running: t('status_running'), pending: t('status_pending'), queued: t('status_pending'),
|
||||
completed: t('status_completed'), failed: t('status_failed'), error: t('status_failed'),
|
||||
cancelled: t('status_cancelled'), canceled: t('status_cancelled')
|
||||
};
|
||||
const STATUS_ICON = {
|
||||
running: '<i class="fa-solid fa-circle-notch task-icon-spin"></i>',
|
||||
pending: '<i class="fa-solid fa-clock"></i>',
|
||||
queued: '<i class="fa-solid fa-clock"></i>',
|
||||
completed: '<i class="fa-solid fa-circle-check"></i>',
|
||||
failed: '<i class="fa-solid fa-circle-xmark"></i>',
|
||||
error: '<i class="fa-solid fa-circle-xmark"></i>',
|
||||
cancelled: '<i class="fa-solid fa-ban"></i>',
|
||||
canceled: '<i class="fa-solid fa-ban"></i>',
|
||||
};
|
||||
function normalizeStatus(s) {
|
||||
const map = { queued: 'pending', error: 'failed', canceled: 'cancelled' };
|
||||
return map[s] || s || 'pending';
|
||||
}
|
||||
function progressPct(task) {
|
||||
const s = normalizeStatus(task.status);
|
||||
if (s === 'completed') return 100;
|
||||
if (s === 'failed' || s === 'cancelled') return 100;
|
||||
if (task.kind === 'bulk' && task.total_items > 0) {
|
||||
return Math.round((task.processed_items / task.total_items) * 100);
|
||||
}
|
||||
if (s === 'running') return null; // indeterminate
|
||||
return 0;
|
||||
}
|
||||
function escH(s) {
|
||||
if (s == null) return '';
|
||||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
function fmtTime(iso) {
|
||||
if (!iso) return '—';
|
||||
try { return new Date(iso).toLocaleString(window.currentLang === 'en' ? 'en-US' : 'ru-RU', { day:'2-digit', month:'2-digit', year:'2-digit', hour:'2-digit', minute:'2-digit' }); }
|
||||
catch { return iso; }
|
||||
}
|
||||
function elapsed(start, end) {
|
||||
if (!start) return '';
|
||||
const a = new Date(start), b = end ? new Date(end) : new Date();
|
||||
const s = Math.floor((b - a) / 1000);
|
||||
if (s < 60) return `${s}${t('seconds_s')}`;
|
||||
if (s < 3600) return `${Math.floor(s/60)}${t('minutes_m')} ${s%60}${t('seconds_s')}`;
|
||||
return `${Math.floor(s/3600)}${t('hours_h')} ${Math.floor((s%3600)/60)}${t('minutes_m')}`;
|
||||
}
|
||||
|
||||
// ── Build card HTML ────────────────────────────────────────────
|
||||
function buildCard(task) {
|
||||
const st = normalizeStatus(task.status);
|
||||
const pct = progressPct(task);
|
||||
const indeterminate = (pct === null);
|
||||
const fillPct = indeterminate ? 60 : pct;
|
||||
const desc = task.description || task.action || task.task_type || t('task_default_name');
|
||||
const meta = [];
|
||||
if (task.kind === 'bulk') {
|
||||
if (task.total_items > 0) meta.push(`<span><i class="fa-solid fa-layer-group"></i> ${task.processed_items||0} / ${task.total_items} ${t('objects')}</span>`);
|
||||
} else {
|
||||
if (task.vmid) meta.push(`<span><i class="fa-solid fa-server"></i> VM ${task.vmid}</span>`);
|
||||
if (task.node) meta.push(`<span><i class="fa-solid fa-network-wired"></i> ${escH(task.node)}</span>`);
|
||||
}
|
||||
meta.push(`<span><i class="fa-regular fa-clock"></i> ${fmtTime(task.created_at)}</span>`);
|
||||
if (task.completed_at || task.updated_at) meta.push(`<span><i class="fa-solid fa-hourglass-end"></i> ${elapsed(task.created_at, task.completed_at || task.updated_at)}</span>`);
|
||||
const cancelBtn = (st === 'pending' && task.kind === 'bulk') ?
|
||||
`<button class="btn btn-sm" onclick="event.stopPropagation(); TaskManager.cancelTaskById('${task.kind}','${task.id}')"
|
||||
style="background:rgba(239,68,68,.12); color:#ef4444; border:1px solid rgba(239,68,68,.3); border-radius:8px; padding:3px 10px; font-size:.75rem;">
|
||||
<i class="fa-solid fa-ban"></i> ${t('cancel')}
|
||||
</button>` : '';
|
||||
|
||||
return `<div class="task-card ${st}" data-id="${task.id}" data-kind="${task.kind}" onclick="TaskManager.openTrace('${task.kind}','${task.id}')">
|
||||
<div class="task-card-header">
|
||||
<span class="task-dot ${st}"></span>
|
||||
<span class="task-card-title">${escH(desc)}</span>
|
||||
<span class="task-badge task-badge-${st}">
|
||||
${STATUS_ICON[st] || ''} ${STATUS_LABEL[st] || st}
|
||||
</span>
|
||||
${cancelBtn}
|
||||
</div>
|
||||
<div class="task-progress-track">
|
||||
<div class="task-progress-fill ${st}" style="width:${fillPct}%;"></div>
|
||||
</div>
|
||||
<div class="task-card-meta">
|
||||
${meta.join('')}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── Render all cards ───────────────────────────────────────────
|
||||
function render() {
|
||||
const counts = { all:0, running:0, pending:0, completed:0, failed:0, cancelled:0 };
|
||||
const filtered = [];
|
||||
for (const [, task] of Object.entries(tasks)) {
|
||||
const st = normalizeStatus(task.status);
|
||||
counts.all++;
|
||||
counts[st] = (counts[st] || 0) + 1;
|
||||
if (currentFilter === 'all' || st === currentFilter) filtered.push(task);
|
||||
}
|
||||
// Sort: running first, then pending, then by date desc
|
||||
const order = ['running','pending','failed','cancelled','completed'];
|
||||
filtered.sort((a,b) => {
|
||||
const oa = order.indexOf(normalizeStatus(a.status));
|
||||
const ob = order.indexOf(normalizeStatus(b.status));
|
||||
if (oa !== ob) return oa - ob;
|
||||
return new Date(b.created_at) - new Date(a.created_at);
|
||||
});
|
||||
|
||||
document.getElementById('cnt-all').textContent = counts.all;
|
||||
document.getElementById('cnt-running').textContent = counts.running || 0;
|
||||
document.getElementById('cnt-pending').textContent = counts.pending || 0;
|
||||
document.getElementById('cnt-completed').textContent = counts.completed || 0;
|
||||
document.getElementById('cnt-failed').textContent = counts.failed || 0;
|
||||
document.getElementById('cnt-cancelled').textContent = counts.cancelled || 0;
|
||||
|
||||
const cardsEl = document.getElementById('taskCards');
|
||||
const emptyEl = document.getElementById('taskEmpty');
|
||||
if (filtered.length === 0) {
|
||||
cardsEl.innerHTML = '';
|
||||
emptyEl.style.display = 'flex';
|
||||
emptyEl.style.flexDirection = 'column';
|
||||
emptyEl.style.alignItems = 'center';
|
||||
} else {
|
||||
emptyEl.style.display = 'none';
|
||||
cardsEl.innerHTML = filtered.map(buildCard).join('');
|
||||
}
|
||||
|
||||
// Update open trace if modal is visible
|
||||
const modal = document.getElementById('traceModal');
|
||||
if (modal.classList.contains('show') && openTaskId) {
|
||||
const t = findTask(openTaskKind, openTaskId);
|
||||
if (t) refreshTraceModal(t);
|
||||
}
|
||||
}
|
||||
|
||||
function findTask(kind, id) {
|
||||
const key = `${kind}_${id}`;
|
||||
return tasks[key] || null;
|
||||
}
|
||||
|
||||
// ── WebSocket ──────────────────────────────────────────────────
|
||||
function connect() {
|
||||
const token = localStorage.getItem('access_token');
|
||||
if (!token) return;
|
||||
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
const url = `${proto}://${location.host}/ws/tasks?token=${encodeURIComponent(token)}`;
|
||||
ws = new WebSocket(url);
|
||||
setWsStatus('connecting');
|
||||
|
||||
ws.onopen = () => {
|
||||
setWsStatus('connected');
|
||||
reconnectDelay = 2000;
|
||||
// heartbeat
|
||||
heartbeatTimer = setInterval(() => {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({type:'ping'}));
|
||||
}, 20000);
|
||||
};
|
||||
|
||||
ws.onmessage = ({data}) => {
|
||||
try {
|
||||
const msg = JSON.parse(data);
|
||||
handleMessage(msg);
|
||||
} catch {}
|
||||
};
|
||||
|
||||
ws.onerror = () => {};
|
||||
ws.onclose = () => {
|
||||
clearInterval(heartbeatTimer);
|
||||
setWsStatus('disconnected');
|
||||
reconnectTimer = setTimeout(connect, reconnectDelay);
|
||||
reconnectDelay = Math.min(reconnectDelay * 2, 30000);
|
||||
};
|
||||
}
|
||||
|
||||
function handleMessage(msg) {
|
||||
if (msg.type === 'task_list') {
|
||||
tasks = {};
|
||||
(msg.tasks || []).forEach(t => { tasks[`${t.kind}_${t.id}`] = t; });
|
||||
render();
|
||||
} else if (msg.type === 'task_update' || msg.type === 'task_created') {
|
||||
const t = msg.task;
|
||||
if (t) { tasks[`${t.kind}_${t.id}`] = t; render(); }
|
||||
} else if (msg.type === 'task_removed') {
|
||||
const t = msg.task;
|
||||
if (t) { delete tasks[`${t.kind}_${t.id}`]; render(); }
|
||||
} else if (msg.type === 'pong') { /* ok */ }
|
||||
}
|
||||
|
||||
function setWsStatus(status) {
|
||||
const dot = document.getElementById('wsDot');
|
||||
const txt = document.getElementById('wsStatusText');
|
||||
const colors = { connected:'#10b981', connecting:'#f97316', disconnected:'#ef4444' };
|
||||
const labels = { connected: t('connected'), connecting: t('connecting'), disconnected: t('disconnected') };
|
||||
if (dot) dot.style.background = colors[status];
|
||||
if (txt) txt.textContent = labels[status];
|
||||
}
|
||||
|
||||
// ── Trace Modal ────────────────────────────────────────────────
|
||||
function openTrace(kind, id) {
|
||||
openTaskId = id;
|
||||
openTaskKind = kind;
|
||||
const task = findTask(kind, id);
|
||||
if (!task) return;
|
||||
refreshTraceModal(task);
|
||||
const modal = new bootstrap.Modal(document.getElementById('traceModal'));
|
||||
modal.show();
|
||||
// If proxmox task and running, fetch fresh log
|
||||
if (kind === 'proxmox') loadProxmoxLog(id);
|
||||
}
|
||||
|
||||
function refreshTraceModal(task) {
|
||||
const st = normalizeStatus(task.status);
|
||||
const desc = task.description || task.action || task.task_type || t('task_default_name');
|
||||
document.getElementById('traceModalTitle').textContent = desc;
|
||||
document.getElementById('traceModalMeta').innerHTML =
|
||||
`${t('created_label')}: ${fmtTime(task.created_at)}` +
|
||||
(task.completed_at ? ` · ${t('completed_label')}: ${fmtTime(task.completed_at)}` : '') +
|
||||
` · ${t('elapsed_label')}: ${elapsed(task.created_at, task.completed_at)}`;
|
||||
|
||||
// Status badge
|
||||
document.getElementById('traceStatusBadge').innerHTML =
|
||||
`<span class="task-badge task-badge-${st}">${STATUS_ICON[st]||''} ${STATUS_LABEL[st]||st}</span>`;
|
||||
|
||||
// Progress
|
||||
const pct = progressPct(task);
|
||||
const fill = document.getElementById('traceProgressFill');
|
||||
fill.className = `task-progress-fill ${st}`;
|
||||
fill.style.width = `${pct === null ? 60 : pct}%`;
|
||||
document.getElementById('traceProgressPct').textContent = pct === null ? '…' : `${pct}%`;
|
||||
|
||||
// Cancel button
|
||||
const cancelBtn = document.getElementById('traceCancelBtn');
|
||||
cancelBtn.style.display = (st === 'pending' && task.kind === 'bulk') ? 'inline-flex' : 'none';
|
||||
|
||||
// Results tab
|
||||
renderTraceResults(task);
|
||||
|
||||
// Log tab
|
||||
if (task.kind === 'proxmox') {
|
||||
document.getElementById('traceLogContent').textContent = task.log_text || t('log_empty');
|
||||
} else {
|
||||
renderBulkLog(task);
|
||||
}
|
||||
}
|
||||
|
||||
function renderTraceResults(task) {
|
||||
const el = document.getElementById('traceResultsContent');
|
||||
const st = normalizeStatus(task.status);
|
||||
|
||||
if (task.kind === 'proxmox') {
|
||||
el.innerHTML = `<table class="table task-results-table" style="margin:0;">
|
||||
<thead><tr>
|
||||
<th>${t('parameter')}</th><th>${t('value_column')}</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
<tr><td>UPID</td><td style="font-family:monospace; font-size:.78rem;">${escH(task.upid)}</td></tr>
|
||||
<tr><td>${t('node_label')}</td><td>${escH(task.node)||'—'}</td></tr>
|
||||
<tr><td>VM ID</td><td>${task.vmid||'—'}</td></tr>
|
||||
<tr><td>${t('type_label')}</td><td>${escH(task.vm_type)||'—'}</td></tr>
|
||||
<tr><td>${t('action_label')}</td><td>${escH(task.action)}</td></tr>
|
||||
<tr><td>${t('exit_status')}</td><td>${task.exit_status ? `<span class="${task.exit_status==='OK'?'task-result-ok':'task-result-fail'}">${escH(task.exit_status)}</span>` : '—'}</td></tr>
|
||||
</tbody>
|
||||
</table>`;
|
||||
} else if (task.kind === 'bulk') {
|
||||
const items = task.processed_items || 0;
|
||||
const total = task.total_items || 0;
|
||||
const results = task.results || [];
|
||||
if (results.length === 0) {
|
||||
el.innerHTML = `<div style="padding:1.5rem; text-align:center; color:var(--text-muted);">
|
||||
${total > 0 ? t('processed_of').replace('{processed}', items).replace('{total}', total) : t('no_results')}
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
const rows = results.map(r => {
|
||||
const ok = r.success || r.status === 'success' || r.status === 'completed';
|
||||
const icon = ok ? '<i class="fa-solid fa-circle-check task-result-ok"></i>' : '<i class="fa-solid fa-circle-xmark task-result-fail"></i>';
|
||||
return `<tr>
|
||||
<td>${icon}</td>
|
||||
<td>${escH(r.vm_name || r.name || r.id || '—')}</td>
|
||||
<td>${escH(r.message || r.error || (ok ? 'OK' : t('status_failed')))}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
el.innerHTML = `<table class="table task-results-table" style="margin:0;">
|
||||
<thead><tr><th width="30"></th><th>${t('object_column')}</th><th>${t('message_column')}</th></tr></thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderBulkLog(task) {
|
||||
const items = task.results || [];
|
||||
const lines = items.map(r => {
|
||||
const ok = r.success || r.status === 'success' || r.status === 'completed';
|
||||
const ts = r.timestamp ? new Date(r.timestamp).toLocaleTimeString(window.currentLang === 'en' ? 'en-US' : 'ru-RU') : '';
|
||||
return `${ts ? '['+ts+'] ' : ''}${ok?'✓':'✗'} ${r.vm_name||r.name||''}: ${r.message||r.error||''}`;
|
||||
}).join('\n');
|
||||
document.getElementById('traceLogContent').textContent = lines || t('log_empty_simple');
|
||||
}
|
||||
|
||||
function switchTraceTab(tab) {
|
||||
traceTab = tab;
|
||||
document.getElementById('tabResults').style.display = tab === 'results' ? '' : 'none';
|
||||
document.getElementById('tabLog').style.display = tab === 'log' ? '' : 'none';
|
||||
document.getElementById('tabResultsBtn').classList.toggle('active', tab === 'results');
|
||||
document.getElementById('tabLogBtn').classList.toggle('active', tab === 'log');
|
||||
// Style active tab
|
||||
['tabResultsBtn','tabLogBtn'].forEach(id => {
|
||||
const btn = document.getElementById(id);
|
||||
const isActive = btn.classList.contains('active');
|
||||
btn.style.color = isActive ? 'var(--primary)' : 'var(--text-muted)';
|
||||
btn.style.borderBottom = isActive ? '2px solid var(--primary)' : '2px solid transparent';
|
||||
});
|
||||
}
|
||||
|
||||
async function loadProxmoxLog(id) {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/proxmox/api/proxmox-tasks/${id}/log`);
|
||||
if (resp && resp.ok) {
|
||||
const data = await resp.json();
|
||||
document.getElementById('traceLogContent').textContent = data.log_text || t('log_empty');
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function cancelTaskById(kind, id) {
|
||||
if (kind !== 'bulk') return;
|
||||
const ok = await showConfirm(t('cancel_task_confirm'), t('confirmation'));
|
||||
if (!ok) return;
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/proxmox/api/tasks/${id}/cancel`, { method:'POST' });
|
||||
if (resp && resp.ok) showToast('success', t('done'), t('task_cancelled'));
|
||||
else showToast('error', t('error'), t('failed_to_cancel'));
|
||||
} catch { showToast('error', t('error'), t('network_error')); }
|
||||
}
|
||||
|
||||
async function cancelTask() {
|
||||
if (openTaskKind && openTaskId) {
|
||||
await cancelTaskById(openTaskKind, openTaskId);
|
||||
bootstrap.Modal.getInstance(document.getElementById('traceModal'))?.hide();
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
const resp = await fetchWithAuth('/proxmox/api/all-tasks');
|
||||
if (resp && resp.ok) {
|
||||
const data = await resp.json();
|
||||
const list = Array.isArray(data) ? data : (data.tasks || []);
|
||||
tasks = {};
|
||||
list.forEach(t => { tasks[`${t.kind}_${t.id}`] = t; });
|
||||
render();
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function setFilter(f, btn) {
|
||||
currentFilter = f;
|
||||
document.querySelectorAll('.tasks-tab').forEach(b => b.classList.remove('active'));
|
||||
if (btn) btn.classList.add('active');
|
||||
render();
|
||||
}
|
||||
|
||||
// ── Init ───────────────────────────────────────────────────────
|
||||
function init() {
|
||||
connect();
|
||||
refresh();
|
||||
}
|
||||
|
||||
return { init, refresh, setFilter, openTrace, switchTraceTab, cancelTask, cancelTaskById };
|
||||
})();
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Wait for token to be available
|
||||
const check = () => {
|
||||
if (localStorage.getItem('access_token')) TaskManager.init();
|
||||
else setTimeout(check, 300);
|
||||
};
|
||||
check();
|
||||
});
|
||||
|
||||
// Style active tab on load
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
TaskManager.switchTraceTab('results');
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,567 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Workspaces{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header-block">
|
||||
<div class="page-header-left">
|
||||
<nav class="breadcrumb-nav">Dashboard / Workspaces</nav>
|
||||
<h1 class="page-header-title">
|
||||
<svg style="width:1.25rem;height:1.25rem;vertical-align:-3px;margin-right:6px;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="7" width="20" height="14" rx="2"/><path d="M16 7V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v2"/></svg>
|
||||
Workspaces
|
||||
</h1>
|
||||
</div>
|
||||
<div class="page-header-right">
|
||||
<button class="btn btn-success" onclick="showCreateModal()">
|
||||
<i class="fa-solid fa-plus"></i> {{ t('create_workspace') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Workspace Cards -->
|
||||
<div id="workspacesGrid" class="ws-grid">
|
||||
<div class="loading-placeholder">
|
||||
<i class="fa-solid fa-spinner fa-spin"></i> {{ t('loading') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============================================================
|
||||
Create / Edit Modal
|
||||
============================================================ -->
|
||||
<div id="wsModal" class="modal-overlay" style="display:none;" onclick="if(event.target===this)closeWsModal()">
|
||||
<div class="modal-container" style="max-width:600px;">
|
||||
<div class="modal-header">
|
||||
<h3 id="wsModalTitle">{{ t('create_workspace') }}</h3>
|
||||
<button class="modal-close" onclick="closeWsModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label>{{ t('name') }} <span style="color:var(--danger)">*</span></label>
|
||||
<input type="text" id="wsName" class="form-input" placeholder="Production" maxlength="100">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>{{ t('description') }}</label>
|
||||
<textarea id="wsDescription" class="form-input" rows="2" placeholder="{{ t('workspace_desc_placeholder') }}" maxlength="500"></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>{{ t('color') }}</label>
|
||||
<div style="display:flex;align-items:center;gap:10px;">
|
||||
<input type="color" id="wsColor" value="#0ea5e9" style="width:48px;height:36px;border:none;background:none;cursor:pointer;padding:0;">
|
||||
<div class="ws-color-presets">
|
||||
<button class="ws-color-preset" style="background:#0ea5e9;" onclick="document.getElementById('wsColor').value='#0ea5e9'"></button>
|
||||
<button class="ws-color-preset" style="background:#10b981;" onclick="document.getElementById('wsColor').value='#10b981'"></button>
|
||||
<button class="ws-color-preset" style="background:#f59e0b;" onclick="document.getElementById('wsColor').value='#f59e0b'"></button>
|
||||
<button class="ws-color-preset" style="background:#ef4444;" onclick="document.getElementById('wsColor').value='#ef4444'"></button>
|
||||
<button class="ws-color-preset" style="background:#8b5cf6;" onclick="document.getElementById('wsColor').value='#8b5cf6'"></button>
|
||||
<button class="ws-color-preset" style="background:#06b6d4;" onclick="document.getElementById('wsColor').value='#06b6d4'"></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="wsServersGroup">
|
||||
<label>{{ t('servers_label') }}</label>
|
||||
<div id="wsServersList" class="ws-assign-list">
|
||||
<div class="loading-placeholder"><i class="fa-solid fa-spinner fa-spin"></i></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="wsUsersGroup">
|
||||
<label>{{ t('users_with_access') }}</label>
|
||||
<div id="wsUsersList" class="ws-assign-list">
|
||||
<div class="loading-placeholder"><i class="fa-solid fa-spinner fa-spin"></i></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" onclick="closeWsModal()">{{ t('cancel') }}</button>
|
||||
<button class="btn btn-primary" id="wsModalSaveBtn" onclick="saveWorkspace()">{{ t('save') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* ── Workspace Modal Overlay ────────────────────────────────── */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.65);
|
||||
backdrop-filter: blur(5px);
|
||||
-webkit-backdrop-filter: blur(5px);
|
||||
z-index: 1050;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.modal-container {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
width: 100%;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.55);
|
||||
animation: modalIn 0.2s ease;
|
||||
}
|
||||
|
||||
@keyframes modalIn {
|
||||
from { opacity: 0; transform: scale(0.96) translateY(-8px); }
|
||||
to { opacity: 1; transform: scale(1) translateY(0); }
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: space-between !important;
|
||||
padding: 1.25rem 1.5rem !important;
|
||||
border-bottom: 1px solid var(--border-color) !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 1.5rem !important;
|
||||
background: transparent !important;
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex !important;
|
||||
justify-content: flex-end !important;
|
||||
gap: 0.75rem !important;
|
||||
padding: 1rem 1.5rem !important;
|
||||
border-top: 1px solid var(--border-color) !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 1.4rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
/* ── Workspace Grid ─────────────────────────────────────────── */
|
||||
.ws-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 1.25rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.ws-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1.25rem;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.ws-card:hover {
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
.ws-card-accent {
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0;
|
||||
height: 3px;
|
||||
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
|
||||
}
|
||||
|
||||
.ws-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.ws-card-dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ws-card-name {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.ws-card-badge {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-full);
|
||||
background: rgba(102,126,234,0.18);
|
||||
color: #99aaff;
|
||||
border: 1px solid rgba(102,126,234,0.3);
|
||||
letter-spacing: 0.03em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.ws-card-desc {
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 1rem;
|
||||
min-height: 1.2em;
|
||||
}
|
||||
|
||||
.ws-card-stats {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.ws-stat {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.ws-stat i { opacity: 0.6; }
|
||||
|
||||
.ws-card-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.ws-assign-list {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-body);
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.ws-assign-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
font-size: 0.84rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.ws-assign-item:hover { background: var(--bg-hover); }
|
||||
.ws-assign-item input[type=checkbox] { cursor: pointer; accent-color: var(--accent); }
|
||||
|
||||
.ws-color-presets {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.ws-color-preset {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
|
||||
.ws-color-preset:hover {
|
||||
transform: scale(1.25);
|
||||
border-color: rgba(255,255,255,0.5);
|
||||
}
|
||||
|
||||
.loading-placeholder {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.875rem;
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ws-empty {
|
||||
grid-column: 1/-1;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
padding: 3rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
let _allServers = [];
|
||||
let _allUsers = [];
|
||||
let _editingId = null; // null = create mode
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Load & Render workspaces
|
||||
// -----------------------------------------------------------------------
|
||||
async function loadWorkspaces() {
|
||||
const grid = document.getElementById('workspacesGrid');
|
||||
try {
|
||||
const resp = await fetchWithAuth('/api/workspaces');
|
||||
if (!resp || !resp.ok) throw new Error('Failed');
|
||||
const workspaces = await resp.json();
|
||||
|
||||
if (workspaces.length === 0) {
|
||||
grid.innerHTML = `<div class="ws-empty"><i class="fa-solid fa-layer-group" style="font-size:2rem;opacity:0.2;display:block;margin-bottom:0.5rem;"></i>${t('no_workspaces')}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
grid.innerHTML = workspaces.map(ws => `
|
||||
<div class="ws-card" id="wsCard_${ws.id}">
|
||||
<div class="ws-card-accent" style="background:${ws.color || '#0ea5e9'};"></div>
|
||||
<div class="ws-card-header">
|
||||
<span class="ws-card-dot" style="background:${ws.color || '#0ea5e9'};"></span>
|
||||
<span class="ws-card-name">${escapeHtml(ws.name)}</span>
|
||||
${ws.is_default ? '<span class="ws-card-badge">Default</span>' : ''}
|
||||
</div>
|
||||
<div class="ws-card-desc">${escapeHtml(ws.description || '')}</div>
|
||||
<div class="ws-card-stats">
|
||||
<span class="ws-stat"><i class="fa-solid fa-server"></i>${ws.server_count} ${t('servers_count_sfx')}</span>
|
||||
<span class="ws-stat"><i class="fa-solid fa-users"></i>${ws.user_count} ${t('users_count_sfx')}</span>
|
||||
</div>
|
||||
<div class="ws-card-actions">
|
||||
<button class="btn btn-sm btn-secondary" onclick="showEditModal(${ws.id})">
|
||||
<i class="fa-solid fa-pen"></i>
|
||||
</button>
|
||||
${!ws.is_default ? `<button class="btn btn-sm btn-danger" onclick="deleteWorkspace(${ws.id}, '${escapeHtml(ws.name)}')">
|
||||
<i class="fa-solid fa-trash"></i>
|
||||
</button>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
} catch (e) {
|
||||
grid.innerHTML = `<div class="ws-empty">${t('loading_error')}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Load servers + users for checkboxes
|
||||
// -----------------------------------------------------------------------
|
||||
async function loadAssignLists() {
|
||||
try {
|
||||
const [sResp, uResp] = await Promise.all([
|
||||
fetchWithAuth('/proxmox/api/servers'),
|
||||
fetchWithAuth('/admin/api/users')
|
||||
]);
|
||||
if (sResp && sResp.ok) _allServers = await sResp.json();
|
||||
if (uResp && uResp.ok) {
|
||||
const data = await uResp.json();
|
||||
_allUsers = Array.isArray(data) ? data : (data.users || []);
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
function renderServerCheckboxes(selectedIds = []) {
|
||||
const container = document.getElementById('wsServersList');
|
||||
if (_allServers.length === 0) {
|
||||
container.innerHTML = `<div class="ws-assign-item" style="color:var(--text-muted)">${t('no_servers_available')}</div>`;
|
||||
return;
|
||||
}
|
||||
const selectedSet = new Set(selectedIds);
|
||||
container.innerHTML = _allServers.map(s => `
|
||||
<label class="ws-assign-item">
|
||||
<input type="checkbox" value="${s.id}" ${selectedSet.has(s.id) ? 'checked' : ''}>
|
||||
<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:${s.is_online ? '#10b981' : '#ef4444'};margin-right:2px;"></span>
|
||||
${escapeHtml(s.name)}
|
||||
<small style="margin-left:auto;color:var(--text-muted)">${escapeHtml(s.ip_address || '')}</small>
|
||||
</label>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function renderUserCheckboxes(selectedIds = []) {
|
||||
const container = document.getElementById('wsUsersList');
|
||||
if (_allUsers.length === 0) {
|
||||
container.innerHTML = `<div class="ws-assign-item" style="color:var(--text-muted)">${t('no_users_available')}</div>`;
|
||||
return;
|
||||
}
|
||||
const selectedSet = new Set(selectedIds);
|
||||
container.innerHTML = _allUsers.map(u => `
|
||||
<label class="ws-assign-item">
|
||||
<input type="checkbox" value="${u.id}" ${selectedSet.has(u.id) ? 'checked' : ''}>
|
||||
<i class="fa-solid fa-user" style="opacity:0.5;font-size:.75rem;"></i>
|
||||
${escapeHtml(u.username)}
|
||||
<small style="margin-left:auto;color:var(--text-muted)">${escapeHtml(u.email || '')}</small>
|
||||
</label>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Modal: Create
|
||||
// -----------------------------------------------------------------------
|
||||
async function showCreateModal() {
|
||||
_editingId = null;
|
||||
document.getElementById('wsModalTitle').textContent = t('create_workspace');
|
||||
document.getElementById('wsName').value = '';
|
||||
document.getElementById('wsDescription').value = '';
|
||||
document.getElementById('wsColor').value = '#0ea5e9';
|
||||
|
||||
await loadAssignLists();
|
||||
renderServerCheckboxes([]);
|
||||
renderUserCheckboxes([]);
|
||||
|
||||
document.getElementById('wsModal').style.display = 'flex';
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Modal: Edit
|
||||
// -----------------------------------------------------------------------
|
||||
async function showEditModal(id) {
|
||||
_editingId = id;
|
||||
document.getElementById('wsModalTitle').textContent = t('edit_workspace');
|
||||
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/api/workspaces/${id}`);
|
||||
if (!resp || !resp.ok) throw new Error();
|
||||
const ws = await resp.json();
|
||||
|
||||
document.getElementById('wsName').value = ws.name;
|
||||
document.getElementById('wsDescription').value = ws.description || '';
|
||||
document.getElementById('wsColor').value = ws.color || '#0ea5e9';
|
||||
|
||||
await loadAssignLists();
|
||||
const selServerIds = (ws.servers || []).map(s => s.id);
|
||||
const selUserIds = (ws.users || []).map(u => u.id);
|
||||
renderServerCheckboxes(selServerIds);
|
||||
renderUserCheckboxes(selUserIds);
|
||||
|
||||
document.getElementById('wsModal').style.display = 'flex';
|
||||
} catch (e) {
|
||||
showAlertModal(t('error'), t('failed_to_load_workspace'));
|
||||
}
|
||||
}
|
||||
|
||||
function closeWsModal() {
|
||||
document.getElementById('wsModal').style.display = 'none';
|
||||
_editingId = null;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Save (create or update)
|
||||
// -----------------------------------------------------------------------
|
||||
async function saveWorkspace() {
|
||||
const name = document.getElementById('wsName').value.trim();
|
||||
if (!name) {
|
||||
showAlertModal(t('error'), t('workspace_name_required'));
|
||||
return;
|
||||
}
|
||||
|
||||
const color = document.getElementById('wsColor').value || '#0ea5e9';
|
||||
const description = document.getElementById('wsDescription').value.trim() || null;
|
||||
|
||||
const serverIds = [...document.querySelectorAll('#wsServersList input[type=checkbox]:checked')].map(el => parseInt(el.value));
|
||||
const userIds = [...document.querySelectorAll('#wsUsersList input[type=checkbox]:checked')].map(el => parseInt(el.value));
|
||||
|
||||
const btn = document.getElementById('wsModalSaveBtn');
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i>';
|
||||
|
||||
try {
|
||||
let resp;
|
||||
if (_editingId === null) {
|
||||
// Create
|
||||
resp = await fetchWithAuth('/api/workspaces', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, description, color, server_ids: serverIds, user_ids: userIds })
|
||||
});
|
||||
} else {
|
||||
// Update name/color/desc
|
||||
resp = await fetchWithAuth(`/api/workspaces/${_editingId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, description, color })
|
||||
});
|
||||
if (resp && resp.ok) {
|
||||
// Update servers
|
||||
await fetchWithAuth(`/api/workspaces/${_editingId}/servers`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ids: serverIds })
|
||||
});
|
||||
// Update users
|
||||
await fetchWithAuth(`/api/workspaces/${_editingId}/users`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ids: userIds })
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (resp && resp.ok) {
|
||||
closeWsModal();
|
||||
await loadWorkspaces();
|
||||
// Refresh sidebar workspace list
|
||||
if (typeof loadSidebarWorkspace === 'function') loadSidebarWorkspace();
|
||||
} else {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
showAlertModal(t('error'), err.detail || t('failed_to_save'));
|
||||
}
|
||||
} catch (e) {
|
||||
showAlertModal(t('error'), e.message || t('error_occurred'));
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = t('save');
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Delete
|
||||
// -----------------------------------------------------------------------
|
||||
async function deleteWorkspace(id, name) {
|
||||
showConfirmModal(
|
||||
t('delete_workspace'),
|
||||
t('delete_workspace_confirm').replace('{name}', name),
|
||||
async () => {
|
||||
const resp = await fetchWithAuth(`/api/workspaces/${id}`, { method: 'DELETE' });
|
||||
if (resp && (resp.ok || resp.status === 204)) {
|
||||
await loadWorkspaces();
|
||||
if (typeof loadSidebarWorkspace === 'function') loadSidebarWorkspace();
|
||||
} else {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
showAlertModal(t('error'), err.detail || t('failed_to_delete'));
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -----------------------------------------------------------------------
|
||||
function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
return str.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Init
|
||||
// -----------------------------------------------------------------------
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadWorkspaces();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -18,6 +18,8 @@ services:
|
||||
depends_on:
|
||||
app:
|
||||
condition: service_healthy
|
||||
frontend:
|
||||
condition: service_started
|
||||
networks:
|
||||
- serverpanel-network
|
||||
restart: unless-stopped
|
||||
@@ -57,3 +59,9 @@ services:
|
||||
- ~/.ssh:/root/.ssh:ro
|
||||
- ./logs:/app/logs
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
|
||||
# Override frontend service - remove port exposure (nginx will handle it)
|
||||
frontend:
|
||||
ports: []
|
||||
expose:
|
||||
- "80"
|
||||
|
||||
21
compose.yml
@@ -89,6 +89,27 @@ services:
|
||||
cpus: '0.5'
|
||||
memory: 256M
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
image: server-panel-frontend:latest
|
||||
pull_policy: build
|
||||
container_name: serverpanel-frontend
|
||||
ports:
|
||||
- "3000:80"
|
||||
depends_on:
|
||||
app:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- serverpanel-network
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '0.5'
|
||||
memory: 128M
|
||||
|
||||
volumes:
|
||||
db_data:
|
||||
logs:
|
||||
|
||||
24
frontend/.gitignore
vendored
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<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
@@ -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
54
frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
BIN
frontend/public/favicon.ico
Normal file
|
After Width: | Height: | Size: 658 B |
1
frontend/public/favicon.svg
Normal file
|
After Width: | Height: | Size: 9.3 KiB |
24
frontend/public/icons.svg
Normal 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 |
BIN
frontend/public/logo.png
Normal file
|
After Width: | Height: | Size: 8.1 KiB |
34
frontend/src/App.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
BIN
frontend/src/assets/hero.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
1
frontend/src/assets/react.svg
Normal 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 |
1
frontend/src/assets/vite.svg
Normal file
|
After Width: | Height: | Size: 8.5 KiB |
91
frontend/src/components/shared/NotificationsDropdown.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
30
frontend/src/components/shared/route-guards.tsx
Normal 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 />;
|
||||
}
|
||||
28
frontend/src/components/shared/status-dot.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
107
frontend/src/components/ui/avatar.tsx
Normal 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,
|
||||
}
|
||||
52
frontend/src/components/ui/badge.tsx
Normal 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 }
|
||||
58
frontend/src/components/ui/button.tsx
Normal 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 }
|
||||
103
frontend/src/components/ui/card.tsx
Normal 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,
|
||||
}
|
||||
160
frontend/src/components/ui/dialog.tsx
Normal 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,
|
||||
}
|
||||
266
frontend/src/components/ui/dropdown-menu.tsx
Normal 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,
|
||||
}
|
||||
20
frontend/src/components/ui/input.tsx
Normal 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 }
|
||||
18
frontend/src/components/ui/label.tsx
Normal 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 }
|
||||
201
frontend/src/components/ui/select.tsx
Normal 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,
|
||||
}
|
||||
25
frontend/src/components/ui/separator.tsx
Normal 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 }
|
||||
136
frontend/src/components/ui/sheet.tsx
Normal 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,
|
||||
}
|
||||
47
frontend/src/components/ui/sonner.tsx
Normal 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 }
|
||||
116
frontend/src/components/ui/table.tsx
Normal 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,
|
||||
}
|
||||
80
frontend/src/components/ui/tabs.tsx
Normal 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 }
|
||||
66
frontend/src/components/ui/tooltip.tsx
Normal 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 }
|
||||
87
frontend/src/features/auth/LoginPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
150
frontend/src/features/backups/BackupsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
336
frontend/src/features/console/ConsolePage.tsx
Normal file
@@ -0,0 +1,336 @@
|
||||
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 rfbModule = await import('@novnc/novnc/lib/rfb');
|
||||
// Handle both ESM default export and CJS module.exports
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const RFB = (rfbModule.default ?? rfbModule) as any;
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
344
frontend/src/features/dashboard/DashboardPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
388
frontend/src/features/instances/CreateInstanceWizard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
183
frontend/src/features/instances/InstanceDetailPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
598
frontend/src/features/instances/InstancesPage.tsx
Normal 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}`} target="_blank" rel="noopener noreferrer" />}>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
83
frontend/src/features/instances/tabs/DestroyTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
188
frontend/src/features/instances/tabs/GraphsTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
120
frontend/src/features/instances/tabs/NetworkingTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
175
frontend/src/features/instances/tabs/OverviewTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
172
frontend/src/features/instances/tabs/SettingsTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
263
frontend/src/features/instances/tabs/SnapshotsTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
110
frontend/src/features/ipam/IPAMAllocationsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
76
frontend/src/features/ipam/IPAMDashboardPage.tsx
Normal 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;
|
||||
}
|
||||
92
frontend/src/features/ipam/IPAMHistoryPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
153
frontend/src/features/ipam/IPAMNetworkDetailPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
95
frontend/src/features/ipam/IPAMNetworksPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
87
frontend/src/features/nodes/NodeDetailPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||