2 Commits

Author SHA1 Message Date
markmorado
297082dc72 feat: improve nodes, workspaces, settings pages; add favicon and logo; update locales 2026-04-29 16:38:32 +02:00
markmorado
1d08c815a7 fix: frontend-backend integration — 14 issues resolved
- nginx: add proxy routes for /settings/, /templates/, /ipam/, /logs/, /admin/, /health
- dashboard: fix data format mismatch (servers[] not nodes[]/vms[])
- dashboard: add WebSocket subscription to dashboard_metrics for real-time updates
- dashboard: connect activity feed to /logs/api/logs endpoint
- dashboard: reduce polling to 15s with WS as primary update source
- graphs: fix rrddata response unwrap (backend returns {data:[],timeframe:''})
- graphs: add i18n translations for chart titles
- console: add xterm.js CSS import for proper terminal rendering
- console: add i18n translations (connecting, reconnect)
- workspace: add switcher dropdown in TopBar
- workspace: add edit dialog in WorkspacesPage
- workspace: create Zustand store with localStorage persistence
- workspace: add X-Active-Workspace header to API client
- sidebar: replace generic icon with actual favicon.svg logo
- instances: translate RowActionMenu and InstanceDetailPage strings
- locales: add graphs, console, workspace, instance tab keys to en/ru
2026-04-07 00:46:04 +02:00
140 changed files with 18338 additions and 32024 deletions

View 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 |
---
*Конец документа*

Binary file not shown.

View File

@@ -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)

View File

@@ -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)

View File

@@ -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 ====================

View File

@@ -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)

View File

@@ -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 ====================

View File

@@ -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}")

View File

@@ -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")

View File

@@ -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])

View File

@@ -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()

View File

@@ -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()

View File

@@ -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()

View File

@@ -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")

View File

@@ -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 ====================

View File

@@ -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])

View File

@@ -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
# ===========================================================================

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -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;
})();

View File

@@ -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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -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 %}

View File

@@ -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">&times;</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 %}

View File

@@ -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 %}

File diff suppressed because it is too large Load Diff

View File

@@ -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;">&times;</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 %}

View File

@@ -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;">&times;</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;">&times;</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;">&times;</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 %}

View File

@@ -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 %}

View File

@@ -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;">&times;</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;">&times;</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;">&times;</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 %}

View File

@@ -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;">&times;</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 %}

View File

@@ -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>

View File

@@ -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()">&times;</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 %}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
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 ? ` &nbsp;·&nbsp; ${t('completed_label')}: ${fmtTime(task.completed_at)}` : '') +
` &nbsp;·&nbsp; ${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 %}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -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()">&times;</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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// -----------------------------------------------------------------------
// Init
// -----------------------------------------------------------------------
document.addEventListener('DOMContentLoaded', () => {
loadWorkspaces();
});
</script>
{% endblock %}

View File

@@ -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"

View File

@@ -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
View File

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

12
frontend/Dockerfile Normal file
View File

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

73
frontend/README.md Normal file
View File

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

25
frontend/components.json Normal file
View File

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

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

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

16
frontend/index.html Normal file
View File

@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/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
View File

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

7513
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

54
frontend/package.json Normal file
View File

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

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 658 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

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

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

After

Width:  |  Height:  |  Size: 4.9 KiB

BIN
frontend/public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

View File

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

After

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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>
);
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,598 @@
import { useState, useMemo } from 'react';
import { Link } from 'react-router';
import { useTranslation } from 'react-i18next';
import {
useReactTable,
getCoreRowModel,
getFilteredRowModel,
getSortedRowModel,
getPaginationRowModel,
flexRender,
type ColumnDef,
type SortingState,
type RowSelectionState,
} from '@tanstack/react-table';
import {
Plus,
Search,
Play,
Square,
Trash2,
MoreHorizontal,
Terminal,
RotateCcw,
Camera,
ArrowUpDown,
ChevronLeft,
ChevronRight,
Monitor,
Container,
Loader2,
} from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { StatusDot } from '@/components/shared/status-dot';
import { useVirtualMachines, useBulkOperation, usePowerAction } from '@/hooks/use-instances';
import { formatBytes, vmTypeLabel, formatUptime } from '@/lib/format';
import type { VMInstance } from '@/types';
import { toast } from 'sonner';
// ==================== Inline Progress Bar ====================
function InlineProgress({ value, max, className }: { value: number; max: number; className?: string }) {
const pct = max > 0 ? Math.min((value / max) * 100, 100) : 0;
const color = pct >= 90 ? 'bg-red-500' : pct >= 70 ? 'bg-amber-500' : 'bg-primary';
return (
<div className="flex items-center gap-2">
<div className={`h-1.5 w-16 overflow-hidden rounded-full bg-muted ${className}`}>
<div className={`h-full rounded-full ${color}`} style={{ width: `${pct}%` }} />
</div>
<span className="text-xs tabular-nums text-muted-foreground">{pct.toFixed(0)}%</span>
</div>
);
}
// ==================== Row Action Menu ====================
function RowActionMenu({ vm }: { vm: VMInstance }) {
const { t } = useTranslation();
const power = usePowerAction(vm.server_id!, vm.vmid, vm.type, vm.node);
const isRunning = vm.status === 'running';
const handleAction = (action: string) => {
power.mutate(
{ action },
{
onSuccess: () => toast.success(`${action} sent for ${vm.name || vm.vmid}`),
onError: (err) => toast.error(err.message),
}
);
};
return (
<DropdownMenu>
<DropdownMenuTrigger render={<Button variant="ghost" size="icon" className="h-8 w-8" />}>
<MoreHorizontal className="h-4 w-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem render={<Link to={`/console/${vm.server_id}/${vm.vmid}?node=${vm.node}&type=${vm.type}`} 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>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More