Initial commit: PoE Manager modern UI rebuild
- Neues, eigenständiges Frontend (Sidebar, zentriertes Logo in der Topbar, Dark/Light-Theme, Karten-Dashboard, Toasts/Modals statt Bootstrap) - Oeffentliches Kurz-Dashboard ohne Login (Status-Uebersicht) - Browser-SSH-Terminal (paramiko, plattformunabhaengig) zum Testen von Switch-Zugangsdaten inkl. interaktiver Host-Key-Bestaetigung - Granulares Rechtesystem mit Gruppen (Devices/Switches-Berechtigungen) - Aufgeraeumtes Backend mit konfigurierbaren Pfaden, auto-generierten Secrets statt hart codierter Werte im Original Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+13
@@ -0,0 +1,13 @@
|
||||
# Virtuelle Umgebung (Linux — das Projekt ist ausschließlich für Linux ausgelegt)
|
||||
/srv/poe_manager/venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# Logfiles
|
||||
*.log
|
||||
|
||||
# Secrets — niemals committen
|
||||
/srv/poe_manager/sqlite.db
|
||||
/srv/poe_manager/fernet.key
|
||||
/srv/poe_manager/secret.key
|
||||
/srv/poe_manager/known_hosts
|
||||
@@ -0,0 +1,197 @@
|
||||
# PoE Manager — Modern UI Rebuild
|
||||
|
||||
Webbasierte Verwaltung und Monitoring von PoE-Devices und Aruba-Switchen —
|
||||
funktionales Rebuild von [Aruba-PoE](https://gitea.int.eertmoed.net/WiS/Aruba-PoE)
|
||||
mit komplett neuem, modernem Frontend und einem granularen Rechtesystem.
|
||||
|
||||
**Das Projekt ist ausschließlich für Linux ausgelegt** (Zielsystem: eine
|
||||
Linux-VM). Entwickelt wird unter WSL2 (Ubuntu) unter Windows — siehe
|
||||
[Entwicklung unter WSL](#entwicklung-unter-wsl) weiter unten.
|
||||
|
||||
Die App ermöglicht:
|
||||
|
||||
- **Öffentliches Kurz-Dashboard ohne Login** (Hostname, IP, Status, letzte
|
||||
Prüfung) — für den schnellen Blick auf dem Flur-Monitor; volle Verwaltung
|
||||
und PoE-Neustart bleiben hinter dem Login
|
||||
- Verwaltung von Devices und Switches (inkl. verschlüsselter SSH-Zugangsdaten)
|
||||
- **Browser-SSH-Terminal beim Anlegen/Bearbeiten eines Switches**, um den
|
||||
Host-Key zu bestätigen und die Zugangsdaten direkt zu prüfen, bevor der
|
||||
Switch gespeichert wird — funktioniert auf jedem Betriebssystem, die
|
||||
SSH-Verbindung wird dabei immer vom PoE-Server aus aufgebaut
|
||||
- Manueller PoE-Neustart einzelner Geräte per Klick
|
||||
- Einstellbares Prüfintervall (steuert den Hintergrund-Check-Dienst)
|
||||
- Live-Log-Ansicht mit farblicher Hervorhebung (online/offline)
|
||||
- **Rechtesystem mit Gruppen**: Admins dürfen immer alles; normalen
|
||||
Benutzern lassen sich über Gruppen einzelne Verwaltungsrechte gezielt
|
||||
zuweisen (siehe [Rechtesystem](#rechtesystem-gruppen--berechtigungen))
|
||||
|
||||
## Was ist neu gegenüber dem Original?
|
||||
|
||||
- **Komplett neues Frontend**: eigenes Design-System (kein Bootstrap mehr),
|
||||
Logo mittig in der Topbar (auf jeder Seite, skaliert auf die Balkenhöhe),
|
||||
Sidebar-Navigation (nur für eingeloggte Nutzer sichtbar), Dark/Light-
|
||||
Umschalter, Karten-Dashboard mit Live-Zählern, moderne Modals/Toasts statt
|
||||
Browser-`confirm()` und Bootstrap-Alerts.
|
||||
- **Dashboard jetzt teilweise öffentlich**: `/` ist ohne Login erreichbar und
|
||||
zeigt eine reduzierte Lese-Ansicht (Hostname, IP, Status, letzte Prüfung)
|
||||
mit Login-Button; alle Verwaltungsseiten und der PoE-Neustart bleiben
|
||||
`@login_required` und zusätzlich rechtebasiert (s.u.).
|
||||
- **Browser-SSH-Terminal für Switche**, implementiert mit **paramiko** (reines
|
||||
Python) statt eines lokalen pty + System-`ssh`-Client — läuft dadurch
|
||||
plattformunabhängig. Die SSH-Verbindung zum Switch wird immer serverseitig
|
||||
(vom PoE-Server) aufgebaut, nie vom Browser/Client aus.
|
||||
- **Granulares Rechtesystem mit Gruppen** (neu, siehe unten) statt der
|
||||
ursprünglichen reinen Admin/User-Unterscheidung.
|
||||
- **Gleiche Kernlogik**: Datenbankschema (erweitert um Gruppen/Rechte),
|
||||
Login, PoE-Restart-Mechanismus (`poe.sh` per SSH/`expect` auf die
|
||||
Aruba-Switche) sind ansonsten unverändert.
|
||||
- **Aufgeräumtes Backend**: konsistente, konfigurierbare Pfade (`POE_DB_PATH`,
|
||||
`POE_SCRIPT`, `POE_LOG_GLOB`, …) statt hart codierter/inkonsistenter Pfade;
|
||||
Secret-Key und Fernet-Key werden beim ersten Start automatisch generiert
|
||||
statt im Repo zu liegen.
|
||||
|
||||
> ⚠️ **Sicherheitshinweis zum Original-Repo:** Im Original sind `sqlite.db`
|
||||
> (Nutzer-Passwort-Hashes, verschlüsselte Switch-Passwörter) und `fernet.key`
|
||||
> (der zugehörige Schlüssel) mit im Git-Repo eingecheckt — die `.gitignore`
|
||||
> dort schließt sie über die `!`-Regeln versehentlich wieder ein. In diesem
|
||||
> Rebuild sind beide Dateien in `.gitignore` ausgeschlossen und werden beim
|
||||
> ersten Start frisch erzeugt. Empfehlung fürs Original-Repo: beide Dateien
|
||||
> aus der Git-Historie entfernen und Fernet-Key sowie alle Switch-/User-
|
||||
> Passwörter rotieren.
|
||||
|
||||
## Rechtesystem (Gruppen & Berechtigungen)
|
||||
|
||||
Admins (`is_admin=1`) dürfen wie bisher alles, inklusive Benutzer-/Gruppen-/
|
||||
Settingsverwaltung — das bleibt exklusiv Admins vorbehalten. Zusätzlich gibt
|
||||
es jetzt **Gruppen**: eine Gruppe bündelt einzelne Verwaltungsrechte, die
|
||||
dann normalen Benutzern zugewiesen werden können, ohne sie zu Admins zu
|
||||
machen. Ein Benutzer kann mehreren Gruppen angehören — die Rechte addieren
|
||||
sich (Vereinigung, nicht Schnittmenge).
|
||||
|
||||
Verfügbare Rechte:
|
||||
|
||||
| Bereich | Recht | Bedeutung |
|
||||
|----------|---------------------|---------------------------------------------|
|
||||
| Devices | `devices.toggle` | Geräte aktivieren/deaktivieren |
|
||||
| Devices | `devices.create` | Geräte anlegen |
|
||||
| Devices | `devices.edit` | Geräte bearbeiten (inkl. Switch-Zuordnung) |
|
||||
| Devices | `devices.delete` | Geräte löschen |
|
||||
| Devices | `devices.restart` | PoE-Neustart über das Dashboard |
|
||||
| Switches | `switches.create` | Switche anlegen |
|
||||
| Switches | `switches.edit` | Switche bearbeiten |
|
||||
| Switches | `switches.delete` | Switche löschen |
|
||||
|
||||
Verwaltet werden Gruppen unter **Gruppen** (nur für Admins sichtbar/nutzbar):
|
||||
Name vergeben, Rechte per Checkbox zuweisen, Mitglieder auswählen. Die
|
||||
Devices-Seite bleibt für alle eingeloggten Benutzer les- und durchsuchbar;
|
||||
nur die tatsächlichen Aktionen (Buttons, Toggle-Switches, Formulare) sind an
|
||||
das jeweilige Recht gekoppelt — sowohl im Frontend (ausgeblendet) als auch
|
||||
im Backend (serverseitig durchgesetzt, unabhängig vom Frontend). Die
|
||||
Switches-Seite ist komplett unsichtbar/gesperrt für Benutzer ohne mindestens
|
||||
ein `switches.*`-Recht.
|
||||
|
||||
Datenmodell: `groups`, `group_permissions` (Gruppe → Recht), `user_groups`
|
||||
(Benutzer → Gruppe). Bestehende Datenbanken werden beim App-Start automatisch
|
||||
um diese Tabellen ergänzt (`_ensure_schema()` in `app.py`), ein manuelles
|
||||
Migrations-Skript ist nicht nötig.
|
||||
|
||||
## Entwicklung unter WSL
|
||||
|
||||
Da das Projekt ausschließlich für Linux gebaut ist, empfiehlt sich die
|
||||
Entwicklung direkt unter WSL2 (Ubuntu), auch wenn der Editor unter Windows
|
||||
läuft — das Projektverzeichnis liegt dabei ganz normal unter
|
||||
`C:\...\aruba-poe-modern` und wird aus WSL über `/mnt/c/...` erreicht:
|
||||
|
||||
```bash
|
||||
wsl --install -d Ubuntu # einmalig, falls noch nicht vorhanden
|
||||
wsl -d Ubuntu
|
||||
sudo apt update && sudo apt install -y python3 python3-venv python3-pip sqlite3 expect openssh-client
|
||||
cd /mnt/c/Users/<dich>/aruba-poe-modern/srv/poe_manager
|
||||
python3 -m venv venv
|
||||
./venv/bin/pip install -r requirements.txt
|
||||
./venv/bin/python create_db.py
|
||||
./venv/bin/python create_admin.py
|
||||
./venv/bin/python app.py # http://localhost:5000 — via WSL2-Portweiterleitung
|
||||
# auch aus dem Windows-Browser erreichbar
|
||||
```
|
||||
|
||||
## Lokal starten (Entwicklung)
|
||||
|
||||
```bash
|
||||
cd srv/poe_manager
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
|
||||
python create_db.py # legt sqlite.db + Tabellen an
|
||||
python create_admin.py # legt ersten Admin-Benutzer an
|
||||
|
||||
python app.py # http://localhost:5000
|
||||
```
|
||||
|
||||
## Produktiv-Deployment (auf der Ziel-VM, unter `/srv/poe_manager`)
|
||||
|
||||
```bash
|
||||
sudo git clone <dieses-repo> /srv/poe_manager
|
||||
cd /srv/poe_manager
|
||||
sudo bash install.sh
|
||||
sudo /srv/poe_manager/venv/bin/python3 /srv/poe_manager/create_admin.py
|
||||
```
|
||||
|
||||
`install.sh` installiert Systempakete, richtet die venv ein, initialisiert die
|
||||
Datenbank (falls nicht vorhanden), kopiert `poe.sh` nach
|
||||
`/usr/local/bin/custom/`, registriert die systemd-Units
|
||||
(`poe_web.service`, `rpi-check.service`, `rpi-check-restart.timer`) und
|
||||
konfiguriert nginx als Reverse-Proxy auf Port 80.
|
||||
|
||||
### Relevante Umgebungsvariablen (optional)
|
||||
|
||||
| Variable | Zweck | Default |
|
||||
|-----------------------|------------------------------------------|---------------------------------------|
|
||||
| `POE_DB_PATH` | Pfad zur SQLite-DB | `<app-dir>/sqlite.db` |
|
||||
| `POE_SCRIPT` | Pfad zum PoE-Restart-Skript | `/usr/local/bin/custom/poe.sh` |
|
||||
| `POE_LOG_GLOB` | Glob-Pattern der Check-Logs | `/var/log/rpi-*.log` |
|
||||
| `POE_FERNET_KEY` | Pfad zum Fernet-Schlüssel | `<app-dir>/fernet.key` (auto-erzeugt)|
|
||||
| `POE_KNOWN_HOSTS` | Pfad zur SSH-known_hosts-Datei des Terminals| `<app-dir>/known_hosts` (auto-erzeugt)|
|
||||
| `POE_SECRET_KEY` | Flask Secret Key (überschreibt Datei) | zufällig, in `secret.key` gespeichert|
|
||||
| `PORT`, `FLASK_DEBUG` | Flask-Serverport / Debug-Modus | `5000` / aus |
|
||||
|
||||
## Browser-SSH-Terminal (Switch-Verbindungstest)
|
||||
|
||||
„Verbindung testen“ im Switch-Modal öffnet eine WebSocket-Verbindung zu
|
||||
`/ws/ssh_terminal`. Serverseitig baut **paramiko** eine echte SSH-Sitzung
|
||||
zum Switch auf (inkl. interaktiver Host-Key-Bestätigung beim Erstkontakt,
|
||||
danach dauerhaft in `known_hosts` gemerkt) und reicht eine interaktive Shell
|
||||
1:1 an ein xterm.js-Terminal im Browser durch. Die Zugangsdaten aus dem
|
||||
Formular werden direkt für die SSH-Authentifizierung verwendet (nicht als
|
||||
Tastatureingabe in die Shell getippt) — Erfolg/Fehlschlag der Anmeldung wird
|
||||
sofort und eindeutig im Terminal angezeigt. Ein Klick auf „Passwort
|
||||
einfügen“ sendet den aktuell im Formular eingetragenen Wert zusätzlich als
|
||||
Tastatureingabe (z.B. für ein `sudo`-Passwort in der geöffneten Sitzung).
|
||||
|
||||
Voraussetzungen:
|
||||
- Läuft auf **jedem Betriebssystem** (paramiko ist reines Python) — die
|
||||
SSH-Verbindung wird dabei immer vom PoE-Server aus aufgebaut, nie vom
|
||||
Browser/Client.
|
||||
- Nur für eingeloggte **Admins** erreichbar.
|
||||
- Der Flask-Entwicklungsserver muss mit `threaded=True` laufen (ist bereits
|
||||
so konfiguriert) — für einen echten Produktivbetrieb empfiehlt sich
|
||||
ohnehin ein WSGI-Server mit WebSocket-Unterstützung (z.B. Gunicorn mit
|
||||
`gevent`- oder `eventlet`-Worker) statt des eingebauten Dev-Servers.
|
||||
- Da `poe_web.service` (wie im Original) als `root` läuft, kann über dieses
|
||||
Terminal im Prinzip jeder erreichbare Host per SSH kontaktiert werden —
|
||||
bewusst so gewählt, da Admins über die App ohnehin schon vollen Zugriff auf
|
||||
alle hinterlegten Switch-Zugangsdaten haben. Für eine strengere Trennung
|
||||
empfiehlt es sich, `poe_web.service` künftig unter einem eigenen,
|
||||
unprivilegierten Systembenutzer laufen zu lassen (im Original wie auch hier
|
||||
aktuell `root`, um Änderungen am produktiven Setup nicht zu vergrößern).
|
||||
|
||||
## Projektstruktur
|
||||
|
||||
```
|
||||
srv/poe_manager/ Flask-App (Backend + Templates + Static-Assets)
|
||||
usr/local/bin/custom/ poe.sh — Ping-Check & PoE-Restart per SSH (expect)
|
||||
etc/systemd/system/ Service-/Timer-Units (Web-App + Check-Dienst)
|
||||
etc/nginx/ Reverse-Proxy-Konfiguration
|
||||
install.sh Produktiv-Installer
|
||||
```
|
||||
@@ -0,0 +1,17 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:5000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /static/ {
|
||||
alias /srv/poe_manager/static/;
|
||||
expires 7d;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
[Unit]
|
||||
Description=PoE Manager Web App
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
WorkingDirectory=/srv/poe_manager
|
||||
# Nutze die virtuelle Umgebung
|
||||
ExecStart=/srv/poe_manager/venv/bin/python3 /srv/poe_manager/app.py
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
[Unit]
|
||||
Description=Restart rpi-check.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/bin/systemctl restart rpi-check.service
|
||||
@@ -0,0 +1,10 @@
|
||||
[Unit]
|
||||
Description=Restart RPI Check daily at 00:00
|
||||
|
||||
[Timer]
|
||||
OnCalendar=*-*-* 00:00:00
|
||||
Persistent=true
|
||||
Unit=rpi-check-restart.service
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -0,0 +1,14 @@
|
||||
[Unit]
|
||||
Description=RPI Ping Check
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
WorkingDirectory=/srv/poe_manager
|
||||
ExecStart=/srv/poe_manager/venv/bin/python3 /srv/poe_manager/poe_wrapper.py
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
#!/bin/bash
|
||||
# ============================================================================
|
||||
# PoE Manager — Installer
|
||||
# Richtet System-Pakete, die Flask-App (systemd) sowie den Erreichbarkeits-
|
||||
# Check-Dienst (systemd + Timer) ein. Auszuführen als root, nachdem dieses
|
||||
# Repo z.B. nach /root/aruba-poe-modern geklont wurde.
|
||||
# ============================================================================
|
||||
set -e
|
||||
|
||||
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
NC='\033[0m'
|
||||
|
||||
print_status() { echo -e "${GREEN}✔${NC} ${1} completed."; }
|
||||
step() { echo -e "${RED}→${NC} ${1}..." | tee -a /var/log/poe-manager-install.log; }
|
||||
|
||||
clear
|
||||
|
||||
# ---- Pakete ----
|
||||
step "Installing system packages"
|
||||
sudo apt-get update >>/var/log/poe-manager-install.log 2>&1 && print_status "apt update"
|
||||
sudo apt-get install -y python3 python3-venv python3-pip nginx sqlite3 expect openssh-client git >>/var/log/poe-manager-install.log 2>&1 && print_status "Packages installed"
|
||||
|
||||
# ---- App-Verzeichnis ----
|
||||
step "Deploying application to /srv/poe_manager"
|
||||
sudo mkdir -p /srv/poe_manager
|
||||
sudo rsync -a --delete --exclude 'venv' --exclude 'sqlite.db' --exclude 'fernet.key' --exclude 'secret.key' \
|
||||
"$REPO_DIR/srv/poe_manager/" /srv/poe_manager/ >>/var/log/poe-manager-install.log 2>&1
|
||||
print_status "Application files copied"
|
||||
|
||||
# ---- Python venv ----
|
||||
step "Setting up Python virtual environment"
|
||||
cd /srv/poe_manager
|
||||
sudo python3 -m venv venv
|
||||
sudo ./venv/bin/pip install --upgrade pip >>/var/log/poe-manager-install.log 2>&1
|
||||
sudo ./venv/bin/pip install -r requirements.txt >>/var/log/poe-manager-install.log 2>&1
|
||||
print_status "Virtual environment ready"
|
||||
|
||||
# ---- Datenbank ----
|
||||
if [ ! -f /srv/poe_manager/sqlite.db ]; then
|
||||
step "Initializing database"
|
||||
sudo ./venv/bin/python3 create_db.py >>/var/log/poe-manager-install.log 2>&1
|
||||
print_status "Database initialized"
|
||||
echo -e "${RED}→${NC} Kein Admin-Benutzer vorhanden. Bitte danach ausführen: sudo /srv/poe_manager/venv/bin/python3 /srv/poe_manager/create_admin.py"
|
||||
fi
|
||||
|
||||
# ---- Custom-Skripte + systemd ----
|
||||
step "Copying custom scripts and systemd units"
|
||||
sudo mkdir -p /usr/local/bin/custom
|
||||
sudo cp "$REPO_DIR/usr/local/bin/custom/poe.sh" /usr/local/bin/custom/poe.sh
|
||||
sudo chmod +x /usr/local/bin/custom/poe.sh
|
||||
sudo cp "$REPO_DIR"/etc/systemd/system/*.service /etc/systemd/system/
|
||||
sudo cp "$REPO_DIR"/etc/systemd/system/*.timer /etc/systemd/system/
|
||||
print_status "Scripts and units copied"
|
||||
|
||||
# ---- nginx ----
|
||||
step "Configuring nginx reverse proxy"
|
||||
sudo cp "$REPO_DIR/etc/nginx/sites-available/poe_manager" /etc/nginx/sites-available/poe_manager
|
||||
sudo ln -sf /etc/nginx/sites-available/poe_manager /etc/nginx/sites-enabled/poe_manager
|
||||
sudo rm -f /etc/nginx/sites-enabled/default
|
||||
sudo nginx -t >>/var/log/poe-manager-install.log 2>&1 && sudo systemctl reload nginx
|
||||
print_status "nginx configured"
|
||||
|
||||
# ---- Dienste aktivieren ----
|
||||
step "Enabling services"
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now poe_web.service
|
||||
sudo systemctl enable --now rpi-check.service rpi-check-restart.timer
|
||||
print_status "Services enabled and started"
|
||||
|
||||
echo -e "${GREEN}✔${NC} Installation abgeschlossen. Web-App erreichbar auf Port 80 (nginx) bzw. 5000 (Flask direkt)."
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,40 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Legt einen Admin-Benutzer im PoE Manager an."""
|
||||
import os
|
||||
import sqlite3
|
||||
from getpass import getpass
|
||||
from flask_bcrypt import Bcrypt
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
DB_PATH = os.environ.get("POE_DB_PATH", os.path.join(BASE_DIR, "sqlite.db"))
|
||||
bcrypt = Bcrypt()
|
||||
|
||||
|
||||
def main():
|
||||
username = input("Admin-Benutzername: ").strip()
|
||||
password = getpass("Passwort: ")
|
||||
password_confirm = getpass("Passwort bestätigen: ")
|
||||
|
||||
if password != password_confirm:
|
||||
print("Passwörter stimmen nicht überein!")
|
||||
return
|
||||
|
||||
pw_hash = bcrypt.generate_password_hash(password).decode("utf-8")
|
||||
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
cur = conn.cursor()
|
||||
try:
|
||||
cur.execute(
|
||||
"INSERT INTO users (username, password, is_admin) VALUES (?, ?, ?)",
|
||||
(username, pw_hash, 1),
|
||||
)
|
||||
conn.commit()
|
||||
print(f"Admin-Benutzer '{username}' erfolgreich angelegt.")
|
||||
except sqlite3.IntegrityError:
|
||||
print("Benutzername existiert bereits!")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Initialisiert die SQLite-Datenbank für den PoE Manager."""
|
||||
import os
|
||||
import sqlite3
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
DB_PATH = os.environ.get("POE_DB_PATH", os.path.join(BASE_DIR, "sqlite.db"))
|
||||
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
c = conn.cursor()
|
||||
|
||||
# Switches (Aruba-Switche mit SSH-Zugangsdaten; Passwort wird verschlüsselt gespeichert)
|
||||
c.execute("""
|
||||
CREATE TABLE IF NOT EXISTS switches (
|
||||
hostname TEXT PRIMARY KEY,
|
||||
ip TEXT NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
password TEXT NOT NULL
|
||||
);
|
||||
""")
|
||||
|
||||
# Devices
|
||||
c.execute("""
|
||||
CREATE TABLE IF NOT EXISTS devices (
|
||||
mac TEXT PRIMARY KEY,
|
||||
rpi_ip TEXT NOT NULL,
|
||||
switch_hostname TEXT,
|
||||
port TEXT,
|
||||
name TEXT NOT NULL,
|
||||
is_active INTEGER DEFAULT 1,
|
||||
FOREIGN KEY (switch_hostname) REFERENCES switches(hostname)
|
||||
);
|
||||
""")
|
||||
|
||||
# Benutzer
|
||||
c.execute("""
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password TEXT NOT NULL,
|
||||
is_admin INTEGER DEFAULT 0
|
||||
);
|
||||
""")
|
||||
|
||||
# Gruppen (Rechtesystem) — Admins besitzen ohnehin alle Rechte; Gruppen
|
||||
# dienen dazu, normalen Benutzern gezielt einzelne Verwaltungsrechte für
|
||||
# Devices/Switches zu geben, ohne sie zu Admins zu machen.
|
||||
c.execute("""
|
||||
CREATE TABLE IF NOT EXISTS groups (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT UNIQUE NOT NULL
|
||||
);
|
||||
""")
|
||||
|
||||
# Welche Berechtigungen eine Gruppe gewährt (siehe app.py: PERMISSIONS)
|
||||
c.execute("""
|
||||
CREATE TABLE IF NOT EXISTS group_permissions (
|
||||
group_id INTEGER NOT NULL,
|
||||
permission TEXT NOT NULL,
|
||||
PRIMARY KEY (group_id, permission),
|
||||
FOREIGN KEY (group_id) REFERENCES groups(id)
|
||||
);
|
||||
""")
|
||||
|
||||
# Gruppenmitgliedschaft (ein Benutzer kann in mehreren Gruppen sein;
|
||||
# effektive Rechte = Vereinigung aller Gruppen)
|
||||
c.execute("""
|
||||
CREATE TABLE IF NOT EXISTS user_groups (
|
||||
user_id INTEGER NOT NULL,
|
||||
group_id INTEGER NOT NULL,
|
||||
PRIMARY KEY (user_id, group_id),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id),
|
||||
FOREIGN KEY (group_id) REFERENCES groups(id)
|
||||
);
|
||||
""")
|
||||
|
||||
# Settings (z.B. Prüfintervall)
|
||||
c.execute("""
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
""")
|
||||
|
||||
# Standard-Setting: Prüfintervall 5 Minuten (interval = Minuten, check_interval = Sekunden)
|
||||
c.execute("INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)", ("interval", "5"))
|
||||
c.execute("INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)", ("check_interval", "300"))
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
print(f"Datenbank '{DB_PATH}' wurde initialisiert inklusive Settings.")
|
||||
@@ -0,0 +1,40 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Legt einen normalen (Nicht-Admin) Benutzer im PoE Manager an."""
|
||||
import os
|
||||
import sqlite3
|
||||
from getpass import getpass
|
||||
from flask_bcrypt import Bcrypt
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
DB_PATH = os.environ.get("POE_DB_PATH", os.path.join(BASE_DIR, "sqlite.db"))
|
||||
bcrypt = Bcrypt()
|
||||
|
||||
|
||||
def main():
|
||||
username = input("Benutzername: ").strip()
|
||||
password = getpass("Passwort: ")
|
||||
password_confirm = getpass("Passwort bestätigen: ")
|
||||
|
||||
if password != password_confirm:
|
||||
print("Passwörter stimmen nicht überein!")
|
||||
return
|
||||
|
||||
pw_hash = bcrypt.generate_password_hash(password).decode("utf-8")
|
||||
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
cur = conn.cursor()
|
||||
try:
|
||||
cur.execute(
|
||||
"INSERT INTO users (username, password, is_admin) VALUES (?, ?, ?)",
|
||||
(username, pw_hash, 0),
|
||||
)
|
||||
conn.commit()
|
||||
print(f"Benutzer '{username}' erfolgreich angelegt.")
|
||||
except sqlite3.IntegrityError:
|
||||
print("Benutzername existiert bereits!")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Gibt pipe-getrennt die Zugangsdaten aller aktiven Geräte + ihrer Switche aus.
|
||||
Wird von poe.sh (Bash) konsumiert, um Erreichbarkeit zu prüfen und im
|
||||
Fehlerfall den PoE-Port neu zu starten.
|
||||
"""
|
||||
import sqlite3
|
||||
from app import decrypt_password, DB_PATH
|
||||
|
||||
|
||||
def generate_ips_list():
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
|
||||
switches = {
|
||||
row["hostname"]: row
|
||||
for row in conn.execute("SELECT hostname, ip, username, password FROM switches")
|
||||
}
|
||||
|
||||
devices = conn.execute("""
|
||||
SELECT mac, rpi_ip, port, name, switch_hostname
|
||||
FROM devices
|
||||
WHERE is_active=1
|
||||
""").fetchall()
|
||||
conn.close()
|
||||
|
||||
for dev in devices:
|
||||
switch = switches.get(dev["switch_hostname"])
|
||||
if switch:
|
||||
switch_ip = switch["ip"]
|
||||
switch_user = switch["username"]
|
||||
switch_pass = decrypt_password(switch["password"])
|
||||
else:
|
||||
switch_ip = ""
|
||||
switch_user = ""
|
||||
switch_pass = ""
|
||||
|
||||
port = dev["port"] or ""
|
||||
print(
|
||||
f"{dev['rpi_ip']}|"
|
||||
f"{dev['name']}|"
|
||||
f"{switch_ip}|"
|
||||
f"{dev['switch_hostname'] or 'kein Switch'}|"
|
||||
f"{port}|"
|
||||
f"{switch_user}|"
|
||||
f"{switch_pass}|"
|
||||
f"{dev['mac']}"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
generate_ips_list()
|
||||
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Startet poe.sh innerhalb der venv des PoE Managers (z.B. für Cron/Timer)."""
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
POE_SCRIPT = os.environ.get("POE_SCRIPT", "/usr/local/bin/custom/poe.sh")
|
||||
|
||||
subprocess.run(
|
||||
["/bin/bash", POE_SCRIPT],
|
||||
env={"PATH": os.path.join(BASE_DIR, "venv", "bin") + ":" + os.environ.get("PATH", "")},
|
||||
)
|
||||
@@ -0,0 +1,22 @@
|
||||
bcrypt==5.0.0
|
||||
blinker==1.9.0
|
||||
cffi==2.0.0
|
||||
click==8.3.0
|
||||
cryptography==46.0.1
|
||||
Flask==3.1.2
|
||||
Flask-Bcrypt==1.0.1
|
||||
Flask-Login==0.6.3
|
||||
Flask-Sock==0.7.0
|
||||
itsdangerous==2.2.0
|
||||
Jinja2==3.1.6
|
||||
MarkupSafe==3.0.2
|
||||
paramiko==3.5.1
|
||||
pycparser==2.23
|
||||
PyNaCl==1.5.0
|
||||
simple-websocket==1.1.0
|
||||
Werkzeug==3.1.3
|
||||
|
||||
# Hinweis: Das Browser-SSH-Terminal (Switch-Verbindungstest) nutzt paramiko
|
||||
# (reines Python) statt eines lokalen pty + System-ssh-Client und läuft
|
||||
# damit plattformunabhängig — auch unter Windows. Die SSH-Verbindung wird
|
||||
# dabei immer serverseitig (vom PoE-Server) aufgebaut, nie vom Browser aus.
|
||||
@@ -0,0 +1,925 @@
|
||||
/* ==========================================================================
|
||||
PoE Manager — Design System
|
||||
========================================================================== */
|
||||
|
||||
:root {
|
||||
--bg: #0e1015;
|
||||
--bg-elevated: #14171e;
|
||||
--bg-card: #1a1e27;
|
||||
--bg-card-hover: #20242f;
|
||||
--bg-input: #12151b;
|
||||
--border: #2a2f3b;
|
||||
--border-soft: #21252f;
|
||||
--text: #eef0f4;
|
||||
--text-dim: #9aa2b4;
|
||||
--text-faint: #656d80;
|
||||
--accent: #ff7a1a;
|
||||
--accent-strong: #ff8f3d;
|
||||
--accent-dim: rgba(255, 122, 26, 0.14);
|
||||
--accent-ring: rgba(255, 122, 26, 0.35);
|
||||
--success: #2fd07a;
|
||||
--success-dim: rgba(47, 208, 122, 0.14);
|
||||
--danger: #f0475c;
|
||||
--danger-dim: rgba(240, 71, 92, 0.14);
|
||||
--warning: #f5a623;
|
||||
--warning-dim: rgba(245, 166, 35, 0.14);
|
||||
--muted: #6b7280;
|
||||
--muted-dim: rgba(107, 114, 128, 0.16);
|
||||
--radius-sm: 8px;
|
||||
--radius: 14px;
|
||||
--radius-lg: 20px;
|
||||
--shadow: 0 10px 30px -12px rgba(0, 0, 0, 0.55);
|
||||
--shadow-sm: 0 4px 14px -6px rgba(0, 0, 0, 0.4);
|
||||
--sidebar-w: 244px;
|
||||
--topbar-h: 64px;
|
||||
--font: -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
--font-mono: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", Menlo, monospace;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
[data-theme="light"] {
|
||||
--bg: #f3f4f7;
|
||||
--bg-elevated: #ffffff;
|
||||
--bg-card: #ffffff;
|
||||
--bg-card-hover: #f8f8fb;
|
||||
--bg-input: #ffffff;
|
||||
--border: #e3e5ec;
|
||||
--border-soft: #ebedf2;
|
||||
--text: #1a1d24;
|
||||
--text-dim: #565d6d;
|
||||
--text-faint: #8a90a0;
|
||||
--accent-dim: rgba(255, 122, 26, 0.1);
|
||||
--muted-dim: rgba(107, 114, 128, 0.12);
|
||||
--shadow: 0 10px 30px -14px rgba(20, 20, 30, 0.18);
|
||||
--shadow-sm: 0 4px 14px -8px rgba(20, 20, 30, 0.14);
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: var(--font);
|
||||
font-size: 14.5px;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
a { color: inherit; text-decoration: none; }
|
||||
|
||||
h1, h2, h3, h4 { margin: 0; font-weight: 650; letter-spacing: -0.01em; }
|
||||
|
||||
p { margin: 0; }
|
||||
|
||||
button { font-family: inherit; }
|
||||
|
||||
::selection { background: var(--accent-dim); }
|
||||
|
||||
/* Scrollbars */
|
||||
::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 8px; border: 2px solid transparent; background-clip: content-box; }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--text-faint); background-clip: content-box; }
|
||||
|
||||
/* ==========================================================================
|
||||
App shell
|
||||
========================================================================== */
|
||||
|
||||
.app-shell {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* ---- Sidebar ---- */
|
||||
|
||||
.sidebar {
|
||||
width: var(--sidebar-w);
|
||||
flex-shrink: 0;
|
||||
background: var(--bg-elevated);
|
||||
border-right: 1px solid var(--border-soft);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: fixed;
|
||||
top: 0; bottom: 0; left: 0;
|
||||
z-index: 40;
|
||||
transition: transform 0.25s ease;
|
||||
}
|
||||
|
||||
.sidebar-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 22px 20px 16px;
|
||||
}
|
||||
|
||||
.sidebar-brand .brand-name {
|
||||
font-weight: 700;
|
||||
font-size: 15px;
|
||||
color: var(--text);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.sidebar-brand .brand-sub {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: var(--text-faint);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
flex: 1;
|
||||
padding: 8px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-dim);
|
||||
font-weight: 550;
|
||||
font-size: 13.5px;
|
||||
transition: background 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.nav-item svg { width: 18px; height: 18px; flex-shrink: 0; opacity: 0.85; }
|
||||
|
||||
.nav-item:hover { background: var(--bg-card-hover); color: var(--text); }
|
||||
|
||||
.nav-item.active {
|
||||
background: var(--accent-dim);
|
||||
color: var(--accent-strong);
|
||||
}
|
||||
.nav-item.active svg { opacity: 1; }
|
||||
|
||||
.nav-divider {
|
||||
height: 1px;
|
||||
background: var(--border-soft);
|
||||
margin: 10px 8px;
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
padding: 14px 12px 18px;
|
||||
border-top: 1px solid var(--border-soft);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.user-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 30px; height: 30px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, var(--accent), #ff4d6d);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
color: #fff; font-weight: 700; font-size: 12.5px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.user-meta { overflow: hidden; }
|
||||
.user-meta .u-name { font-size: 13px; font-weight: 600; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.user-meta .u-role { font-size: 11px; color: var(--text-faint); }
|
||||
|
||||
.footer-actions { display: flex; gap: 6px; }
|
||||
.footer-actions .icon-btn { flex: 1; }
|
||||
|
||||
.sidebar-toggle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar-backdrop {
|
||||
display: none;
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(0,0,0,0.5);
|
||||
z-index: 39;
|
||||
}
|
||||
|
||||
/* ---- Main area ---- */
|
||||
|
||||
.main {
|
||||
margin-left: var(--sidebar-w);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.main.no-sidebar { margin-left: 0; }
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 10px 28px;
|
||||
min-height: var(--topbar-h);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
background: color-mix(in srgb, var(--bg) 82%, transparent);
|
||||
backdrop-filter: blur(10px);
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
}
|
||||
|
||||
.topbar-left { display: flex; align-items: center; gap: 14px; min-width: 0; flex: 1; z-index: 1; }
|
||||
.topbar-title { font-size: 19px; font-weight: 700; letter-spacing: -0.015em; }
|
||||
.topbar-sub { color: var(--text-faint); font-size: 12.5px; margin-top: 2px; }
|
||||
|
||||
.topbar-right { display: flex; align-items: center; gap: 10px; flex: 1; justify-content: flex-end; z-index: 1; }
|
||||
|
||||
/* Logo mittig über die volle Höhe der Topbar, unabhängig vom Padding —
|
||||
passt sich per height:100% der Balkenhöhe an, egal wie breit es dabei
|
||||
wird. pointer-events:none, damit es Klicks auf Titel/Buttons nicht
|
||||
blockiert, falls es auf schmalen Screens überlappt. */
|
||||
.topbar-logo {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 0; bottom: 0;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 9px 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.topbar-logo img {
|
||||
height: 100%;
|
||||
width: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.hamburger {
|
||||
display: none;
|
||||
width: 36px; height: 36px;
|
||||
align-items: center; justify-content: center;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-card);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 24px 28px 48px;
|
||||
max-width: 1320px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Buttons
|
||||
========================================================================== */
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 9px 16px;
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
border-radius: 10px;
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: transform 0.06s ease, background 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
}
|
||||
.btn:active { transform: translateY(1px); }
|
||||
.btn svg { width: 15px; height: 15px; }
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: #1a0e03;
|
||||
box-shadow: 0 6px 18px -6px rgba(255, 122, 26, 0.55);
|
||||
}
|
||||
.btn-primary:hover { background: var(--accent-strong); }
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--bg-card);
|
||||
color: var(--text);
|
||||
border-color: var(--border);
|
||||
}
|
||||
.btn-secondary:hover { background: var(--bg-card-hover); border-color: var(--text-faint); }
|
||||
|
||||
.btn-success { background: var(--success); color: #06210f; }
|
||||
.btn-success:hover { filter: brightness(1.08); }
|
||||
|
||||
.btn-danger { background: transparent; color: var(--danger); border-color: var(--danger-dim); }
|
||||
.btn-danger:hover { background: var(--danger-dim); }
|
||||
|
||||
.btn-ghost { background: transparent; color: var(--text-dim); border-color: transparent; }
|
||||
.btn-ghost:hover { background: var(--bg-card-hover); color: var(--text); }
|
||||
|
||||
.btn-sm { padding: 6px 12px; font-size: 12.5px; border-radius: 8px; }
|
||||
.btn-block { width: 100%; }
|
||||
.btn:disabled { opacity: 0.55; cursor: not-allowed; }
|
||||
|
||||
.icon-btn {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 34px; height: 34px;
|
||||
border-radius: 9px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-card);
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
}
|
||||
.icon-btn:hover { color: var(--text); border-color: var(--text-faint); }
|
||||
.icon-btn svg { width: 16px; height: 16px; }
|
||||
|
||||
/* ==========================================================================
|
||||
Cards / surfaces
|
||||
========================================================================== */
|
||||
|
||||
.card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.card-pad { padding: 20px 22px; }
|
||||
|
||||
.section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 18px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.section-head h2 { font-size: 20px; }
|
||||
.section-head .hint { color: var(--text-faint); font-size: 12.5px; margin-top: 3px; }
|
||||
|
||||
.stat-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px 18px;
|
||||
}
|
||||
.stat-card .stat-label { font-size: 12px; color: var(--text-faint); font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; }
|
||||
.stat-card .stat-value { font-size: 26px; font-weight: 700; margin-top: 6px; letter-spacing: -0.02em; }
|
||||
.stat-card .stat-value.online { color: var(--success); }
|
||||
.stat-card .stat-value.offline { color: var(--danger); }
|
||||
.stat-card .stat-value.disabled { color: var(--muted); }
|
||||
|
||||
/* ==========================================================================
|
||||
Badges / pills
|
||||
========================================================================== */
|
||||
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
}
|
||||
.pill svg { width: 12px; height: 12px; }
|
||||
.pill::before {
|
||||
content: "";
|
||||
width: 6px; height: 6px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.pill.online { background: var(--success-dim); color: var(--success); }
|
||||
.pill.offline { background: var(--danger-dim); color: var(--danger); }
|
||||
.pill.unknown { background: var(--warning-dim); color: var(--warning); }
|
||||
.pill.disabled { background: var(--muted-dim); color: var(--muted); }
|
||||
.pill.admin { background: var(--accent-dim); color: var(--accent-strong); }
|
||||
.pill.user { background: var(--muted-dim); color: var(--text-dim); }
|
||||
|
||||
.timer-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 6px 13px;
|
||||
border-radius: 999px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.timer-pill .dot {
|
||||
width: 7px; height: 7px; border-radius: 50%;
|
||||
background: var(--success);
|
||||
box-shadow: 0 0 0 0 var(--success-dim);
|
||||
animation: pulse-dot 2s infinite;
|
||||
}
|
||||
@keyframes pulse-dot {
|
||||
0% { box-shadow: 0 0 0 0 rgba(47,208,122,0.5); }
|
||||
70% { box-shadow: 0 0 0 6px rgba(47,208,122,0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(47,208,122,0); }
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Device grid (dashboard)
|
||||
========================================================================== */
|
||||
|
||||
.device-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.device-card {
|
||||
position: relative;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px 16px 14px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s ease, transform 0.1s ease, background 0.15s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
.device-card:hover { border-color: var(--text-faint); background: var(--bg-card-hover); transform: translateY(-2px); }
|
||||
.device-card:active { transform: translateY(0); }
|
||||
.device-card.is-disabled { cursor: default; opacity: 0.65; }
|
||||
.device-card.is-disabled:hover { transform: none; border-color: var(--border-soft); background: var(--bg-card); }
|
||||
|
||||
.device-card::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0; top: 0; bottom: 0;
|
||||
width: 3px;
|
||||
background: var(--muted);
|
||||
}
|
||||
.device-card[data-status="online"]::before { background: var(--success); }
|
||||
.device-card[data-status="offline"]::before { background: var(--danger); }
|
||||
.device-card[data-status="unbekannt"]::before { background: var(--warning); }
|
||||
|
||||
.device-card .dc-top {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.device-card .dc-icon {
|
||||
width: 34px; height: 34px;
|
||||
border-radius: 9px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: var(--muted-dim);
|
||||
color: var(--muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.device-card[data-status="online"] .dc-icon { background: var(--success-dim); color: var(--success); }
|
||||
.device-card[data-status="offline"] .dc-icon { background: var(--danger-dim); color: var(--danger); }
|
||||
.device-card .dc-icon svg { width: 17px; height: 17px; }
|
||||
|
||||
.device-card .dc-name {
|
||||
margin-top: 12px;
|
||||
font-weight: 650;
|
||||
font-size: 14px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.device-card .dc-ip { color: var(--text-faint); font-size: 11.5px; margin-top: 2px; font-family: var(--font-mono); }
|
||||
|
||||
.device-card .dc-status { margin-top: 12px; }
|
||||
|
||||
.spinner {
|
||||
width: 13px; height: 13px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid currentColor;
|
||||
border-right-color: transparent;
|
||||
display: inline-block;
|
||||
animation: spin 0.65s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* ==========================================================================
|
||||
Tables
|
||||
========================================================================== */
|
||||
|
||||
.table-wrap {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.table-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 220px;
|
||||
}
|
||||
.search-input svg {
|
||||
position: absolute; left: 11px;
|
||||
width: 15px; height: 15px;
|
||||
color: var(--text-faint);
|
||||
pointer-events: none;
|
||||
}
|
||||
.search-input input { padding-left: 34px; }
|
||||
|
||||
table.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13.5px;
|
||||
}
|
||||
|
||||
.data-table thead th {
|
||||
text-align: left;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-faint);
|
||||
font-weight: 650;
|
||||
padding: 11px 16px;
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.data-table tbody td {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.data-table tbody tr:last-child td { border-bottom: none; }
|
||||
.data-table tbody tr { transition: background 0.12s ease; }
|
||||
.data-table tbody tr:hover { background: var(--bg-card-hover); }
|
||||
|
||||
.mono { font-family: var(--font-mono); font-size: 12.5px; color: var(--text-dim); }
|
||||
.cell-name { font-weight: 600; }
|
||||
.row-actions { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
|
||||
.empty-row td {
|
||||
text-align: center;
|
||||
padding: 40px 16px;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Forms
|
||||
========================================================================== */
|
||||
|
||||
.field { margin-bottom: 15px; }
|
||||
.field label {
|
||||
display: block;
|
||||
font-size: 12.5px;
|
||||
font-weight: 650;
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.field .field-hint { font-size: 11.5px; color: var(--text-faint); margin-top: 5px; }
|
||||
|
||||
input[type="text"], input[type="password"], input[type="number"], input[type="email"], select, textarea {
|
||||
width: 100%;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
padding: 9px 12px;
|
||||
border-radius: 9px;
|
||||
font-size: 13.5px;
|
||||
font-family: inherit;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
input::placeholder { color: var(--text-faint); }
|
||||
input:focus, select:focus, textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-dim);
|
||||
}
|
||||
input.is-invalid { border-color: var(--danger) !important; box-shadow: 0 0 0 3px var(--danger-dim) !important; }
|
||||
.invalid-feedback { display: none; color: var(--danger); font-size: 11.5px; margin-top: 5px; }
|
||||
input.is-invalid + .invalid-feedback,
|
||||
input.is-invalid ~ .invalid-feedback { display: block; }
|
||||
|
||||
select {
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='%239aa2b4'%3E%3Cpath fill-rule='evenodd' d='M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z' clip-rule='evenodd'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 10px center;
|
||||
padding-right: 32px;
|
||||
}
|
||||
|
||||
.switch-check {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.switch-check input { display: none; }
|
||||
.switch-check .track {
|
||||
width: 38px; height: 22px;
|
||||
border-radius: 999px;
|
||||
background: var(--muted-dim);
|
||||
border: 1px solid var(--border);
|
||||
position: relative;
|
||||
transition: background 0.15s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.switch-check .track::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 2px; left: 2px;
|
||||
width: 16px; height: 16px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-faint);
|
||||
transition: transform 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
.switch-check input:checked + .track { background: var(--success-dim); border-color: var(--success); }
|
||||
.switch-check input:checked + .track::after { transform: translateX(16px); background: var(--success); }
|
||||
.switch-check input:disabled + .track { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
/* ==========================================================================
|
||||
Modal
|
||||
========================================================================== */
|
||||
|
||||
.modal-overlay {
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(6, 7, 10, 0.6);
|
||||
backdrop-filter: blur(3px);
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
padding: 20px;
|
||||
}
|
||||
.modal-overlay.open { display: flex; }
|
||||
|
||||
.modal {
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow);
|
||||
width: 100%;
|
||||
max-width: 440px;
|
||||
max-height: 88vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
animation: modal-in 0.16s ease;
|
||||
}
|
||||
@keyframes modal-in {
|
||||
from { opacity: 0; transform: translateY(8px) scale(0.98); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 18px 20px;
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
}
|
||||
.modal-header h3 { font-size: 16px; }
|
||||
.modal-body { padding: 20px; overflow-y: auto; }
|
||||
.modal-footer {
|
||||
display: flex; justify-content: flex-end; gap: 8px;
|
||||
padding: 16px 20px;
|
||||
border-top: 1px solid var(--border-soft);
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
width: 28px; height: 28px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
border-radius: 8px;
|
||||
color: var(--text-faint);
|
||||
cursor: pointer;
|
||||
border: none; background: transparent;
|
||||
}
|
||||
.modal-close:hover { background: var(--bg-card-hover); color: var(--text); }
|
||||
|
||||
.detail-list { display: flex; flex-direction: column; gap: 12px; }
|
||||
.detail-row { display: flex; justify-content: space-between; gap: 12px; padding-bottom: 10px; border-bottom: 1px solid var(--border-soft); }
|
||||
.detail-row:last-child { border-bottom: none; padding-bottom: 0; }
|
||||
.detail-row .k { color: var(--text-faint); font-size: 12.5px; font-weight: 600; }
|
||||
.detail-row .v { font-weight: 600; text-align: right; }
|
||||
|
||||
/* ==========================================================================
|
||||
Toasts
|
||||
========================================================================== */
|
||||
|
||||
.toast-stack {
|
||||
position: fixed;
|
||||
top: 18px; right: 18px;
|
||||
z-index: 200;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
max-width: 340px;
|
||||
}
|
||||
|
||||
.toast {
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-left: 3px solid var(--accent);
|
||||
border-radius: 10px;
|
||||
padding: 12px 14px;
|
||||
box-shadow: var(--shadow);
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
animation: toast-in 0.18s ease;
|
||||
}
|
||||
.toast.success { border-left-color: var(--success); }
|
||||
.toast.danger { border-left-color: var(--danger); }
|
||||
.toast.info { border-left-color: var(--accent); }
|
||||
.toast .toast-close { margin-left: auto; cursor: pointer; color: var(--text-faint); flex-shrink: 0; }
|
||||
.toast.hide { animation: toast-out 0.18s ease forwards; }
|
||||
@keyframes toast-in { from { opacity: 0; transform: translateX(16px); } to { opacity: 1; transform: translateX(0); } }
|
||||
@keyframes toast-out { to { opacity: 0; transform: translateX(16px); } }
|
||||
|
||||
/* ==========================================================================
|
||||
Login page
|
||||
========================================================================== */
|
||||
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background:
|
||||
radial-gradient(circle at 15% 15%, rgba(255, 122, 26, 0.14), transparent 45%),
|
||||
radial-gradient(circle at 85% 85%, rgba(255, 122, 26, 0.08), transparent 40%),
|
||||
var(--bg);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: 100%;
|
||||
max-width: 380px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow);
|
||||
padding: 34px 32px 28px;
|
||||
}
|
||||
|
||||
.login-logo { display: flex; justify-content: center; margin-bottom: 22px; }
|
||||
.login-logo img { height: 34px; }
|
||||
|
||||
.login-card h1 { font-size: 18px; text-align: center; margin-bottom: 4px; }
|
||||
.login-card .login-sub { text-align: center; color: var(--text-faint); font-size: 12.5px; margin-bottom: 24px; }
|
||||
|
||||
/* ==========================================================================
|
||||
Logs page
|
||||
========================================================================== */
|
||||
|
||||
.log-shell {
|
||||
background: #0a0c10;
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - 210px);
|
||||
min-height: 320px;
|
||||
}
|
||||
[data-theme="light"] .log-shell { background: #0e1116; }
|
||||
|
||||
.log-toolbar {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid #1f232c;
|
||||
background: #101319;
|
||||
}
|
||||
.log-dots { display: flex; gap: 6px; }
|
||||
.log-dots span { width: 10px; height: 10px; border-radius: 50%; }
|
||||
.log-dots span:nth-child(1) { background: #f0475c; }
|
||||
.log-dots span:nth-child(2) { background: #f5a623; }
|
||||
.log-dots span:nth-child(3) { background: #2fd07a; }
|
||||
|
||||
#log-box {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 14px 18px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.7;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
color: #c7ccd6;
|
||||
}
|
||||
.log-line { display: block; }
|
||||
.log-line.online { color: #4ade80; }
|
||||
.log-line.offline { color: #f87171; }
|
||||
.log-line.sep { color: #3a4050; }
|
||||
.log-line.manual { color: #ffb454; }
|
||||
|
||||
/* ==========================================================================
|
||||
Permission checklists (Gruppen)
|
||||
========================================================================== */
|
||||
|
||||
.permission-group-title {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-faint);
|
||||
font-weight: 650;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.check-list { display: flex; flex-direction: column; gap: 9px; }
|
||||
.check-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
font-size: 13px;
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
}
|
||||
.check-row input[type="checkbox"] {
|
||||
width: 16px; height: 16px;
|
||||
accent-color: var(--accent);
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
SSH terminal panel (switch connection test)
|
||||
========================================================================== */
|
||||
|
||||
.term-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 12px 18px;
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
}
|
||||
|
||||
.xterm-container {
|
||||
height: 380px;
|
||||
background: #0a0c10;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
.xterm-container .xterm { height: 100%; }
|
||||
|
||||
/* ==========================================================================
|
||||
Utilities
|
||||
========================================================================== */
|
||||
|
||||
.flex { display: flex; }
|
||||
.gap-2 { gap: 8px; }
|
||||
.mt-3 { margin-top: 14px; }
|
||||
.text-dim { color: var(--text-dim); }
|
||||
.text-faint { color: var(--text-faint); }
|
||||
.hidden { display: none !important; }
|
||||
|
||||
/* ==========================================================================
|
||||
Responsive
|
||||
========================================================================== */
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.sidebar {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
.sidebar.open { transform: translateX(0); box-shadow: var(--shadow); }
|
||||
.sidebar-backdrop.open { display: block; }
|
||||
.main { margin-left: 0; }
|
||||
.hamburger { display: inline-flex; }
|
||||
.content { padding: 18px 16px 40px; }
|
||||
.topbar { padding: 10px 16px; }
|
||||
.topbar-logo { opacity: 0.5; }
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.topbar-logo { display: none; }
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.device-grid { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); }
|
||||
.topbar-title { font-size: 16px; }
|
||||
.stat-row { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
+218
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* Copyright (c) 2014 The xterm.js authors. All rights reserved.
|
||||
* Copyright (c) 2012-2013, Christopher Jeffrey (MIT License)
|
||||
* https://github.com/chjj/term.js
|
||||
* @license MIT
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in
|
||||
* all copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
* THE SOFTWARE.
|
||||
*
|
||||
* Originally forked from (with the author's permission):
|
||||
* Fabrice Bellard's javascript vt100 for jslinux:
|
||||
* http://bellard.org/jslinux/
|
||||
* Copyright (c) 2011 Fabrice Bellard
|
||||
* The original design remains. The terminal itself
|
||||
* has been extended to include xterm CSI codes, among
|
||||
* other features.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Default styles for xterm.js
|
||||
*/
|
||||
|
||||
.xterm {
|
||||
cursor: text;
|
||||
position: relative;
|
||||
user-select: none;
|
||||
-ms-user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.xterm.focus,
|
||||
.xterm:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.xterm .xterm-helpers {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
/**
|
||||
* The z-index of the helpers must be higher than the canvases in order for
|
||||
* IMEs to appear on top.
|
||||
*/
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.xterm .xterm-helper-textarea {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
margin: 0;
|
||||
/* Move textarea out of the screen to the far left, so that the cursor is not visible */
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
left: -9999em;
|
||||
top: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
z-index: -5;
|
||||
/** Prevent wrapping so the IME appears against the textarea at the correct position */
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.xterm .composition-view {
|
||||
/* TODO: Composition position got messed up somewhere */
|
||||
background: #000;
|
||||
color: #FFF;
|
||||
display: none;
|
||||
position: absolute;
|
||||
white-space: nowrap;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.xterm .composition-view.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.xterm .xterm-viewport {
|
||||
/* On OS X this is required in order for the scroll bar to appear fully opaque */
|
||||
background-color: #000;
|
||||
overflow-y: scroll;
|
||||
cursor: default;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.xterm .xterm-screen {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.xterm .xterm-screen canvas {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.xterm .xterm-scroll-area {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.xterm-char-measure-element {
|
||||
display: inline-block;
|
||||
visibility: hidden;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -9999em;
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
.xterm.enable-mouse-events {
|
||||
/* When mouse events are enabled (eg. tmux), revert to the standard pointer cursor */
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.xterm.xterm-cursor-pointer,
|
||||
.xterm .xterm-cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.xterm.column-select.focus {
|
||||
/* Column selection mode */
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.xterm .xterm-accessibility:not(.debug),
|
||||
.xterm .xterm-message {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
color: transparent;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.xterm .xterm-accessibility-tree:not(.debug) *::selection {
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.xterm .xterm-accessibility-tree {
|
||||
user-select: text;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.xterm .live-region {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.xterm-dim {
|
||||
/* Dim should not apply to background, so the opacity of the foreground color is applied
|
||||
* explicitly in the generated class and reset to 1 here */
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
.xterm-underline-1 { text-decoration: underline; }
|
||||
.xterm-underline-2 { text-decoration: double underline; }
|
||||
.xterm-underline-3 { text-decoration: wavy underline; }
|
||||
.xterm-underline-4 { text-decoration: dotted underline; }
|
||||
.xterm-underline-5 { text-decoration: dashed underline; }
|
||||
|
||||
.xterm-overline {
|
||||
text-decoration: overline;
|
||||
}
|
||||
|
||||
.xterm-overline.xterm-underline-1 { text-decoration: overline underline; }
|
||||
.xterm-overline.xterm-underline-2 { text-decoration: overline double underline; }
|
||||
.xterm-overline.xterm-underline-3 { text-decoration: overline wavy underline; }
|
||||
.xterm-overline.xterm-underline-4 { text-decoration: overline dotted underline; }
|
||||
.xterm-overline.xterm-underline-5 { text-decoration: overline dashed underline; }
|
||||
|
||||
.xterm-strikethrough {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.xterm-screen .xterm-decoration-container .xterm-decoration {
|
||||
z-index: 6;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer {
|
||||
z-index: 7;
|
||||
}
|
||||
|
||||
.xterm-decoration-overview-ruler {
|
||||
z-index: 8;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.xterm-decoration-top {
|
||||
z-index: 2;
|
||||
position: relative;
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.6 KiB |
@@ -0,0 +1,202 @@
|
||||
/* ==========================================================================
|
||||
PoE Manager — shared UI behaviour (sidebar, theme, modals, toasts)
|
||||
========================================================================== */
|
||||
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
/* ---------------- Theme ---------------- */
|
||||
|
||||
const THEME_KEY = "poe-theme";
|
||||
|
||||
function applyTheme(theme) {
|
||||
document.documentElement.setAttribute("data-theme", theme);
|
||||
document.querySelectorAll("[data-theme-icon]").forEach((el) => {
|
||||
el.innerHTML = theme === "light" ? ICONS.moon : ICONS.sun;
|
||||
});
|
||||
}
|
||||
|
||||
function initTheme() {
|
||||
const saved = localStorage.getItem(THEME_KEY) ||
|
||||
(window.matchMedia && window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark");
|
||||
applyTheme(saved);
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
const current = document.documentElement.getAttribute("data-theme") === "light" ? "light" : "dark";
|
||||
const next = current === "light" ? "dark" : "light";
|
||||
localStorage.setItem(THEME_KEY, next);
|
||||
applyTheme(next);
|
||||
}
|
||||
|
||||
const ICONS = {
|
||||
sun: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>',
|
||||
moon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.8A9 9 0 1111.2 3a7 7 0 009.8 9.8z"/></svg>',
|
||||
};
|
||||
|
||||
/* ---------------- Sidebar (mobile) ---------------- */
|
||||
|
||||
function initSidebar() {
|
||||
const sidebar = document.querySelector(".sidebar");
|
||||
const backdrop = document.querySelector(".sidebar-backdrop");
|
||||
const toggles = document.querySelectorAll("[data-sidebar-toggle]");
|
||||
if (!sidebar) return;
|
||||
|
||||
function open() {
|
||||
sidebar.classList.add("open");
|
||||
backdrop && backdrop.classList.add("open");
|
||||
}
|
||||
function close() {
|
||||
sidebar.classList.remove("open");
|
||||
backdrop && backdrop.classList.remove("open");
|
||||
}
|
||||
toggles.forEach((btn) => btn.addEventListener("click", () => {
|
||||
sidebar.classList.contains("open") ? close() : open();
|
||||
}));
|
||||
backdrop && backdrop.addEventListener("click", close);
|
||||
document.querySelectorAll(".nav-item").forEach((a) => a.addEventListener("click", close));
|
||||
}
|
||||
|
||||
/* ---------------- Modals ---------------- */
|
||||
|
||||
function openModal(id) {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
el.classList.add("open");
|
||||
document.body.style.overflow = "hidden";
|
||||
}
|
||||
|
||||
function closeModal(el) {
|
||||
const overlay = el.closest ? el.closest(".modal-overlay") : el;
|
||||
if (!overlay) return;
|
||||
overlay.classList.remove("open");
|
||||
document.body.style.overflow = "";
|
||||
}
|
||||
|
||||
function initModals() {
|
||||
document.querySelectorAll("[data-open-modal]").forEach((trigger) => {
|
||||
trigger.addEventListener("click", () => openModal(trigger.getAttribute("data-open-modal")));
|
||||
});
|
||||
document.querySelectorAll("[data-close-modal]").forEach((btn) => {
|
||||
btn.addEventListener("click", () => closeModal(btn));
|
||||
});
|
||||
document.querySelectorAll(".modal-overlay").forEach((overlay) => {
|
||||
overlay.addEventListener("click", (e) => {
|
||||
if (e.target === overlay) closeModal(overlay);
|
||||
});
|
||||
});
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape") {
|
||||
document.querySelectorAll(".modal-overlay.open").forEach((o) => closeModal(o));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------------- Confirm dialog (replaces window.confirm) ---------------- */
|
||||
|
||||
function ensureConfirmModal() {
|
||||
if (document.getElementById("confirm-modal")) return;
|
||||
const html = `
|
||||
<div class="modal-overlay" id="confirm-modal">
|
||||
<div class="modal" style="max-width:380px;">
|
||||
<div class="modal-header">
|
||||
<h3 id="confirm-title">Bist du sicher?</h3>
|
||||
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p id="confirm-message" class="text-dim"></p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
|
||||
<button type="button" class="btn btn-danger" id="confirm-ok" style="background:var(--danger);color:#fff;">Bestätigen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
document.body.insertAdjacentHTML("beforeend", html);
|
||||
document.querySelectorAll("#confirm-modal [data-close-modal]").forEach((btn) => {
|
||||
btn.addEventListener("click", () => closeModal(btn));
|
||||
});
|
||||
document.getElementById("confirm-modal").addEventListener("click", (e) => {
|
||||
if (e.target.id === "confirm-modal") closeModal(e.target);
|
||||
});
|
||||
}
|
||||
|
||||
window.confirmAction = function (message, onConfirm, title) {
|
||||
ensureConfirmModal();
|
||||
document.getElementById("confirm-title").innerText = title || "Bist du sicher?";
|
||||
document.getElementById("confirm-message").innerText = message;
|
||||
const okBtn = document.getElementById("confirm-ok");
|
||||
const freshBtn = okBtn.cloneNode(true);
|
||||
okBtn.parentNode.replaceChild(freshBtn, okBtn);
|
||||
freshBtn.addEventListener("click", () => {
|
||||
closeModal(freshBtn);
|
||||
onConfirm();
|
||||
});
|
||||
openModal("confirm-modal");
|
||||
};
|
||||
|
||||
/* Intercept forms/buttons marked with data-confirm="message" */
|
||||
function initConfirmables() {
|
||||
document.querySelectorAll("form[data-confirm]").forEach((form) => {
|
||||
form.addEventListener("submit", function (e) {
|
||||
if (form.dataset.confirmed === "1") return;
|
||||
e.preventDefault();
|
||||
window.confirmAction(form.getAttribute("data-confirm"), () => {
|
||||
form.dataset.confirmed = "1";
|
||||
form.requestSubmit ? form.requestSubmit() : form.submit();
|
||||
}, form.getAttribute("data-confirm-title"));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------------- Toasts ---------------- */
|
||||
|
||||
function ensureToastStack() {
|
||||
let stack = document.querySelector(".toast-stack");
|
||||
if (!stack) {
|
||||
stack = document.createElement("div");
|
||||
stack.className = "toast-stack";
|
||||
document.body.appendChild(stack);
|
||||
}
|
||||
return stack;
|
||||
}
|
||||
|
||||
window.showToast = function (message, type) {
|
||||
const stack = ensureToastStack();
|
||||
const toast = document.createElement("div");
|
||||
toast.className = "toast " + (type || "info");
|
||||
toast.innerHTML = `<span>${message}</span><span class="toast-close">×</span>`;
|
||||
stack.appendChild(toast);
|
||||
const remove = () => {
|
||||
toast.classList.add("hide");
|
||||
setTimeout(() => toast.remove(), 180);
|
||||
};
|
||||
toast.querySelector(".toast-close").addEventListener("click", remove);
|
||||
setTimeout(remove, 5000);
|
||||
};
|
||||
|
||||
function initFlashedMessages() {
|
||||
const data = document.getElementById("flashed-data");
|
||||
if (!data) return;
|
||||
try {
|
||||
const messages = JSON.parse(data.textContent);
|
||||
messages.forEach(([category, msg]) => {
|
||||
const type = category === "danger" ? "danger" : category === "success" ? "success" : "info";
|
||||
window.showToast(msg, type);
|
||||
});
|
||||
} catch (e) { /* noop */ }
|
||||
}
|
||||
|
||||
/* ---------------- Init ---------------- */
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
initTheme();
|
||||
initSidebar();
|
||||
initModals();
|
||||
initConfirmables();
|
||||
initFlashedMessages();
|
||||
document.querySelectorAll("[data-theme-toggle]").forEach((btn) => btn.addEventListener("click", toggleTheme));
|
||||
});
|
||||
|
||||
window.PoeUI = { openModal, closeModal };
|
||||
})();
|
||||
@@ -0,0 +1,2 @@
|
||||
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.FitAddon=t():e.FitAddon=t()}(self,(()=>(()=>{"use strict";var e={};return(()=>{var t=e;Object.defineProperty(t,"__esModule",{value:!0}),t.FitAddon=void 0,t.FitAddon=class{activate(e){this._terminal=e}dispose(){}fit(){const e=this.proposeDimensions();if(!e||!this._terminal||isNaN(e.cols)||isNaN(e.rows))return;const t=this._terminal._core;this._terminal.rows===e.rows&&this._terminal.cols===e.cols||(t._renderService.clear(),this._terminal.resize(e.cols,e.rows))}proposeDimensions(){if(!this._terminal)return;if(!this._terminal.element||!this._terminal.element.parentElement)return;const e=this._terminal._core,t=e._renderService.dimensions;if(0===t.css.cell.width||0===t.css.cell.height)return;const r=0===this._terminal.options.scrollback?0:e.viewport.scrollBarWidth,i=window.getComputedStyle(this._terminal.element.parentElement),o=parseInt(i.getPropertyValue("height")),s=Math.max(0,parseInt(i.getPropertyValue("width"))),n=window.getComputedStyle(this._terminal.element),l=o-(parseInt(n.getPropertyValue("padding-top"))+parseInt(n.getPropertyValue("padding-bottom"))),a=s-(parseInt(n.getPropertyValue("padding-right"))+parseInt(n.getPropertyValue("padding-left")))-r;return{cols:Math.max(2,Math.floor(a/t.css.cell.width)),rows:Math.max(1,Math.floor(l/t.css.cell.height))}}}})(),e})()));
|
||||
//# sourceMappingURL=addon-fit.js.map
|
||||
+2
File diff suppressed because one or more lines are too long
@@ -0,0 +1,141 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{ title or "PoE Manager" }}</title>
|
||||
<link rel="icon" href="{{ url_for('static', filename='images/logo.png') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||
{% block extra_head %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
|
||||
{% set nav_items = [
|
||||
("index", "Dashboard", "grid", true),
|
||||
("devices", "Devices", "cpu", true),
|
||||
("switches", "Switches", "share", false),
|
||||
("users", "Users", "users", false),
|
||||
("logs", "Live-Log", "terminal", false),
|
||||
("settings", "Settings", "sliders", false),
|
||||
] %}
|
||||
|
||||
{% set icons = {
|
||||
"grid": '<rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/>',
|
||||
"cpu": '<rect x="6" y="6" width="12" height="12" rx="1.5"/><path d="M9 1v3M15 1v3M9 20v3M15 20v3M1 9h3M1 15h3M20 9h3M20 15h3"/>',
|
||||
"share": '<circle cx="18" cy="5" r="2.5"/><circle cx="6" cy="12" r="2.5"/><circle cx="18" cy="19" r="2.5"/><path d="M8.2 10.7l7.6-4.4M8.2 13.3l7.6 4.4"/>',
|
||||
"users": '<circle cx="9" cy="8" r="3.2"/><path d="M2.5 20c0-3.6 2.9-6 6.5-6s6.5 2.4 6.5 6"/><circle cx="17.5" cy="8.5" r="2.4"/><path d="M15.8 14.2c2.7.3 4.7 2.4 4.7 5.3"/>',
|
||||
"groups": '<rect x="3" y="4" width="8" height="7" rx="1.5"/><rect x="13" y="4" width="8" height="7" rx="1.5"/><rect x="3" y="13" width="8" height="7" rx="1.5"/><rect x="13" y="13" width="8" height="7" rx="1.5"/>',
|
||||
"terminal": '<rect x="2" y="4" width="20" height="16" rx="2"/><path d="M6 9l4 3-4 3M13 15h5"/>',
|
||||
"sliders": '<path d="M4 6h9M17 6h3M4 12h3M11 12h9M4 18h13M20 18h0"/><circle cx="15" cy="6" r="2"/><circle cx="9" cy="12" r="2"/><circle cx="17" cy="18" r="2"/>',
|
||||
"logout": '<path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4"/><path d="M16 17l5-5-5-5"/><path d="M21 12H9"/>',
|
||||
} %}
|
||||
|
||||
<div class="app-shell">
|
||||
|
||||
{% if current_user.is_authenticated %}
|
||||
<div class="sidebar-backdrop" data-sidebar-toggle></div>
|
||||
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-brand">
|
||||
<div>
|
||||
<span class="brand-name">PoE Manager</span>
|
||||
<span class="brand-sub">Device Monitoring</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="sidebar-nav">
|
||||
<a href="{{ url_for('index') }}" class="nav-item {% if active_page == 'index' %}active{% endif %}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">{{ icons['grid']|safe }}</svg>
|
||||
Dashboard
|
||||
</a>
|
||||
<a href="{{ url_for('devices') }}" class="nav-item {% if active_page == 'devices' %}active{% endif %}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">{{ icons['cpu']|safe }}</svg>
|
||||
Devices
|
||||
</a>
|
||||
{% if current_user.is_admin %}
|
||||
<a href="{{ url_for('switches') }}" class="nav-item {% if active_page == 'switches' %}active{% endif %}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">{{ icons['share']|safe }}</svg>
|
||||
Switches
|
||||
</a>
|
||||
<a href="{{ url_for('users') }}" class="nav-item {% if active_page == 'users' %}active{% endif %}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">{{ icons['users']|safe }}</svg>
|
||||
Users
|
||||
</a>
|
||||
<a href="{{ url_for('groups') }}" class="nav-item {% if active_page == 'groups' %}active{% endif %}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">{{ icons['groups']|safe }}</svg>
|
||||
Gruppen
|
||||
</a>
|
||||
<a href="{{ url_for('logs') }}" class="nav-item {% if active_page == 'logs' %}active{% endif %}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">{{ icons['terminal']|safe }}</svg>
|
||||
Live-Log
|
||||
</a>
|
||||
<a href="{{ url_for('settings') }}" class="nav-item {% if active_page == 'settings' %}active{% endif %}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">{{ icons['sliders']|safe }}</svg>
|
||||
Settings
|
||||
</a>
|
||||
{% endif %}
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
<div class="user-chip">
|
||||
<div class="user-avatar">{{ current_user.username[:2]|upper }}</div>
|
||||
<div class="user-meta">
|
||||
<div class="u-name">{{ current_user.username }}</div>
|
||||
<div class="u-role">{{ "Administrator" if current_user.is_admin else current_user.group_names or "Benutzer" }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer-actions">
|
||||
<button type="button" class="icon-btn" data-theme-toggle title="Theme wechseln">
|
||||
<span data-theme-icon></span>
|
||||
</button>
|
||||
<a href="{{ url_for('logout') }}" class="icon-btn" title="Abmelden" style="flex:1; display:flex;">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin:auto;">{{ icons['logout']|safe }}</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
{% endif %}
|
||||
|
||||
<div class="main {% if not current_user.is_authenticated %}no-sidebar{% endif %}">
|
||||
<div class="topbar">
|
||||
<div class="topbar-left">
|
||||
{% if current_user.is_authenticated %}
|
||||
<button type="button" class="hamburger" data-sidebar-toggle aria-label="Menü">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M3 6h18M3 12h18M3 18h18"/></svg>
|
||||
</button>
|
||||
{% endif %}
|
||||
<div>
|
||||
<div class="topbar-title">{% block page_title %}Dashboard{% endblock %}</div>
|
||||
{% block page_sub %}{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="topbar-logo">
|
||||
<img src="{{ url_for('static', filename='images/logo.png') }}" alt="WiS">
|
||||
</div>
|
||||
|
||||
<div class="topbar-right">
|
||||
{% block topbar_right %}{% endblock %}
|
||||
{% if not current_user.is_authenticated %}
|
||||
<a href="{{ url_for('login') }}" class="btn btn-primary">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h4a2 2 0 012 2v14a2 2 0 01-2 2h-4"/><path d="M10 17l5-5-5-5"/><path d="M15 12H3"/></svg>
|
||||
Login
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
<script type="application/json" id="flashed-data">{{ messages|tojson }}</script>
|
||||
{% endwith %}
|
||||
|
||||
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,267 @@
|
||||
{% extends "base.html" %}
|
||||
{% set active_page = "devices" %}
|
||||
{% set can_toggle = current_user.has_permission('devices.toggle') %}
|
||||
{% set can_create = current_user.has_permission('devices.create') %}
|
||||
{% set can_edit = current_user.has_permission('devices.edit') %}
|
||||
{% set can_delete = current_user.has_permission('devices.delete') %}
|
||||
{% set show_actions_col = can_edit or can_delete %}
|
||||
{% set col_count = 5 + (1 if can_toggle else 0) + (1 if show_actions_col else 0) %}
|
||||
{% block page_title %}Devices{% endblock %}
|
||||
{% block page_sub %}<div class="topbar-sub">{{ devices|length }} Geräte</div>{% endblock %}
|
||||
{% block topbar_right %}
|
||||
{% if can_create %}
|
||||
<button type="button" class="btn btn-primary" data-open-modal="deviceModal" onclick="resetDeviceForm()">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
|
||||
Neues Gerät
|
||||
</button>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="table-wrap">
|
||||
<div class="table-toolbar">
|
||||
<div class="search-input">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>
|
||||
<input type="text" id="deviceSearch" placeholder="Geräte durchsuchen…" oninput="filterTable('deviceSearch','devicesTable')">
|
||||
</div>
|
||||
</div>
|
||||
<div style="overflow-x:auto;">
|
||||
<table class="data-table" id="devicesTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Hostname</th>
|
||||
<th>IP-Adresse</th>
|
||||
<th>MAC-Adresse</th>
|
||||
<th>Switch</th>
|
||||
<th>Port</th>
|
||||
{% if can_toggle %}<th>Status</th>{% endif %}
|
||||
{% if show_actions_col %}<th style="width:1%;">Aktionen</th>{% endif %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for d in devices %}
|
||||
<tr>
|
||||
<td class="cell-name">{{ d['name'] }}</td>
|
||||
<td class="mono">{{ d['rpi_ip'] }}</td>
|
||||
<td class="mono">{{ d['mac'] }}</td>
|
||||
<td>{{ d['switch_hostname'] or '—' }}</td>
|
||||
<td>{{ d['port'] or '—' }}</td>
|
||||
{% if can_toggle %}
|
||||
<td>
|
||||
<label class="switch-check">
|
||||
<input type="checkbox" {% if d['is_active'] %}checked{% endif %} onchange="toggleDevice('{{ d['mac'] }}', this)">
|
||||
<span class="track"></span>
|
||||
</label>
|
||||
</td>
|
||||
{% endif %}
|
||||
{% if show_actions_col %}
|
||||
<td>
|
||||
<div class="row-actions">
|
||||
{% if can_edit %}
|
||||
<button class="icon-btn" title="Bearbeiten"
|
||||
onclick="openEditDeviceModal('{{ d['mac'] }}','{{ d['name'] }}','{{ d['rpi_ip'] }}','{{ d['port'] or '' }}')">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 013 3L7 19l-4 1 1-4z"/></svg>
|
||||
</button>
|
||||
<button class="icon-btn" title="Switch ändern"
|
||||
onclick="openSwitchModal('{{ d['mac'] }}','{{ d['switch_hostname'] or '' }}')">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="5" r="2.5"/><circle cx="6" cy="12" r="2.5"/><circle cx="18" cy="19" r="2.5"/><path d="M8.2 10.7l7.6-4.4M8.2 13.3l7.6 4.4"/></svg>
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if can_delete %}
|
||||
<form method="post" data-confirm="Willst du „{{ d['name'] }}“ wirklich löschen?">
|
||||
<input type="hidden" name="delete_device" value="{{ d['mac'] }}">
|
||||
<button type="submit" class="icon-btn" style="color:var(--danger);" title="Löschen">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0l-1 14a2 2 0 01-2 2H7a2 2 0 01-2-2L4 6"/></svg>
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr class="empty-row"><td colspan="{{ col_count }}">Noch keine Geräte vorhanden.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if can_create %}
|
||||
<!-- Modal: Neues Gerät -->
|
||||
<div class="modal-overlay" id="deviceModal">
|
||||
<div class="modal">
|
||||
<form method="post" onsubmit="return validateDeviceForm(this);">
|
||||
<div class="modal-header">
|
||||
<h3>Neues Gerät hinzufügen</h3>
|
||||
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" name="add_device" value="1">
|
||||
<div class="field"><label>Hostname</label>
|
||||
<input type="text" name="name" required placeholder="z.B. Sensor01">
|
||||
</div>
|
||||
<div class="field"><label>IP-Adresse</label>
|
||||
<input type="text" name="rpi_ip" required placeholder="z.B. 192.168.1.100">
|
||||
<div class="invalid-feedback">Bitte eine gültige IP-Adresse eingeben.</div>
|
||||
</div>
|
||||
<div class="field"><label>MAC-Adresse</label>
|
||||
<input type="text" name="mac" required placeholder="z.B. AA:BB:CC:DD:EE:FF">
|
||||
<div class="invalid-feedback">Bitte eine gültige MAC-Adresse eingeben (xx:xx:xx:xx:xx:xx).</div>
|
||||
</div>
|
||||
<div class="field"><label>Port</label>
|
||||
<input type="text" name="port" placeholder="z.B. 3">
|
||||
</div>
|
||||
<div class="field"><label>Switch (optional)</label>
|
||||
<select name="switch_hostname">
|
||||
<option value="">Kein Switch</option>
|
||||
{% for sw in switches %}<option value="{{ sw['hostname'] }}">{{ sw['hostname'] }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
|
||||
<button type="submit" class="btn btn-primary">Hinzufügen</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if can_edit %}
|
||||
<!-- Modal: Bearbeiten -->
|
||||
<div class="modal-overlay" id="editDeviceModal">
|
||||
<div class="modal">
|
||||
<form method="post" onsubmit="return validateDeviceForm(this);">
|
||||
<input type="hidden" name="edit_device" value="1">
|
||||
<input type="hidden" name="old_mac" id="edit_old_mac">
|
||||
<div class="modal-header">
|
||||
<h3>Gerät bearbeiten</h3>
|
||||
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="field"><label>Hostname</label>
|
||||
<input type="text" name="name" id="edit_name" required>
|
||||
</div>
|
||||
<div class="field"><label>IP-Adresse</label>
|
||||
<input type="text" name="rpi_ip" id="edit_ip" required>
|
||||
<div class="invalid-feedback">Bitte eine gültige IP-Adresse eingeben.</div>
|
||||
</div>
|
||||
<div class="field"><label>MAC-Adresse</label>
|
||||
<input type="text" name="mac" id="edit_mac" required>
|
||||
<div class="invalid-feedback">Bitte eine gültige MAC-Adresse eingeben (xx:xx:xx:xx:xx:xx).</div>
|
||||
</div>
|
||||
<div class="field"><label>Port</label>
|
||||
<input type="text" name="port" id="edit_port">
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
|
||||
<button type="submit" class="btn btn-primary">Speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal: Switch ändern -->
|
||||
<div class="modal-overlay" id="switchModal">
|
||||
<div class="modal" style="max-width:380px;">
|
||||
<form method="post">
|
||||
<input type="hidden" name="edit_device" value="1">
|
||||
<input type="hidden" name="old_mac" id="switch_mac">
|
||||
<div class="modal-header">
|
||||
<h3>Switch ändern</h3>
|
||||
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="field">
|
||||
<label>Zugeordneter Switch</label>
|
||||
<select name="switch_hostname" id="switch_select">
|
||||
<option value="">Kein Switch</option>
|
||||
{% for sw in switches %}<option value="{{ sw['hostname'] }}">{{ sw['hostname'] }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
|
||||
<button type="submit" class="btn btn-primary">Speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function resetDeviceForm() {
|
||||
document.querySelector("#deviceModal form").reset();
|
||||
}
|
||||
function openEditDeviceModal(mac, name, ip, port) {
|
||||
document.getElementById("edit_old_mac").value = mac;
|
||||
document.getElementById("edit_name").value = name;
|
||||
document.getElementById("edit_ip").value = ip;
|
||||
document.getElementById("edit_mac").value = mac;
|
||||
document.getElementById("edit_port").value = port;
|
||||
PoeUI.openModal("editDeviceModal");
|
||||
}
|
||||
function openSwitchModal(mac, switchHostname) {
|
||||
document.getElementById("switch_mac").value = mac;
|
||||
document.getElementById("switch_select").value = switchHostname;
|
||||
PoeUI.openModal("switchModal");
|
||||
}
|
||||
|
||||
const ipPattern = /^(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$/;
|
||||
const macPattern = /^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$/;
|
||||
|
||||
function validateIP(input) {
|
||||
const ok = ipPattern.test(input.value);
|
||||
input.classList.toggle("is-invalid", !ok);
|
||||
return ok;
|
||||
}
|
||||
function validateMAC(input) {
|
||||
const ok = macPattern.test(input.value);
|
||||
input.classList.toggle("is-invalid", !ok);
|
||||
return ok;
|
||||
}
|
||||
function validateDeviceForm(form) {
|
||||
const ipInput = form.querySelector("input[name='rpi_ip']");
|
||||
const macInput = form.querySelector("input[name='mac']");
|
||||
let valid = true;
|
||||
if (ipInput) valid = validateIP(ipInput) && valid;
|
||||
if (macInput) valid = validateMAC(macInput) && valid;
|
||||
return valid;
|
||||
}
|
||||
document.addEventListener("input", (e) => {
|
||||
if (e.target.name === "rpi_ip") validateIP(e.target);
|
||||
if (e.target.name === "mac") validateMAC(e.target);
|
||||
});
|
||||
|
||||
function toggleDevice(mac, checkbox) {
|
||||
checkbox.disabled = true;
|
||||
fetch(`/devices/toggle/${mac}`, { method: "POST" })
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
checkbox.disabled = false;
|
||||
if (data.success) {
|
||||
checkbox.checked = data.new_status === 1;
|
||||
showToast(data.msg, "success");
|
||||
} else {
|
||||
checkbox.checked = !checkbox.checked;
|
||||
showToast(data.msg, "danger");
|
||||
}
|
||||
})
|
||||
.catch(() => { checkbox.disabled = false; checkbox.checked = !checkbox.checked; showToast("Fehler beim Umschalten.", "danger"); });
|
||||
}
|
||||
|
||||
function filterTable(inputId, tableId) {
|
||||
const q = document.getElementById(inputId).value.trim().toLowerCase();
|
||||
document.querySelectorAll(`#${tableId} tbody tr`).forEach(row => {
|
||||
if (row.classList.contains("empty-row")) return;
|
||||
row.style.display = row.innerText.toLowerCase().includes(q) ? "" : "none";
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,112 @@
|
||||
{% extends "base.html" %}
|
||||
{% set active_page = "groups" %}
|
||||
{% block page_title %}Gruppen{% endblock %}
|
||||
{% block page_sub %}<div class="topbar-sub">{{ groups|length }} Gruppen · Rechteverwaltung</div>{% endblock %}
|
||||
{% block topbar_right %}
|
||||
<button type="button" class="btn btn-primary" data-open-modal="addGroupModal">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
|
||||
Neue Gruppe
|
||||
</button>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<p class="text-faint" style="font-size:12.5px; margin-bottom:18px; max-width:720px;">
|
||||
Admins dürfen immer alles. Über Gruppen lassen sich einzelne Verwaltungsrechte für
|
||||
Devices und Switches gezielt an normale Benutzer vergeben, ohne sie zu Admins zu machen.
|
||||
Ein Benutzer kann mehreren Gruppen angehören — die Rechte addieren sich.
|
||||
</p>
|
||||
|
||||
{% if groups %}
|
||||
<div style="display:flex; flex-direction:column; gap:16px;">
|
||||
{% for g in groups %}
|
||||
<div class="card card-pad">
|
||||
<form method="post">
|
||||
<input type="hidden" name="save_group" value="1">
|
||||
<input type="hidden" name="group_id" value="{{ g.id }}">
|
||||
|
||||
<div class="section-head">
|
||||
<div class="field" style="margin-bottom:0; max-width:280px; flex:1;">
|
||||
<label>Gruppenname</label>
|
||||
<input type="text" name="name" value="{{ g.name }}" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style="display:grid; grid-template-columns:repeat(auto-fit, minmax(220px, 1fr)); gap:24px; margin-top:8px;">
|
||||
{% for cat_key, cat in permission_catalog.items() %}
|
||||
<div>
|
||||
<div class="permission-group-title">{{ cat['label'] }}</div>
|
||||
<div class="check-list">
|
||||
{% for key, label in cat['items'].items() %}
|
||||
<label class="check-row">
|
||||
<input type="checkbox" name="permissions" value="{{ key }}" {% if key in g.permissions %}checked{% endif %}>
|
||||
{{ label }}
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<div>
|
||||
<div class="permission-group-title">Mitglieder</div>
|
||||
<div class="check-list" style="max-height:180px; overflow-y:auto; padding-right:4px;">
|
||||
{% for u in all_users %}
|
||||
<label class="check-row">
|
||||
<input type="checkbox" name="members" value="{{ u['id'] }}" {% if u['id'] in g.members %}checked{% endif %}>
|
||||
{{ u['username'] }}
|
||||
</label>
|
||||
{% else %}
|
||||
<p class="text-faint" style="font-size:12px;">Keine Nicht-Admin-Benutzer vorhanden.</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="flex" style="justify-content:space-between; align-items:center; margin-top:16px; padding-top:14px; border-top:1px solid var(--border-soft);">
|
||||
<span class="text-faint" style="font-size:12px;">
|
||||
{% if g.member_names %}Mitglieder: {{ g.member_names|join(', ') }}{% else %}Keine Mitglieder{% endif %}
|
||||
</span>
|
||||
<form method="post" data-confirm="Gruppe „{{ g.name }}“ wirklich löschen? Mitglieder verlieren die zugehörigen Rechte.">
|
||||
<input type="hidden" name="delete_group" value="{{ g.id }}">
|
||||
<button type="submit" class="btn btn-sm btn-danger">Gruppe löschen</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card card-pad" style="text-align:center; color:var(--text-faint);">
|
||||
Noch keine Gruppen angelegt.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Modal: Neue Gruppe -->
|
||||
<div class="modal-overlay" id="addGroupModal">
|
||||
<div class="modal" style="max-width:380px;">
|
||||
<form method="post">
|
||||
<div class="modal-header">
|
||||
<h3>Neue Gruppe</h3>
|
||||
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" name="add_group" value="1">
|
||||
<div class="field">
|
||||
<label>Name</label>
|
||||
<input type="text" name="name" required placeholder="z.B. Facility-Team">
|
||||
<div class="field-hint">Rechte und Mitglieder werden danach auf der Gruppenkarte eingestellt.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
|
||||
<button type="submit" class="btn btn-primary">Anlegen</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,271 @@
|
||||
{% extends "base.html" %}
|
||||
{% set active_page = "index" %}
|
||||
{% block page_title %}Dashboard{% endblock %}
|
||||
{% block page_sub %}<div class="topbar-sub">{{ devices|length }} Geräte im Bestand</div>{% endblock %}
|
||||
{% block topbar_right %}
|
||||
<span class="timer-pill"><span class="dot"></span><span id="dashboard-timer">Nächste Prüfung in --s</span></span>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="stat-row">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Online</div>
|
||||
<div class="stat-value online">{{ stats.online }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Offline</div>
|
||||
<div class="stat-value offline">{{ stats.offline }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Deaktiviert</div>
|
||||
<div class="stat-value disabled">{{ stats.disabled }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Gesamt</div>
|
||||
<div class="stat-value">{{ stats.total }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if not current_user.is_authenticated %}
|
||||
{# ============================================================ #}
|
||||
{# Öffentliche, schreibgeschützte Kurzübersicht (kein Login) #}
|
||||
{# ============================================================ #}
|
||||
<div class="table-wrap">
|
||||
<div style="overflow-x:auto;">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr><th>Hostname</th><th>IP-Adresse</th><th>Status</th><th>Letzte Prüfung</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for d in devices %}
|
||||
{% set st = status.get(d['mac'], 'unbekannt') %}
|
||||
<tr>
|
||||
<td class="cell-name">{{ d['name'] }}</td>
|
||||
<td class="mono">{{ d['rpi_ip'] }}</td>
|
||||
<td>
|
||||
{% if d['is_active'] == 0 %}
|
||||
<span class="pill disabled">Deaktiviert</span>
|
||||
{% else %}
|
||||
<span class="pill {{ st }}">{{ {'online':'Online','offline':'Offline','unbekannt':'Unbekannt'}[st] }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-dim">{{ last_checked.get(d['mac']) or '—' }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr class="empty-row"><td colspan="4">Noch keine Geräte vorhanden.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-faint mt-3" style="font-size:12px;">
|
||||
Nur Lese-Ansicht. <a href="{{ url_for('login') }}" style="color:var(--accent-strong); font-weight:600;">Anmelden</a>,
|
||||
um Geräte zu verwalten oder einen PoE-Neustart auszulösen.
|
||||
</p>
|
||||
|
||||
{% else %}
|
||||
{# ============================================================ #}
|
||||
{# Vollständiges Dashboard für eingeloggte Benutzer #}
|
||||
{# ============================================================ #}
|
||||
{% if devices %}
|
||||
<div class="device-grid">
|
||||
{% for d in devices %}
|
||||
{% set st = status.get(d['mac'], 'unbekannt') %}
|
||||
<div class="device-card {% if d['is_active'] == 0 %}is-disabled{% endif %}"
|
||||
data-status="{{ 'offline' if d['is_active'] == 0 else st }}"
|
||||
data-mac="{{ d['mac'] }}"
|
||||
data-name="{{ d['name'] }}"
|
||||
data-ip="{{ d['rpi_ip'] }}"
|
||||
data-switch="{{ d['switch_hostname'] or '-' }}"
|
||||
data-port="{{ d['port'] or '-' }}"
|
||||
data-active="{{ d['is_active'] }}"
|
||||
data-checked="{{ last_checked.get(d['mac']) or '-' }}"
|
||||
{% if last_seen.get(d['mac']) %}title="{{ last_seen[d['mac']] }}"{% endif %}>
|
||||
<div class="dc-top">
|
||||
<div class="dc-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="2.5"/><path d="M8 2v3M16 2v3M8 19v3M16 19v3M2 8h3M2 16h3M19 8h3M19 16h3"/></svg>
|
||||
</div>
|
||||
{% if d['is_active'] == 0 %}
|
||||
<span class="pill disabled">Aus</span>
|
||||
{% else %}
|
||||
<span class="pill {{ st }}">{{ {'online':'Online','offline':'Offline','unbekannt':'Unbekannt'}[st] }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="dc-name">{{ d['name'] }}</div>
|
||||
<div class="dc-ip">{{ d['rpi_ip'] }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card card-pad" style="text-align:center; color:var(--text-faint);">
|
||||
Noch keine Geräte angelegt. Füge welche unter <a href="{{ url_for('devices') }}" style="color:var(--accent-strong); font-weight:600;">Devices</a> hinzu.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Device detail modal -->
|
||||
<div class="modal-overlay" id="deviceModal">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3 id="deviceModalTitle">Gerät</h3>
|
||||
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="detail-list">
|
||||
<div class="detail-row"><span class="k">IP-Adresse</span><span class="v mono" id="deviceIp">-</span></div>
|
||||
<div class="detail-row"><span class="k">Switch</span><span class="v" id="deviceSwitch">-</span></div>
|
||||
<div class="detail-row"><span class="k">Port</span><span class="v" id="devicePort">-</span></div>
|
||||
<div class="detail-row"><span class="k">Status</span><span class="v" id="deviceStatus">-</span></div>
|
||||
<div class="detail-row"><span class="k">Letzte Prüfung</span><span class="v" id="deviceChecked">-</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-close-modal>Schließen</button>
|
||||
{% if current_user.has_permission('devices.restart') %}
|
||||
<button type="button" class="btn btn-primary" id="restartButton">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 11-3.2-6.9M21 4v5h-5"/></svg>
|
||||
Neustarten
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if current_user.has_permission('devices.restart') %}
|
||||
<!-- Restart confirm modal -->
|
||||
<div class="modal-overlay" id="restartModal">
|
||||
<div class="modal" style="max-width:400px;">
|
||||
<div class="modal-header">
|
||||
<h3 id="restartTitle">Neustart</h3>
|
||||
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="text-dim">Der Neustart erfolgt durch einen kurzen PoE-Reset. Das Gerät wird für wenige Sekunden vom Netzwerk getrennt.</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
|
||||
<button type="button" class="btn btn-primary" id="confirmRestart" style="background:var(--warning); color:#241a00;">Neustarten</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const intervalMinutes = {{ interval | int }};
|
||||
const intervalMilliseconds = intervalMinutes * 60 * 1000;
|
||||
const isAuthenticated = {{ "true" if current_user.is_authenticated else "false" }};
|
||||
let lastUpdateTime = Date.now();
|
||||
|
||||
function updateTimer() {
|
||||
const now = Date.now();
|
||||
const elapsed = now - lastUpdateTime;
|
||||
const remainingMs = intervalMilliseconds - (elapsed % intervalMilliseconds);
|
||||
const remainingSec = Math.ceil(remainingMs / 1000);
|
||||
const el = document.getElementById("dashboard-timer");
|
||||
if (el) el.innerText = `Nächste Prüfung in ${remainingSec}s`;
|
||||
if (remainingSec <= 1) window.location.reload();
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
// Öffentliche Ansicht: einfacher Reload-Timer, kein Zugriff auf /get_log nötig.
|
||||
setInterval(updateTimer, 1000);
|
||||
return;
|
||||
}
|
||||
|
||||
let selectedCard = null, selectedMac = null, selectedName = null;
|
||||
|
||||
function parseLogTimestamp(ts) {
|
||||
const parts = ts.match(/^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})$/);
|
||||
if (!parts) return Date.now();
|
||||
const [, year, month, day, hour, minute, second] = parts.map(Number);
|
||||
return new Date(year, month - 1, day, hour, minute, second).getTime();
|
||||
}
|
||||
|
||||
function fetchLastLog() {
|
||||
fetch("{{ url_for('get_log') }}")
|
||||
.then(r => r.text())
|
||||
.then(data => {
|
||||
const lines = data.split("\n");
|
||||
let lastSepIndex = -1;
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
if (lines[i].startsWith("----")) { lastSepIndex = i; break; }
|
||||
}
|
||||
if (lastSepIndex >= 0 && lastSepIndex + 1 < lines.length) {
|
||||
const match = lines[lastSepIndex + 1].match(/^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})/);
|
||||
if (match) lastUpdateTime = parseLogTimestamp(match[1]);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
document.querySelectorAll(".device-card").forEach(card => {
|
||||
const mac = card.dataset.mac;
|
||||
const restartKey = "restart_" + mac;
|
||||
if (sessionStorage.getItem(restartKey)) {
|
||||
const pill = card.querySelector(".pill");
|
||||
if (pill && pill.innerText.trim().toLowerCase() === "online") {
|
||||
sessionStorage.removeItem(restartKey);
|
||||
} else if (pill) {
|
||||
pill.innerHTML = '<span class="spinner"></span> Restarting…';
|
||||
}
|
||||
}
|
||||
card.addEventListener("click", function () {
|
||||
if (this.dataset.active == "0") return;
|
||||
if (sessionStorage.getItem("restart_" + this.dataset.mac)) return;
|
||||
selectedCard = this;
|
||||
selectedMac = this.dataset.mac;
|
||||
selectedName = this.dataset.name;
|
||||
document.getElementById("deviceModalTitle").innerText = this.dataset.name;
|
||||
document.getElementById("deviceIp").innerText = this.dataset.ip || "-";
|
||||
document.getElementById("deviceSwitch").innerText = this.dataset.switch || "-";
|
||||
document.getElementById("devicePort").innerText = this.dataset.port || "-";
|
||||
document.getElementById("deviceChecked").innerText = this.dataset.checked || "-";
|
||||
const pill = this.querySelector(".pill");
|
||||
document.getElementById("deviceStatus").innerText = pill ? pill.innerText.trim() : "-";
|
||||
PoeUI.openModal("deviceModal");
|
||||
});
|
||||
});
|
||||
|
||||
const restartButton = document.getElementById("restartButton");
|
||||
if (restartButton) {
|
||||
restartButton.addEventListener("click", function () {
|
||||
PoeUI.closeModal(document.getElementById("deviceModal"));
|
||||
document.getElementById("restartTitle").innerText = selectedName + " neu starten?";
|
||||
PoeUI.openModal("restartModal");
|
||||
});
|
||||
}
|
||||
|
||||
const confirmRestart = document.getElementById("confirmRestart");
|
||||
if (confirmRestart) {
|
||||
confirmRestart.addEventListener("click", function () {
|
||||
const button = this;
|
||||
button.disabled = true;
|
||||
fetch("/restart/" + encodeURIComponent(selectedMac), { method: "POST" })
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
button.disabled = false;
|
||||
if (!data.success) {
|
||||
showToast(data.message || "Neustart konnte nicht gestartet werden.", "danger");
|
||||
return;
|
||||
}
|
||||
PoeUI.closeModal(document.getElementById("restartModal"));
|
||||
PoeUI.closeModal(document.getElementById("deviceModal"));
|
||||
sessionStorage.setItem("restart_" + selectedMac, "1");
|
||||
const pill = selectedCard.querySelector(".pill");
|
||||
if (pill) pill.innerHTML = '<span class="spinner"></span> Restarting…';
|
||||
showToast(`Neustart von ${data.device} gestartet.`, "success");
|
||||
})
|
||||
.catch(err => { button.disabled = false; showToast("Fehler beim Starten des Neustarts.", "danger"); });
|
||||
});
|
||||
}
|
||||
|
||||
setInterval(updateTimer, 1000);
|
||||
fetchLastLog();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,46 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Login · PoE Manager</title>
|
||||
<link rel="icon" href="{{ url_for('static', filename='images/logo.png') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="login-page">
|
||||
<div class="login-card">
|
||||
<div class="login-logo">
|
||||
<img src="{{ url_for('static', filename='images/logo.png') }}" alt="WiS">
|
||||
</div>
|
||||
<h1>PoE Manager</h1>
|
||||
<p class="login-sub">Melde dich an, um fortzufahren</p>
|
||||
|
||||
{% with messages = get_flashed_messages() %}
|
||||
{% if messages %}
|
||||
<div class="field" style="margin-bottom:18px;">
|
||||
{% for message in messages %}
|
||||
<div class="toast danger" style="position:static; animation:none;">{{ message }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<form method="post">
|
||||
<div class="field">
|
||||
<label for="username">Benutzername</label>
|
||||
<input type="text" id="username" name="username" autocomplete="username" required autofocus>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="password">Passwort</label>
|
||||
<input type="password" id="password" name="password" autocomplete="current-password" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-block" style="margin-top:6px;">Anmelden</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,91 @@
|
||||
{% extends "base.html" %}
|
||||
{% set active_page = "logs" %}
|
||||
{% block page_title %}Live-Log{% endblock %}
|
||||
{% block page_sub %}<div class="topbar-sub">{{ log_name or "kein Logfile" }}</div>{% endblock %}
|
||||
{% block topbar_right %}
|
||||
<span class="timer-pill"><span class="dot"></span>Update in <span id="timer">--</span>s</span>
|
||||
<button id="refresh-btn" class="btn btn-secondary">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 11-3.2-6.9M21 4v5h-5"/></svg>
|
||||
Aktualisieren
|
||||
</button>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="log-shell">
|
||||
<div class="log-toolbar">
|
||||
<div class="log-dots"><span></span><span></span><span></span></div>
|
||||
<span class="text-faint mono" style="font-size:11.5px;">{{ log_name or "" }}</span>
|
||||
</div>
|
||||
<div id="log-box">{{ log_content or "Keine Logfiles gefunden." }}</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function parseLogTimestamp(ts) {
|
||||
const parts = ts.match(/^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})$/);
|
||||
if (!parts) return Date.now();
|
||||
const [, year, month, day, hour, minute, second] = parts.map(Number);
|
||||
return new Date(year, month - 1, day, hour, minute, second).getTime();
|
||||
}
|
||||
|
||||
function colorizeLine(line) {
|
||||
let cls = "";
|
||||
if (line.includes(" ist erreichbar!")) cls = "online";
|
||||
else if (line.includes(" ist nicht erreichbar!")) cls = "offline";
|
||||
else if (line.startsWith("----")) cls = "sep";
|
||||
else if (line.toLowerCase().includes("manuell")) cls = "manual";
|
||||
const span = document.createElement("span");
|
||||
span.className = "log-line" + (cls ? " " + cls : "");
|
||||
span.textContent = line;
|
||||
return span;
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const intervalMinutes = {{ interval | int }};
|
||||
const intervalMilliseconds = intervalMinutes * 60 * 1000;
|
||||
let lastUpdateTime = Date.now();
|
||||
|
||||
function renderLog(data) {
|
||||
const box = document.getElementById("log-box");
|
||||
box.innerHTML = "";
|
||||
const lines = data.split("\n");
|
||||
lines.forEach((line, i) => {
|
||||
box.appendChild(colorizeLine(line));
|
||||
if (i < lines.length - 1) box.appendChild(document.createElement("br"));
|
||||
});
|
||||
box.scrollTop = box.scrollHeight;
|
||||
|
||||
let lastSep = -1;
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
if (lines[i].startsWith("----")) { lastSep = i; break; }
|
||||
}
|
||||
if (lastSep >= 0 && lastSep + 1 < lines.length) {
|
||||
const match = lines[lastSep + 1].match(/^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})/);
|
||||
lastUpdateTime = match ? parseLogTimestamp(match[1]) : Date.now();
|
||||
} else {
|
||||
lastUpdateTime = Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
function fetchLog() {
|
||||
fetch("{{ url_for('get_log') }}")
|
||||
.then(r => r.text())
|
||||
.then(renderLog)
|
||||
.catch(err => console.error(err));
|
||||
}
|
||||
|
||||
function updateTimer() {
|
||||
const now = Date.now();
|
||||
const elapsed = now - lastUpdateTime;
|
||||
const remainingMs = intervalMilliseconds - (elapsed % intervalMilliseconds);
|
||||
document.getElementById("timer").innerText = Math.ceil(remainingMs / 1000);
|
||||
}
|
||||
|
||||
document.getElementById("refresh-btn").addEventListener("click", fetchLog);
|
||||
fetchLog();
|
||||
setInterval(fetchLog, intervalMilliseconds);
|
||||
setInterval(updateTimer, 1000);
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,26 @@
|
||||
{% extends "base.html" %}
|
||||
{% set active_page = "settings" %}
|
||||
{% block page_title %}Settings{% endblock %}
|
||||
{% block page_sub %}<div class="topbar-sub">Prüfintervall für das Monitoring</div>{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card card-pad" style="max-width:420px;">
|
||||
<div class="section-head" style="margin-bottom:16px;">
|
||||
<div>
|
||||
<h2 style="font-size:16px;">Prüfintervall</h2>
|
||||
<div class="hint">Wie oft sollen Geräte auf Erreichbarkeit geprüft werden?</div>
|
||||
</div>
|
||||
</div>
|
||||
<form method="post">
|
||||
<div class="field">
|
||||
<label for="interval">Intervall (Minuten)</label>
|
||||
<input type="number" name="interval" id="interval" value="{{ interval }}" min="1" required>
|
||||
<div class="field-hint">Der Hintergrund-Dienst (rpi-check.service) wird nach dem Speichern automatisch neu gestartet.</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-block">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
|
||||
Speichern & Service neustarten
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,334 @@
|
||||
{% extends "base.html" %}
|
||||
{% set active_page = "switches" %}
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/vendor/xterm.css') }}">
|
||||
{% endblock %}
|
||||
{% set can_create = current_user.has_permission('switches.create') %}
|
||||
{% set can_edit = current_user.has_permission('switches.edit') %}
|
||||
{% set can_delete = current_user.has_permission('switches.delete') %}
|
||||
{% block page_title %}Switches{% endblock %}
|
||||
{% block page_sub %}<div class="topbar-sub">{{ switches|length }} Switche</div>{% endblock %}
|
||||
{% block topbar_right %}
|
||||
{% if can_create %}
|
||||
<button type="button" class="btn btn-primary" data-open-modal="addSwitchModal">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
|
||||
Neuer Switch
|
||||
</button>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="table-wrap">
|
||||
<div class="table-toolbar">
|
||||
<div class="search-input">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>
|
||||
<input type="text" id="switchSearch" placeholder="Switches durchsuchen…" oninput="filterTable('switchSearch','switchesTable')">
|
||||
</div>
|
||||
</div>
|
||||
<div style="overflow-x:auto;">
|
||||
<table class="data-table" id="switchesTable">
|
||||
<thead>
|
||||
<tr><th>Hostname</th><th>IP-Adresse</th><th>Username</th><th style="width:1%;">Aktionen</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for s in switches %}
|
||||
<tr>
|
||||
<td class="cell-name">{{ s['hostname'] }}</td>
|
||||
<td class="mono">{{ s['ip'] }}</td>
|
||||
<td>{{ s['username'] }}</td>
|
||||
<td>
|
||||
<div class="row-actions">
|
||||
{% if can_edit %}
|
||||
<button class="icon-btn" title="Bearbeiten" data-open-modal="editSwitchModal{{ loop.index }}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 013 3L7 19l-4 1 1-4z"/></svg>
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if can_delete %}
|
||||
<form method="post" action="{{ url_for('delete_switch', hostname=s['hostname']) }}" data-confirm="Willst du den Switch „{{ s['hostname'] }}“ wirklich löschen?">
|
||||
<button type="submit" class="icon-btn" style="color:var(--danger);" title="Löschen">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0l-1 14a2 2 0 01-2 2H7a2 2 0 01-2-2L4 6"/></svg>
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{% if can_edit %}
|
||||
<div class="modal-overlay" id="editSwitchModal{{ loop.index }}">
|
||||
<div class="modal">
|
||||
<form method="post" onsubmit="return validateSwitchForm(this, '{{ loop.index }}');">
|
||||
<input type="hidden" name="edit_switch" value="1">
|
||||
<input type="hidden" name="old_hostname" value="{{ s['hostname'] }}">
|
||||
<div class="modal-header">
|
||||
<h3>Switch bearbeiten</h3>
|
||||
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="field"><label>Hostname</label>
|
||||
<input type="text" name="hostname" value="{{ s['hostname'] }}" required>
|
||||
</div>
|
||||
<div class="field"><label>IP-Adresse</label>
|
||||
<input type="text" name="ip" value="{{ s['ip'] }}" required placeholder="z.B. 192.168.1.100">
|
||||
<div class="invalid-feedback">Bitte eine gültige IP-Adresse eingeben.</div>
|
||||
</div>
|
||||
<div class="field"><label>Username</label>
|
||||
<input type="text" name="username" value="{{ s['username'] }}" required>
|
||||
</div>
|
||||
<div class="field"><label>Neues Passwort</label>
|
||||
<input type="password" id="password_edit_{{ loop.index }}" name="password" placeholder="Nur bei Änderung ausfüllen">
|
||||
</div>
|
||||
<div class="field"><label>Passwort bestätigen</label>
|
||||
<input type="password" id="password_confirm_edit_{{ loop.index }}" name="password_confirm" placeholder="Bestätigen">
|
||||
<div class="invalid-feedback">Passwörter stimmen nicht überein!</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" style="margin-right:auto;" onclick="openTerminal(this.closest('form'))">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="M6 9l4 3-4 3M13 15h5"/></svg>
|
||||
Verbindung testen
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
|
||||
<button type="submit" class="btn btn-primary">Speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<tr class="empty-row"><td colspan="4">Noch keine Switche vorhanden.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if can_create %}
|
||||
<!-- Modal: Neuer Switch -->
|
||||
<div class="modal-overlay" id="addSwitchModal">
|
||||
<div class="modal">
|
||||
<form method="post" onsubmit="return validateSwitchForm(this, 'add');">
|
||||
<div class="modal-header">
|
||||
<h3>Neuen Switch hinzufügen</h3>
|
||||
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" name="add_switch" value="1">
|
||||
<div class="field"><label>Hostname</label>
|
||||
<input type="text" name="hostname" required placeholder="z.B. Switch01">
|
||||
</div>
|
||||
<div class="field"><label>IP-Adresse</label>
|
||||
<input type="text" name="ip" required placeholder="z.B. 192.168.1.100">
|
||||
<div class="invalid-feedback">Bitte eine gültige IP-Adresse eingeben.</div>
|
||||
</div>
|
||||
<div class="field"><label>Username</label>
|
||||
<input type="text" name="username" required placeholder="z.B. admin">
|
||||
</div>
|
||||
<div class="field"><label>Passwort</label>
|
||||
<input type="password" id="password_add" name="password" required>
|
||||
</div>
|
||||
<div class="field"><label>Passwort bestätigen</label>
|
||||
<input type="password" id="password_confirm_add" name="password_confirm" required>
|
||||
<div class="invalid-feedback">Passwörter stimmen nicht überein!</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" style="margin-right:auto;" onclick="openTerminal(this.closest('form'))">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="M6 9l4 3-4 3M13 15h5"/></svg>
|
||||
Verbindung testen
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
|
||||
<button type="submit" class="btn btn-primary">Hinzufügen</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if can_create or can_edit %}
|
||||
<!-- Modal: SSH-Verbindungstest (echtes Terminal, zum Akzeptieren von Host-Keys
|
||||
und Prüfen der Zugangsdaten, bevor der Switch gespeichert wird) -->
|
||||
<div class="modal-overlay" id="terminalModal">
|
||||
<div class="modal" style="max-width:720px;">
|
||||
<div class="modal-header">
|
||||
<h3>SSH-Verbindungstest</h3>
|
||||
<button type="button" class="modal-close" data-close-modal onclick="closeTerminal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body" style="padding:0;">
|
||||
<div class="term-toolbar">
|
||||
<div class="flex gap-2" style="align-items:center;">
|
||||
<span id="termStatus" class="pill unknown">Bereit</span>
|
||||
<span id="termTarget" class="text-faint mono" style="font-size:12px;"></span>
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-secondary" id="termPastePassword">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="10" rx="2"/><path d="M7 11V7a5 5 0 0110 0v4"/></svg>
|
||||
Passwort einfügen
|
||||
</button>
|
||||
</div>
|
||||
<div id="terminal" class="xterm-container"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<p class="text-faint" style="font-size:11.5px; margin-right:auto;">
|
||||
Erste Verbindung? Bestätige den Host-Key mit „yes“, gib danach das Passwort ein — direkt hier im Terminal.
|
||||
</p>
|
||||
<button type="button" class="btn btn-secondary" data-close-modal onclick="closeTerminal()">Schließen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="{{ url_for('static', filename='js/vendor/xterm.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/vendor/xterm-addon-fit.js') }}"></script>
|
||||
<script>
|
||||
// -------------------------------------------------------------------------
|
||||
// SSH-Verbindungstest (Web-Terminal via /ws/ssh_terminal)
|
||||
// -------------------------------------------------------------------------
|
||||
let term = null, fitAddon = null, termSocket = null, activePasswordInput = null;
|
||||
|
||||
function ensureTerminal() {
|
||||
if (term) return;
|
||||
term = new Terminal({
|
||||
convertEol: true,
|
||||
fontSize: 13,
|
||||
fontFamily: "ui-monospace, SFMono-Regular, Consolas, monospace",
|
||||
cursorBlink: true,
|
||||
theme: { background: "#0a0c10", foreground: "#c7ccd6" },
|
||||
});
|
||||
fitAddon = new FitAddon.FitAddon();
|
||||
term.loadAddon(fitAddon);
|
||||
term.open(document.getElementById("terminal"));
|
||||
fitAddon.fit();
|
||||
term.onData((data) => {
|
||||
if (termSocket && termSocket.readyState === WebSocket.OPEN) {
|
||||
termSocket.send(JSON.stringify({ type: "input", data }));
|
||||
}
|
||||
});
|
||||
term.onResize(({ cols, rows }) => {
|
||||
if (termSocket && termSocket.readyState === WebSocket.OPEN) {
|
||||
termSocket.send(JSON.stringify({ type: "resize", cols, rows }));
|
||||
}
|
||||
});
|
||||
window.addEventListener("resize", () => fitAddon && fitAddon.fit());
|
||||
}
|
||||
|
||||
function setTermStatus(text, cls) {
|
||||
const el = document.getElementById("termStatus");
|
||||
el.className = "pill " + cls;
|
||||
el.innerText = text;
|
||||
}
|
||||
|
||||
function openTerminal(form) {
|
||||
const host = (form.querySelector("input[name='ip']") || {}).value?.trim();
|
||||
const username = (form.querySelector("input[name='username']") || {}).value?.trim();
|
||||
activePasswordInput = form.querySelector("input[name='password']");
|
||||
|
||||
if (!host || !username) {
|
||||
showToast("Bitte IP-Adresse und Username ausfüllen, bevor du die Verbindung testest.", "danger");
|
||||
return;
|
||||
}
|
||||
|
||||
PoeUI.openModal("terminalModal");
|
||||
ensureTerminal();
|
||||
term.reset();
|
||||
document.getElementById("termTarget").innerText = `${username}@${host}`;
|
||||
setTermStatus("Verbinde…", "unknown");
|
||||
setTimeout(() => fitAddon && fitAddon.fit(), 60);
|
||||
|
||||
if (termSocket) { try { termSocket.close(); } catch (e) {} }
|
||||
|
||||
const proto = location.protocol === "https:" ? "wss:" : "ws:";
|
||||
termSocket = new WebSocket(`${proto}//${location.host}/ws/ssh_terminal`);
|
||||
|
||||
termSocket.onopen = () => {
|
||||
termSocket.send(JSON.stringify({ host, username, port: 22 }));
|
||||
setTermStatus("Verbunden", "online");
|
||||
};
|
||||
termSocket.onmessage = (event) => term.write(event.data);
|
||||
termSocket.onclose = () => setTermStatus("Getrennt", "offline");
|
||||
// Bewusst kein Toast bei "error": manche Browser melden beim Schließen
|
||||
// einer WebSocket-Verbindung ein error-Event, obwohl die Sitzung
|
||||
// inhaltlich erfolgreich war. Terminal-Inhalt + Status-Pill genügen als
|
||||
// Feedback — der Verbindungsstatus wird zuverlässig über onclose gepflegt.
|
||||
termSocket.onerror = () => setTermStatus("Fehler", "offline");
|
||||
}
|
||||
|
||||
function closeTerminal() {
|
||||
if (termSocket) {
|
||||
try { termSocket.close(); } catch (e) {}
|
||||
termSocket = null;
|
||||
}
|
||||
}
|
||||
|
||||
const termPasteBtn = document.getElementById("termPastePassword");
|
||||
if (termPasteBtn) {
|
||||
termPasteBtn.addEventListener("click", () => {
|
||||
if (!activePasswordInput || !activePasswordInput.value) {
|
||||
showToast("Kein Passwort im Formular eingetragen.", "danger");
|
||||
return;
|
||||
}
|
||||
if (!termSocket || termSocket.readyState !== WebSocket.OPEN) {
|
||||
showToast("Keine aktive Terminal-Verbindung.", "danger");
|
||||
return;
|
||||
}
|
||||
termSocket.send(JSON.stringify({ type: "input", data: activePasswordInput.value + "\n" }));
|
||||
});
|
||||
}
|
||||
|
||||
// Verbindung auch beim Schließen per Klick auf Backdrop / Escape sauber trennen
|
||||
const terminalModalEl = document.getElementById("terminalModal");
|
||||
if (terminalModalEl) {
|
||||
terminalModalEl.addEventListener("click", (e) => {
|
||||
if (e.target.id === "terminalModal") closeTerminal();
|
||||
});
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && terminalModalEl.classList.contains("open")) closeTerminal();
|
||||
});
|
||||
}
|
||||
|
||||
const ipPattern = /^(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$/;
|
||||
|
||||
function validateIP(input) {
|
||||
const ok = ipPattern.test(input.value);
|
||||
input.classList.toggle("is-invalid", !ok);
|
||||
return ok;
|
||||
}
|
||||
|
||||
function validatePassword(id, isEdit) {
|
||||
const pass = document.getElementById(isEdit ? `password_edit_${id}` : "password_add");
|
||||
const confirm = document.getElementById(isEdit ? `password_confirm_edit_${id}` : "password_confirm_add");
|
||||
if (!pass || !confirm) return true;
|
||||
if (pass.value !== confirm.value) { confirm.classList.add("is-invalid"); return false; }
|
||||
confirm.classList.remove("is-invalid");
|
||||
return true;
|
||||
}
|
||||
|
||||
function validateSwitchForm(form, id) {
|
||||
const ipInput = form.querySelector("input[name='ip']");
|
||||
let valid = true;
|
||||
if (ipInput) valid = validateIP(ipInput) && valid;
|
||||
valid = validatePassword(id, id !== "add") && valid;
|
||||
return valid;
|
||||
}
|
||||
|
||||
document.addEventListener("input", (e) => {
|
||||
if (e.target.name === "ip") validateIP(e.target);
|
||||
if (e.target.id.startsWith("password_confirm")) {
|
||||
const id = e.target.id.replace("password_confirm_", "");
|
||||
const pass = document.getElementById("password_" + id);
|
||||
if (pass) e.target.classList.toggle("is-invalid", pass.value !== e.target.value);
|
||||
}
|
||||
});
|
||||
|
||||
function filterTable(inputId, tableId) {
|
||||
const q = document.getElementById(inputId).value.trim().toLowerCase();
|
||||
document.querySelectorAll(`#${tableId} tbody tr`).forEach(row => {
|
||||
if (row.classList.contains("empty-row")) return;
|
||||
row.style.display = row.innerText.toLowerCase().includes(q) ? "" : "none";
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,128 @@
|
||||
{% extends "base.html" %}
|
||||
{% set active_page = "users" %}
|
||||
{% block page_title %}Users{% endblock %}
|
||||
{% block page_sub %}<div class="topbar-sub">{{ users|length }} Benutzer</div>{% endblock %}
|
||||
{% block topbar_right %}
|
||||
<button type="button" class="btn btn-primary" data-open-modal="userModal" onclick="document.getElementById('userForm').reset();">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
|
||||
Neuer Benutzer
|
||||
</button>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="table-wrap">
|
||||
<div style="overflow-x:auto;">
|
||||
<table class="data-table">
|
||||
<thead><tr><th>Username</th><th>Rolle</th><th>Gruppen</th><th style="width:1%;">Aktionen</th></tr></thead>
|
||||
<tbody>
|
||||
{% for u in users %}
|
||||
<tr>
|
||||
<td class="cell-name">{{ u['username'] }}</td>
|
||||
<td><span class="pill {{ 'admin' if u['is_admin'] else 'user' }}">{{ "Admin" if u['is_admin'] else "User" }}</span></td>
|
||||
<td class="text-dim">{{ u['group_names'] or '—' }}</td>
|
||||
<td>
|
||||
<div class="row-actions">
|
||||
<button class="btn btn-sm btn-secondary" onclick="openRoleModal({{ u['id'] }}, '{{ u['username'] }}', {{ u['is_admin'] }})">Rolle</button>
|
||||
<button class="btn btn-sm btn-secondary" onclick="openPasswordModal({{ u['id'] }}, '{{ u['username'] }}')">Passwort</button>
|
||||
<form method="post" data-confirm="Willst du „{{ u['username'] }}“ wirklich löschen?">
|
||||
<button type="submit" name="delete_user" value="{{ u['id'] }}" class="icon-btn" style="color:var(--danger);" title="Löschen">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0l-1 14a2 2 0 01-2 2H7a2 2 0 01-2-2L4 6"/></svg>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr class="empty-row"><td colspan="4">Noch keine Benutzer vorhanden.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal: Neuer Benutzer -->
|
||||
<div class="modal-overlay" id="userModal">
|
||||
<div class="modal">
|
||||
<form method="post" id="userForm">
|
||||
<div class="modal-header">
|
||||
<h3>Neuen Benutzer anlegen</h3>
|
||||
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="field"><label>Username</label><input type="text" name="username" required></div>
|
||||
<div class="field"><label>Passwort</label><input type="password" name="password" required></div>
|
||||
<div class="field"><label>Rolle</label>
|
||||
<select name="is_admin"><option value="0">User</option><option value="1">Admin</option></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
|
||||
<button type="submit" name="add_user" value="1" class="btn btn-primary">Anlegen</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal: Rolle ändern -->
|
||||
<div class="modal-overlay" id="roleModal">
|
||||
<div class="modal">
|
||||
<form method="post" id="roleForm">
|
||||
<input type="hidden" name="user_id" id="role_user_id">
|
||||
<div class="modal-header">
|
||||
<h3>Rolle ändern</h3>
|
||||
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="field"><label>Username</label><input type="text" name="username" id="role_username" required></div>
|
||||
<div class="field"><label>Rolle</label>
|
||||
<select name="is_admin" id="role_is_admin"><option value="0">User</option><option value="1">Admin</option></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
|
||||
<button type="submit" name="change_role" value="1" class="btn btn-primary">Speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal: Passwort ändern -->
|
||||
<div class="modal-overlay" id="passwordModal">
|
||||
<div class="modal">
|
||||
<form method="post" id="passwordForm">
|
||||
<input type="hidden" name="user_id" id="password_user_id">
|
||||
<div class="modal-header">
|
||||
<h3>Passwort ändern</h3>
|
||||
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="field"><label>Username</label><input type="text" name="username" id="password_username" readonly></div>
|
||||
<div class="field"><label>Neues Passwort</label><input type="password" name="new_password" required></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
|
||||
<button type="submit" name="change_password" value="1" class="btn btn-primary">Ändern</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function openRoleModal(userId, username, isAdmin) {
|
||||
document.getElementById("role_user_id").value = userId;
|
||||
document.getElementById("role_username").value = username;
|
||||
document.getElementById("role_is_admin").value = isAdmin;
|
||||
PoeUI.openModal("roleModal");
|
||||
}
|
||||
function openPasswordModal(userId, username) {
|
||||
document.getElementById("password_user_id").value = userId;
|
||||
document.getElementById("password_username").value = username;
|
||||
document.querySelector("#passwordForm input[name='new_password']").value = "";
|
||||
PoeUI.openModal("passwordModal");
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,139 @@
|
||||
#!/bin/bash
|
||||
# ============================================================================
|
||||
# PoE Device Check Script
|
||||
# - prüft Erreichbarkeit von Geräten
|
||||
# - startet PoE-Port bei Ausfall neu
|
||||
# - loggt Ereignisse
|
||||
# - löscht alte Logfiles (>30 Tage)
|
||||
# ============================================================================
|
||||
|
||||
LOG_DIR="/var/log"
|
||||
LOGFILE="$LOG_DIR/rpi-$(date '+%Y%m%d%H%M%S').log"
|
||||
|
||||
# Alte Logfiles löschen (älter als 30 Tage)
|
||||
find "$LOG_DIR" -type f -name "rpi-*.log" -mtime +30 -delete
|
||||
|
||||
# Intervall aus DB abrufen
|
||||
SLEEP=$(python3 - <<'END'
|
||||
import sqlite3
|
||||
conn = sqlite3.connect("/srv/poe_manager/sqlite.db")
|
||||
row = conn.execute("SELECT value FROM settings WHERE key='check_interval'").fetchone()
|
||||
conn.close()
|
||||
print(row[0] if row else 300)
|
||||
END
|
||||
)
|
||||
|
||||
SLEEP=${SLEEP:-300}
|
||||
|
||||
function disable_poe() {
|
||||
local switch_ip=$1
|
||||
local switch_port=$2
|
||||
local username=$3
|
||||
local password=$4
|
||||
expect <<EOF
|
||||
set timeout 5
|
||||
spawn ssh $username@$switch_ip
|
||||
expect {
|
||||
"assword:" { send "$password\r"; exp_continue }
|
||||
"Press any key" { send "\r"; exp_continue }
|
||||
-re ".*> $" { }
|
||||
}
|
||||
send "configure terminal\r"
|
||||
expect "(config)#"
|
||||
send "interface $switch_port\r"
|
||||
expect "(eth-$switch_port)#"
|
||||
send "no power-over-ethernet\r"
|
||||
expect "(eth-$switch_port)#"
|
||||
send "exit\r"
|
||||
expect "(config)#"
|
||||
send "exit\r"
|
||||
expect "#"
|
||||
send "exit\r"
|
||||
expect ">"; send "exit\r"
|
||||
expect "Do you want to log out (y/n)?" { send "y\r" }
|
||||
expect eof
|
||||
EOF
|
||||
}
|
||||
|
||||
function enable_poe() {
|
||||
local switch_ip=$1
|
||||
local switch_port=$2
|
||||
local username=$3
|
||||
local password=$4
|
||||
expect <<EOF
|
||||
set timeout 5
|
||||
spawn ssh $username@$switch_ip
|
||||
expect {
|
||||
"assword:" { send "$password\r"; exp_continue }
|
||||
"Press any key" { send "\r"; exp_continue }
|
||||
-re ".*> $" { }
|
||||
}
|
||||
send "configure terminal\r"
|
||||
expect "(config)#"
|
||||
send "interface $switch_port\r"
|
||||
expect "(eth-$switch_port)#"
|
||||
send "power-over-ethernet\r"
|
||||
expect "(eth-$switch_port)#"
|
||||
send "exit\r"
|
||||
expect "(config)#"
|
||||
send "exit\r"
|
||||
expect "#"
|
||||
send "exit\r"
|
||||
expect ">"; send "exit\r"
|
||||
expect "Do you want to log out (y/n)?" { send "y\r" }
|
||||
expect eof
|
||||
EOF
|
||||
}
|
||||
|
||||
function manual_restart() {
|
||||
local target_mac="$1"
|
||||
python3 /srv/poe_manager/generate_ips.py | while IFS='|' read -r rpi_ip dev_name switch_ip switch_hostname switch_port switch_user switch_pass mac; do
|
||||
if [[ "$mac" != "$target_mac" ]]; then
|
||||
continue
|
||||
fi
|
||||
echo "$(date '+%Y-%m-%d %H:%M:%S') Manueller Neustart von $dev_name gestartet." >> "$LOGFILE"
|
||||
if [ -n "$switch_port" ] && [ "$switch_port" != "None" ]; then
|
||||
disable_poe "$switch_ip" "$switch_port" "$switch_user" "$switch_pass"
|
||||
echo "$(date '+%Y-%m-%d %H:%M:%S') $dev_name PoE deaktiviert." >> "$LOGFILE"
|
||||
sleep 2
|
||||
enable_poe "$switch_ip" "$switch_port" "$switch_user" "$switch_pass"
|
||||
echo "$(date '+%Y-%m-%d %H:%M:%S') $dev_name PoE aktiviert." >> "$LOGFILE"
|
||||
echo "$(date '+%Y-%m-%d %H:%M:%S') Manueller Neustart von $dev_name abgeschlossen." >> "$LOGFILE"
|
||||
else
|
||||
echo "$(date '+%Y-%m-%d %H:%M:%S') $dev_name besitzt keinen PoE-Port." >> "$LOGFILE"
|
||||
fi
|
||||
exit 0
|
||||
done
|
||||
}
|
||||
|
||||
if [[ "$1" == "restart" ]]; then
|
||||
if [[ -z "$2" ]]; then
|
||||
echo "MAC-Adresse fehlt."
|
||||
exit 1
|
||||
fi
|
||||
manual_restart "$2"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "" > "$LOGFILE"
|
||||
|
||||
while true; do
|
||||
echo "--------------------------------------------------------------------" >> "$LOGFILE"
|
||||
python3 /srv/poe_manager/generate_ips.py | while IFS='|' read -r rpi_ip dev_name switch_ip switch_hostname switch_port switch_user switch_pass; do
|
||||
if ping -c 1 -W 2 "$rpi_ip" &> /dev/null; then
|
||||
echo "$(date '+%Y-%m-%d %H:%M:%S') $dev_name ist erreichbar!" >> "$LOGFILE"
|
||||
else
|
||||
echo "$(date '+%Y-%m-%d %H:%M:%S') $dev_name ist nicht erreichbar!" >> "$LOGFILE"
|
||||
|
||||
# Nur PoE neu starten, wenn Port vorhanden ist
|
||||
if [ -n "$switch_port" ] && [ "$switch_port" != "None" ]; then
|
||||
disable_poe "$switch_ip" "$switch_port" "$switch_user" "$switch_pass"
|
||||
echo "$(date '+%Y-%m-%d %H:%M:%S') $dev_name PoE auf Port $switch_port am Switch $switch_hostname deaktiviert." >> "$LOGFILE"
|
||||
sleep 2
|
||||
enable_poe "$switch_ip" "$switch_port" "$switch_user" "$switch_pass"
|
||||
echo "$(date '+%Y-%m-%d %H:%M:%S') $dev_name PoE auf Port $switch_port am Switch $switch_hostname aktiviert." >> "$LOGFILE"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
sleep "$SLEEP"
|
||||
done
|
||||
Reference in New Issue
Block a user