Linux/Proxmox Agent + Plattform-Updater + Proxmox Frontend
- agent-linux/: Kompletter Linux/Proxmox RMM Agent - Collectors: CPU, Memory, Disk, Network, Services, Updates, Proxmox (VMs, Container, Storage, ZFS), Backups - Backup Collector: Jobs nach Node gefiltert (Cluster-aware) - WebSocket: Tunnel-Support, writeMux fuer stabile Verbindungen - Systemd Service Template - updater/: Plattform-unabhaengiger Updater (FreeBSD + Linux) - runtime.GOOS erkennt Plattform automatisch - FreeBSD: /usr/local/rmm/, service rmm_agent - Linux: /usr/local/bin/, /etc/rmm/, systemctl rmm-agent - Firmware-Download mit ?platform= Parameter - frontend/: Proxmox-Bereich - ProxmoxServers.jsx: Eigene Seite fuer PVE Server mit VM/CT Counts - ProxmoxPanel.jsx: Detail-Panel mit Uebersicht, VMs, Container, Storage, ZFS, Tunnel, Dienste, Updates, Backups - Agents.jsx: Filtert Linux-Agents raus (nur OPNsense) - Sidebar: Proxmox Navigationspunkt - Installationsanleitung mit rmm-agent-linux + rmm-updater-linux - backend: Platform-Feld fuer Agents, Firmware multi-platform
This commit is contained in:
+413
-9
@@ -1,16 +1,420 @@
|
||||
# React + Vite
|
||||
# RMM Frontend
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
React + Vite + TailwindCSS Web-Frontend fuer das OPNsense RMM System.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
## Eckdaten
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
| | |
|
||||
|---|---|
|
||||
| **Server** | 192.168.85.20 (Debian 13, Nginx) |
|
||||
| **Tech** | React 18, Vite, TailwindCSS, Recharts, Zustand, lucide-react |
|
||||
| **Theme** | Dark mit Orange-Akzent, komplett deutsch |
|
||||
| **Auth** | JWT Login (8h Token) |
|
||||
| **Default-Login** | `admin` / `Start!123` |
|
||||
| **Backend** | https://192.168.85.13:8443 |
|
||||
|
||||
## React Compiler
|
||||
## Seiten
|
||||
|
||||
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).
|
||||
| Seite | Route | Beschreibung |
|
||||
|-------|-------|-------------|
|
||||
| Login | `/login` | JWT Login |
|
||||
| Dashboard | `/` | Status-Kacheln, Agent-Liste, AgentPanel per Klick |
|
||||
| Firewalls | `/agents` | Suchbare Tabelle, AgentPanel mit Tabs, Hinzufuegen/Loeschen |
|
||||
| Tunnel | `/tunnels` | Globale Tunnel-Uebersicht ueber alle Firewalls |
|
||||
| Firmware | `/firmware` | Multi-Plattform Upload, Agent-Update-Trigger |
|
||||
| Kunden | `/customers` | CRUD Kundenverwaltung |
|
||||
| Einstellungen | `/settings` | Benutzerverwaltung, Passwoerter aendern |
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
## AgentPanel Tabs
|
||||
|
||||
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
|
||||
Das Side-Panel oeffnet sich per Klick auf eine Firewall (Dashboard oder Firewalls-Seite):
|
||||
|
||||
| Tab | Inhalt |
|
||||
|-----|--------|
|
||||
| Uebersicht | Hardware, OS, Uptime, CPU/RAM, Lizenz (Business Edition) |
|
||||
| Interfaces | Netzwerk-Interfaces mit IP, MAC, Status, RX/TX |
|
||||
| Dienste | Laufende Services mit Status-Badge |
|
||||
| Tunnel | Quick-Buttons (WebGUI, SSH, Custom), aktive Tunnel |
|
||||
| VPN | WireGuard-Instanzen mit Transfer-Statistiken |
|
||||
| WireGuard | Peer-Management (CRUD), Client-Config mit Copy-Button |
|
||||
| Routen | Routing-Tabelle |
|
||||
| DHCP | Aktive Leases mit Suche (IP, MAC, Hostname) |
|
||||
| Zertifikate | Karten-Layout mit Ablaufdatum und Fortschrittsbalken |
|
||||
| Backups | Config-Backup erstellen, Liste, Download als XML |
|
||||
| Agent | Version, Agent-ID, Update-Button, Remote-Befehle |
|
||||
|
||||
---
|
||||
|
||||
## API Endpoints
|
||||
|
||||
Alle Aufrufe gehen ueber den API Client (`src/api/client.js`).
|
||||
Base-URL wird ueber `VITE_API_URL` oder Nginx-Proxy konfiguriert.
|
||||
Auth: JWT Bearer Token im `Authorization` Header.
|
||||
|
||||
### Authentifizierung
|
||||
|
||||
```
|
||||
POST /api/v1/auth/login Login (username, password) → JWT Token
|
||||
GET /api/v1/auth/me Eigene Benutzerdaten
|
||||
```
|
||||
|
||||
**Beispiel:**
|
||||
```bash
|
||||
# Login
|
||||
curl -sk -X POST https://192.168.85.13:8443/api/v1/auth/login \
|
||||
-d '{"username":"admin","password":"Start!123"}'
|
||||
# → {"token":"eyJhbG...","user":{"id":1,"username":"admin"}}
|
||||
|
||||
# Eigene Daten
|
||||
curl -sk -H "Authorization: Bearer <TOKEN>" \
|
||||
https://192.168.85.13:8443/api/v1/auth/me
|
||||
```
|
||||
|
||||
### Agents (Firewalls)
|
||||
|
||||
```
|
||||
GET /api/v1/agents Alle Agents auflisten
|
||||
GET /api/v1/agents/{id} Agent + Systemdaten abrufen
|
||||
DELETE /api/v1/agents/{id} Agent loeschen
|
||||
POST /api/v1/agents Firewall pre-registrieren
|
||||
GET /api/v1/agents/{id}/events Agent-Events (limit=N)
|
||||
PUT /api/v1/agents/{id}/customer Kunden zuordnen
|
||||
DELETE /api/v1/agents/{id}/customer Kundenzuordnung entfernen
|
||||
```
|
||||
|
||||
**Beispiele:**
|
||||
```bash
|
||||
# Alle Agents
|
||||
curl -sk -H "Authorization: Bearer <TOKEN>" \
|
||||
https://192.168.85.13:8443/api/v1/agents
|
||||
|
||||
# Agent-Details mit Systemdaten
|
||||
curl -sk -H "Authorization: Bearer <TOKEN>" \
|
||||
https://192.168.85.13:8443/api/v1/agents/6beb8dfd38ecf03eab6c0f21b4ac7bee
|
||||
|
||||
# Firewall pre-registrieren
|
||||
curl -sk -H "Authorization: Bearer <TOKEN>" -X POST \
|
||||
https://192.168.85.13:8443/api/v1/agents \
|
||||
-d '{"name":"OPN.intra.kunde.de","customer_id":1}'
|
||||
|
||||
# Agent loeschen
|
||||
curl -sk -H "Authorization: Bearer <TOKEN>" -X DELETE \
|
||||
https://192.168.85.13:8443/api/v1/agents/e92e87a684b20db15d8d2344583c5887
|
||||
|
||||
# Events (letzte 50)
|
||||
curl -sk -H "Authorization: Bearer <TOKEN>" \
|
||||
"https://192.168.85.13:8443/api/v1/agents/6beb8dfd38ecf03eab6c0f21b4ac7bee/events?limit=50"
|
||||
|
||||
# Kunden zuordnen
|
||||
curl -sk -H "Authorization: Bearer <TOKEN>" -X PUT \
|
||||
https://192.168.85.13:8443/api/v1/agents/6beb8dfd38ecf03eab6c0f21b4ac7bee/customer \
|
||||
-d '{"customer_id":1}'
|
||||
```
|
||||
|
||||
### Kunden (Customers)
|
||||
|
||||
```
|
||||
GET /api/v1/customers Alle Kunden
|
||||
POST /api/v1/customers Neuen Kunden anlegen
|
||||
PUT /api/v1/customers/{id} Kunden aktualisieren
|
||||
DELETE /api/v1/customers/{id} Kunden loeschen
|
||||
GET /api/v1/customers/{id}/agents Agents eines Kunden
|
||||
```
|
||||
|
||||
**Beispiele:**
|
||||
```bash
|
||||
# Kunden anlegen
|
||||
curl -sk -H "Authorization: Bearer <TOKEN>" -X POST \
|
||||
https://192.168.85.13:8443/api/v1/customers \
|
||||
-d '{"number":"K05001","name":"Beispiel GmbH"}'
|
||||
|
||||
# Kunden aktualisieren
|
||||
curl -sk -H "Authorization: Bearer <TOKEN>" -X PUT \
|
||||
https://192.168.85.13:8443/api/v1/customers/1 \
|
||||
-d '{"number":"K05001","name":"Beispiel AG"}'
|
||||
|
||||
# Agents eines Kunden
|
||||
curl -sk -H "Authorization: Bearer <TOKEN>" \
|
||||
https://192.168.85.13:8443/api/v1/customers/1/agents
|
||||
```
|
||||
|
||||
### Benutzer (Users)
|
||||
|
||||
```
|
||||
GET /api/v1/users Alle Benutzer
|
||||
POST /api/v1/users Neuen Benutzer anlegen
|
||||
PUT /api/v1/users/{id}/password Passwort aendern (min. 6 Zeichen)
|
||||
DELETE /api/v1/users/{id} Benutzer loeschen
|
||||
```
|
||||
|
||||
**Beispiele:**
|
||||
```bash
|
||||
# Benutzer anlegen
|
||||
curl -sk -H "Authorization: Bearer <TOKEN>" -X POST \
|
||||
https://192.168.85.13:8443/api/v1/users \
|
||||
-d '{"username":"operator","password":"Sicher!123"}'
|
||||
|
||||
# Passwort aendern
|
||||
curl -sk -H "Authorization: Bearer <TOKEN>" -X PUT \
|
||||
https://192.168.85.13:8443/api/v1/users/1/password \
|
||||
-d '{"password":"NeuesPasswort!123"}'
|
||||
```
|
||||
|
||||
### Metriken (TimescaleDB)
|
||||
|
||||
```
|
||||
GET /api/v1/agents/{id}/metrics Rohe Metriken
|
||||
GET /api/v1/agents/{id}/metrics/summary Aggregierte Metriken
|
||||
```
|
||||
|
||||
Verfuegbare Metriken: `cpu_usage`, `memory_used_percent`, `memory_used_bytes`, `memory_total_bytes`, `uptime_seconds`, `disk_used_percent`, `disk_used_bytes`, `interface_rx_bytes`, `interface_tx_bytes`, `gateway_rtt_ms`, `gateway_loss_percent`
|
||||
|
||||
**Beispiele:**
|
||||
```bash
|
||||
# CPU letzte 24h
|
||||
curl -sk -H "Authorization: Bearer <TOKEN>" \
|
||||
"https://192.168.85.13:8443/api/v1/agents/6beb8dfd.../metrics?metric=cpu_usage"
|
||||
|
||||
# RAM mit Zeitraum
|
||||
curl -sk -H "Authorization: Bearer <TOKEN>" \
|
||||
"https://192.168.85.13:8443/api/v1/agents/6beb8dfd.../metrics?metric=memory_used_percent&from=2026-03-01T00:00:00Z&to=2026-03-01T23:59:59Z"
|
||||
|
||||
# 5-Minuten-Durchschnitte
|
||||
curl -sk -H "Authorization: Bearer <TOKEN>" \
|
||||
"https://192.168.85.13:8443/api/v1/agents/6beb8dfd.../metrics/summary?metric=cpu_usage&bucket=5+minutes"
|
||||
```
|
||||
|
||||
### Config-Backups
|
||||
|
||||
```
|
||||
POST /api/v1/agents/{id}/backup Backup jetzt ausloesen
|
||||
GET /api/v1/agents/{id}/backups Alle Backups auflisten
|
||||
GET /api/v1/agents/{id}/backups/{id} Backup herunterladen (?format=xml)
|
||||
GET /api/v1/agents/{id}/backups/latest Neuestes Backup
|
||||
DELETE /api/v1/agents/{id}/backups/{id} Backup loeschen
|
||||
GET /api/v1/agents/{id}/backups/diff Diff (?a=ID&b=ID)
|
||||
```
|
||||
|
||||
**Beispiele:**
|
||||
```bash
|
||||
# Backup ausloesen
|
||||
curl -sk -H "Authorization: Bearer <TOKEN>" -X POST \
|
||||
https://192.168.85.13:8443/api/v1/agents/6beb8dfd.../backup
|
||||
|
||||
# Backups auflisten
|
||||
curl -sk -H "Authorization: Bearer <TOKEN>" \
|
||||
https://192.168.85.13:8443/api/v1/agents/6beb8dfd.../backups
|
||||
|
||||
# Als XML herunterladen
|
||||
curl -sk -H "Authorization: Bearer <TOKEN>" \
|
||||
"https://192.168.85.13:8443/api/v1/agents/6beb8dfd.../backups/latest?format=xml" \
|
||||
-o config-backup.xml
|
||||
|
||||
# Diff zwischen zwei Backups
|
||||
curl -sk -H "Authorization: Bearer <TOKEN>" \
|
||||
"https://192.168.85.13:8443/api/v1/agents/6beb8dfd.../backups/diff?a=1&b=3"
|
||||
```
|
||||
|
||||
### Tunnel
|
||||
|
||||
```
|
||||
POST /api/v1/agents/{id}/tunnel Tunnel oeffnen
|
||||
GET /api/v1/agents/{id}/tunnels Aktive Tunnel auflisten
|
||||
DELETE /api/v1/agents/{id}/tunnel/{tid} Tunnel schliessen
|
||||
```
|
||||
|
||||
**Beispiele:**
|
||||
```bash
|
||||
# SSH-Tunnel
|
||||
curl -sk -H "Authorization: Bearer <TOKEN>" -X POST \
|
||||
https://192.168.85.13:8443/api/v1/agents/e92e87a6.../tunnel \
|
||||
-d '{"target_host":"127.0.0.1","target_port":22}'
|
||||
# → {"tunnel_id":"abc123","proxy_port":10000,...}
|
||||
# Dann: ssh -p 10000 root@192.168.85.13
|
||||
|
||||
# WebGUI-Tunnel (OPNsense Port 4444)
|
||||
curl -sk -H "Authorization: Bearer <TOKEN>" -X POST \
|
||||
https://192.168.85.13:8443/api/v1/agents/e92e87a6.../tunnel \
|
||||
-d '{"target_host":"127.0.0.1","target_port":4444}'
|
||||
# Im Browser: https://192.168.85.13:10001/
|
||||
|
||||
# PVE WebGUI-Tunnel (Proxmox Port 8006)
|
||||
curl -sk -H "Authorization: Bearer <TOKEN>" -X POST \
|
||||
https://192.168.85.13:8443/api/v1/agents/3f0a83a2.../tunnel \
|
||||
-d '{"target_host":"127.0.0.1","target_port":8006}'
|
||||
|
||||
# Aktive Tunnel
|
||||
curl -sk -H "Authorization: Bearer <TOKEN>" \
|
||||
https://192.168.85.13:8443/api/v1/agents/e92e87a6.../tunnels
|
||||
|
||||
# Tunnel schliessen
|
||||
curl -sk -H "Authorization: Bearer <TOKEN>" -X DELETE \
|
||||
https://192.168.85.13:8443/api/v1/agents/e92e87a6.../tunnel/abc123
|
||||
```
|
||||
|
||||
### WireGuard Peer-Management
|
||||
|
||||
```
|
||||
GET /api/v1/agents/{id}/wireguard/peers Alle Peers auflisten
|
||||
POST /api/v1/agents/{id}/wireguard/peers Neuen Peer anlegen
|
||||
DELETE /api/v1/agents/{id}/wireguard/peers/{uuid} Peer loeschen
|
||||
```
|
||||
|
||||
**Beispiele:**
|
||||
```bash
|
||||
# Peer anlegen (Auto: Keypair, naechste freie IP, Endpoint)
|
||||
curl -sk -H "Authorization: Bearer <TOKEN>" -X POST \
|
||||
https://192.168.85.13:8443/api/v1/agents/6beb8dfd.../wireguard/peers \
|
||||
-d '{"name":"VPN_MUELLER","allowed_ips":"0.0.0.0/0","dns":"10.172.100.210"}'
|
||||
# → {peer_config: "[Interface]\nPrivateKey=...\n[Peer]\n...", ...}
|
||||
|
||||
# Alle Peers
|
||||
curl -sk -H "Authorization: Bearer <TOKEN>" \
|
||||
https://192.168.85.13:8443/api/v1/agents/6beb8dfd.../wireguard/peers
|
||||
|
||||
# Peer loeschen
|
||||
curl -sk -H "Authorization: Bearer <TOKEN>" -X DELETE \
|
||||
https://192.168.85.13:8443/api/v1/agents/6beb8dfd.../wireguard/peers/89c10e2d-148e-11f1-a7af-7c5a1c6c7b73
|
||||
```
|
||||
|
||||
### Remote-Befehle (Exec)
|
||||
|
||||
```
|
||||
POST /api/v1/agents/{id}/exec Befehl ausfuehren
|
||||
```
|
||||
|
||||
**Beispiel:**
|
||||
```bash
|
||||
curl -sk -H "Authorization: Bearer <TOKEN>" -X POST \
|
||||
https://192.168.85.13:8443/api/v1/agents/e92e87a6.../exec \
|
||||
-d '{"command":"uptime","timeout":30}'
|
||||
# → {"data":{"output":"10:30AM up 45 days, ..."}, "status":"ok"}
|
||||
```
|
||||
|
||||
### Updates
|
||||
|
||||
```
|
||||
POST /api/v1/agents/{id}/update-check Verfuegbare Updates pruefen
|
||||
POST /api/v1/agents/{id}/update Normales Update (Core + Packages)
|
||||
POST /api/v1/agents/{id}/major-update Major-Upgrade (2-Phasen)
|
||||
POST /api/v1/agents/{id}/reboot Reboot
|
||||
```
|
||||
|
||||
**Beispiele:**
|
||||
```bash
|
||||
# Update-Check
|
||||
curl -sk -H "Authorization: Bearer <TOKEN>" -X POST \
|
||||
https://192.168.85.13:8443/api/v1/agents/e92e87a6.../update-check
|
||||
|
||||
# Update mit Reboot
|
||||
curl -sk -H "Authorization: Bearer <TOKEN>" -X POST \
|
||||
https://192.168.85.13:8443/api/v1/agents/e92e87a6.../update \
|
||||
-d '{"reboot":true}'
|
||||
|
||||
# Major-Update Phase 1
|
||||
curl -sk -H "Authorization: Bearer <TOKEN>" -X POST \
|
||||
https://192.168.85.13:8443/api/v1/agents/e92e87a6.../major-update \
|
||||
-d '{"version":"26.1","reboot":true}'
|
||||
```
|
||||
|
||||
### Firmware / Agent-Update
|
||||
|
||||
```
|
||||
GET /api/v1/firmware Alle Firmware-Versionen
|
||||
POST /api/v1/firmware/upload?version=X&platform=Y Binary hochladen
|
||||
GET /api/v1/firmware/download?platform=Y Binary herunterladen (Updater)
|
||||
POST /api/v1/agents/{id}/request-update Update-Flag setzen
|
||||
DELETE /api/v1/agents/{id}/request-update Update-Flag zuruecksetzen
|
||||
POST /api/v1/agents/request-update-all Alle Agents updaten
|
||||
```
|
||||
|
||||
**Beispiele:**
|
||||
```bash
|
||||
# Firmware-Info
|
||||
curl -sk -H "Authorization: Bearer <TOKEN>" \
|
||||
https://192.168.85.13:8443/api/v1/firmware
|
||||
|
||||
# Firmware hochladen (FreeBSD)
|
||||
curl -sk -H "Authorization: Bearer <TOKEN>" -X POST \
|
||||
"https://192.168.85.13:8443/api/v1/firmware/upload?version=1.0.3&platform=freebsd" \
|
||||
--data-binary @build/rmm-agent
|
||||
|
||||
# Firmware hochladen (Linux)
|
||||
curl -sk -H "Authorization: Bearer <TOKEN>" -X POST \
|
||||
"https://192.168.85.13:8443/api/v1/firmware/upload?version=1.0.1&platform=linux" \
|
||||
--data-binary @build/rmm-agent-linux
|
||||
|
||||
# Update fuer einzelnen Agent anfordern
|
||||
curl -sk -H "Authorization: Bearer <TOKEN>" -X POST \
|
||||
https://192.168.85.13:8443/api/v1/agents/e92e87a6.../request-update
|
||||
|
||||
# Update fuer alle Agents
|
||||
curl -sk -H "Authorization: Bearer <TOKEN>" -X POST \
|
||||
https://192.168.85.13:8443/api/v1/agents/request-update-all
|
||||
```
|
||||
|
||||
### Hub-Status
|
||||
|
||||
```
|
||||
GET /api/v1/hub/status WebSocket Hub Status
|
||||
```
|
||||
|
||||
**Beispiel:**
|
||||
```bash
|
||||
curl -sk -H "Authorization: Bearer <TOKEN>" \
|
||||
https://192.168.85.13:8443/api/v1/hub/status
|
||||
# → {"connected_agents":3,"active_tunnels":1}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Build & Deploy
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run build # ~330 KB JS, ~32 KB CSS
|
||||
|
||||
# Deploy
|
||||
ssh root@192.168.85.20 'rm -rf /var/www/html/assets/*' # Alte Hashes loeschen!
|
||||
scp -r dist/* root@192.168.85.20:/var/www/html/
|
||||
```
|
||||
|
||||
**Wichtig:** Vor Deploy immer `assets/` loeschen — Vite generiert Hash-basierte Dateinamen, alte Dateien bleiben sonst auf dem Server.
|
||||
|
||||
## Entwicklung
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm run dev # Startet Vite Dev-Server mit Proxy zu Backend
|
||||
```
|
||||
|
||||
Vite Proxy-Config (`vite.config.js`): `/api/` wird auf `https://192.168.85.13:8443` weitergeleitet.
|
||||
|
||||
## Dateistruktur
|
||||
|
||||
```
|
||||
frontend/
|
||||
├── src/
|
||||
│ ├── App.jsx # Router + ProtectedRoute
|
||||
│ ├── main.jsx # Entry Point
|
||||
│ ├── index.css # TailwindCSS + Custom Styles
|
||||
│ ├── api/client.js # API Client (alle Endpoints)
|
||||
│ ├── stores/auth.js # Zustand Auth Store (JWT Token)
|
||||
│ ├── layouts/AppLayout.jsx # Sidebar Navigation + Outlet
|
||||
│ ├── components/
|
||||
│ │ ├── AgentPanel.jsx # Side Panel mit 11 Tabs
|
||||
│ │ └── StatusBadge.jsx # Online/Offline/Stale Badge
|
||||
│ └── pages/
|
||||
│ ├── Dashboard.jsx # Status-Kacheln + Agent-Liste
|
||||
│ ├── Agents.jsx # Firewall-Tabelle + Pre-Registration
|
||||
│ ├── AgentDetail.jsx # Detail-Ansicht (full page, legacy)
|
||||
│ ├── Tunnels.jsx # Globale Tunnel-Uebersicht
|
||||
│ ├── Firmware.jsx # Multi-Plattform Firmware + Updates
|
||||
│ ├── Customers.jsx # Kunden CRUD
|
||||
│ ├── SettingsPage.jsx # User-Management + Passwort
|
||||
│ └── Login.jsx # JWT Login
|
||||
├── vite.config.js # Vite Config mit API Proxy
|
||||
├── tailwind.config.js
|
||||
└── package.json
|
||||
```
|
||||
|
||||
@@ -6,6 +6,7 @@ import Login from './pages/Login'
|
||||
import Dashboard from './pages/Dashboard'
|
||||
import Agents from './pages/Agents'
|
||||
import AgentDetail from './pages/AgentDetail'
|
||||
import ProxmoxServers from './pages/ProxmoxServers'
|
||||
import Customers from './pages/Customers'
|
||||
import SettingsPage from './pages/SettingsPage'
|
||||
import Tunnels from './pages/Tunnels'
|
||||
@@ -51,6 +52,7 @@ export default function App() {
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="agents" element={<Agents />} />
|
||||
<Route path="agents/:id" element={<AgentDetail />} />
|
||||
<Route path="proxmox" element={<ProxmoxServers />} />
|
||||
<Route path="tunnels" element={<Tunnels />} />
|
||||
<Route path="customers" element={<Customers />} />
|
||||
<Route path="firmware" element={<Firmware />} />
|
||||
|
||||
@@ -6,16 +6,17 @@ import {
|
||||
Globe, Key, Download, Terminal, Wifi, Search, Plus, Trash2, Copy, Check, ExternalLink, Unplug, Plug, MonitorSmartphone,
|
||||
} from 'lucide-react'
|
||||
|
||||
const tabs = [
|
||||
const allTabs = [
|
||||
{ id: 'overview', label: 'Uebersicht' },
|
||||
{ id: 'interfaces', label: 'Interfaces' },
|
||||
{ id: 'services', label: 'Dienste' },
|
||||
{ id: 'tunnels', label: 'Tunnel' },
|
||||
{ id: 'vpn', label: 'VPN' },
|
||||
{ id: 'wireguard', label: 'WireGuard' },
|
||||
{ id: 'vpn', label: 'VPN', platforms: ['freebsd'] },
|
||||
{ id: 'wireguard', label: 'WireGuard', platforms: ['freebsd'] },
|
||||
{ id: 'routes', label: 'Routen' },
|
||||
{ id: 'dhcp', label: 'DHCP' },
|
||||
{ id: 'certs', label: 'Zertifikate' },
|
||||
{ id: 'dhcp', label: 'DHCP', platforms: ['freebsd'] },
|
||||
{ id: 'certs', label: 'Zertifikate', platforms: ['freebsd'] },
|
||||
{ id: 'updates', label: 'Updates', platforms: ['freebsd'] },
|
||||
{ id: 'backups', label: 'Backups' },
|
||||
{ id: 'agent', label: 'Agent' },
|
||||
]
|
||||
@@ -44,6 +45,8 @@ export default function AgentPanel({ agentId, customers, onClose, onReload }) {
|
||||
|
||||
const { agent, system_data: sys, status } = data
|
||||
const cust = agent.customer_id ? customers.find((c) => c.id === agent.customer_id) : null
|
||||
const platform = agent.platform || 'freebsd'
|
||||
const tabs = allTabs.filter(t => !t.platforms || t.platforms.includes(platform))
|
||||
|
||||
return (
|
||||
<Panel onClose={onClose}>
|
||||
@@ -57,19 +60,19 @@ export default function AgentPanel({ agentId, customers, onClose, onReload }) {
|
||||
</div>
|
||||
<CustomerAssign agent={agent} customers={customers} current={cust} onReload={onReload} />
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
{agent.hostname} · IP: {agent.ip} · {sys?.opnsense_version || agent.opnsense_version || '—'}
|
||||
{agent.hostname} · IP: {agent.ip} · {platform === 'linux' ? 'Linux' : 'OPNsense'} · {sys?.opnsense_version || agent.opnsense_version || '—'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (!confirm(`Firewall "${agent.name}" wirklich loeschen?`)) return
|
||||
if (!confirm(`Geraet "${agent.name}" wirklich loeschen?`)) return
|
||||
await api.deleteAgent(agent.id)
|
||||
if (onReload) onReload()
|
||||
onClose()
|
||||
}}
|
||||
className="text-gray-500 hover:text-red-400 transition-colors"
|
||||
title="Firewall loeschen"
|
||||
title="Geraet loeschen"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
@@ -119,10 +122,12 @@ export default function AgentPanel({ agentId, customers, onClose, onReload }) {
|
||||
<DHCPTab sys={sys} />
|
||||
) : tab === 'certs' ? (
|
||||
<CertsTab sys={sys} />
|
||||
) : tab === 'updates' ? (
|
||||
<UpdatesTab agentId={agentId} />
|
||||
) : tab === 'backups' ? (
|
||||
<BackupsTab agentId={agentId} />
|
||||
platform === 'linux' ? <PVEBackupsTab sys={sys} /> : <BackupsTab agentId={agentId} />
|
||||
) : tab === 'agent' ? (
|
||||
<AgentTab agent={data?.agent} status={data?.status} />
|
||||
<AgentTab agent={data?.agent} status={data?.status} platform={platform} />
|
||||
) : null}
|
||||
</div>
|
||||
</Panel>
|
||||
@@ -253,7 +258,7 @@ function OverviewTab({ sys }) {
|
||||
<MetricCard icon={Clock} label="Uptime" value={sys.uptime_seconds ? formatUptime(sys.uptime_seconds) : '—'} />
|
||||
</div>
|
||||
|
||||
<h3 className="text-sm font-medium text-gray-300 mt-4">Firewall Status</h3>
|
||||
<h3 className="text-sm font-medium text-gray-300 mt-4">System Status</h3>
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<StatusCard icon={Shield} value={sys.opnsense_version || '—'} label="OPNsense" />
|
||||
<StatusCard icon={Network} value={ifaceCount} label="Interfaces" />
|
||||
@@ -833,7 +838,7 @@ function WireGuardTab({ agentId, sys }) {
|
||||
}
|
||||
|
||||
if (noInstance) {
|
||||
return <div className="text-gray-500">Kein ALWAYSON_VPN Tunnel auf dieser Firewall gefunden.</div>
|
||||
return <div className="text-gray-500">Kein ALWAYSON_VPN Tunnel auf diesem Geraet gefunden.</div>
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -1145,6 +1150,204 @@ function CertsTab({ sys }) {
|
||||
)
|
||||
}
|
||||
|
||||
function PVEBackupsTab({ sys }) {
|
||||
const [monthOffset, setMonthOffset] = useState(0)
|
||||
|
||||
const backups = sys?.backups || {}
|
||||
const jobs = backups.jobs || []
|
||||
const tasks = backups.tasks || []
|
||||
|
||||
// Stats
|
||||
const totalJobs = jobs.filter(j => j.enabled === 1 || j.enabled === true).length
|
||||
const okTasks = tasks.filter(t => t.status === 'OK').length
|
||||
const failedTasks = tasks.filter(t => t.status && t.status !== 'OK').length
|
||||
const successRate = tasks.length > 0 ? Math.round((okTasks / tasks.length) * 100) : null
|
||||
|
||||
// Naechster Lauf
|
||||
const nextRuns = jobs.map(j => j.next_run).filter(Boolean).sort()
|
||||
const nextRun = nextRuns[0] ? new Date(nextRuns[0] * 1000) : null
|
||||
|
||||
// Kalender
|
||||
const now = new Date()
|
||||
const calMonth = new Date(now.getFullYear(), now.getMonth() + monthOffset, 1)
|
||||
const year = calMonth.getFullYear()
|
||||
const month = calMonth.getMonth()
|
||||
const daysInMonth = new Date(year, month + 1, 0).getDate()
|
||||
const firstDayOfWeek = (new Date(year, month, 1).getDay() + 6) % 7 // Mo=0
|
||||
|
||||
// Tasks nach Tag gruppieren
|
||||
const tasksByDay = {}
|
||||
tasks.forEach(t => {
|
||||
const d = new Date(t.starttime * 1000)
|
||||
if (d.getFullYear() === year && d.getMonth() === month) {
|
||||
const day = d.getDate()
|
||||
if (!tasksByDay[day]) tasksByDay[day] = []
|
||||
tasksByDay[day].push(t)
|
||||
}
|
||||
})
|
||||
|
||||
// Geplante Tage (aus Schedule-Pattern)
|
||||
const plannedDays = new Set()
|
||||
jobs.forEach(j => {
|
||||
if (!j.enabled) return
|
||||
const nr = j.next_run
|
||||
if (nr) {
|
||||
const d = new Date(nr * 1000)
|
||||
if (d.getFullYear() === year && d.getMonth() === month) {
|
||||
plannedDays.add(d.getDate())
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const today = new Date()
|
||||
const isCurrentMonth = today.getFullYear() === year && today.getMonth() === month
|
||||
|
||||
const dayNames = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']
|
||||
const monthNames = ['Januar', 'Februar', 'Maerz', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember']
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-3 text-center">
|
||||
<div className="text-gray-500 text-xs mb-1">Jobs</div>
|
||||
<div className="text-xl font-bold text-white">{totalJobs}</div>
|
||||
</div>
|
||||
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-3 text-center">
|
||||
<div className="text-gray-500 text-xs mb-1">30 Tage</div>
|
||||
<div className="text-xl font-bold text-white">{tasks.length}</div>
|
||||
</div>
|
||||
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-3 text-center">
|
||||
<div className="text-gray-500 text-xs mb-1">Erfolgsrate</div>
|
||||
<div className={`text-xl font-bold ${successRate === 100 ? 'text-emerald-400' : successRate === null ? 'text-gray-500' : successRate >= 90 ? 'text-yellow-400' : 'text-red-400'}`}>
|
||||
{successRate !== null ? `${successRate}%` : '--'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-3 text-center">
|
||||
<div className="text-gray-500 text-xs mb-1">Naechstes</div>
|
||||
<div className="text-sm font-bold text-white">
|
||||
{nextRun ? nextRun.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' }) + ', ' + nextRun.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }) : '--'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Kalender */}
|
||||
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<button onClick={() => setMonthOffset(monthOffset - 1)} className="text-gray-400 hover:text-white px-2 py-1"><</button>
|
||||
<h3 className="text-white font-semibold">{monthNames[month]} {year}</h3>
|
||||
<button onClick={() => setMonthOffset(monthOffset + 1)} className="text-gray-400 hover:text-white px-2 py-1">></button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-7 gap-1 text-center">
|
||||
{dayNames.map(d => (
|
||||
<div key={d} className="text-gray-500 text-xs py-1">{d}</div>
|
||||
))}
|
||||
{/* Leere Zellen vor dem 1. */}
|
||||
{Array.from({ length: firstDayOfWeek }).map((_, i) => (
|
||||
<div key={`empty-${i}`} />
|
||||
))}
|
||||
{/* Tage */}
|
||||
{Array.from({ length: daysInMonth }).map((_, i) => {
|
||||
const day = i + 1
|
||||
const dayTasks = tasksByDay[day] || []
|
||||
const isToday = isCurrentMonth && day === today.getDate()
|
||||
const hasOk = dayTasks.some(t => t.status === 'OK')
|
||||
const hasFailed = dayTasks.some(t => t.status && t.status !== 'OK')
|
||||
const isPlanned = plannedDays.has(day)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={day}
|
||||
className={`relative py-2 rounded text-sm ${
|
||||
isToday ? 'border border-emerald-500/50' :
|
||||
'border border-transparent'
|
||||
} ${hasOk ? 'bg-emerald-900/20' : hasFailed ? 'bg-red-900/20' : ''}`}
|
||||
>
|
||||
<span className={`${isToday ? 'text-emerald-400 font-bold' : 'text-gray-300'}`}>{day}</span>
|
||||
{/* Dot indicators */}
|
||||
<div className="flex justify-center gap-0.5 mt-0.5">
|
||||
{hasOk && <span className="w-1.5 h-1.5 rounded-full bg-emerald-400" />}
|
||||
{hasFailed && <span className="w-1.5 h-1.5 rounded-full bg-red-400" />}
|
||||
{isPlanned && !hasOk && !hasFailed && <span className="w-1.5 h-1.5 rounded-full bg-blue-400" />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Legende */}
|
||||
<div className="flex items-center gap-4 mt-3 text-xs text-gray-500">
|
||||
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-emerald-400" /> OK</span>
|
||||
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-red-400" /> Fehler</span>
|
||||
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-blue-400" /> Geplant</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Konfigurierte Jobs */}
|
||||
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-4">
|
||||
<h3 className="text-white font-semibold mb-3">Konfigurierte Jobs</h3>
|
||||
{jobs.length === 0 ? (
|
||||
<div className="text-gray-500 text-sm">Keine Backup-Jobs konfiguriert</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{jobs.map((job, i) => {
|
||||
const nr = job.next_run ? new Date(job.next_run * 1000) : null
|
||||
const vmids = job.vmid || (job.all ? 'Alle' + (job.exclude ? ` (ohne ${job.exclude})` : '') : '--')
|
||||
return (
|
||||
<div key={i} className="bg-gray-900 rounded p-3 flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`w-2 h-2 rounded-full ${job.enabled ? 'bg-emerald-400' : 'bg-gray-600'}`} />
|
||||
<span className="text-white text-sm font-medium">{vmids}</span>
|
||||
</div>
|
||||
<div className="text-gray-500 text-xs mt-1">
|
||||
{job.schedule} · {job.storage} · {job.mode}
|
||||
{job.node && <span> · Node: {job.node}</span>}
|
||||
</div>
|
||||
</div>
|
||||
{nr && (
|
||||
<div className="text-right text-xs text-gray-400">
|
||||
Naechstes: {nr.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' })}, {nr.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Letzte Backups */}
|
||||
{tasks.length > 0 && (
|
||||
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-4">
|
||||
<h3 className="text-white font-semibold mb-3">Letzte Backups ({tasks.length})</h3>
|
||||
<div className="space-y-1 max-h-60 overflow-y-auto">
|
||||
{tasks.slice(0, 30).map((t, i) => {
|
||||
const start = new Date(t.starttime * 1000)
|
||||
const duration = t.endtime && t.starttime ? Math.round((t.endtime - t.starttime) / 60) : null
|
||||
return (
|
||||
<div key={i} className="flex items-center justify-between text-xs py-1 border-b border-gray-800/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`w-2 h-2 rounded-full ${t.status === 'OK' ? 'bg-emerald-400' : 'bg-red-400'}`} />
|
||||
<span className="text-gray-300">
|
||||
{start.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })} {start.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{duration !== null && <span className="text-gray-500">{duration} min</span>}
|
||||
<span className={`${t.status === 'OK' ? 'text-emerald-400' : 'text-red-400'}`}>{t.status}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BackupsTab({ agentId }) {
|
||||
const [backups, setBackups] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
@@ -1236,7 +1439,278 @@ function BackupsTab({ agentId }) {
|
||||
)
|
||||
}
|
||||
|
||||
function AgentTab({ agent, status }) {
|
||||
function UpdatesTab({ agentId }) {
|
||||
const [checking, setChecking] = useState(false)
|
||||
const [updating, setUpdating] = useState(false)
|
||||
const [result, setResult] = useState(null)
|
||||
const [updateResult, setUpdateResult] = useState(null)
|
||||
const [reboot, setReboot] = useState(false)
|
||||
const [majorVer, setMajorVer] = useState('')
|
||||
const [majorPhase, setMajorPhase] = useState('1')
|
||||
const [majorReboot, setMajorReboot] = useState(true)
|
||||
|
||||
const checkUpdates = async () => {
|
||||
setChecking(true)
|
||||
setResult(null)
|
||||
try {
|
||||
const res = await api.checkUpdates(agentId)
|
||||
setResult(res.data || res)
|
||||
} catch (err) {
|
||||
setResult({ error: err.message })
|
||||
} finally {
|
||||
setChecking(false)
|
||||
}
|
||||
}
|
||||
|
||||
const runUpdate = async () => {
|
||||
if (!confirm('Update jetzt ausfuehren?' + (reboot ? ' (mit Reboot)' : ''))) return
|
||||
setUpdating(true)
|
||||
setUpdateResult(null)
|
||||
try {
|
||||
const res = await api.runUpdate(agentId, reboot)
|
||||
setUpdateResult(res.data || res)
|
||||
} catch (err) {
|
||||
setUpdateResult({ error: err.message })
|
||||
} finally {
|
||||
setUpdating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const runMajorUpdate = async () => {
|
||||
if (!majorVer) return
|
||||
const phaseText = majorPhase === '1' ? 'Phase 1: Base+Kernel installieren' + (majorReboot ? ' + Reboot' : '') : 'Phase 2: Packages aktualisieren'
|
||||
if (!confirm(`Major-Update auf ${majorVer}?\n${phaseText}`)) return
|
||||
setUpdating(true)
|
||||
setUpdateResult(null)
|
||||
try {
|
||||
const res = await api.post(`/api/v1/agents/${agentId}/major-update`, {
|
||||
version: majorVer,
|
||||
phase: majorPhase,
|
||||
reboot: majorReboot
|
||||
})
|
||||
setUpdateResult(res.data || res)
|
||||
} catch (err) {
|
||||
setUpdateResult({ error: err.message })
|
||||
} finally {
|
||||
setUpdating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const runReboot = async () => {
|
||||
if (!confirm('Geraet jetzt neu starten?')) return
|
||||
try {
|
||||
await api.post(`/api/v1/agents/${agentId}/reboot`, { delay: 5 })
|
||||
setUpdateResult({ message: 'Reboot in 5 Sekunden...' })
|
||||
} catch (err) {
|
||||
setUpdateResult({ error: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
const pendingPkgs = result?.pending_packages || []
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Update Check */}
|
||||
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-white font-semibold">Update-Check</h3>
|
||||
<button
|
||||
onClick={checkUpdates}
|
||||
disabled={checking}
|
||||
className="flex items-center gap-2 bg-orange-600 hover:bg-orange-500 disabled:bg-gray-700 disabled:text-gray-500 text-white px-3 py-1.5 rounded text-sm transition-colors"
|
||||
>
|
||||
<Search className="w-4 h-4" />
|
||||
{checking ? 'Pruefe...' : 'Updates pruefen'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{result?.error && (
|
||||
<div className="text-red-400 text-sm bg-red-900/20 rounded p-2">{result.error}</div>
|
||||
)}
|
||||
|
||||
{result && !result.error && (
|
||||
<div className="space-y-3">
|
||||
{/* Core Update */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-2.5 h-2.5 rounded-full ${result.core_update_available ? 'bg-orange-400' : 'bg-emerald-400'}`} />
|
||||
<span className="text-sm text-gray-300">
|
||||
{result.core_update_available ? 'Core-Update verfuegbar' : 'Core ist aktuell'}
|
||||
</span>
|
||||
</div>
|
||||
{result.core_update_info && result.core_update_available && (
|
||||
<pre className="bg-gray-900 text-gray-400 text-xs p-2 rounded overflow-x-auto max-h-32">{result.core_update_info}</pre>
|
||||
)}
|
||||
|
||||
{/* Reboot Required */}
|
||||
{result.reboot_required && (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-red-400" />
|
||||
<span className="text-sm text-red-400 font-medium">Reboot erforderlich</span>
|
||||
<button onClick={runReboot} className="ml-auto text-xs bg-red-600 hover:bg-red-500 text-white px-2 py-1 rounded">
|
||||
Jetzt neustarten
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Package Updates */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-2.5 h-2.5 rounded-full ${pendingPkgs.length > 0 ? 'bg-orange-400' : 'bg-emerald-400'}`} />
|
||||
<span className="text-sm text-gray-300">
|
||||
{pendingPkgs.length > 0 ? `${pendingPkgs.length} Paket-Updates verfuegbar` : 'Alle Pakete aktuell'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Package List */}
|
||||
{pendingPkgs.length > 0 && (
|
||||
<div className="bg-gray-900 rounded overflow-hidden">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="text-gray-500 border-b border-gray-800">
|
||||
<th className="text-left px-3 py-1.5">Paket</th>
|
||||
<th className="text-left px-3 py-1.5">Aktuell</th>
|
||||
<th className="text-left px-3 py-1.5">Neu</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pendingPkgs.map((pkg, i) => (
|
||||
<tr key={i} className="border-b border-gray-800/50">
|
||||
<td className="px-3 py-1 text-gray-300 font-mono">{pkg.package}</td>
|
||||
<td className="px-3 py-1 text-gray-500">{pkg.current_version}</td>
|
||||
<td className="px-3 py-1 text-emerald-400">{pkg.new_version}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Normales Update */}
|
||||
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-4">
|
||||
<h3 className="text-white font-semibold mb-1">Normales Update</h3>
|
||||
<p className="text-gray-500 text-xs mb-3">OPNsense Core + Paket-Updates installieren.</p>
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={runUpdate}
|
||||
disabled={updating}
|
||||
className="flex items-center gap-2 bg-orange-600 hover:bg-orange-500 disabled:bg-gray-700 disabled:text-gray-500 text-white px-4 py-2 rounded text-sm transition-colors"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
{updating ? 'Update laeuft...' : 'Update starten'}
|
||||
</button>
|
||||
<label className="flex items-center gap-2 text-sm text-gray-400 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={reboot}
|
||||
onChange={(e) => setReboot(e.target.checked)}
|
||||
className="rounded bg-gray-700 border-gray-600 text-orange-500 focus:ring-orange-500"
|
||||
/>
|
||||
Nach Update neustarten
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Major Update */}
|
||||
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-4">
|
||||
<h3 className="text-white font-semibold mb-1">Major-Update</h3>
|
||||
<p className="text-gray-500 text-xs mb-3">
|
||||
Upgrade auf neue OPNsense-Version (2 Phasen: Phase 1 = Base+Kernel + Reboot, Phase 2 = Packages).
|
||||
</p>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<input
|
||||
type="text"
|
||||
value={majorVer}
|
||||
onChange={(e) => setMajorVer(e.target.value)}
|
||||
placeholder="z.B. 26.1"
|
||||
className="bg-gray-900 border border-gray-700 text-white text-sm rounded px-3 py-1.5 w-28 focus:border-orange-500 focus:outline-none"
|
||||
/>
|
||||
<select
|
||||
value={majorPhase}
|
||||
onChange={(e) => setMajorPhase(e.target.value)}
|
||||
className="bg-gray-900 border border-gray-700 text-white text-sm rounded px-3 py-1.5 focus:border-orange-500 focus:outline-none"
|
||||
>
|
||||
<option value="1">Phase 1 (Base+Kernel)</option>
|
||||
<option value="2">Phase 2 (Packages)</option>
|
||||
</select>
|
||||
{majorPhase === '1' && (
|
||||
<label className="flex items-center gap-2 text-sm text-gray-400 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={majorReboot}
|
||||
onChange={(e) => setMajorReboot(e.target.checked)}
|
||||
className="rounded bg-gray-700 border-gray-600 text-orange-500 focus:ring-orange-500"
|
||||
/>
|
||||
Reboot
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={runMajorUpdate}
|
||||
disabled={updating || !majorVer}
|
||||
className="flex items-center gap-2 bg-red-600 hover:bg-red-500 disabled:bg-gray-700 disabled:text-gray-500 text-white px-4 py-2 rounded text-sm transition-colors"
|
||||
>
|
||||
<Shield className="w-4 h-4" />
|
||||
{updating ? 'Update laeuft...' : `Major-Update Phase ${majorPhase} starten`}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Reboot */}
|
||||
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-4">
|
||||
<h3 className="text-white font-semibold mb-1">Reboot</h3>
|
||||
<p className="text-gray-500 text-xs mb-3">Geraet manuell neu starten.</p>
|
||||
<button
|
||||
onClick={runReboot}
|
||||
className="flex items-center gap-2 bg-red-600/80 hover:bg-red-600 text-white px-4 py-2 rounded text-sm transition-colors"
|
||||
>
|
||||
Jetzt neustarten
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Update-Ergebnis */}
|
||||
{updateResult && (
|
||||
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-4">
|
||||
<h3 className="text-white font-semibold mb-2">Ergebnis</h3>
|
||||
{updateResult.error ? (
|
||||
<div className="text-red-400 text-sm">{updateResult.error}</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{updateResult.message && (
|
||||
<div className="text-emerald-400 text-sm">{updateResult.message}</div>
|
||||
)}
|
||||
{updateResult.steps?.map((step, i) => (
|
||||
<div key={i} className="bg-gray-900 rounded p-2">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<div className={`w-2 h-2 rounded-full ${step.ok ? 'bg-emerald-400' : 'bg-red-400'}`} />
|
||||
<span className="text-gray-300 text-xs font-mono">{step.step}</span>
|
||||
</div>
|
||||
{step.output && (
|
||||
<pre className="text-gray-500 text-xs overflow-x-auto max-h-40 whitespace-pre-wrap">{step.output}</pre>
|
||||
)}
|
||||
{step.error && <div className="text-red-400 text-xs mt-1">{step.error}</div>}
|
||||
</div>
|
||||
))}
|
||||
{updateResult.reboot_required && (
|
||||
<div className="flex items-center gap-2 text-red-400 text-sm">
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-red-400" />
|
||||
Reboot erforderlich
|
||||
{!updateResult.reboot_requested && (
|
||||
<button onClick={runReboot} className="ml-2 text-xs bg-red-600 hover:bg-red-500 text-white px-2 py-1 rounded">
|
||||
Jetzt neustarten
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AgentTab({ agent, status, platform }) {
|
||||
const [updating, setUpdating] = useState(false)
|
||||
const [msg, setMsg] = useState('')
|
||||
|
||||
@@ -1275,13 +1749,17 @@ function AgentTab({ agent, status }) {
|
||||
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-4">
|
||||
<h3 className="text-white font-semibold mb-3">Nuetzliche Befehle</h3>
|
||||
<div className="space-y-2">
|
||||
{[
|
||||
{ label: 'Service Status pruefen:', cmd: 'service rmm_agent status' },
|
||||
{(platform === 'linux' ? [
|
||||
{ label: 'Service Status:', cmd: 'systemctl status rmm-agent' },
|
||||
{ label: 'Logs anzeigen:', cmd: 'journalctl -u rmm-agent -f' },
|
||||
{ label: 'Agent neustarten:', cmd: 'systemctl restart rmm-agent' },
|
||||
] : [
|
||||
{ label: 'Service Status:', cmd: 'service rmm_agent status' },
|
||||
{ label: 'Logs anzeigen:', cmd: 'tail -f /var/log/rmm-agent.log' },
|
||||
{ label: 'Agent neustarten:', cmd: 'service rmm_agent restart' },
|
||||
{ label: 'Updater Status:', cmd: 'service rmm_updater status' },
|
||||
{ label: 'Updater Logs:', cmd: 'tail -f /var/log/rmm-updater.log' },
|
||||
].map((item, i) => (
|
||||
]).map((item, i) => (
|
||||
<div key={i} className="flex items-center gap-3">
|
||||
<span className="text-gray-500 text-xs w-40 shrink-0">{item.label}</span>
|
||||
<code className="bg-gray-900 text-gray-300 text-xs px-2 py-1 rounded font-mono">{item.cmd}</code>
|
||||
@@ -1294,6 +1772,10 @@ function AgentTab({ agent, status }) {
|
||||
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-4">
|
||||
<h3 className="text-white font-semibold mb-3">Agent Info</h3>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500 text-sm">Plattform:</span>
|
||||
<span className="text-white text-sm font-medium">{platform === 'linux' ? 'Linux' : platform === 'freebsd' ? 'FreeBSD / OPNsense' : platform}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500 text-sm">Agent Version:</span>
|
||||
<span className="text-white text-sm font-medium">{agent?.agent_version || '--'}</span>
|
||||
|
||||
@@ -0,0 +1,948 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import api from '../api/client'
|
||||
import StatusBadge from './StatusBadge'
|
||||
import {
|
||||
X, Cpu, MemoryStick, HardDrive, Clock, Network, Server, Monitor,
|
||||
Container, Database, FolderTree, Settings, Download, Trash2, Copy, Check,
|
||||
MonitorSmartphone, Terminal, Cable, ExternalLink,
|
||||
} from 'lucide-react'
|
||||
|
||||
const tabs = [
|
||||
{ id: 'overview', label: 'Uebersicht' },
|
||||
{ id: 'vms', label: 'Virtuelle Maschinen' },
|
||||
{ id: 'containers', label: 'Container' },
|
||||
{ id: 'storage', label: 'Storage' },
|
||||
{ id: 'zfs', label: 'ZFS' },
|
||||
{ id: 'tunnel', label: 'Tunnel' },
|
||||
{ id: 'services', label: 'Dienste' },
|
||||
{ id: 'updates', label: 'Updates' },
|
||||
{ id: 'backups', label: 'Backups' },
|
||||
{ id: 'agent', label: 'Agent' },
|
||||
]
|
||||
|
||||
export default function ProxmoxPanel({ agentId, customers, onClose, onReload }) {
|
||||
const [data, setData] = useState(null)
|
||||
const [tab, setTab] = useState('overview')
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true)
|
||||
setTab('overview')
|
||||
api.getAgent(agentId).then(setData).finally(() => setLoading(false))
|
||||
}, [agentId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) return
|
||||
const iv = setInterval(() => {
|
||||
api.getAgent(agentId).then(setData)
|
||||
}, 30000)
|
||||
return () => clearInterval(iv)
|
||||
}, [agentId, data])
|
||||
|
||||
if (loading) return <Panel onClose={onClose}><div className="p-6 text-gray-500">Laden...</div></Panel>
|
||||
if (!data) return <Panel onClose={onClose}><div className="p-6 text-red-400">Nicht gefunden</div></Panel>
|
||||
|
||||
const { agent, system_data: sys, status } = data
|
||||
const proxmox = sys?.proxmox
|
||||
const cust = agent.customer_id ? customers.find((c) => c.id === agent.customer_id) : null
|
||||
|
||||
if (!proxmox?.available) {
|
||||
return <Panel onClose={onClose}><div className="p-6 text-red-400">Keine Proxmox-Daten verfuegbar</div></Panel>
|
||||
}
|
||||
|
||||
return (
|
||||
<Panel onClose={onClose}>
|
||||
{/* Header */}
|
||||
<div className="px-6 py-4 bg-gradient-to-r from-gray-800 to-gray-900 border-b border-gray-700">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-lg font-bold text-white">{agent.name}</h2>
|
||||
<StatusBadge status={status} />
|
||||
</div>
|
||||
<CustomerAssign agent={agent} customers={customers} current={cust} onReload={onReload} />
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
{agent.hostname} · IP: {agent.ip} · PVE {proxmox.version || '—'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (!confirm(`Server "${agent.name}" wirklich loeschen?`)) return
|
||||
await api.deleteAgent(agent.id)
|
||||
if (onReload) onReload()
|
||||
onClose()
|
||||
}}
|
||||
className="text-gray-500 hover:text-red-400 transition-colors"
|
||||
title="Server loeschen"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-white">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 mt-4 overflow-x-auto text-xs">
|
||||
{tabs.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => setTab(t.id)}
|
||||
className={`px-3 py-1.5 rounded-t whitespace-nowrap transition-colors ${
|
||||
tab === t.id
|
||||
? 'bg-gray-900 text-orange-400 border-t border-x border-gray-700'
|
||||
: 'text-gray-400 hover:text-gray-200'
|
||||
}`}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-4">
|
||||
{!sys ? (
|
||||
<div className="text-gray-500">Keine Systemdaten</div>
|
||||
) : tab === 'overview' ? (
|
||||
<OverviewTab sys={sys} proxmox={proxmox} />
|
||||
) : tab === 'vms' ? (
|
||||
<VmsTab proxmox={proxmox} />
|
||||
) : tab === 'containers' ? (
|
||||
<ContainersTab proxmox={proxmox} />
|
||||
) : tab === 'storage' ? (
|
||||
<StorageTab proxmox={proxmox} />
|
||||
) : tab === 'zfs' ? (
|
||||
<ZfsTab proxmox={proxmox} />
|
||||
) : tab === 'tunnel' ? (
|
||||
<TunnelTab agentId={agentId} agent={data?.agent} webguiPort={8006} />
|
||||
) : tab === 'services' ? (
|
||||
<ServicesTab sys={sys} />
|
||||
) : tab === 'updates' ? (
|
||||
<UpdatesTab sys={sys} />
|
||||
) : tab === 'backups' ? (
|
||||
<BackupsTab sys={sys} />
|
||||
) : tab === 'agent' ? (
|
||||
<AgentTab agent={data?.agent} status={data?.status} />
|
||||
) : null}
|
||||
</div>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
function CustomerAssign({ agent, customers, current, onReload }) {
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const handleChange = async (e) => {
|
||||
const val = e.target.value
|
||||
setSaving(true)
|
||||
try {
|
||||
if (val === '') {
|
||||
await api.unassignCustomer(agent.id)
|
||||
} else {
|
||||
await api.assignCustomer(agent.id, parseInt(val))
|
||||
}
|
||||
if (onReload) onReload()
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
setEditing(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="text-xs text-gray-400 mt-1">
|
||||
{!editing ? (
|
||||
<button
|
||||
onClick={() => setEditing(true)}
|
||||
className="hover:text-orange-400 transition-colors"
|
||||
>
|
||||
Kunde: {current ? `${current.number} — ${current.name}` : 'Kein Kunde'}
|
||||
</button>
|
||||
) : (
|
||||
<select
|
||||
onChange={handleChange}
|
||||
defaultValue={current?.id || ''}
|
||||
disabled={saving}
|
||||
className="bg-gray-800 border border-gray-600 rounded px-2 py-0.5 text-xs text-white focus:outline-none focus:border-orange-500"
|
||||
onBlur={() => setEditing(false)}
|
||||
autoFocus
|
||||
>
|
||||
<option value="">Kein Kunde</option>
|
||||
{customers.map(c => (
|
||||
<option key={c.id} value={c.id}>{c.number} — {c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Panel({ onClose, children }) {
|
||||
return (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40 bg-black/50" onClick={onClose} />
|
||||
<div className="fixed inset-y-0 right-0 z-50 w-full max-w-5xl bg-gray-900 border-l border-gray-800 flex flex-col shadow-2xl animate-slide-in">
|
||||
{children}
|
||||
</div>
|
||||
<style>{`
|
||||
@keyframes slideIn { from { transform: translateX(100%); } to { transform: translateX(0); } }
|
||||
.animate-slide-in { animation: slideIn 0.2s ease-out; }
|
||||
`}</style>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function MetricCard({ icon: Icon, label, value, sub, bar, barColor }) {
|
||||
return (
|
||||
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-4">
|
||||
<div className="flex items-center gap-2 text-gray-500 mb-2">
|
||||
{Icon && <Icon className="w-4 h-4" />}
|
||||
<span className="text-xs uppercase">{label}</span>
|
||||
</div>
|
||||
<div className="text-xl font-bold text-white">{value}</div>
|
||||
{sub && <div className="text-xs text-gray-500 mt-0.5">{sub}</div>}
|
||||
{bar !== undefined && bar !== null && (
|
||||
<div className="mt-2 h-1.5 bg-gray-700 rounded overflow-hidden">
|
||||
<div className={`h-full rounded ${barColor || 'bg-blue-500'}`} style={{ width: `${Math.min(bar, 100)}%` }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusCard({ icon: Icon, value, label }) {
|
||||
return (
|
||||
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-3 text-center">
|
||||
<div className="flex justify-center mb-2">
|
||||
{Icon && <Icon className="w-5 h-5 text-gray-500" />}
|
||||
</div>
|
||||
<div className="text-lg font-bold text-white">{value}</div>
|
||||
<div className="text-xs text-gray-500 uppercase">{label}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function OverviewTab({ sys, proxmox }) {
|
||||
const rootDisk = sys.disks?.find((d) => d.mount_point === '/')
|
||||
const diskPct = rootDisk && rootDisk.total_bytes > 0
|
||||
? (rootDisk.used_bytes / rootDisk.total_bytes * 100) : null
|
||||
const ramPct = sys.memory?.total_bytes > 0
|
||||
? (sys.memory.used_bytes / sys.memory.total_bytes * 100) : null
|
||||
const cpuPct = sys.cpu?.usage_percent
|
||||
|
||||
const barColor = (v) => v > 90 ? 'bg-red-500' : v > 70 ? 'bg-yellow-500' : v > 50 ? 'bg-orange-500' : 'bg-green-500'
|
||||
|
||||
const vmsRunning = proxmox.vms?.filter(vm => vm.status === 'running').length || 0
|
||||
const vmsTotal = proxmox.vms?.length || 0
|
||||
const ctRunning = proxmox.containers?.filter(ct => ct.status === 'running').length || 0
|
||||
const ctTotal = proxmox.containers?.length || 0
|
||||
const storageCount = proxmox.storage?.length || 0
|
||||
const zfsCount = proxmox.zfs_pools?.length || 0
|
||||
|
||||
return (
|
||||
<>
|
||||
<h3 className="text-sm font-medium text-gray-300">System-Metriken</h3>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<MetricCard icon={Cpu} label="CPU Auslastung" value={cpuPct != null ? `${cpuPct.toFixed(1)}%` : '—'}
|
||||
sub={sys.cpu?.model} bar={cpuPct} barColor={barColor(cpuPct || 0)} />
|
||||
<MetricCard icon={MemoryStick} label="RAM Auslastung" value={ramPct != null ? `${ramPct.toFixed(1)}%` : '—'}
|
||||
sub={sys.memory ? `${formatBytes(sys.memory.used_bytes)} / ${formatBytes(sys.memory.total_bytes)}` : ''}
|
||||
bar={ramPct} barColor={barColor(ramPct || 0)} />
|
||||
<MetricCard icon={HardDrive} label="Festplatte" value={diskPct != null ? `${diskPct.toFixed(1)}%` : '—'}
|
||||
sub={rootDisk ? `${formatBytes(rootDisk.used_bytes)} / ${formatBytes(rootDisk.total_bytes)}` : ''}
|
||||
bar={diskPct} barColor={barColor(diskPct || 0)} />
|
||||
<MetricCard icon={Clock} label="Uptime" value={sys.uptime_seconds ? formatUptime(sys.uptime_seconds) : '—'} />
|
||||
</div>
|
||||
|
||||
<h3 className="text-sm font-medium text-gray-300 mt-4">Proxmox-Info</h3>
|
||||
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<div className="text-xs text-gray-500 uppercase">PVE Version</div>
|
||||
<div className="text-white font-medium">{proxmox.version || '—'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-gray-500 uppercase">Node Name</div>
|
||||
<div className="text-white font-medium">{proxmox.node || '—'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-gray-500 uppercase">Cluster Name</div>
|
||||
<div className="text-white font-medium">{proxmox.cluster?.name || '—'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Subscription */}
|
||||
{proxmox.subscription && (
|
||||
<>
|
||||
<h3 className="text-sm font-medium text-gray-300 mt-4">Subscription</h3>
|
||||
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-white font-medium">{proxmox.subscription.productname || 'Proxmox VE'}</div>
|
||||
{proxmox.subscription.key && (
|
||||
<div className="text-xs text-gray-500 mt-1 font-mono">{proxmox.subscription.key}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className={`text-sm font-bold ${
|
||||
proxmox.subscription.status === 'Active' ? 'text-green-400' : 'text-red-400'
|
||||
}`}>
|
||||
{proxmox.subscription.status || 'Unknown'}
|
||||
</div>
|
||||
{proxmox.subscription.next_due_date && (
|
||||
<div className="text-xs text-gray-500">bis {proxmox.subscription.next_due_date}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<h3 className="text-sm font-medium text-gray-300 mt-4">Kurzuebersicht</h3>
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<StatusCard icon={Monitor} value={`${vmsRunning}/${vmsTotal}`} label="VMs" />
|
||||
<StatusCard icon={Container} value={`${ctRunning}/${ctTotal}`} label="Container" />
|
||||
<StatusCard icon={Database} value={storageCount} label="Storage" />
|
||||
<StatusCard icon={FolderTree} value={zfsCount} label="ZFS Pools" />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function VmsTab({ proxmox }) {
|
||||
const vms = proxmox.vms || []
|
||||
|
||||
// Sort: running first, then by name
|
||||
const sortedVms = [...vms].sort((a, b) => {
|
||||
if (a.status === 'running' && b.status !== 'running') return -1
|
||||
if (a.status !== 'running' && b.status === 'running') return 1
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<h3 className="text-sm font-medium text-gray-300">Virtuelle Maschinen ({vms.length})</h3>
|
||||
{sortedVms.length === 0 ? (
|
||||
<div className="text-gray-500">Keine VMs gefunden</div>
|
||||
) : (
|
||||
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-gray-500 text-xs border-b border-gray-700">
|
||||
<th className="px-3 py-2">VMID</th>
|
||||
<th className="px-3 py-2">Name</th>
|
||||
<th className="px-3 py-2">Status</th>
|
||||
<th className="px-3 py-2">CPU</th>
|
||||
<th className="px-3 py-2">RAM</th>
|
||||
<th className="px-3 py-2">Disk</th>
|
||||
<th className="px-3 py-2">Uptime</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-700/50">
|
||||
{sortedVms.map((vm) => (
|
||||
<tr key={vm.vmid}>
|
||||
<td className="px-3 py-2 text-gray-400">{vm.vmid}</td>
|
||||
<td className="px-3 py-2 text-white font-medium">{vm.name}</td>
|
||||
<td className="px-3 py-2">
|
||||
<StatusBadge status={vm.status} />
|
||||
</td>
|
||||
<td className="px-3 py-2 text-gray-400">
|
||||
{vm.cpu_usage != null ? `${vm.cpu_usage.toFixed(1)}%` : '—'}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-gray-400">
|
||||
{vm.memory_used && vm.memory_max ?
|
||||
`${formatBytes(vm.memory_used)} / ${formatBytes(vm.memory_max)}` : '—'}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-gray-400">
|
||||
{vm.disk_used != null && vm.disk_max ?
|
||||
`${formatBytes(vm.disk_used)} / ${formatBytes(vm.disk_max)}` : '—'}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-gray-400">
|
||||
{vm.uptime ? formatUptime(vm.uptime) : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ContainersTab({ proxmox }) {
|
||||
const containers = proxmox.containers || []
|
||||
|
||||
// Sort: running first, then by name
|
||||
const sortedCt = [...containers].sort((a, b) => {
|
||||
if (a.status === 'running' && b.status !== 'running') return -1
|
||||
if (a.status !== 'running' && b.status === 'running') return 1
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<h3 className="text-sm font-medium text-gray-300">Container ({containers.length})</h3>
|
||||
{sortedCt.length === 0 ? (
|
||||
<div className="text-gray-500">Keine Container gefunden</div>
|
||||
) : (
|
||||
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-gray-500 text-xs border-b border-gray-700">
|
||||
<th className="px-3 py-2">VMID</th>
|
||||
<th className="px-3 py-2">Name</th>
|
||||
<th className="px-3 py-2">Status</th>
|
||||
<th className="px-3 py-2">CPU</th>
|
||||
<th className="px-3 py-2">RAM</th>
|
||||
<th className="px-3 py-2">Uptime</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-700/50">
|
||||
{sortedCt.map((ct) => (
|
||||
<tr key={ct.vmid}>
|
||||
<td className="px-3 py-2 text-gray-400">{ct.vmid}</td>
|
||||
<td className="px-3 py-2 text-white font-medium">{ct.name}</td>
|
||||
<td className="px-3 py-2">
|
||||
<StatusBadge status={ct.status} />
|
||||
</td>
|
||||
<td className="px-3 py-2 text-gray-400">
|
||||
{ct.cpu_usage != null ? `${ct.cpu_usage.toFixed(1)}%` : '—'}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-gray-400">
|
||||
{ct.memory_used && ct.memory_max ?
|
||||
`${formatBytes(ct.memory_used)} / ${formatBytes(ct.memory_max)}` : '—'}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-gray-400">
|
||||
{ct.uptime ? formatUptime(ct.uptime) : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function StorageTab({ proxmox }) {
|
||||
const storage = proxmox.storage || []
|
||||
|
||||
return (
|
||||
<>
|
||||
<h3 className="text-sm font-medium text-gray-300">Storage ({storage.length})</h3>
|
||||
{storage.length === 0 ? (
|
||||
<div className="text-gray-500">Keine Storage-Pools gefunden</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{storage.map((s) => {
|
||||
const usedPct = s.total && s.total > 0 ? (s.used / s.total * 100) : 0
|
||||
const barColor = usedPct > 90 ? 'bg-red-500' : usedPct > 70 ? 'bg-yellow-500' : 'bg-green-500'
|
||||
|
||||
return (
|
||||
<div key={s.storage} className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-4">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div>
|
||||
<div className="text-white font-medium">{s.storage}</div>
|
||||
<div className="text-xs text-gray-500">{s.type} • {s.content}</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<StatusBadge status={s.active ? 'online' : 'offline'} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{s.total && (
|
||||
<>
|
||||
<div className="flex justify-between text-sm mb-1">
|
||||
<span className="text-gray-400">{formatBytes(s.used)} / {formatBytes(s.total)}</span>
|
||||
<span className="text-gray-400">{usedPct.toFixed(1)}%</span>
|
||||
</div>
|
||||
<div className="w-full h-2 bg-gray-700 rounded overflow-hidden">
|
||||
<div className={`h-full rounded ${barColor}`} style={{ width: `${usedPct}%` }} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ZfsTab({ proxmox }) {
|
||||
const zfsPools = proxmox.zfs_pools || []
|
||||
|
||||
return (
|
||||
<>
|
||||
<h3 className="text-sm font-medium text-gray-300">ZFS Pools ({zfsPools.length})</h3>
|
||||
{zfsPools.length === 0 ? (
|
||||
<div className="text-gray-500">Keine ZFS-Pools gefunden</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{zfsPools.map((pool) => {
|
||||
const healthColor = pool.health === 'ONLINE' ? 'text-green-400' :
|
||||
pool.health === 'DEGRADED' ? 'text-yellow-400' : 'text-red-400'
|
||||
const healthBg = pool.health === 'ONLINE' ? 'bg-green-500' :
|
||||
pool.health === 'DEGRADED' ? 'bg-yellow-500' : 'bg-red-500'
|
||||
|
||||
return (
|
||||
<div key={pool.name} className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-4">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="text-white font-medium">{pool.name}</div>
|
||||
<div className={`text-xs font-bold ${healthColor}`}>{pool.health}</div>
|
||||
</div>
|
||||
|
||||
{(() => {
|
||||
const usedPct = pool.size > 0 ? (pool.allocated / pool.size * 100) : 0
|
||||
const pctColor = usedPct > 90 ? 'bg-red-500' : usedPct > 70 ? 'bg-yellow-500' : 'bg-green-500'
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Groesse:</span>
|
||||
<span className="text-gray-300">{formatBytes(pool.size)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Belegt:</span>
|
||||
<span className="text-gray-300">{formatBytes(pool.allocated)} ({usedPct.toFixed(1)}%)</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Frei:</span>
|
||||
<span className="text-gray-300">{formatBytes(pool.free)}</span>
|
||||
</div>
|
||||
{pool.fragmentation && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Fragmentierung:</span>
|
||||
<span className="text-gray-300">{pool.fragmentation}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 w-full h-2 bg-gray-700 rounded overflow-hidden">
|
||||
<div className={`h-full rounded ${pctColor}`} style={{ width: `${Math.min(usedPct, 100)}%` }} />
|
||||
</div>
|
||||
|
||||
{pool.scrub_status && (
|
||||
<div className="mt-3 pt-3 border-t border-gray-700">
|
||||
<div className="text-xs text-gray-500">{pool.scrub_status}</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ServicesTab({ sys }) {
|
||||
const services = sys.services || []
|
||||
|
||||
// Sort: running first
|
||||
const sortedServices = [...services].sort((a, b) => {
|
||||
const aRunning = a.running || a.status === 'running'
|
||||
const bRunning = b.running || b.status === 'running'
|
||||
if (aRunning && !bRunning) return -1
|
||||
if (!aRunning && bRunning) return 1
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<h3 className="text-sm font-medium text-gray-300">Systemd Dienste ({services.length})</h3>
|
||||
{sortedServices.length === 0 ? (
|
||||
<div className="text-gray-500">Keine Dienste gefunden</div>
|
||||
) : (
|
||||
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-gray-500 text-xs border-b border-gray-700">
|
||||
<th className="px-3 py-2">Name</th>
|
||||
<th className="px-3 py-2">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-700/50">
|
||||
{sortedServices.map((svc, i) => {
|
||||
const running = svc.running || svc.status === 'running'
|
||||
return (
|
||||
<tr key={i}>
|
||||
<td className="px-3 py-2 text-white">{svc.name}</td>
|
||||
<td className="px-3 py-2">
|
||||
<StatusBadge status={running ? 'running' : 'stopped'} />
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function UpdatesTab({ sys }) {
|
||||
const pendingUpdates = sys.pending_updates || []
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-gray-300">Updates</h3>
|
||||
<div className="text-sm text-orange-400">
|
||||
{pendingUpdates.length} Updates verfuegbar
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{pendingUpdates.length === 0 ? (
|
||||
<div className="text-gray-500">Keine Updates verfuegbar</div>
|
||||
) : (
|
||||
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-gray-500 text-xs border-b border-gray-700">
|
||||
<th className="px-3 py-2">Package</th>
|
||||
<th className="px-3 py-2">Current Version</th>
|
||||
<th className="px-3 py-2">New Version</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-700/50">
|
||||
{pendingUpdates.map((update, i) => (
|
||||
<tr key={i}>
|
||||
<td className="px-3 py-2 text-white font-medium">{update.package}</td>
|
||||
<td className="px-3 py-2 text-gray-400">{update.current_version}</td>
|
||||
<td className="px-3 py-2 text-orange-400">{update.new_version}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function AgentTab({ agent, status }) {
|
||||
return (
|
||||
<>
|
||||
<h3 className="text-sm font-medium text-gray-300">Agent Information</h3>
|
||||
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-4 space-y-3">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<div className="text-xs text-gray-500 uppercase">Agent Version</div>
|
||||
<div className="text-white">{agent?.agent_version || '—'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-gray-500 uppercase">Status</div>
|
||||
<StatusBadge status={status} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-gray-500 uppercase">Last Heartbeat</div>
|
||||
<div className="text-white text-xs">
|
||||
{agent?.last_heartbeat ? new Date(agent.last_heartbeat).toLocaleString('de-DE') : '—'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-gray-500 uppercase">Agent ID</div>
|
||||
<div className="text-white text-xs font-mono">{agent?.id?.substring(0, 12)}...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function TunnelTab({ agentId, agent, webguiPort = 8006 }) {
|
||||
const [tunnels, setTunnels] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [creating, setCreating] = useState(null)
|
||||
const [customOpen, setCustomOpen] = useState(false)
|
||||
const [customHost, setCustomHost] = useState('127.0.0.1')
|
||||
const [customPort, setCustomPort] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const backendHost = '192.168.85.13'
|
||||
|
||||
const loadTunnels = () => {
|
||||
api.getTunnels(agentId)
|
||||
.then((resp) => {
|
||||
const list = resp?.tunnels || resp?.data?.tunnels || resp?.data || resp || []
|
||||
setTunnels(Array.isArray(list) ? list : [])
|
||||
})
|
||||
.catch(() => setTunnels([]))
|
||||
.finally(() => setLoading(false))
|
||||
}
|
||||
|
||||
useEffect(() => { loadTunnels() }, [agentId])
|
||||
useEffect(() => {
|
||||
if (tunnels.length > 0) {
|
||||
const iv = setInterval(loadTunnels, 10000)
|
||||
return () => clearInterval(iv)
|
||||
}
|
||||
}, [tunnels.length, agentId])
|
||||
|
||||
const openTunnel = async (targetHost, targetPort, label) => {
|
||||
setError('')
|
||||
setCreating(label)
|
||||
try {
|
||||
const resp = await api.openTunnel(agentId, targetHost, targetPort)
|
||||
const data = resp?.data || resp
|
||||
loadTunnels()
|
||||
if (targetPort === webguiPort && data?.proxy_port) {
|
||||
setTimeout(() => {
|
||||
window.open(`https://${backendHost}:${data.proxy_port}`, '_blank')
|
||||
}, 1000)
|
||||
}
|
||||
return data
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
} finally {
|
||||
setCreating(null)
|
||||
}
|
||||
}
|
||||
|
||||
const closeTunnel = async (tunnelId) => {
|
||||
try {
|
||||
await api.closeTunnel(agentId, tunnelId)
|
||||
setTunnels((prev) => prev.filter((t) => t.id !== tunnelId && t.tunnel_id !== tunnelId))
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCustom = () => {
|
||||
const port = parseInt(customPort)
|
||||
if (!port || port < 1 || port > 65535) return
|
||||
openTunnel(customHost, port, 'custom')
|
||||
setCustomOpen(false)
|
||||
setCustomPort('')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-gray-400">Schnellzugriff:</span>
|
||||
<button
|
||||
onClick={() => openTunnel('127.0.0.1', webguiPort, 'webgui')}
|
||||
disabled={creating === 'webgui'}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-orange-600 hover:bg-orange-700 disabled:bg-orange-800 disabled:opacity-50 rounded text-sm text-white transition-colors"
|
||||
>
|
||||
<MonitorSmartphone className="w-4 h-4" />
|
||||
{creating === 'webgui' ? 'Verbinde...' : `WebGUI (${webguiPort})`}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openTunnel('127.0.0.1', 22, 'ssh')}
|
||||
disabled={creating === 'ssh'}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-gray-700 hover:bg-gray-600 disabled:opacity-50 rounded text-sm text-white transition-colors"
|
||||
>
|
||||
<Terminal className="w-4 h-4" />
|
||||
{creating === 'ssh' ? 'Verbinde...' : 'SSH (22)'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCustomOpen(!customOpen)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-gray-700 hover:bg-gray-600 rounded text-sm text-white transition-colors"
|
||||
>
|
||||
<Cable className="w-4 h-4" /> Benutzerdefiniert
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{customOpen && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<input type="text" value={customHost} onChange={(e) => setCustomHost(e.target.value)}
|
||||
className="bg-gray-800 border border-gray-700 rounded px-2 py-1 text-white w-36 focus:outline-none focus:border-orange-500" placeholder="Host" />
|
||||
<span className="text-gray-500">:</span>
|
||||
<input type="number" value={customPort} onChange={(e) => setCustomPort(e.target.value)}
|
||||
className="bg-gray-800 border border-gray-700 rounded px-2 py-1 text-white w-20 focus:outline-none focus:border-orange-500" placeholder="Port"
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleCustom()} />
|
||||
<button onClick={handleCustom} className="px-3 py-1 bg-orange-600 hover:bg-orange-500 rounded text-white transition-colors">Verbinden</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <div className="text-red-400 text-sm">{error}</div>}
|
||||
|
||||
{tunnels.length > 0 && (
|
||||
<>
|
||||
<h3 className="text-sm font-medium text-gray-300">Aktive Tunnel ({tunnels.length})</h3>
|
||||
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-gray-500 text-xs border-b border-gray-700">
|
||||
<th className="px-3 py-2">Ziel</th>
|
||||
<th className="px-3 py-2">Proxy Port</th>
|
||||
<th className="px-3 py-2">Zugriff</th>
|
||||
<th className="px-3 py-2 w-10"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-700/50">
|
||||
{tunnels.map((t) => (
|
||||
<tr key={t.tunnel_id || t.id}>
|
||||
<td className="px-3 py-2 text-white">{t.target_host}:{t.target_port}</td>
|
||||
<td className="px-3 py-2 text-orange-400 font-mono">{t.proxy_port}</td>
|
||||
<td className="px-3 py-2">
|
||||
<a href={`https://${backendHost}:${t.proxy_port}`} target="_blank" rel="noreferrer"
|
||||
className="text-blue-400 hover:text-blue-300 flex items-center gap-1">
|
||||
<ExternalLink className="w-3 h-3" /> Oeffnen
|
||||
</a>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<button onClick={() => closeTunnel(t.tunnel_id || t.id)} className="text-gray-500 hover:text-red-400">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BackupsTab({ sys }) {
|
||||
const backups = sys?.backups || {}
|
||||
const jobs = backups.jobs || []
|
||||
const tasks = backups.tasks || []
|
||||
|
||||
const totalJobs = jobs.filter(j => j.enabled === 1 || j.enabled === true).length
|
||||
const okTasks = tasks.filter(t => t.status === 'OK').length
|
||||
const failedTasks = tasks.filter(t => t.status && t.status !== 'OK').length
|
||||
const successRate = tasks.length > 0 ? Math.round((okTasks / tasks.length) * 100) : null
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-3 text-center">
|
||||
<div className="text-gray-500 text-xs mb-1">Jobs (aktiv)</div>
|
||||
<div className="text-xl font-bold text-white">{totalJobs}</div>
|
||||
</div>
|
||||
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-3 text-center">
|
||||
<div className="text-gray-500 text-xs mb-1">Aufgaben (30d)</div>
|
||||
<div className="text-xl font-bold text-white">{tasks.length}</div>
|
||||
</div>
|
||||
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-3 text-center">
|
||||
<div className="text-gray-500 text-xs mb-1">Erfolgreich</div>
|
||||
<div className="text-xl font-bold text-emerald-400">{okTasks}</div>
|
||||
</div>
|
||||
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-3 text-center">
|
||||
<div className="text-gray-500 text-xs mb-1">Erfolgsrate</div>
|
||||
<div className={`text-xl font-bold ${successRate === 100 ? 'text-emerald-400' : successRate === null ? 'text-gray-500' : successRate >= 90 ? 'text-yellow-400' : 'text-red-400'}`}>
|
||||
{successRate !== null ? `${successRate}%` : '--'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Backup Jobs */}
|
||||
<h3 className="text-sm font-medium text-gray-300">Backup Jobs</h3>
|
||||
{jobs.length === 0 ? (
|
||||
<div className="text-gray-500">Keine Backup-Jobs konfiguriert</div>
|
||||
) : (
|
||||
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-gray-500 text-xs border-b border-gray-700">
|
||||
<th className="px-3 py-2">ID</th>
|
||||
<th className="px-3 py-2">Storage</th>
|
||||
<th className="px-3 py-2">Schedule</th>
|
||||
<th className="px-3 py-2">Modus</th>
|
||||
<th className="px-3 py-2">VMs/CTs</th>
|
||||
<th className="px-3 py-2">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-700/50">
|
||||
{jobs.map((j, i) => (
|
||||
<tr key={i}>
|
||||
<td className="px-3 py-2 text-white font-mono text-xs">{j.id || i}</td>
|
||||
<td className="px-3 py-2 text-gray-400">{j.storage || '—'}</td>
|
||||
<td className="px-3 py-2 text-gray-400 text-xs">{j.schedule || '—'}</td>
|
||||
<td className="px-3 py-2 text-gray-400">{j.mode || '—'}</td>
|
||||
<td className="px-3 py-2 text-gray-400 text-xs">{j.vmid || j.all ? 'Alle' : '—'}</td>
|
||||
<td className="px-3 py-2">
|
||||
<span className={`text-xs ${j.enabled ? 'text-green-400' : 'text-gray-500'}`}>
|
||||
{j.enabled ? 'Aktiv' : 'Inaktiv'}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Task History */}
|
||||
<h3 className="text-sm font-medium text-gray-300">Letzte Backup-Aufgaben</h3>
|
||||
{tasks.length === 0 ? (
|
||||
<div className="text-gray-500">Keine Backup-Aufgaben in den letzten 30 Tagen</div>
|
||||
) : (
|
||||
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-gray-500 text-xs border-b border-gray-700">
|
||||
<th className="px-3 py-2">Zeitpunkt</th>
|
||||
<th className="px-3 py-2">Typ</th>
|
||||
<th className="px-3 py-2">VMID</th>
|
||||
<th className="px-3 py-2">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-700/50">
|
||||
{tasks.slice(0, 50).map((t, i) => (
|
||||
<tr key={i}>
|
||||
<td className="px-3 py-2 text-gray-400 text-xs">
|
||||
{t.starttime ? new Date(t.starttime * 1000).toLocaleString('de-DE') : '—'}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-gray-400 text-xs">{t.type || 'vzdump'}</td>
|
||||
<td className="px-3 py-2 text-gray-400">{t.id || '—'}</td>
|
||||
<td className="px-3 py-2">
|
||||
<span className={`text-xs font-medium px-2 py-0.5 rounded ${
|
||||
t.status === 'OK'
|
||||
? 'bg-green-900/40 text-green-400 border border-green-700/50'
|
||||
: 'bg-red-900/40 text-red-400 border border-red-700/50'
|
||||
}`}>
|
||||
{t.status || '—'}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{failedTasks > 0 && (
|
||||
<div className="text-sm text-red-400 bg-red-900/20 rounded px-3 py-2 border border-red-800/50">
|
||||
{failedTasks} fehlgeschlagene Backup(s) in den letzten 30 Tagen
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
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 parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
function formatUptime(seconds) {
|
||||
const d = Math.floor(seconds / 86400)
|
||||
const h = Math.floor((seconds % 86400) / 3600)
|
||||
const m = Math.floor((seconds % 3600) / 60)
|
||||
if (d > 0) return `${d}d ${h}h`
|
||||
return `${h}h ${m}m`
|
||||
}
|
||||
@@ -12,11 +12,13 @@ import {
|
||||
X,
|
||||
Cable,
|
||||
Download,
|
||||
HardDrive,
|
||||
} from 'lucide-react'
|
||||
|
||||
const navItems = [
|
||||
{ path: '/', label: 'Dashboard', icon: LayoutDashboard },
|
||||
{ path: '/agents', label: 'Firewalls', icon: Server },
|
||||
{ path: '/proxmox', label: 'Proxmox', icon: HardDrive },
|
||||
{ path: '/tunnels', label: 'Tunnel', icon: Cable },
|
||||
{ path: '/firmware', label: 'Firmware', icon: Download },
|
||||
{ path: '/customers', label: 'Kunden', icon: Users },
|
||||
|
||||
@@ -63,6 +63,10 @@ export default function Agents() {
|
||||
const detail = agentDetails[a.id]
|
||||
const sys = detail?.system_data
|
||||
const cust = a.customer_id ? customerMap[a.customer_id] : null
|
||||
|
||||
// Nur OPNsense/FreeBSD Agents anzeigen
|
||||
if (a.platform === 'linux') return null
|
||||
|
||||
const rootDisk = sys?.disks?.find((d) => d.mount_point === '/')
|
||||
const diskPct = rootDisk && rootDisk.total_bytes > 0
|
||||
? (rootDisk.used_bytes / rootDisk.total_bytes * 100)
|
||||
@@ -85,7 +89,7 @@ export default function Agents() {
|
||||
gateways,
|
||||
sys,
|
||||
}
|
||||
})
|
||||
}).filter(Boolean) // Entferne null Werte
|
||||
}, [agents, agentDetails, customerMap])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
@@ -138,12 +142,12 @@ export default function Agents() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-bold text-white">Firewalls ({agents.length})</h1>
|
||||
<h1 className="text-xl font-bold text-white">Geraete ({agents.length})</h1>
|
||||
<button
|
||||
onClick={() => { setShowAddDialog(true); setAddResult(null) }}
|
||||
className="flex items-center gap-2 px-3 py-1.5 bg-orange-600 hover:bg-orange-500 text-white text-sm rounded transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" /> Firewall hinzufuegen
|
||||
<Plus className="w-4 h-4" /> Geraet hinzufuegen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -192,7 +196,14 @@ export default function Agents() {
|
||||
<td className="px-3 py-2 text-gray-400">
|
||||
{a.customer ? <span className="text-orange-400">{a.customerNumber}</span> : <span className="text-gray-600">—</span>}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-white font-medium">{a.name}</td>
|
||||
<td className="px-3 py-2 text-white font-medium">
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<span className={`text-[10px] font-mono px-1 rounded ${a.platform === 'linux' ? 'bg-blue-900/50 text-blue-400' : 'bg-orange-900/50 text-orange-400'}`}>
|
||||
{a.platform === 'linux' ? 'LNX' : 'OPN'}
|
||||
</span>
|
||||
{a.name}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-gray-400 text-xs">{a.hostname}</td>
|
||||
<td className="px-3 py-2 text-gray-400">{a.version || '—'}</td>
|
||||
<td className="px-3 py-2 text-gray-400">{a.ip}</td>
|
||||
@@ -227,7 +238,7 @@ export default function Agents() {
|
||||
</table>
|
||||
{filtered.length === 0 && (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
{search ? 'Keine Treffer' : 'Keine Firewalls registriert'}
|
||||
{search ? 'Keine Treffer' : 'Keine Geraete registriert'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -243,9 +254,9 @@ export default function Agents() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Firewall hinzufuegen Dialog */}
|
||||
{/* Geraet hinzufuegen Dialog */}
|
||||
{showAddDialog && (
|
||||
<AddFirewallDialog
|
||||
<AddDeviceDialog
|
||||
customers={customers}
|
||||
result={addResult}
|
||||
onAdd={async (name, custId) => {
|
||||
@@ -260,7 +271,7 @@ export default function Agents() {
|
||||
)
|
||||
}
|
||||
|
||||
function AddFirewallDialog({ customers, result, onAdd, onClose }) {
|
||||
function AddDeviceDialog({ customers, result, onAdd, onClose }) {
|
||||
const [name, setName] = useState('')
|
||||
const [custId, setCustId] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
@@ -299,7 +310,7 @@ function AddFirewallDialog({ customers, result, onAdd, onClose }) {
|
||||
# 1. Ins RMM-Verzeichnis wechseln:
|
||||
cd /Users/cynfo/.openclaw/workspace/rmm
|
||||
|
||||
# 2. Agent + Updater + Installer auf die Firewall kopieren:
|
||||
# 2. Agent + Updater + Installer auf das Geraet kopieren:
|
||||
scp build/rmm-agent build/rmm-updater root@<FIREWALL-IP>:/tmp/
|
||||
scp opnsense-plugin/install.sh root@<FIREWALL-IP>:/tmp/
|
||||
|
||||
@@ -316,7 +327,7 @@ ssh root@<FIREWALL-IP> '/bin/sh /tmp/install.sh'
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={onClose}>
|
||||
<div className="bg-gray-900 border border-gray-700 rounded-lg shadow-xl w-full max-w-2xl mx-4" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between px-5 py-3 border-b border-gray-800">
|
||||
<h2 className="text-lg font-semibold text-white">Firewall hinzufuegen</h2>
|
||||
<h2 className="text-lg font-semibold text-white">Geraet hinzufuegen</h2>
|
||||
<button onClick={onClose} className="text-gray-500 hover:text-white"><X className="w-5 h-5" /></button>
|
||||
</div>
|
||||
|
||||
@@ -354,13 +365,13 @@ ssh root@<FIREWALL-IP> '/bin/sh /tmp/install.sh'
|
||||
disabled={submitting}
|
||||
className="w-full py-2 bg-orange-600 hover:bg-orange-500 disabled:opacity-50 text-white text-sm font-medium rounded transition-colors"
|
||||
>
|
||||
{submitting ? 'Wird angelegt...' : 'Firewall anlegen'}
|
||||
{submitting ? 'Wird angelegt...' : 'Geraet anlegen'}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="bg-green-900/30 border border-green-800 rounded p-3 text-sm text-green-300">
|
||||
Firewall "{result.name}" erfolgreich angelegt (ID: {result.id?.substring(0, 12)}...)
|
||||
Geraet "{result.name}" erfolgreich angelegt (ID: {result.id?.substring(0, 12)}...)
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
@@ -2,23 +2,39 @@ import { useEffect, useState } from 'react'
|
||||
import api from '../api/client'
|
||||
import StatusBadge from '../components/StatusBadge'
|
||||
import AgentPanel from '../components/AgentPanel'
|
||||
import { Server, Users, Activity, AlertTriangle } from 'lucide-react'
|
||||
import ProxmoxPanel from '../components/ProxmoxPanel'
|
||||
import { Server, HardDrive, Users, Activity, AlertTriangle } from 'lucide-react'
|
||||
|
||||
export default function Dashboard() {
|
||||
const [agents, setAgents] = useState([])
|
||||
const [customers, setCustomers] = useState([])
|
||||
const [agentDetails, setAgentDetails] = useState({})
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [selectedId, setSelectedId] = useState(null)
|
||||
const [selectedType, setSelectedType] = useState(null) // 'device' or 'proxmox'
|
||||
|
||||
const reload = () => {
|
||||
Promise.all([api.getAgents(), api.getCustomers()])
|
||||
.then(([a, c]) => {
|
||||
setAgents(a || [])
|
||||
setCustomers(c || [])
|
||||
// Lade Details fuer Typ-Erkennung
|
||||
;(a || []).forEach((agent) => {
|
||||
if (!agentDetails[agent.id]) {
|
||||
api.getAgent(agent.id).then((d) => {
|
||||
setAgentDetails((prev) => ({ ...prev, [agent.id]: d }))
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}
|
||||
|
||||
const isProxmox = (agentId) => {
|
||||
const d = agentDetails[agentId]
|
||||
return d?.system_data?.proxmox?.available === true
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
reload()
|
||||
}, [])
|
||||
@@ -48,8 +64,9 @@ export default function Dashboard() {
|
||||
<h1 className="text-xl font-bold text-white">Dashboard</h1>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<StatCard icon={Server} label="Firewalls" value={agents.length} color="text-blue-400" />
|
||||
<div className="grid grid-cols-2 lg:grid-cols-5 gap-4">
|
||||
<StatCard icon={Server} label="Geraete" value={agents.filter(a => !isProxmox(a.id)).length} color="text-blue-400" />
|
||||
<StatCard icon={HardDrive} label="Proxmox" value={agents.filter(a => isProxmox(a.id)).length} color="text-purple-400" />
|
||||
<StatCard icon={Activity} label="Online" value={online} color="text-green-400" />
|
||||
<StatCard icon={AlertTriangle} label="Offline" value={offline} color="text-red-400" />
|
||||
<StatCard icon={Users} label="Kunden" value={customers.length} color="text-orange-400" />
|
||||
@@ -67,12 +84,12 @@ export default function Dashboard() {
|
||||
<span className="text-orange-400">{customer.number}</span> — {customer.name}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{customer.agents.length} {customer.agents.length === 1 ? 'Firewall' : 'Firewalls'}
|
||||
{customer.agents.length} {customer.agents.length === 1 ? 'Geraet' : 'Geraete'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="divide-y divide-gray-800">
|
||||
{customer.agents.map((agent) => (
|
||||
<AgentRow key={agent.id} agent={agent} onClick={() => setSelectedId(agent.id)} selected={selectedId === agent.id} />
|
||||
<AgentRow key={agent.id} agent={agent} isPve={isProxmox(agent.id)} onClick={() => { setSelectedId(agent.id); setSelectedType(isProxmox(agent.id) ? 'proxmox' : 'device') }} selected={selectedId === agent.id} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -88,19 +105,27 @@ export default function Dashboard() {
|
||||
{agents
|
||||
.filter((a) => !a.customer_id)
|
||||
.map((agent) => (
|
||||
<AgentRow key={agent.id} agent={agent} onClick={() => setSelectedId(agent.id)} selected={selectedId === agent.id} />
|
||||
<AgentRow key={agent.id} agent={agent} isPve={isProxmox(agent.id)} onClick={() => { setSelectedId(agent.id); setSelectedType(isProxmox(agent.id) ? 'proxmox' : 'device') }} selected={selectedId === agent.id} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Detail Panel */}
|
||||
{selectedId && (
|
||||
{/* Detail Panel — Geraet-Detail */}
|
||||
{selectedId && selectedType === 'proxmox' && (
|
||||
<ProxmoxPanel
|
||||
agentId={selectedId}
|
||||
customers={customers}
|
||||
onClose={() => { setSelectedId(null); setSelectedType(null) }}
|
||||
onReload={reload}
|
||||
/>
|
||||
)}
|
||||
{selectedId && selectedType !== 'proxmox' && (
|
||||
<AgentPanel
|
||||
agentId={selectedId}
|
||||
customers={customers}
|
||||
onClose={() => setSelectedId(null)}
|
||||
onClose={() => { setSelectedId(null); setSelectedType(null) }}
|
||||
onReload={reload}
|
||||
/>
|
||||
)}
|
||||
@@ -122,7 +147,7 @@ function StatCard({ icon: Icon, label, value, color }) {
|
||||
)
|
||||
}
|
||||
|
||||
function AgentRow({ agent, onClick, selected }) {
|
||||
function AgentRow({ agent, isPve, onClick, selected }) {
|
||||
const lastHB = agent.last_heartbeat
|
||||
? new Date(agent.last_heartbeat).toLocaleString('de-DE')
|
||||
: '—'
|
||||
@@ -135,7 +160,12 @@ function AgentRow({ agent, onClick, selected }) {
|
||||
<div className="flex items-center gap-3">
|
||||
<StatusBadge status={agent.status} size="dot" />
|
||||
<div>
|
||||
<div className="text-sm text-white">{agent.name}</div>
|
||||
<div className="text-sm text-white inline-flex items-center gap-2">
|
||||
<span className={`text-[10px] font-mono px-1 rounded ${agent.platform === 'linux' ? 'bg-blue-900/50 text-blue-400' : 'bg-orange-900/50 text-orange-400'}`}>
|
||||
{agent.platform === 'linux' ? 'LNX' : 'OPN'}
|
||||
</span>
|
||||
{agent.name}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">{agent.ip}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -102,14 +102,19 @@ export default function Firmware() {
|
||||
|
||||
if (loading) return <div className="text-gray-500">Laden...</div>
|
||||
|
||||
// Fuer jetzt: alle Agents sind freebsd
|
||||
const freebsdFw = getFwForPlatform('freebsd')
|
||||
const getFwForAgent = (agent) => {
|
||||
const p = agent.platform || 'freebsd'
|
||||
return getFwForPlatform(p)
|
||||
}
|
||||
|
||||
const needsUpdate = (agent) => {
|
||||
if (!freebsdFw) return false
|
||||
return agent.agent_version !== freebsdFw.version
|
||||
const fw = getFwForAgent(agent)
|
||||
if (!fw) return false
|
||||
return agent.agent_version !== fw.version
|
||||
}
|
||||
|
||||
const hasAnyFirmware = PLATFORMS.some(p => getFwForPlatform(p.id))
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
<h1 className="text-2xl font-bold text-white">Agent-Firmware</h1>
|
||||
@@ -201,7 +206,7 @@ export default function Firmware() {
|
||||
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider">Agents</h2>
|
||||
{freebsdFw && (
|
||||
{hasAnyFirmware && (
|
||||
<button
|
||||
onClick={requestAll}
|
||||
className="flex items-center gap-2 bg-orange-600/20 hover:bg-orange-600/30 text-orange-400 px-3 py-1.5 rounded text-xs transition-colors border border-orange-600/30"
|
||||
@@ -216,6 +221,10 @@ export default function Firmware() {
|
||||
{agents.map((agent) => {
|
||||
const outdated = needsUpdate(agent)
|
||||
const pending = agent.update_requested
|
||||
const agentPlatform = agent.platform || 'freebsd'
|
||||
const fw = getFwForAgent(agent)
|
||||
const platformLabel = agentPlatform === 'linux' ? 'Linux' : 'FreeBSD'
|
||||
const platformColor = agentPlatform === 'linux' ? 'bg-blue-900/50 text-blue-400' : 'bg-orange-900/50 text-orange-400'
|
||||
return (
|
||||
<div key={agent.id} className="flex items-center justify-between bg-gray-900/50 rounded-lg px-4 py-3 border border-gray-700/30">
|
||||
<div className="min-w-0 flex-1">
|
||||
@@ -225,18 +234,20 @@ export default function Firmware() {
|
||||
agent.status === 'stale' ? 'bg-yellow-500' : 'bg-gray-600'
|
||||
}`} />
|
||||
<span className="text-white text-sm font-medium truncate">{agent.name}</span>
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-gray-700/50 text-gray-500">FreeBSD</span>
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded ${platformColor}`}>{platformLabel}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
<span className="text-xs text-gray-500">
|
||||
Version: <span className={outdated ? 'text-yellow-400' : 'text-gray-400'}>{agent.agent_version || '--'}</span>
|
||||
{fw && <span className="text-gray-600 ml-1">(aktuell: v{fw.version})</span>}
|
||||
{!fw && <span className="text-gray-600 ml-1">(keine Firmware fuer {platformLabel})</span>}
|
||||
</span>
|
||||
{pending && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-orange-500/20 text-orange-400 border border-orange-500/30">
|
||||
Update ausstehend
|
||||
</span>
|
||||
)}
|
||||
{!outdated && freebsdFw && agent.agent_version && (
|
||||
{!outdated && fw && agent.agent_version && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-emerald-500/20 text-emerald-400 border border-emerald-500/30">
|
||||
Aktuell
|
||||
</span>
|
||||
|
||||
@@ -0,0 +1,483 @@
|
||||
import { useEffect, useState, useMemo } from 'react'
|
||||
import api from '../api/client'
|
||||
import StatusBadge from '../components/StatusBadge'
|
||||
import ProxmoxPanel from '../components/ProxmoxPanel'
|
||||
import { Search, ChevronUp, ChevronDown, Plus, Copy, Check, X } from 'lucide-react'
|
||||
|
||||
export default function ProxmoxServers() {
|
||||
const [agents, setAgents] = useState([])
|
||||
const [customers, setCustomers] = useState([])
|
||||
const [search, setSearch] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [sortKey, setSortKey] = useState('customer')
|
||||
const [sortDir, setSortDir] = useState('asc')
|
||||
const [selectedId, setSelectedId] = useState(null)
|
||||
const [showAddDialog, setShowAddDialog] = useState(false)
|
||||
const [addResult, setAddResult] = useState(null)
|
||||
|
||||
const reload = () => {
|
||||
Promise.all([api.getAgents(), api.getCustomers()])
|
||||
.then(([a, c]) => {
|
||||
setAgents(a || [])
|
||||
setCustomers(c || [])
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
reload()
|
||||
const iv = setInterval(reload, 30000)
|
||||
return () => clearInterval(iv)
|
||||
}, [])
|
||||
|
||||
const customerMap = useMemo(() => {
|
||||
const m = {}
|
||||
customers.forEach((c) => (m[c.id] = c))
|
||||
return m
|
||||
}, [customers])
|
||||
|
||||
// Enrich agents with system data summaries
|
||||
const [agentDetails, setAgentDetails] = useState({})
|
||||
|
||||
useEffect(() => {
|
||||
agents.forEach((a) => {
|
||||
if (!agentDetails[a.id]) {
|
||||
api.getAgent(a.id).then((d) => {
|
||||
setAgentDetails((prev) => ({ ...prev, [a.id]: d }))
|
||||
})
|
||||
}
|
||||
})
|
||||
}, [agents])
|
||||
|
||||
const toggleSort = (key) => {
|
||||
if (sortKey === key) {
|
||||
setSortDir(sortDir === 'asc' ? 'desc' : 'asc')
|
||||
} else {
|
||||
setSortKey(key)
|
||||
setSortDir('asc')
|
||||
}
|
||||
}
|
||||
|
||||
const enriched = useMemo(() => {
|
||||
return agents.map((a) => {
|
||||
const detail = agentDetails[a.id]
|
||||
const sys = detail?.system_data
|
||||
const proxmox = sys?.proxmox
|
||||
const cust = a.customer_id ? customerMap[a.customer_id] : null
|
||||
|
||||
// Nur Proxmox-Agents anzeigen
|
||||
if (!proxmox?.available) return null
|
||||
|
||||
const rootDisk = sys?.disks?.find((d) => d.mount_point === '/')
|
||||
const diskPct = rootDisk && rootDisk.total_bytes > 0
|
||||
? (rootDisk.used_bytes / rootDisk.total_bytes * 100)
|
||||
: null
|
||||
const ramPct = sys?.memory?.total_bytes > 0
|
||||
? (sys.memory.used_bytes / sys.memory.total_bytes * 100)
|
||||
: null
|
||||
|
||||
const vmsRunning = proxmox.vms?.filter(vm => vm.status === 'running').length || 0
|
||||
const vmsTotal = proxmox.vms?.length || 0
|
||||
const ctRunning = proxmox.containers?.filter(ct => ct.status === 'running').length || 0
|
||||
const ctTotal = proxmox.containers?.length || 0
|
||||
|
||||
return {
|
||||
...a,
|
||||
customer: cust,
|
||||
customerName: cust?.name || '',
|
||||
customerNumber: cust?.number || '',
|
||||
cpuPct: sys?.cpu?.usage_percent ?? null,
|
||||
ramPct,
|
||||
diskPct,
|
||||
uptime: sys?.uptime_seconds || 0,
|
||||
pveVersion: proxmox.version || '',
|
||||
vms: `${vmsRunning}/${vmsTotal}`,
|
||||
vmsRunning,
|
||||
vmsTotal,
|
||||
containers: `${ctRunning}/${ctTotal}`,
|
||||
ctRunning,
|
||||
ctTotal,
|
||||
sys,
|
||||
proxmox,
|
||||
}
|
||||
}).filter(Boolean) // Entferne null Werte
|
||||
}, [agents, agentDetails, customerMap])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let list = enriched
|
||||
if (search) {
|
||||
const q = search.toLowerCase()
|
||||
list = list.filter((a) =>
|
||||
a.name.toLowerCase().includes(q) ||
|
||||
a.ip.toLowerCase().includes(q) ||
|
||||
a.hostname.toLowerCase().includes(q) ||
|
||||
a.customerName.toLowerCase().includes(q) ||
|
||||
a.customerNumber.toLowerCase().includes(q)
|
||||
)
|
||||
}
|
||||
list.sort((a, b) => {
|
||||
let va, vb
|
||||
switch (sortKey) {
|
||||
case 'customer': va = a.customerNumber; vb = b.customerNumber; break
|
||||
case 'name': va = a.name; vb = b.name; break
|
||||
case 'ip': va = a.ip; vb = b.ip; break
|
||||
case 'version': va = a.pveVersion; vb = b.pveVersion; break
|
||||
case 'uptime': return sortDir === 'asc' ? a.uptime - b.uptime : b.uptime - a.uptime
|
||||
case 'cpu': return sortDir === 'asc' ? (a.cpuPct ?? 999) - (b.cpuPct ?? 999) : (b.cpuPct ?? -1) - (a.cpuPct ?? -1)
|
||||
case 'ram': return sortDir === 'asc' ? (a.ramPct ?? 999) - (b.ramPct ?? 999) : (b.ramPct ?? -1) - (a.ramPct ?? -1)
|
||||
case 'disk': return sortDir === 'asc' ? (a.diskPct ?? 999) - (b.diskPct ?? 999) : (b.diskPct ?? -1) - (a.diskPct ?? -1)
|
||||
case 'vms': return sortDir === 'asc' ? a.vmsTotal - b.vmsTotal : b.vmsTotal - a.vmsTotal
|
||||
case 'containers': return sortDir === 'asc' ? a.ctTotal - b.ctTotal : b.ctTotal - a.ctTotal
|
||||
case 'status': va = a.status; vb = b.status; break
|
||||
default: va = a.name; vb = b.name
|
||||
}
|
||||
if (typeof va === 'string') {
|
||||
const cmp = va.localeCompare(vb)
|
||||
return sortDir === 'asc' ? cmp : -cmp
|
||||
}
|
||||
return 0
|
||||
})
|
||||
return list
|
||||
}, [enriched, search, sortKey, sortDir])
|
||||
|
||||
const SortHeader = ({ label, k, className = '' }) => (
|
||||
<th
|
||||
className={`px-3 py-2 font-medium cursor-pointer hover:text-gray-300 select-none ${className}`}
|
||||
onClick={() => toggleSort(k)}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{label}
|
||||
{sortKey === k && (sortDir === 'asc' ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />)}
|
||||
</span>
|
||||
</th>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-bold text-white">Proxmox Server ({enriched.length})</h1>
|
||||
<button
|
||||
onClick={() => { setShowAddDialog(true); setAddResult(null) }}
|
||||
className="flex items-center gap-2 px-3 py-1.5 bg-orange-600 hover:bg-orange-500 text-white text-sm rounded transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" /> Server hinzufuegen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-2.5 w-4 h-4 text-gray-500" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Suchen (Name, IP, Kunde)..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full bg-gray-900 border border-gray-800 rounded pl-10 pr-3 py-2 text-sm text-white placeholder-gray-600 focus:outline-none focus:border-orange-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-gray-500">Laden...</div>
|
||||
) : (
|
||||
<div className="bg-gray-900 rounded-lg border border-gray-800 overflow-x-auto">
|
||||
<table className="w-full text-sm whitespace-nowrap">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-800 text-left text-gray-500 text-xs">
|
||||
<th className="px-3 py-2 font-medium w-8"></th>
|
||||
<SortHeader label="KUNDE" k="customer" />
|
||||
<SortHeader label="NAME" k="name" />
|
||||
<th className="px-3 py-2 font-medium">HOSTNAME</th>
|
||||
<SortHeader label="PVE VERSION" k="version" />
|
||||
<th className="px-3 py-2 font-medium">IP</th>
|
||||
<SortHeader label="UPTIME" k="uptime" />
|
||||
<SortHeader label="CPU" k="cpu" />
|
||||
<SortHeader label="RAM" k="ram" />
|
||||
<SortHeader label="DISK" k="disk" />
|
||||
<SortHeader label="VMS" k="vms" />
|
||||
<SortHeader label="CONTAINER" k="containers" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-800">
|
||||
{filtered.map((a) => (
|
||||
<tr
|
||||
key={a.id}
|
||||
onClick={() => setSelectedId(a.id)}
|
||||
className={`hover:bg-gray-800/50 transition-colors cursor-pointer ${selectedId === a.id ? 'bg-gray-800/70' : ''}`}
|
||||
>
|
||||
<td className="px-3 py-2"><StatusBadge status={a.status} size="dot" /></td>
|
||||
<td className="px-3 py-2 text-gray-400">
|
||||
{a.customer ? <span className="text-orange-400">{a.customerNumber}</span> : <span className="text-gray-600">—</span>}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-white font-medium">{a.name}</td>
|
||||
<td className="px-3 py-2 text-gray-400 text-xs">{a.hostname}</td>
|
||||
<td className="px-3 py-2 text-gray-400">{a.pveVersion || '—'}</td>
|
||||
<td className="px-3 py-2 text-gray-400">{a.ip}</td>
|
||||
<td className="px-3 py-2 text-gray-400">{a.uptime ? formatUptime(a.uptime) : '—'}</td>
|
||||
<td className="px-3 py-2">
|
||||
<MiniBar value={a.cpuPct} />
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<MiniBar value={a.ramPct} />
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<MiniBar value={a.diskPct} />
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<VmBadge running={a.vmsRunning} total={a.vmsTotal} />
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<VmBadge running={a.ctRunning} total={a.ctTotal} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{filtered.length === 0 && (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
{search ? 'Keine Treffer' : 'Keine Proxmox Server registriert'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Detail Panel */}
|
||||
{selectedId && (
|
||||
<ProxmoxPanel
|
||||
agentId={selectedId}
|
||||
customers={customers}
|
||||
onClose={() => setSelectedId(null)}
|
||||
onReload={reload}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Server hinzufuegen Dialog */}
|
||||
{showAddDialog && (
|
||||
<AddServerDialog
|
||||
customers={customers}
|
||||
result={addResult}
|
||||
onAdd={async (name, custId) => {
|
||||
const res = await api.preRegisterAgent(name, custId)
|
||||
setAddResult(res)
|
||||
reload()
|
||||
}}
|
||||
onClose={() => { setShowAddDialog(false); setAddResult(null) }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AddServerDialog({ customers, result, onAdd, onClose }) {
|
||||
const [name, setName] = useState('')
|
||||
const [custId, setCustId] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [copied, setCopied] = useState(null)
|
||||
|
||||
const BACKEND_URL = 'https://192.168.85.13:8443'
|
||||
const API_KEY = '01532e5a7c9e70bf2757df77a2f5b9b9'
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!name.trim()) { setError('Name ist erforderlich'); return }
|
||||
setError('')
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await onAdd(name.trim(), custId ? parseInt(custId) : null)
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
}
|
||||
setSubmitting(false)
|
||||
}
|
||||
|
||||
const copyText = (text, id) => {
|
||||
navigator.clipboard.writeText(text)
|
||||
setCopied(id)
|
||||
setTimeout(() => setCopied(null), 2000)
|
||||
}
|
||||
|
||||
const CopyBtn = ({ text, id }) => (
|
||||
<button onClick={() => copyText(text, id)} className="ml-2 text-gray-500 hover:text-orange-400 transition-colors">
|
||||
{copied === id ? <Check className="w-3.5 h-3.5 text-green-400" /> : <Copy className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
)
|
||||
|
||||
const installCmd = `# Proxmox/Linux Server Installation:
|
||||
|
||||
# 1. Agent + Updater auf den Server kopieren:
|
||||
scp build/rmm-agent-linux build/rmm-updater-linux root@<SERVER-IP>:/tmp/
|
||||
|
||||
# 2. Installation auf dem Server:
|
||||
ssh root@<SERVER-IP> 'bash -s' << 'INSTALL'
|
||||
mkdir -p /usr/local/bin /etc/rmm
|
||||
|
||||
cp /tmp/rmm-agent-linux /usr/local/bin/rmm-agent
|
||||
cp /tmp/rmm-updater-linux /usr/local/bin/rmm-updater
|
||||
chmod +x /usr/local/bin/rmm-agent /usr/local/bin/rmm-updater
|
||||
|
||||
# Config erstellen:
|
||||
cat > /etc/rmm/config.yaml << EOF
|
||||
backend_url: ${BACKEND_URL}
|
||||
api_key: ${API_KEY}
|
||||
agent_name: ${name || '<NAME>'}
|
||||
heartbeat_interval: 30
|
||||
insecure: true
|
||||
EOF
|
||||
|
||||
# Agent Service:
|
||||
cat > /etc/systemd/system/rmm-agent.service << EOF
|
||||
[Unit]
|
||||
Description=RMM Agent
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
ExecStart=/usr/local/bin/rmm-agent
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
# Updater Service:
|
||||
cat > /etc/systemd/system/rmm-updater.service << EOF
|
||||
[Unit]
|
||||
Description=RMM Updater
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
ExecStart=/usr/local/bin/rmm-updater
|
||||
Restart=always
|
||||
RestartSec=30
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now rmm-agent rmm-updater
|
||||
INSTALL`
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={onClose}>
|
||||
<div className="bg-gray-900 border border-gray-700 rounded-lg shadow-xl w-full max-w-2xl mx-4" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between px-5 py-3 border-b border-gray-800">
|
||||
<h2 className="text-lg font-semibold text-white">Server hinzufuegen</h2>
|
||||
<button onClick={onClose} className="text-gray-500 hover:text-white"><X className="w-5 h-5" /></button>
|
||||
</div>
|
||||
|
||||
<div className="px-5 py-4 space-y-4">
|
||||
{!result ? (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
placeholder="z.B. PVE-KUNDE.intra.example.net"
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-white placeholder-gray-600 focus:outline-none focus:border-orange-500"
|
||||
autoFocus
|
||||
onKeyDown={e => e.key === 'Enter' && handleSubmit()}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">Kunde (optional)</label>
|
||||
<select
|
||||
value={custId}
|
||||
onChange={e => setCustId(e.target.value)}
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-white focus:outline-none focus:border-orange-500"
|
||||
>
|
||||
<option value="">Kein Kunde</option>
|
||||
{customers.map(c => (
|
||||
<option key={c.id} value={c.id}>{c.number} — {c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{error && <p className="text-red-400 text-sm">{error}</p>}
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={submitting}
|
||||
className="w-full py-2 bg-orange-600 hover:bg-orange-500 disabled:opacity-50 text-white text-sm font-medium rounded transition-colors"
|
||||
>
|
||||
{submitting ? 'Wird angelegt...' : 'Server anlegen'}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="bg-green-900/30 border border-green-800 rounded p-3 text-sm text-green-300">
|
||||
Server "{result.name}" erfolgreich angelegt (ID: {result.id?.substring(0, 12)}...)
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-white mb-2">Installationsanleitung</h3>
|
||||
<div className="bg-gray-950 rounded p-3 text-xs font-mono text-gray-300 whitespace-pre-wrap relative">
|
||||
{installCmd}
|
||||
<button
|
||||
onClick={() => copyText(installCmd, 'install')}
|
||||
className="absolute top-2 right-2 text-gray-600 hover:text-orange-400 transition-colors"
|
||||
>
|
||||
{copied === 'install' ? <Check className="w-4 h-4 text-green-400" /> : <Copy className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-500">Backend URL:</span>
|
||||
<span className="text-white ml-2">{BACKEND_URL}</span>
|
||||
<CopyBtn text={BACKEND_URL} id="url" />
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">API Key:</span>
|
||||
<span className="text-white ml-2 font-mono text-xs">{API_KEY}</span>
|
||||
<CopyBtn text={API_KEY} id="key" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-gray-500">
|
||||
Der Agent verbindet sich nach der Installation automatisch mit dem Backend.
|
||||
Hostname und IP werden beim ersten Heartbeat aktualisiert.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function VmBadge({ running, total }) {
|
||||
if (total === 0) return <span className="text-gray-600 text-xs">—</span>
|
||||
return (
|
||||
<span className="text-xs">
|
||||
<span className="text-green-400">{running}</span>
|
||||
<span className="text-gray-500">/{total}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function MiniBar({ value }) {
|
||||
if (value === null || value === undefined) return <span className="text-gray-600 text-xs">—</span>
|
||||
const color = value > 90 ? 'bg-red-500' : value > 70 ? 'bg-yellow-500' : value > 50 ? 'bg-blue-500' : 'bg-green-500'
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-12 h-1.5 bg-gray-800 rounded overflow-hidden">
|
||||
<div className={`h-full rounded ${color}`} style={{ width: `${Math.min(value, 100)}%` }} />
|
||||
</div>
|
||||
<span className="text-xs text-gray-400">{value.toFixed(0)}%</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatUptime(seconds) {
|
||||
const d = Math.floor(seconds / 86400)
|
||||
const h = Math.floor((seconds % 86400) / 3600)
|
||||
const m = Math.floor((seconds % 3600) / 60)
|
||||
if (d > 0) return `${d}d ${h}h`
|
||||
return `${h}h ${m}m`
|
||||
}
|
||||
@@ -77,7 +77,7 @@ export default function Tunnels() {
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-xl font-bold text-white">Aktive Tunnel</h1>
|
||||
<span className="text-sm text-gray-500">
|
||||
{allTunnels.length} {allTunnels.length === 1 ? 'Tunnel' : 'Tunnel'} auf {new Set(allTunnels.map((t) => t.agentId)).size} Firewall(s)
|
||||
{allTunnels.length} {allTunnels.length === 1 ? 'Tunnel' : 'Tunnel'} auf {new Set(allTunnels.map((t) => t.agentId)).size} Geraet(e)
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -104,7 +104,7 @@ export default function Tunnels() {
|
||||
<Search className="absolute left-3 top-2.5 w-4 h-4 text-gray-500" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Suchen (Firewall, IP, Ziel, Port)..."
|
||||
placeholder="Suchen (Geraet, IP, Ziel, Port)..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full bg-gray-900 border border-gray-800 rounded pl-10 pr-3 py-2 text-sm text-white placeholder-gray-600 focus:outline-none focus:border-orange-500"
|
||||
@@ -114,7 +114,7 @@ export default function Tunnels() {
|
||||
{error && <div className="text-sm text-red-400 bg-red-900/20 rounded px-3 py-2 border border-red-800/50">{error}</div>}
|
||||
|
||||
{loading ? (
|
||||
<div className="text-gray-500">Lade Tunnel aller Firewalls...</div>
|
||||
<div className="text-gray-500">Lade Tunnel aller Geraete...</div>
|
||||
) : (() => {
|
||||
const filtered = search
|
||||
? allTunnels.filter((t) => {
|
||||
@@ -131,7 +131,7 @@ export default function Tunnels() {
|
||||
<Cable className="w-10 h-10 text-gray-700 mx-auto mb-3" />
|
||||
<div className="text-gray-500">Keine aktiven Tunnel</div>
|
||||
<div className="text-xs text-gray-600 mt-1">
|
||||
Tunnel koennen ueber die Firewall-Detailansicht erstellt werden
|
||||
Tunnel koennen ueber die Geraete-Detailansicht erstellt werden
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -139,7 +139,7 @@ export default function Tunnels() {
|
||||
<table className="w-full text-sm whitespace-nowrap">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-800 text-left text-gray-500 text-xs">
|
||||
<th className="px-4 py-2.5 font-medium">Firewall</th>
|
||||
<th className="px-4 py-2.5 font-medium">Geraet</th>
|
||||
<th className="px-4 py-2.5 font-medium">Ziel</th>
|
||||
<th className="px-4 py-2.5 font-medium">Lokaler Zugang</th>
|
||||
<th className="px-4 py-2.5 font-medium">Status</th>
|
||||
|
||||
Reference in New Issue
Block a user