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:
2026-08-10 12:18:49 +02:00
co-authored by Claude Sonnet 5
commit 82bfeb17ed
31 changed files with 4648 additions and 0 deletions
+13
View File
@@ -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
+197
View File
@@ -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
```
+17
View File
@@ -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;
}
}
+16
View File
@@ -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
+14
View File
@@ -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
View File
@@ -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
+40
View File
@@ -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()
+91
View File
@@ -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.")
+40
View File
@@ -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()
+52
View File
@@ -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()
+12
View File
@@ -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", "")},
)
+22
View File
@@ -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.
+925
View File
@@ -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
View File
@@ -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

+202
View File
@@ -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>&times;</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">&times;</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 };
})();
+2
View File
@@ -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
File diff suppressed because one or more lines are too long
+141
View File
@@ -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>
+267
View File
@@ -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>&times;</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>&times;</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>&times;</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 %}
+112
View File
@@ -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>&times;</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 %}
+271
View File
@@ -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>&times;</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>&times;</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 %}
+46
View File
@@ -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>
+91
View File
@@ -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 %}
+26
View File
@@ -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 %}
+334
View File
@@ -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>&times;</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>&times;</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()">&times;</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 %}
+128
View File
@@ -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>&times;</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>&times;</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>&times;</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 %}
+139
View File
@@ -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