Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af5dfe30b4 | ||
|
|
efb0d2aa01 | ||
|
|
d461f8ca42 | ||
|
|
1b7fe81507 | ||
|
|
2c9ec8a100 | ||
|
|
09b222615a | ||
|
|
0074e48e09 | ||
|
|
a3f5f028f2 | ||
|
|
5bca29f3da | ||
|
|
4de5828573 | ||
|
|
f27dde466b | ||
|
|
8253ab3f0e | ||
|
|
befc76819e | ||
|
|
99d4d3cd01 | ||
|
|
c30ad41101 |
@@ -29,6 +29,11 @@ server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
|
# nginx' Standard (1m) reicht für Fileshare-Uploads nicht -- etwas
|
||||||
|
# großzügiger als Flasks eigenes MAX_CONTENT_LENGTH (siehe app.py),
|
||||||
|
# damit bei einer knapp 15MB großen Datei nginx nicht schon vor
|
||||||
|
# Flask mit seiner eigenen, unschöneren 413-Seite abbricht.
|
||||||
|
client_max_body_size 16m;
|
||||||
proxy_pass http://127.0.0.1:5000;
|
proxy_pass http://127.0.0.1:5000;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
|||||||
@@ -6,6 +6,16 @@ After=network.target
|
|||||||
Type=simple
|
Type=simple
|
||||||
User=root
|
User=root
|
||||||
WorkingDirectory=/srv/tesm
|
WorkingDirectory=/srv/tesm
|
||||||
|
# Markiert genau DIESEN Prozess als den echten Web-App-Dienst -- app.py
|
||||||
|
# wird nämlich NICHT nur hierüber gestartet, sondern auch von
|
||||||
|
# generate_ips.py (via "from app import ...") als reines Hilfsmodul
|
||||||
|
# importiert, z.B. aus poe.sh/tesm-check.service heraus, alle paar
|
||||||
|
# Sekunden. Ein einfacher Import führt JEDEN Modul-Level-Code in app.py
|
||||||
|
# erneut aus -- ohne dieses Flag würde jeder generate_ips.py-Aufruf
|
||||||
|
# _fileshare_cleanup_all_on_startup() erneut auslösen und damit gerade
|
||||||
|
# aktive Fileshare-Mounts anderer, echter Sitzungen sofort wieder
|
||||||
|
# aushängen. Siehe die Prüfung auf TESM_WEB_PROCESS in app.py.
|
||||||
|
Environment=TESM_WEB_PROCESS=1
|
||||||
# Produktiver WSGI-Server (gunicorn) statt Flasks eigenem app.run()-
|
# Produktiver WSGI-Server (gunicorn) statt Flasks eigenem app.run()-
|
||||||
# Entwicklungsserver -- siehe requirements.txt für die ausführliche
|
# Entwicklungsserver -- siehe requirements.txt für die ausführliche
|
||||||
# Begründung von "--workers 1" (In-Memory-Zustand) und "--worker-class
|
# Begründung von "--workers 1" (In-Memory-Zustand) und "--worker-class
|
||||||
@@ -14,8 +24,12 @@ WorkingDirectory=/srv/tesm
|
|||||||
# nach 30s auch bei einer offenen, aber gerade inaktiven SSH-Terminal-
|
# nach 30s auch bei einer offenen, aber gerade inaktiven SSH-Terminal-
|
||||||
# WebSocket-Verbindung und würde den Worker (und damit JEDE Verbindung,
|
# WebSocket-Verbindung und würde den Worker (und damit JEDE Verbindung,
|
||||||
# da nur ein Prozess läuft) killen.
|
# da nur ein Prozess läuft) killen.
|
||||||
|
# "--bind 127.0.0.1:5000" statt 0.0.0.0: Flask/Gunicorn soll ausschließlich
|
||||||
|
# über den nginx-Reverse-Proxy (siehe Einstellungen -> NGINX) erreichbar
|
||||||
|
# sein, nicht zusätzlich direkt und unverschlüsselt über Port 5000 aus dem
|
||||||
|
# Netz -- nginx selbst greift ohnehin nur über 127.0.0.1 zu.
|
||||||
ExecStart=/srv/tesm/venv/bin/gunicorn --workers 1 --worker-class gthread --threads 16 \
|
ExecStart=/srv/tesm/venv/bin/gunicorn --workers 1 --worker-class gthread --threads 16 \
|
||||||
--timeout 0 --bind 0.0.0.0:5000 app:app
|
--timeout 0 --bind 127.0.0.1:5000 app:app
|
||||||
Restart=always
|
Restart=always
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
|
|
||||||
|
|||||||
+101
-45
@@ -8,23 +8,30 @@
|
|||||||
# Erkennt selbstständig, ob unter /srv/tesm bereits eine Installation
|
# Erkennt selbstständig, ob unter /srv/tesm bereits eine Installation
|
||||||
# existiert (an sqlite.db), und verhält sich entsprechend:
|
# existiert (an sqlite.db), und verhält sich entsprechend:
|
||||||
#
|
#
|
||||||
# - Keine vorhandene Installation -> normale Frischinstallation.
|
# - Keine vorhandene Installation -> normale Frischinstallation, kein
|
||||||
# - Vorhanden, SCHEMA_VERSION unverändert -> einfaches In-Place-Update
|
# Backup nötig (nichts, worauf zurückgerollt werden könnte).
|
||||||
# (Code/Templates/Abhängigkeiten aktualisieren; sqlite.db bleibt
|
# - Vorhanden (egal ob SCHEMA_VERSION gleich geblieben oder sich
|
||||||
# unangetastet -- rsync schließt sie ohnehin aus). Kein Backup nötig,
|
# geändert hat) -> Update MIT Sicherheitsnetz: die komplette bisherige
|
||||||
# da sich am Datenbank-Schema nichts ändert.
|
# Installation (Code, venv, Datenbank, Schlüssel) wird vorher 1:1 nach
|
||||||
# - Vorhanden, SCHEMA_VERSION unbekannt oder abweichend -> Update MIT
|
# /srv/tesm-backup-pre-update-<timestamp> kopiert. Eine eigentliche
|
||||||
# Sicherheitsnetz: die komplette bisherige Installation (Code, venv,
|
# Schema-Migration übernimmt danach die App selbst beim Start (siehe
|
||||||
# Datenbank, Schlüssel) wird vorher 1:1 nach /srv/tesm-backup-pre-
|
# _ensure_schema() in app.py). Kommt der Dienst anschließend
|
||||||
# migration-<timestamp> kopiert. Die eigentliche Schema-Migration
|
# nachweislich NICHT gesund hoch (HTTP-Check auf /login), wird
|
||||||
# übernimmt danach die App selbst beim Start (siehe _ensure_schema()
|
# automatisch die komplette Sicherung zurückgespielt (alter Code +
|
||||||
# in app.py). Kommt der Dienst anschließend nachweislich NICHT gesund
|
# alte Datenbank zusammen -- ein reines DB-Rollback allein würde
|
||||||
# hoch (HTTP-Check auf /login), wird automatisch die komplette
|
# denselben Fehler beim nächsten Start mit dem neuen Code sofort
|
||||||
# Sicherung zurückgespielt (alter Code + alte Datenbank zusammen --
|
# wiederholen) und das Backup bleibt zur manuellen Prüfung erhalten.
|
||||||
# ein reines DB-Rollback allein würde denselben Migrationsfehler beim
|
# Kommt er gesund hoch, gilt das Update als erfolgreich und das
|
||||||
# nächsten Start mit dem neuen Code sofort wiederholen) und das
|
# Backup wird gelöscht.
|
||||||
# Backup bleibt zur manuellen Prüfung erhalten. Kommt er gesund hoch,
|
#
|
||||||
# gilt die Migration als erfolgreich und das Backup wird gelöscht.
|
# Das Sicherheitsnetz greift bewusst bei JEDEM Update einer bestehenden
|
||||||
|
# Installation, nicht nur bei einer erkannten Schema-Änderung -- ein
|
||||||
|
# rein code-seitiger Fehler im neuen Paket hat mit dem Datenbank-Schema
|
||||||
|
# nichts zu tun, hätte den Dienst bei einem "Schema unverändert -> kein
|
||||||
|
# Backup"-Kurzschluss aber trotzdem ungeschützt lahmgelegt (live
|
||||||
|
# reproduziert). SCHEMA_VERSION bestimmt nur noch den Wortlaut der
|
||||||
|
# Log-Meldung (In-Place-Update vs. Migration), nicht mehr, OB
|
||||||
|
# abgesichert wird.
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
@@ -37,6 +44,21 @@ NC='\033[0m'
|
|||||||
print_status() { echo -e "${GREEN}✔${NC} ${1} completed."; }
|
print_status() { echo -e "${GREEN}✔${NC} ${1} completed."; }
|
||||||
step() { echo -e "${RED}→${NC} ${1}..." | tee -a /var/log/tesm-install.log; }
|
step() { echo -e "${RED}→${NC} ${1}..." | tee -a /var/log/tesm-install.log; }
|
||||||
|
|
||||||
|
# Kopiert nur, wenn der Inhalt sich tatsächlich unterscheidet (oder das
|
||||||
|
# Ziel noch fehlt) -- bei einem Update sollen ausschließlich Dateien
|
||||||
|
# angefasst werden, die sich zwischen der laufenden und der neuen Version
|
||||||
|
# wirklich geändert haben (unverändert lässt "cp" Zeitstempel/Inode aber
|
||||||
|
# trotzdem hochzählen, was z.B. Monitoring/Backups unnötig triggert).
|
||||||
|
# Gibt 0 zurück, wenn tatsächlich kopiert wurde, 1 wenn übersprungen.
|
||||||
|
install_if_changed() {
|
||||||
|
local src="$1" dst="$2" mode="${3:-644}"
|
||||||
|
if [ -f "$dst" ] && cmp -s "$src" "$dst"; then
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
sudo install -m "$mode" "$src" "$dst"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
# "clear" schlägt ohne TERM/Pseudo-Terminal fehl (z.B. bei Automatisierung
|
# "clear" schlägt ohne TERM/Pseudo-Terminal fehl (z.B. bei Automatisierung
|
||||||
# per SSH exec_command ohne pty, genau wie update.sh dieses Skript aufruft)
|
# per SSH exec_command ohne pty, genau wie update.sh dieses Skript aufruft)
|
||||||
# -- mit "set -e" bricht das sonst die GESAMTE Installation sofort und ohne
|
# -- mit "set -e" bricht das sonst die GESAMTE Installation sofort und ohne
|
||||||
@@ -46,6 +68,15 @@ step() { echo -e "${RED}→${NC} ${1}..." | tee -a /var/log/tesm-install
|
|||||||
clear 2>/dev/null || true
|
clear 2>/dev/null || true
|
||||||
|
|
||||||
# ---- Bestehende Installation erkennen ----
|
# ---- Bestehende Installation erkennen ----
|
||||||
|
# WICHTIG: das Sicherheitsnetz (Backup + Health-Check + ggf. Rückroll)
|
||||||
|
# greift bei JEDEM Update einer bestehenden Installation, nicht nur bei
|
||||||
|
# einer erkannten Schema-Änderung -- live so gefunden: ein rein
|
||||||
|
# code-seitiger Fehler im neuen Paket (z.B. ein kaputtes app.py, mit dem
|
||||||
|
# Schema selbst hat das nichts zu tun) hätte bei einem reinen "Schema
|
||||||
|
# unverändert -> kein Backup"-Kurzschluss ungeschützt den Dienst
|
||||||
|
# lahmgelegt, ganz ohne automatische Wiederherstellung. Die
|
||||||
|
# SCHEMA_VERSION-Auswertung dient jetzt nur noch der Log-Meldung (WARUM
|
||||||
|
# aktualisiert wird), nicht mehr der Entscheidung OB abgesichert wird.
|
||||||
EXISTING_INSTALL=0
|
EXISTING_INSTALL=0
|
||||||
[ -f /srv/tesm/sqlite.db ] && EXISTING_INSTALL=1
|
[ -f /srv/tesm/sqlite.db ] && EXISTING_INSTALL=1
|
||||||
|
|
||||||
@@ -63,7 +94,7 @@ if [ "$EXISTING_INSTALL" -eq 1 ]; then
|
|||||||
print_status "Service stopped"
|
print_status "Service stopped"
|
||||||
|
|
||||||
if [ -n "$OLD_SCHEMA_VERSION" ] && [ -n "$NEW_SCHEMA_VERSION" ] && [ "$OLD_SCHEMA_VERSION" == "$NEW_SCHEMA_VERSION" ]; then
|
if [ -n "$OLD_SCHEMA_VERSION" ] && [ -n "$NEW_SCHEMA_VERSION" ] && [ "$OLD_SCHEMA_VERSION" == "$NEW_SCHEMA_VERSION" ]; then
|
||||||
echo -e "${GREEN}Bestehende Installation erkannt, Datenbank-Schema unverändert (Version ${OLD_SCHEMA_VERSION}):${NC} einfaches In-Place-Update, kein Backup nötig."
|
echo -e "${GREEN}Bestehende Installation erkannt, Datenbank-Schema unverändert (Version ${OLD_SCHEMA_VERSION}):${NC} In-Place-Update."
|
||||||
else
|
else
|
||||||
if [ -n "$OLD_SCHEMA_VERSION" ] && [ -n "$NEW_SCHEMA_VERSION" ]; then
|
if [ -n "$OLD_SCHEMA_VERSION" ] && [ -n "$NEW_SCHEMA_VERSION" ]; then
|
||||||
reason="Datenbank-Schema hat sich geändert (${OLD_SCHEMA_VERSION} -> ${NEW_SCHEMA_VERSION})"
|
reason="Datenbank-Schema hat sich geändert (${OLD_SCHEMA_VERSION} -> ${NEW_SCHEMA_VERSION})"
|
||||||
@@ -71,33 +102,39 @@ if [ "$EXISTING_INSTALL" -eq 1 ]; then
|
|||||||
reason="Schema-Version der bestehenden Installation oder dieses Pakets unbekannt"
|
reason="Schema-Version der bestehenden Installation oder dieses Pakets unbekannt"
|
||||||
fi
|
fi
|
||||||
echo -e "${YELLOW}Bestehende Installation erkannt, ${reason}:${NC}"
|
echo -e "${YELLOW}Bestehende Installation erkannt, ${reason}:${NC}"
|
||||||
echo "Update mit automatischer Migration und Sicherheitsnetz (Rückroll-Backup)."
|
echo "Update mit automatischer Migration."
|
||||||
|
fi
|
||||||
NEEDS_MIGRATION_GUARD=1
|
NEEDS_MIGRATION_GUARD=1
|
||||||
BACKUP_DIR="/srv/tesm-backup-pre-migration-$(date +%Y%m%d-%H%M%S)"
|
BACKUP_DIR="/srv/tesm-backup-pre-update-$(date +%Y%m%d-%H%M%S)"
|
||||||
step "Backing up current installation to $BACKUP_DIR before migration"
|
step "Backing up current installation to $BACKUP_DIR before update"
|
||||||
cp -a /srv/tesm "$BACKUP_DIR"
|
cp -a /srv/tesm "$BACKUP_DIR"
|
||||||
print_status "Backup created"
|
print_status "Backup created"
|
||||||
fi
|
fi
|
||||||
fi
|
|
||||||
|
|
||||||
# ---- Pakete ----
|
# ---- Pakete ----
|
||||||
step "Installing system packages"
|
step "Installing system packages"
|
||||||
sudo apt-get update >>/var/log/tesm-install.log 2>&1 && print_status "apt update"
|
sudo apt-get update >>/var/log/tesm-install.log 2>&1 && print_status "apt update"
|
||||||
sudo apt-get install -y python3 python3-venv python3-pip nginx sqlite3 expect openssh-client git rsync iputils-ping logrotate >>/var/log/tesm-install.log 2>&1 && print_status "Packages installed"
|
sudo apt-get install -y python3 python3-venv python3-pip nginx sqlite3 expect openssh-client git rsync iputils-ping logrotate certbot cifs-utils >>/var/log/tesm-install.log 2>&1 && print_status "Packages installed"
|
||||||
|
|
||||||
# ---- Log-Verzeichnis ----
|
# ---- Log-Verzeichnis ----
|
||||||
# Weltweit beschreibbar, da sowohl root (tesm.service/poe.sh) als auch
|
# NICHT weltweit beschreibbar (0755 reicht) -- sowohl tesm.service als
|
||||||
# der i.d.R. unprivilegierte kea-dhcp4-server-Dienstbenutzer (_kea) hier
|
# auch tesm-check.service (poe.sh) laufen als root, kein anderer
|
||||||
# ihre jeweils eigene Log-Datei anlegen/schreiben müssen. Die eigentliche
|
# Dienstbenutzer schreibt hierher (Keas eigenes Log liegt separat unter
|
||||||
# logrotate-Konfiguration (/etc/logrotate.d/tesm) schreibt die App
|
# TESM_KEA_LOG_PATH, i.d.R. /var/log/kea/). Ein group-/world-writable
|
||||||
# selbst beim Start bzw. beim Speichern unter Systemeinstellungen → Logs.
|
# Verzeichnis lässt logrotate die Rotation aus Sicherheitsgründen
|
||||||
|
# komplett verweigern ("insecure permissions") -- live reproduziert: bei
|
||||||
|
# 0777 rotierte live.log über Wochen hinweg gar nicht mehr. Die
|
||||||
|
# eigentliche logrotate-Konfiguration (/etc/logrotate.d/tesm) schreibt
|
||||||
|
# die App selbst beim Start bzw. beim Speichern unter
|
||||||
|
# Systemeinstellungen → Logs.
|
||||||
sudo mkdir -p /var/log/tesm
|
sudo mkdir -p /var/log/tesm
|
||||||
sudo chmod 777 /var/log/tesm
|
sudo chmod 755 /var/log/tesm
|
||||||
|
|
||||||
# ---- App-Verzeichnis ----
|
# ---- App-Verzeichnis ----
|
||||||
step "Deploying application to /srv/tesm"
|
step "Deploying application to /srv/tesm"
|
||||||
sudo mkdir -p /srv/tesm
|
sudo mkdir -p /srv/tesm
|
||||||
sudo rsync -a --delete --exclude 'venv' --exclude 'sqlite.db' --exclude 'fernet.key' --exclude 'secret.key' \
|
sudo rsync -a --delete --exclude 'venv' --exclude 'sqlite.db' --exclude 'fernet.key' --exclude 'secret.key' \
|
||||||
|
--exclude 'known_hosts' \
|
||||||
"$REPO_DIR/srv/tesm/" /srv/tesm/ >>/var/log/tesm-install.log 2>&1
|
"$REPO_DIR/srv/tesm/" /srv/tesm/ >>/var/log/tesm-install.log 2>&1
|
||||||
print_status "Application files copied"
|
print_status "Application files copied"
|
||||||
|
|
||||||
@@ -129,10 +166,13 @@ fi
|
|||||||
# ---- Custom-Skripte + systemd ----
|
# ---- Custom-Skripte + systemd ----
|
||||||
step "Copying custom scripts and systemd units"
|
step "Copying custom scripts and systemd units"
|
||||||
sudo mkdir -p /usr/local/bin/custom
|
sudo mkdir -p /usr/local/bin/custom
|
||||||
sudo cp "$REPO_DIR/usr/local/bin/custom/poe.sh" /usr/local/bin/custom/poe.sh
|
UNITS_CHANGED=0
|
||||||
sudo chmod +x /usr/local/bin/custom/poe.sh
|
install_if_changed "$REPO_DIR/usr/local/bin/custom/poe.sh" /usr/local/bin/custom/poe.sh 755 || true
|
||||||
sudo cp "$REPO_DIR"/etc/systemd/system/*.service /etc/systemd/system/
|
for f in "$REPO_DIR"/etc/systemd/system/*.service "$REPO_DIR"/etc/systemd/system/*.timer; do
|
||||||
sudo cp "$REPO_DIR"/etc/systemd/system/*.timer /etc/systemd/system/
|
if install_if_changed "$f" "/etc/systemd/system/$(basename "$f")" 644; then
|
||||||
|
UNITS_CHANGED=1
|
||||||
|
fi
|
||||||
|
done
|
||||||
print_status "Scripts and units copied"
|
print_status "Scripts and units copied"
|
||||||
|
|
||||||
# ---- SSH-Login-Banner (MOTD) ----
|
# ---- SSH-Login-Banner (MOTD) ----
|
||||||
@@ -141,8 +181,7 @@ print_status "Scripts and units copied"
|
|||||||
# Die Standard-Skripte bleiben erhalten, werden aber deaktiviert (chmod -x,
|
# Die Standard-Skripte bleiben erhalten, werden aber deaktiviert (chmod -x,
|
||||||
# nicht gelöscht), damit ein Rückbau jederzeit per chmod +x möglich ist.
|
# nicht gelöscht), damit ein Rückbau jederzeit per chmod +x möglich ist.
|
||||||
step "Setting up TESM SSH login banner"
|
step "Setting up TESM SSH login banner"
|
||||||
sudo cp "$REPO_DIR/etc/update-motd.d/50-tesm" /etc/update-motd.d/50-tesm
|
install_if_changed "$REPO_DIR/etc/update-motd.d/50-tesm" /etc/update-motd.d/50-tesm 755 || true
|
||||||
sudo chmod 755 /etc/update-motd.d/50-tesm
|
|
||||||
sudo chmod -x /etc/update-motd.d/00-header /etc/update-motd.d/10-help-text \
|
sudo chmod -x /etc/update-motd.d/00-header /etc/update-motd.d/10-help-text \
|
||||||
/etc/update-motd.d/50-motd-news /etc/update-motd.d/60-unminimize \
|
/etc/update-motd.d/50-motd-news /etc/update-motd.d/60-unminimize \
|
||||||
/etc/update-motd.d/91-release-upgrade /etc/update-motd.d/92-unattended-upgrades \
|
/etc/update-motd.d/91-release-upgrade /etc/update-motd.d/92-unattended-upgrades \
|
||||||
@@ -150,8 +189,16 @@ sudo chmod -x /etc/update-motd.d/00-header /etc/update-motd.d/10-help-text \
|
|||||||
print_status "SSH login banner configured"
|
print_status "SSH login banner configured"
|
||||||
|
|
||||||
# ---- nginx ----
|
# ---- nginx ----
|
||||||
|
# Nur bei einer Frischinstallation aus dem Repo-Template geschrieben --
|
||||||
|
# eine bereits vorhandene Konfiguration (egal ob unverändert oder von der
|
||||||
|
# App selbst z.B. um SSL/HSTS erweitert, siehe Systemeinstellungen -> SSL)
|
||||||
|
# wird NIE wieder überschrieben. Symlink/Default-Site-Entfernung und der
|
||||||
|
# Validierungs-Reload laufen dagegen immer -- rein idempotent, ändern an
|
||||||
|
# einer bestehenden Konfiguration selbst nichts.
|
||||||
step "Configuring nginx reverse proxy"
|
step "Configuring nginx reverse proxy"
|
||||||
|
if [ ! -f /etc/nginx/sites-available/tesm ]; then
|
||||||
sudo cp "$REPO_DIR/etc/nginx/sites-available/tesm" /etc/nginx/sites-available/tesm
|
sudo cp "$REPO_DIR/etc/nginx/sites-available/tesm" /etc/nginx/sites-available/tesm
|
||||||
|
fi
|
||||||
sudo ln -sf /etc/nginx/sites-available/tesm /etc/nginx/sites-enabled/tesm
|
sudo ln -sf /etc/nginx/sites-available/tesm /etc/nginx/sites-enabled/tesm
|
||||||
sudo rm -f /etc/nginx/sites-enabled/default
|
sudo rm -f /etc/nginx/sites-enabled/default
|
||||||
sudo nginx -t >>/var/log/tesm-install.log 2>&1 && sudo systemctl reload nginx
|
sudo nginx -t >>/var/log/tesm-install.log 2>&1 && sudo systemctl reload nginx
|
||||||
@@ -159,16 +206,17 @@ print_status "nginx configured"
|
|||||||
|
|
||||||
# ---- Dienste aktivieren ----
|
# ---- Dienste aktivieren ----
|
||||||
step "Enabling services"
|
step "Enabling services"
|
||||||
|
if [ "$UNITS_CHANGED" -eq 1 ]; then
|
||||||
sudo systemctl daemon-reload
|
sudo systemctl daemon-reload
|
||||||
|
fi
|
||||||
sudo systemctl enable --now tesm.service
|
sudo systemctl enable --now tesm.service
|
||||||
sudo systemctl enable --now tesm-check.service tesm-check-restart.timer
|
sudo systemctl enable --now tesm-check.service tesm-check-restart.timer
|
||||||
print_status "Services enabled and started"
|
print_status "Services enabled and started"
|
||||||
|
|
||||||
# ---- Migrations-Sicherheitsnetz prüfen ----
|
# ---- Sicherheitsnetz prüfen ----
|
||||||
# Nur relevant, wenn oben tatsächlich eine riskante Migration (abweichende/
|
# Läuft bei JEDEM Update einer bestehenden Installation (NEEDS_MIGRATION_
|
||||||
# unbekannte SCHEMA_VERSION) erkannt wurde -- beim einfachen In-Place-Update
|
# GUARD=1, siehe oben) -- nur bei einer Frischinstallation ist BACKUP_DIR
|
||||||
# oder einer Frischinstallation ist BACKUP_DIR leer und dieser Block wird
|
# leer und dieser Block wird komplett übersprungen.
|
||||||
# komplett übersprungen.
|
|
||||||
if [ "$NEEDS_MIGRATION_GUARD" -eq 1 ]; then
|
if [ "$NEEDS_MIGRATION_GUARD" -eq 1 ]; then
|
||||||
step "Verifying migrated installation"
|
step "Verifying migrated installation"
|
||||||
HEALTHY=0
|
HEALTHY=0
|
||||||
@@ -182,15 +230,23 @@ if [ "$NEEDS_MIGRATION_GUARD" -eq 1 ]; then
|
|||||||
if [ "$HEALTHY" -eq 1 ]; then
|
if [ "$HEALTHY" -eq 1 ]; then
|
||||||
print_status "Migration verified healthy"
|
print_status "Migration verified healthy"
|
||||||
rm -rf "$BACKUP_DIR"
|
rm -rf "$BACKUP_DIR"
|
||||||
echo -e "${GREEN}✔${NC} Migration erfolgreich -- Backup wieder entfernt."
|
echo -e "${GREEN}✔${NC} Update erfolgreich -- Backup wieder entfernt."
|
||||||
else
|
else
|
||||||
echo -e "${RED}✖ Migration fehlgeschlagen -- Dienst antwortet nicht gesund. Rolle zurück...${NC}"
|
echo -e "${RED}✖ Update fehlgeschlagen -- Dienst antwortet nicht gesund. Rolle zurück...${NC}"
|
||||||
sudo systemctl stop tesm.service 2>/dev/null || true
|
sudo systemctl stop tesm.service 2>/dev/null || true
|
||||||
sudo rm -rf /srv/tesm
|
# Fehlgeschlagene Installation NICHT einfach löschen, sondern separat
|
||||||
sudo mv "$BACKUP_DIR" /srv/tesm
|
# beiseite legen (zur Fehlersuche) -- und das Backup per "cp" statt
|
||||||
|
# "mv" zurückspielen, damit BEIDES danach getrennt erhalten bleibt
|
||||||
|
# (sonst würde das Backup beim Zurückspielen selbst zum neuen
|
||||||
|
# /srv/tesm werden und als eigenständige Sicherung verschwinden).
|
||||||
|
FAILED_DIR="/srv/tesm-failed-update-$(date +%Y%m%d-%H%M%S)"
|
||||||
|
sudo mv /srv/tesm "$FAILED_DIR"
|
||||||
|
sudo cp -a "$BACKUP_DIR" /srv/tesm
|
||||||
sudo systemctl start tesm.service
|
sudo systemctl start tesm.service
|
||||||
echo -e "${YELLOW}Zurückgerollt auf den Stand vor dem Update (alter Code + alte Datenbank zusammen).${NC}"
|
echo -e "${YELLOW}Zurückgerollt auf den Stand vor dem Update (alter Code + alte Datenbank zusammen).${NC}"
|
||||||
echo "Bitte /var/log/tesm/app.log prüfen oder Export/Import nutzen, bevor erneut aktualisiert wird."
|
echo "Backup bleibt erhalten unter: ${BACKUP_DIR}"
|
||||||
|
echo "Die fehlgeschlagene Installation liegt zur Fehlersuche unter: ${FAILED_DIR}"
|
||||||
|
echo "Bitte /var/log/tesm/app.log dort prüfen, bevor erneut aktualisiert wird."
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
1
|
2
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
1.0.3
|
1.1.4
|
||||||
|
|||||||
+1681
-15
File diff suppressed because it is too large
Load Diff
@@ -176,6 +176,17 @@ CREATE TABLE IF NOT EXISTS ldap_group_mappings (
|
|||||||
);
|
);
|
||||||
""")
|
""")
|
||||||
|
|
||||||
|
c.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS ldap_fileshare_mappings (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
ad_group_dn TEXT NOT NULL,
|
||||||
|
ad_group_name TEXT NOT NULL,
|
||||||
|
share_label TEXT NOT NULL,
|
||||||
|
share_unc TEXT NOT NULL,
|
||||||
|
UNIQUE(ad_group_dn, share_unc)
|
||||||
|
);
|
||||||
|
""")
|
||||||
|
|
||||||
c.execute("""
|
c.execute("""
|
||||||
CREATE TABLE IF NOT EXISTS audit_log (
|
CREATE TABLE IF NOT EXISTS audit_log (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
|||||||
@@ -197,13 +197,23 @@ button { font-family: inherit; }
|
|||||||
.nav-group.expanded .nav-group-children { display: flex; }
|
.nav-group.expanded .nav-group-children { display: flex; }
|
||||||
.nav-group-children .nav-item { font-size: 13px; padding: 8px 12px; }
|
.nav-group-children .nav-item { font-size: 13px; padding: 8px 12px; }
|
||||||
|
|
||||||
/* Kacheln nebeneinander (z.B. Im-/Export, Konto-Seite) */
|
/* Kacheln nebeneinander (z.B. Im-/Export, Konto-Seite). minmax(min(...,
|
||||||
|
100%), 1fr) statt eines nackten Pixelwerts -- eine Spalten-Mindestbreite
|
||||||
|
von z.B. 340px würde auf einem 375px-iPhone (abzüglich .content-Padding)
|
||||||
|
sonst waagerechten Overflow der ganzen Seite erzwingen, weil "auto-fit"
|
||||||
|
erst ab einer Breite größer als der Mindestwert umbricht. */
|
||||||
.settings-grid {
|
.settings-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(340px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(min(340px, 100%), 1fr));
|
||||||
gap: 24px;
|
gap: 24px;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
/* Modifier für Seiten, die etwas breitere Karten brauchen (z.B. NGINX,
|
||||||
|
LDAP) -- ersetzt frühere inline style="grid-template-columns:..."
|
||||||
|
Overrides, die denselben 100%-Fallback nicht hatten. */
|
||||||
|
.settings-grid--wide {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(min(420px, 100%), 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
/* Navbar-Reihenfolge (Settings) */
|
/* Navbar-Reihenfolge (Settings) */
|
||||||
.nav-order-list {
|
.nav-order-list {
|
||||||
@@ -683,8 +693,19 @@ button { font-family: inherit; }
|
|||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
border: 1px solid var(--border-soft);
|
border: 1px solid var(--border-soft);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
|
/* Zurück auf overflow:hidden (nur für die abgerundeten Ecken) --
|
||||||
|
JEDES Template, das .table-wrap verwendet, hat bereits einen eigenen
|
||||||
|
<div style="overflow-x:auto;"> exakt um die <table> herum (nicht um
|
||||||
|
die ganze Karte). overflow-x:auto zusätzlich HIER hätte einen zweiten,
|
||||||
|
verschachtelten Scroll-Container um denselben Inhalt erzeugt -- live
|
||||||
|
auf einem iPhone-Viewport nachgewiesen: bei der aufklappbaren
|
||||||
|
Rechte-Tabelle (Gruppen-Seite) landete der Inhalt dadurch dauerhaft
|
||||||
|
unsichtbar bei negativem x. Die Tabellen scrollen also weiterhin
|
||||||
|
horizontal, nur über den bereits vorhandenen inneren Wrapper, nicht
|
||||||
|
zusätzlich über die Karte selbst. */
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
.table-wrap table.data-table { min-width: 560px; }
|
||||||
|
|
||||||
.table-toolbar {
|
.table-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -792,6 +813,12 @@ table.data-table {
|
|||||||
========================================================================== */
|
========================================================================== */
|
||||||
|
|
||||||
.field { margin-bottom: 15px; }
|
.field { margin-bottom: 15px; }
|
||||||
|
/* Zwei .field nebeneinander in einer .flex-Zeile (z.B. HTTP-/HTTPS-Port
|
||||||
|
bei NGINX) -- als Klasse statt inline style="flex:1", damit die
|
||||||
|
Mobile-Stapel-Regel (siehe @media max-width:640px weiter unten) sie
|
||||||
|
per externem Stylesheet überschreiben kann; ein inline style gewinnt
|
||||||
|
sonst grundsätzlich gegen jede @media-Regel, ganz gleich wie spezifisch. */
|
||||||
|
.field--half { flex: 1; min-width: 0; }
|
||||||
.field label {
|
.field label {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 12.5px;
|
font-size: 12.5px;
|
||||||
@@ -1104,6 +1131,21 @@ select {
|
|||||||
.log-line.sep { color: #3a4050; }
|
.log-line.sep { color: #3a4050; }
|
||||||
.log-line.restart { color: #ffb454; }
|
.log-line.restart { color: #ffb454; }
|
||||||
|
|
||||||
|
.raw-log-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
margin: 0;
|
||||||
|
padding: 14px 18px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
color: #c7ccd6;
|
||||||
|
background: #0a0c10;
|
||||||
|
}
|
||||||
|
[data-theme="light"] .raw-log-content { background: #0e1116; }
|
||||||
|
|
||||||
/* ==========================================================================
|
/* ==========================================================================
|
||||||
Permission checklists (Gruppen)
|
Permission checklists (Gruppen)
|
||||||
========================================================================== */
|
========================================================================== */
|
||||||
@@ -1141,8 +1183,14 @@ select {
|
|||||||
Logs mit nur R). Feste/zu schmale Breiten haben die D-Spalte bei
|
Logs mit nur R). Feste/zu schmale Breiten haben die D-Spalte bei
|
||||||
Geräte zuvor lautlos in den Overflow geschoben. */
|
Geräte zuvor lautlos in den Overflow geschoben. */
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
|
max-width: 100%;
|
||||||
padding-right: 28px;
|
padding-right: 28px;
|
||||||
border-right: 1px solid var(--border-soft);
|
border-right: 1px solid var(--border-soft);
|
||||||
|
/* Fällt eine einzelne Spalte (z.B. Geräte mit vielen Rechten-Spalten)
|
||||||
|
trotz .permission-groups-row's flex-wrap auf einem schmalen Screen
|
||||||
|
für sich genommen noch zu breit aus, scrollt nur diese eine Spalte
|
||||||
|
waagerecht statt die Seite aufzureißen. */
|
||||||
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
.permission-group-col:last-child { border-right: none; padding-right: 0; }
|
.permission-group-col:last-child { border-right: none; padding-right: 0; }
|
||||||
.permission-group-header-cell { padding-left: 0 !important; }
|
.permission-group-header-cell { padding-left: 0 !important; }
|
||||||
@@ -1244,6 +1292,92 @@ select {
|
|||||||
}
|
}
|
||||||
.xterm-container .xterm { height: 100%; }
|
.xterm-container .xterm { height: 100%; }
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Fileshare (Baum-Navigation + Vorschau)
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
/* align-items:stretch (statt flex-start) + die main-Seite selbst als
|
||||||
|
Flex-Spalte mit table-wrap{flex:1}, damit beide Kacheln (Baum links,
|
||||||
|
Tabelle rechts) immer gleich hoch sind, unabhängig davon welche Seite
|
||||||
|
gerade mehr Inhalt hat. */
|
||||||
|
.fileshare-layout { display: flex; align-items: stretch; gap: 16px; }
|
||||||
|
.fileshare-main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
|
||||||
|
.fileshare-main .table-wrap { flex: 1; }
|
||||||
|
|
||||||
|
.fileshare-tree {
|
||||||
|
flex: 0 0 260px;
|
||||||
|
max-width: 260px;
|
||||||
|
padding: 14px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.fileshare-tree-title {
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-weight: 650;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-root, .tree-children { list-style: none; margin: 0; padding: 0; }
|
||||||
|
.tree-children { padding-left: 16px; }
|
||||||
|
|
||||||
|
.tree-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 4px 6px;
|
||||||
|
border-radius: 7px;
|
||||||
|
}
|
||||||
|
.tree-row:hover { background: var(--bg-card-hover); }
|
||||||
|
.tree-row.active { background: var(--bg-card-hover); color: var(--accent); font-weight: 600; }
|
||||||
|
|
||||||
|
.tree-toggle {
|
||||||
|
width: 18px; height: 18px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
border: none; background: transparent; color: var(--text-faint);
|
||||||
|
font-size: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.tree-toggle:hover { color: var(--text); }
|
||||||
|
.tree-toggle:disabled { visibility: hidden; }
|
||||||
|
|
||||||
|
.tree-label {
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Vorschau: von mammoth.js/SheetJS erzeugter bzw. selbst gebauter Inhalt */
|
||||||
|
.docx-preview { font-size: 14px; line-height: 1.65; }
|
||||||
|
.docx-preview table { border-collapse: collapse; margin: 10px 0; }
|
||||||
|
.docx-preview table td, .docx-preview table th { border: 1px solid var(--border); padding: 6px 10px; }
|
||||||
|
.docx-preview img { max-width: 100%; }
|
||||||
|
.xlsx-preview-sheet-title { margin: 20px 0 8px; font-size: 13px; font-weight: 650; }
|
||||||
|
.xlsx-preview-sheet-title:first-child { margin-top: 0; }
|
||||||
|
|
||||||
|
/* Angesammelte Dateien im Upload-Modal (Mehrfachauswahl, siehe fileshare.html) */
|
||||||
|
.upload-file-list { margin-top: 8px; display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.upload-file-row {
|
||||||
|
display: flex; align-items: center; justify-content: space-between; gap: 8px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
border: 1px solid var(--border-soft);
|
||||||
|
border-radius: 7px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
.upload-file-row span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.upload-file-remove {
|
||||||
|
flex-shrink: 0;
|
||||||
|
border: none; background: transparent; color: var(--text-faint);
|
||||||
|
font-size: 16px; line-height: 1; cursor: pointer; padding: 0 2px;
|
||||||
|
}
|
||||||
|
.upload-file-remove:hover { color: var(--danger); }
|
||||||
|
|
||||||
/* ==========================================================================
|
/* ==========================================================================
|
||||||
Utilities
|
Utilities
|
||||||
========================================================================== */
|
========================================================================== */
|
||||||
@@ -1271,14 +1405,63 @@ select {
|
|||||||
.content { padding: 18px 16px 40px; }
|
.content { padding: 18px 16px 40px; }
|
||||||
.topbar { padding: 10px 16px; }
|
.topbar { padding: 10px 16px; }
|
||||||
.topbar-logo { opacity: 0.5; }
|
.topbar-logo { opacity: 0.5; }
|
||||||
|
|
||||||
|
/* Ab hier iPad-Breite (900px ist bereits der bestehende Sidebar-
|
||||||
|
Umschaltpunkt) und schmaler: generische Button-/Toolbar-Zeilen
|
||||||
|
(.flex) sollen umbrechen statt seitlich überzulaufen -- betrifft vor
|
||||||
|
allem Kopfzeilen mit mehreren Aktions-Buttons (z.B. NGINX-, Logs-,
|
||||||
|
Verlauf-Seite) und nebeneinander angeordnete Formularfelder. */
|
||||||
|
.flex { flex-wrap: wrap; }
|
||||||
|
.section-head { flex-wrap: wrap; }
|
||||||
|
.term-toolbar, .log-toolbar { flex-wrap: wrap; row-gap: 8px; }
|
||||||
|
.detail-row { flex-wrap: wrap; }
|
||||||
|
.detail-row .v { text-align: left; }
|
||||||
|
|
||||||
|
/* Modal-Fußzeilen (Speichern/Abbrechen) und Tabellen-Kopfzeilen
|
||||||
|
(Suche + Aktion) sollen bei wenig Platz ebenfalls umbrechen statt
|
||||||
|
Buttons/Suchfeld ineinanderzuschieben. */
|
||||||
|
.modal-footer { flex-wrap: wrap; }
|
||||||
|
.table-toolbar .search-input { min-width: 0; flex: 1 1 160px; }
|
||||||
|
|
||||||
|
/* Fileshare-Baum + Tabelle nebeneinander sprengt auf Tablet-/Handy-
|
||||||
|
Breite die Seite (Baum-Spalte ist fest 260px breit) -- Baum stapelt
|
||||||
|
stattdessen oben, Tabelle darunter in voller Breite. */
|
||||||
|
.fileshare-layout { flex-direction: column; }
|
||||||
|
.fileshare-tree { flex: 1 1 auto; max-width: 100%; max-height: 240px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.topbar-logo { display: none; }
|
.topbar-logo { display: none; }
|
||||||
|
|
||||||
|
/* Formulare mit zwei nebeneinander angeordneten Feldern (class="flex
|
||||||
|
gap-2" um zwei .field-Divs, z.B. HTTP-/HTTPS-Port bei NGINX) stapeln
|
||||||
|
auf Handy-Breite volle Breite pro Feld statt zwei sehr schmale
|
||||||
|
Eingaben nebeneinander zu erzwingen. */
|
||||||
|
form .flex > .field { flex-basis: 100%; }
|
||||||
|
|
||||||
|
.modal-header, .modal-body, .modal-footer { padding-left: 16px; padding-right: 16px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 560px) {
|
@media (max-width: 560px) {
|
||||||
.device-grid { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); }
|
.device-grid { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); }
|
||||||
.topbar-title { font-size: 16px; }
|
.topbar-title { font-size: 16px; }
|
||||||
.stat-row { grid-template-columns: repeat(2, 1fr); }
|
.stat-row { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
|
||||||
|
/* Button-Leisten in Kopfzeilen (z.B. section-head/card-Kopf mit
|
||||||
|
"RAW anzeigen" o.ä.) nehmen auf sehr schmalen Screens die volle
|
||||||
|
Breite ein statt als schmaler Block rechts zu kleben. */
|
||||||
|
.section-head > .btn,
|
||||||
|
.section-head > .btn-sm { flex: 1 1 100%; }
|
||||||
|
|
||||||
|
/* Auf einem iPhone quetschen die beiden rechten Topbar-Pillen (Prüf-
|
||||||
|
Countdown + DHCP) .topbar-left (flex:1 1 0%, min-width:0) so weit
|
||||||
|
zusammen, dass der Seitentitel über seine eigene Spaltenbreite
|
||||||
|
hinausläuft und sichtbar mit der Pille kollidiert (per Playwright-
|
||||||
|
Screenshot auf 393px Breite nachgewiesen: .topbar-title ragte 24.5px
|
||||||
|
über .topbar-lefts berechnete Breite hinaus). Titel und Pillen
|
||||||
|
bekommen ab hier je eine eigene Zeile statt sich einen Platz zu
|
||||||
|
teilen, der für beide zusammen nicht reicht. */
|
||||||
|
.topbar { flex-wrap: wrap; row-gap: 6px; }
|
||||||
|
.topbar-left { flex: 1 1 100%; }
|
||||||
|
.topbar-right { flex: 1 1 100%; justify-content: flex-start; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -211,6 +211,59 @@
|
|||||||
openModal("save-discard-modal");
|
openModal("save-discard-modal");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* ---------------- RAW-Log-Popup (Live-Log & Verlauf) ---------------- */
|
||||||
|
/* Gleiches Modal-Grundgerüst wie der Ja/Nein-Dialog für ungespeicherte
|
||||||
|
Änderungen (modal-overlay/modal-header/modal-body/modal-footer), nur
|
||||||
|
breiter/höher und mit lazy nachgeladenem Inhalt -- der komplette
|
||||||
|
Log-Inhalt wird bewusst erst beim tatsächlichen Öffnen per fetch
|
||||||
|
nachgeladen, nicht schon beim Seitenaufbau, damit genau das Problem
|
||||||
|
(unnötig große Ladezeiten) nicht durch das Popup selbst zurückkommt. */
|
||||||
|
function ensureRawLogModal() {
|
||||||
|
if (document.getElementById("raw-log-modal")) return;
|
||||||
|
const html = `
|
||||||
|
<div class="modal-overlay" id="raw-log-modal">
|
||||||
|
<div class="modal" style="max-width:min(1100px, 92vw); height:85vh;">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 id="raw-log-modal-title">Komplettes Log (RAW)</h3>
|
||||||
|
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body" style="padding:0; display:flex; flex-direction:column; flex:1; min-height:0;">
|
||||||
|
<pre id="raw-log-modal-content" class="raw-log-content"></pre>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<span class="text-faint mono" id="raw-log-modal-meta" style="margin-right:auto; font-size:11.5px;"></span>
|
||||||
|
<button type="button" class="btn btn-secondary" data-close-modal>Schließen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
document.body.insertAdjacentHTML("beforeend", html);
|
||||||
|
document.querySelectorAll("#raw-log-modal [data-close-modal]").forEach((btn) => {
|
||||||
|
btn.addEventListener("click", () => closeModal(btn));
|
||||||
|
});
|
||||||
|
document.getElementById("raw-log-modal").addEventListener("click", (e) => {
|
||||||
|
if (e.target.id === "raw-log-modal") closeModal(e.target);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
window.openRawLogModal = function (url, title) {
|
||||||
|
if (!url) return;
|
||||||
|
ensureRawLogModal();
|
||||||
|
document.getElementById("raw-log-modal-title").innerText = title || "Komplettes Log (RAW)";
|
||||||
|
const content = document.getElementById("raw-log-modal-content");
|
||||||
|
const meta = document.getElementById("raw-log-modal-meta");
|
||||||
|
content.textContent = "Lade …";
|
||||||
|
meta.textContent = "";
|
||||||
|
openModal("raw-log-modal");
|
||||||
|
fetch(url)
|
||||||
|
.then((r) => {
|
||||||
|
const logName = r.headers.get("X-Log-Name");
|
||||||
|
if (logName) meta.textContent = logName;
|
||||||
|
return r.text();
|
||||||
|
})
|
||||||
|
.then((text) => { content.textContent = text; })
|
||||||
|
.catch(() => { content.textContent = "Fehler beim Laden des Logs."; });
|
||||||
|
};
|
||||||
|
|
||||||
/* ---------------- Warnung bei ungespeicherten Änderungen ---------------- */
|
/* ---------------- Warnung bei ungespeicherten Änderungen ---------------- */
|
||||||
/* Erkennt generisch auf JEDER Seite, ob ein Formular mit echten
|
/* Erkennt generisch auf JEDER Seite, ob ein Formular mit echten
|
||||||
Eingabefeldern (nicht nur versteckten Aktions-Feldern wie bei Löschen/
|
Eingabefeldern (nicht nur versteckten Aktions-Feldern wie bei Löschen/
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
+22
File diff suppressed because one or more lines are too long
@@ -19,6 +19,10 @@
|
|||||||
"key": '<circle cx="8" cy="15" r="4"/><path d="M11 12l9-9M17 6l3 3M14 9l2 2"/>',
|
"key": '<circle cx="8" cy="15" r="4"/><path d="M11 12l9-9M17 6l3 3M14 9l2 2"/>',
|
||||||
"terminal": '<rect x="2" y="4" width="20" height="16" rx="2"/><path d="M6 9l4 3-4 3M13 15h5"/>',
|
"terminal": '<rect x="2" y="4" width="20" height="16" rx="2"/><path d="M6 9l4 3-4 3M13 15h5"/>',
|
||||||
"history": '<path d="M3 12a9 9 0 109-9 9.75 9.75 0 00-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/>',
|
"history": '<path d="M3 12a9 9 0 109-9 9.75 9.75 0 00-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/>',
|
||||||
|
"clock": '<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 3"/>',
|
||||||
|
"shield": '<path d="M12 2l8 4v6c0 5-3.5 8.5-8 10-4.5-1.5-8-5-8-10V6z"/><path d="M9 12l2 2 4-4"/>',
|
||||||
|
"server": '<rect x="2" y="3" width="20" height="7" rx="1.5"/><rect x="2" y="14" width="20" height="7" rx="1.5"/><path d="M6 6.5h.01M6 17.5h.01"/>',
|
||||||
|
"folder": '<path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/>',
|
||||||
"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"/>',
|
"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"/>',
|
"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"/>',
|
||||||
"gear": '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 11-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 11-2.83-2.83l.06-.06A1.65 1.65 0 004.6 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 112.83-2.83l.06.06A1.65 1.65 0 009 4.6a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 112.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/>',
|
"gear": '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 11-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 11-2.83-2.83l.06-.06A1.65 1.65 0 004.6 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 112.83-2.83l.06.06A1.65 1.65 0 009 4.6a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 112.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/>',
|
||||||
|
|||||||
@@ -0,0 +1,617 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% set active_page = "fileshare" %}
|
||||||
|
{% block page_title %}Dateifreigaben{% endblock %}
|
||||||
|
{% block page_sub %}<div class="topbar-sub">{{ selected_share }}{% if rel_path %} / {{ rel_path }}{% endif %}</div>{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
{% macro render_tree_node(share, path, name, expanded_map, active_path) %}
|
||||||
|
<li class="tree-node" data-share="{{ share }}" data-path="{{ path }}">
|
||||||
|
<div class="tree-row{{ ' active' if path == active_path else '' }}">
|
||||||
|
{% if path in expanded_map %}
|
||||||
|
<button type="button" class="tree-toggle" aria-expanded="true">▼</button>
|
||||||
|
{% else %}
|
||||||
|
<button type="button" class="tree-toggle" aria-expanded="false">▶</button>
|
||||||
|
{% endif %}
|
||||||
|
<span class="tree-label">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:14px; height:14px; margin-right:5px; vertical-align:-2px; color:var(--accent);"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/></svg>
|
||||||
|
{{ name }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ul class="tree-children{{ '' if path in expanded_map else ' hidden' }}"{% if path in expanded_map %} data-loaded="1"{% endif %}>
|
||||||
|
{% if path in expanded_map %}
|
||||||
|
{% for child in expanded_map[path] %}
|
||||||
|
{{ render_tree_node(share, (path ~ '/' ~ child) if path else child, child, expanded_map, active_path) }}
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
<div class="section-head">
|
||||||
|
<div>
|
||||||
|
<h2 style="font-size:16px;">Dateifreigaben</h2>
|
||||||
|
<div class="hint">Freigaben je nach AD-Gruppenmitgliedschaft für diese Sitzung gemountet — wird beim Abmelden wieder ausgehängt.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="fileshare-layout">
|
||||||
|
<div class="fileshare-tree card">
|
||||||
|
<div class="fileshare-tree-title">Freigaben</div>
|
||||||
|
<ul class="tree-root" id="fileshareTree">
|
||||||
|
{% for s in shares %}
|
||||||
|
{{ render_tree_node(s, '', s, tree_expanded if s == selected_share else {}, rel_path if s == selected_share else None) }}
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="fileshare-main">
|
||||||
|
<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" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>
|
||||||
|
<input type="text" id="fileSearch" placeholder="Datei/Ordner suchen..." oninput="filterTable('fileSearch', 'fileTable')">
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
{% if can_create %}
|
||||||
|
<button type="button" class="btn btn-secondary btn-sm" data-open-modal="mkdirModal">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/><path d="M12 11v4M10 13h4"/></svg>
|
||||||
|
Neuer Ordner
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-primary btn-sm" data-open-modal="uploadModal">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><path d="M17 8l-5-5-5 5"/><path d="M12 3v12"/></svg>
|
||||||
|
Hochladen
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="bulkActionsBar" class="flex gap-2 hidden" data-share="{{ selected_share }}" data-path="{{ rel_path }}" style="align-items:center; margin-bottom:10px; flex-wrap:wrap;">
|
||||||
|
<span id="bulkSelectedCount" class="text-faint" style="font-size:12.5px; font-weight:600;"></span>
|
||||||
|
<button type="button" class="btn btn-secondary btn-sm" onclick="bulkDownload()">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><path d="M7 10l5 5 5-5"/><path d="M12 15V3"/></svg>
|
||||||
|
Herunterladen (ZIP)
|
||||||
|
</button>
|
||||||
|
{% if can_edit %}
|
||||||
|
<button type="button" class="btn btn-sm" style="color:var(--danger); background:transparent; border-color:var(--danger-dim);" onclick="bulkDelete()">
|
||||||
|
<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>
|
||||||
|
Löschen
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if entries %}
|
||||||
|
<div style="overflow-x:auto;">
|
||||||
|
<table class="data-table" id="fileTable" data-sortable>
|
||||||
|
<thead><tr>
|
||||||
|
<th style="width:1%;"><input type="checkbox" id="selectAllFiles"></th>
|
||||||
|
<th data-sort-key="name">Name</th>
|
||||||
|
<th data-sort-key="size">Größe</th>
|
||||||
|
<th data-sort-key="mtime">Geändert</th>
|
||||||
|
<th style="width:1%;">Aktionen</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for e in entries %}
|
||||||
|
<tr data-sort-name="{{ e.name|lower }}" data-sort-size="{{ 0 if e.is_dir else e.size_str }}" data-sort-mtime="{{ e.mtime_str }}">
|
||||||
|
<td><input type="checkbox" class="row-select" value="{{ e.name }}"></td>
|
||||||
|
<td class="cell-name">
|
||||||
|
{% if e.is_dir %}
|
||||||
|
<a href="{{ url_for('fileshare', share=selected_share, path=(rel_path ~ '/' ~ e.name) if rel_path else e.name) }}">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:15px; height:15px; margin-right:6px; vertical-align:-2px; color:var(--accent);"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/></svg>
|
||||||
|
{{ e.name }}
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:15px; height:15px; margin-right:6px; vertical-align:-2px; color:var(--text-faint);"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><path d="M14 2v6h6"/></svg>
|
||||||
|
{{ e.name }}
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="text-faint">{{ e.size_str }}</td>
|
||||||
|
<td class="text-faint">{{ e.mtime_str }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="row-actions">
|
||||||
|
{% if e.preview_kind %}
|
||||||
|
<button type="button" class="icon-btn" title="Vorschau"
|
||||||
|
data-preview-kind="{{ e.preview_kind }}"
|
||||||
|
data-preview-name="{{ e.name }}"
|
||||||
|
data-preview-url="{{ url_for('fileshare_view', share=selected_share, path=(rel_path ~ '/' ~ e.name) if rel_path else e.name) }}">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
{% if not e.is_dir %}
|
||||||
|
<a class="icon-btn" title="Herunterladen" href="{{ url_for('fileshare_download', share=selected_share, path=(rel_path ~ '/' ~ e.name) if rel_path else e.name) }}">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><path d="M7 10l5 5 5-5"/><path d="M12 15V3"/></svg>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if can_edit %}
|
||||||
|
<button type="button" class="icon-btn" title="Umbenennen" onclick="openRenameModal('{{ e.name|e }}')">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
||||||
|
</button>
|
||||||
|
<form method="post" action="{{ url_for('fileshare_delete') }}" data-confirm="„{{ e.name }}“ wirklich endgültig löschen?">
|
||||||
|
<input type="hidden" name="share" value="{{ selected_share }}">
|
||||||
|
<input type="hidden" name="path" value="{{ rel_path }}">
|
||||||
|
<input type="hidden" name="name" value="{{ e.name }}">
|
||||||
|
<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>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div style="padding:40px 16px; text-align:center; color:var(--text-faint);">Dieser Ordner ist leer.</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if can_create %}
|
||||||
|
<div class="modal-overlay" id="uploadModal">
|
||||||
|
<div class="modal">
|
||||||
|
<form method="post" action="{{ url_for('fileshare_upload') }}" enctype="multipart/form-data">
|
||||||
|
<input type="hidden" name="share" value="{{ selected_share }}">
|
||||||
|
<input type="hidden" name="path" value="{{ rel_path }}">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>Datei(en) hochladen</h3>
|
||||||
|
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="field">
|
||||||
|
<label>Datei(en)</label>
|
||||||
|
<input type="file" name="file" id="uploadFileInput" multiple required>
|
||||||
|
<div id="uploadFileList" class="upload-file-list"></div>
|
||||||
|
<div class="field-hint">Insgesamt maximal 15 MB pro Upload-Vorgang. Mehrfachauswahl möglich (auch mehrmals nacheinander — bereits hinzugefügte Dateien bleiben dabei erhalten).</div>
|
||||||
|
</div>
|
||||||
|
<div class="field-hint">Wird in „{{ selected_share }}{% if rel_path %} / {{ rel_path }}{% endif %}“ hochgeladen. Eine bereits vorhandene Datei gleichen Namens wird überschrieben.</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">Hochladen</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-overlay" id="mkdirModal">
|
||||||
|
<div class="modal">
|
||||||
|
<form method="post" action="{{ url_for('fileshare_mkdir') }}">
|
||||||
|
<input type="hidden" name="share" value="{{ selected_share }}">
|
||||||
|
<input type="hidden" name="path" value="{{ rel_path }}">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>Neuer Ordner</h3>
|
||||||
|
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="field">
|
||||||
|
<label>Ordnername</label>
|
||||||
|
<input type="text" name="name" required autofocus>
|
||||||
|
</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>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if can_edit %}
|
||||||
|
<div class="modal-overlay" id="renameModal">
|
||||||
|
<div class="modal">
|
||||||
|
<form method="post" action="{{ url_for('fileshare_rename') }}">
|
||||||
|
<input type="hidden" name="share" value="{{ selected_share }}">
|
||||||
|
<input type="hidden" name="path" value="{{ rel_path }}">
|
||||||
|
<input type="hidden" name="old_name" id="renameOldName">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>Umbenennen</h3>
|
||||||
|
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="field">
|
||||||
|
<label>Neuer Name</label>
|
||||||
|
<input type="text" name="new_name" id="renameNewName" required>
|
||||||
|
</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">Umbenennen</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="modal-overlay" id="previewModal">
|
||||||
|
<div class="modal" style="max-width:900px; width:90vw;">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 id="previewTitle">Vorschau</h3>
|
||||||
|
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body" id="previewBody" style="max-height:75vh; overflow:auto;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script src="{{ url_for('static', filename='js/vendor/mammoth.browser.min.js') }}"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/vendor/xlsx.full.min.js') }}"></script>
|
||||||
|
<script>
|
||||||
|
const FILESHARE_SUBFOLDERS_URL = "{{ url_for('fileshare_subfolders') }}";
|
||||||
|
const FILESHARE_BASE_URL = "{{ url_for('fileshare') }}";
|
||||||
|
const FILESHARE_DOWNLOAD_MULTI_URL = "{{ url_for('fileshare_download_multi') }}";
|
||||||
|
const FILESHARE_DELETE_MULTI_URL = "{{ url_for('fileshare_delete_multi') }}";
|
||||||
|
|
||||||
|
function openRenameModal(name) {
|
||||||
|
document.getElementById("renameOldName").value = name;
|
||||||
|
document.getElementById("renameNewName").value = name;
|
||||||
|
PoeUI.openModal("renameModal");
|
||||||
|
}
|
||||||
|
|
||||||
|
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";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- Mehrfachauswahl (Herunterladen als ZIP / Löschen) ---------------- */
|
||||||
|
|
||||||
|
function getSelectedFileNames() {
|
||||||
|
return Array.from(document.querySelectorAll("#fileTable .row-select:checked")).map(function (cb) { return cb.value; });
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateBulkBar() {
|
||||||
|
const names = getSelectedFileNames();
|
||||||
|
const bar = document.getElementById("bulkActionsBar");
|
||||||
|
const countEl = document.getElementById("bulkSelectedCount");
|
||||||
|
if (!bar) return;
|
||||||
|
if (names.length > 0) {
|
||||||
|
bar.classList.remove("hidden");
|
||||||
|
countEl.textContent = names.length + " ausgewählt";
|
||||||
|
} else {
|
||||||
|
bar.classList.add("hidden");
|
||||||
|
}
|
||||||
|
const selectAll = document.getElementById("selectAllFiles");
|
||||||
|
const allBoxes = document.querySelectorAll("#fileTable .row-select");
|
||||||
|
if (selectAll && allBoxes.length) {
|
||||||
|
selectAll.checked = names.length === allBoxes.length;
|
||||||
|
selectAll.indeterminate = names.length > 0 && names.length < allBoxes.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitBulkForm(actionUrl, names) {
|
||||||
|
// data-share/data-path statt Jinja-Werte direkt in einen JS-String-
|
||||||
|
// Literal zu setzen -- Ordnernamen kommen von der echten Freigabe und
|
||||||
|
// koennten Anfuehrungszeichen o.ae. enthalten, ueber HTML-Attribute
|
||||||
|
// (von Jinja automatisch escaped) ist das unproblematisch.
|
||||||
|
const bar = document.getElementById("bulkActionsBar");
|
||||||
|
const form = document.createElement("form");
|
||||||
|
form.method = "post";
|
||||||
|
form.action = actionUrl;
|
||||||
|
form.style.display = "none";
|
||||||
|
[["share", bar.dataset.share], ["path", bar.dataset.path]].forEach(function (pair) {
|
||||||
|
const input = document.createElement("input");
|
||||||
|
input.type = "hidden";
|
||||||
|
input.name = pair[0];
|
||||||
|
input.value = pair[1];
|
||||||
|
form.appendChild(input);
|
||||||
|
});
|
||||||
|
names.forEach(function (name) {
|
||||||
|
const input = document.createElement("input");
|
||||||
|
input.type = "hidden";
|
||||||
|
input.name = "names";
|
||||||
|
input.value = name;
|
||||||
|
form.appendChild(input);
|
||||||
|
});
|
||||||
|
document.body.appendChild(form);
|
||||||
|
form.submit();
|
||||||
|
}
|
||||||
|
|
||||||
|
function bulkDownload() {
|
||||||
|
const names = getSelectedFileNames();
|
||||||
|
if (!names.length) return;
|
||||||
|
submitBulkForm(FILESHARE_DOWNLOAD_MULTI_URL, names);
|
||||||
|
}
|
||||||
|
|
||||||
|
function bulkDelete() {
|
||||||
|
const names = getSelectedFileNames();
|
||||||
|
if (!names.length) return;
|
||||||
|
window.confirmAction(
|
||||||
|
names.length + " ausgewählte Elemente wirklich endgültig löschen?",
|
||||||
|
function () { submitBulkForm(FILESHARE_DELETE_MULTI_URL, names); },
|
||||||
|
"Auswahl löschen?"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
|
const selectAll = document.getElementById("selectAllFiles");
|
||||||
|
if (selectAll) {
|
||||||
|
selectAll.addEventListener("change", function () {
|
||||||
|
document.querySelectorAll("#fileTable .row-select").forEach(function (cb) { cb.checked = selectAll.checked; });
|
||||||
|
updateBulkBar();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
document.querySelectorAll("#fileTable .row-select").forEach(function (cb) {
|
||||||
|
cb.addEventListener("change", updateBulkBar);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ---------------- Mehrfach-Upload (Multiauswahl + mehrmals nacheinander) ---------------- */
|
||||||
|
/* Ein <input type=file multiple> ERSETZT bei jeder erneuten Dateiauswahl
|
||||||
|
die vorherige -- fuer "mehrmals nacheinander hinzufuegen" wird deshalb
|
||||||
|
selbst eine "angesammelte" Auswahl per DataTransfer gepflegt und nach
|
||||||
|
jeder Aenderung zurueck auf das Input-Feld geschrieben, sodass das
|
||||||
|
normale <form>-Submit (kein fetch() noetig) am Ende alle gesammelten
|
||||||
|
Dateien mitschickt. DataTransfer-Zuweisung an .files wird von allen
|
||||||
|
gaengigen Mobil-Browsern (Android Chrome, iOS Safari) mitgetragen; falls
|
||||||
|
nicht, faellt es einfach auf das native Verhalten (letzte Auswahl zaehlt)
|
||||||
|
zurueck, ohne den Upload an sich zu verhindern. */
|
||||||
|
(function () {
|
||||||
|
const input = document.getElementById("uploadFileInput");
|
||||||
|
const listEl = document.getElementById("uploadFileList");
|
||||||
|
if (!input || !listEl) return;
|
||||||
|
let staged = null;
|
||||||
|
try { staged = new DataTransfer(); } catch (e) { staged = null; }
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
listEl.innerHTML = "";
|
||||||
|
if (!staged) return;
|
||||||
|
Array.from(staged.files).forEach(function (file, idx) {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "upload-file-row";
|
||||||
|
const name = document.createElement("span");
|
||||||
|
name.textContent = file.name;
|
||||||
|
const removeBtn = document.createElement("button");
|
||||||
|
removeBtn.type = "button";
|
||||||
|
removeBtn.className = "upload-file-remove";
|
||||||
|
removeBtn.setAttribute("aria-label", "Entfernen");
|
||||||
|
removeBtn.textContent = "×";
|
||||||
|
removeBtn.addEventListener("click", function () {
|
||||||
|
const dt = new DataTransfer();
|
||||||
|
Array.from(staged.files).forEach(function (f, i) {
|
||||||
|
if (i !== idx) dt.items.add(f);
|
||||||
|
});
|
||||||
|
staged = dt;
|
||||||
|
input.files = staged.files;
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
row.appendChild(name);
|
||||||
|
row.appendChild(removeBtn);
|
||||||
|
listEl.appendChild(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
input.addEventListener("change", function () {
|
||||||
|
if (!staged) return; // kein DataTransfer-Support -- natives Verhalten greift
|
||||||
|
Array.from(input.files).forEach(function (file) { staged.items.add(file); });
|
||||||
|
input.files = staged.files;
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Beim (Wieder-)Oeffnen des Modals eine frische Sammlung starten, statt
|
||||||
|
// Dateien aus einem vorherigen, bereits abgeschickten Upload-Vorgang
|
||||||
|
// versehentlich mitzuschleppen.
|
||||||
|
document.querySelectorAll('[data-open-modal="uploadModal"]').forEach(function (btn) {
|
||||||
|
btn.addEventListener("click", function () {
|
||||||
|
try { staged = new DataTransfer(); } catch (e) { staged = null; }
|
||||||
|
input.value = "";
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
|
/* ---------------- Baum-Navigation (Freigaben links) ---------------- */
|
||||||
|
|
||||||
|
function buildTreeNode(share, path, name) {
|
||||||
|
const li = document.createElement("li");
|
||||||
|
li.className = "tree-node";
|
||||||
|
li.dataset.share = share;
|
||||||
|
li.dataset.path = path;
|
||||||
|
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "tree-row";
|
||||||
|
|
||||||
|
const toggle = document.createElement("button");
|
||||||
|
toggle.type = "button";
|
||||||
|
toggle.className = "tree-toggle";
|
||||||
|
toggle.textContent = "▶";
|
||||||
|
toggle.setAttribute("aria-expanded", "false");
|
||||||
|
|
||||||
|
const label = document.createElement("span");
|
||||||
|
label.className = "tree-label";
|
||||||
|
label.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:14px;height:14px;margin-right:5px;vertical-align:-2px;color:var(--accent);"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/></svg>';
|
||||||
|
label.append(document.createTextNode(name));
|
||||||
|
|
||||||
|
row.appendChild(toggle);
|
||||||
|
row.appendChild(label);
|
||||||
|
|
||||||
|
const childUl = document.createElement("ul");
|
||||||
|
childUl.className = "tree-children hidden";
|
||||||
|
|
||||||
|
li.appendChild(row);
|
||||||
|
li.appendChild(childUl);
|
||||||
|
return li;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleTreeNode(btn) {
|
||||||
|
const li = btn.closest(".tree-node");
|
||||||
|
const childUl = li.querySelector(":scope > .tree-children");
|
||||||
|
if (childUl.classList.contains("hidden")) {
|
||||||
|
if (childUl.dataset.loaded === "1") {
|
||||||
|
childUl.classList.remove("hidden");
|
||||||
|
btn.textContent = "▼";
|
||||||
|
btn.setAttribute("aria-expanded", "true");
|
||||||
|
} else {
|
||||||
|
loadTreeChildren(li, childUl, btn);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
childUl.classList.add("hidden");
|
||||||
|
btn.textContent = "▶";
|
||||||
|
btn.setAttribute("aria-expanded", "false");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadTreeChildren(li, childUl, btn) {
|
||||||
|
const share = li.dataset.share;
|
||||||
|
const path = li.dataset.path;
|
||||||
|
const prevLabel = btn.textContent;
|
||||||
|
btn.textContent = "…";
|
||||||
|
fetch(FILESHARE_SUBFOLDERS_URL + "?share=" + encodeURIComponent(share) + "&path=" + encodeURIComponent(path))
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
childUl.innerHTML = "";
|
||||||
|
(data.folders || []).forEach(name => {
|
||||||
|
const childPath = path ? path + "/" + name : name;
|
||||||
|
childUl.appendChild(buildTreeNode(share, childPath, name));
|
||||||
|
});
|
||||||
|
childUl.dataset.loaded = "1";
|
||||||
|
childUl.classList.remove("hidden");
|
||||||
|
btn.textContent = "▼";
|
||||||
|
btn.setAttribute("aria-expanded", "true");
|
||||||
|
})
|
||||||
|
.catch(() => { btn.textContent = prevLabel; });
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigateTree(share, path) {
|
||||||
|
window.location.href = FILESHARE_BASE_URL + "?share=" + encodeURIComponent(share) + "&path=" + encodeURIComponent(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("fileshareTree").addEventListener("click", function (e) {
|
||||||
|
const toggle = e.target.closest(".tree-toggle");
|
||||||
|
if (toggle) { toggleTreeNode(toggle); return; }
|
||||||
|
const label = e.target.closest(".tree-label");
|
||||||
|
if (label) {
|
||||||
|
const li = label.closest(".tree-node");
|
||||||
|
navigateTree(li.dataset.share, li.dataset.path);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ---------------- Datei-Vorschau ---------------- */
|
||||||
|
|
||||||
|
const fileTableEl = document.getElementById("fileTable");
|
||||||
|
if (fileTableEl) {
|
||||||
|
fileTableEl.addEventListener("click", function (e) {
|
||||||
|
const btn = e.target.closest("[data-preview-kind]");
|
||||||
|
if (!btn) return;
|
||||||
|
openPreview(btn.dataset.previewKind, btn.dataset.previewName, btn.dataset.previewUrl);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPreview(kind, name, url) {
|
||||||
|
document.getElementById("previewTitle").textContent = name;
|
||||||
|
const body = document.getElementById("previewBody");
|
||||||
|
body.innerHTML = "";
|
||||||
|
PoeUI.openModal("previewModal");
|
||||||
|
|
||||||
|
if (kind === "pdf") {
|
||||||
|
const iframe = document.createElement("iframe");
|
||||||
|
iframe.src = url;
|
||||||
|
iframe.style.width = "100%";
|
||||||
|
iframe.style.height = "70vh";
|
||||||
|
iframe.style.border = "0";
|
||||||
|
body.appendChild(iframe);
|
||||||
|
} else if (kind === "image") {
|
||||||
|
const img = document.createElement("img");
|
||||||
|
img.src = url;
|
||||||
|
img.alt = name;
|
||||||
|
img.style.maxWidth = "100%";
|
||||||
|
img.style.display = "block";
|
||||||
|
img.style.margin = "0 auto";
|
||||||
|
body.appendChild(img);
|
||||||
|
} else if (kind === "text") {
|
||||||
|
body.textContent = "Lade …";
|
||||||
|
// Nur die ersten 512KB anfordern -- bei sehr großen Textdateien
|
||||||
|
// (Logs etc.) reicht das für eine Vorschau, ohne alles auf einmal
|
||||||
|
// laden zu müssen. Server unterstützt Range ueber send_file(conditional=True).
|
||||||
|
fetch(url, { headers: { "Range": "bytes=0-524287" } })
|
||||||
|
.then(r => {
|
||||||
|
// r.status ist bei einem Range-Request praktisch immer 206,
|
||||||
|
// auch wenn die Datei kleiner als die angefragten 512KB ist
|
||||||
|
// (der Server liefert dann trotzdem "206" mit der kompletten
|
||||||
|
// Datei) -- ob wirklich abgeschnitten wurde, steht nur im
|
||||||
|
// Content-Range-Header ("bytes 0-524287/<Gesamtgroesse>").
|
||||||
|
const contentRange = r.headers.get("Content-Range") || "";
|
||||||
|
const m = contentRange.match(/\/(\d+)$/);
|
||||||
|
const truncated = !!m && parseInt(m[1], 10) > 524288;
|
||||||
|
return r.text().then(text => ({ text: text, truncated: truncated }));
|
||||||
|
})
|
||||||
|
.then(({ text, truncated }) => {
|
||||||
|
body.innerHTML = "";
|
||||||
|
const pre = document.createElement("pre");
|
||||||
|
pre.style.whiteSpace = "pre-wrap";
|
||||||
|
pre.style.wordBreak = "break-word";
|
||||||
|
pre.style.fontSize = "12.5px";
|
||||||
|
pre.textContent = text;
|
||||||
|
body.appendChild(pre);
|
||||||
|
if (truncated) {
|
||||||
|
const hint = document.createElement("div");
|
||||||
|
hint.className = "text-faint";
|
||||||
|
hint.style.marginTop = "10px";
|
||||||
|
hint.style.fontSize = "12px";
|
||||||
|
hint.textContent = "Nur die ersten 512 KB angezeigt — bitte herunterladen für die komplette Datei.";
|
||||||
|
body.appendChild(hint);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => { body.textContent = "Vorschau konnte nicht geladen werden."; });
|
||||||
|
} else if (kind === "docx") {
|
||||||
|
body.textContent = "Lade …";
|
||||||
|
fetch(url).then(r => r.arrayBuffer())
|
||||||
|
.then(buf => mammoth.convertToHtml({ arrayBuffer: buf }))
|
||||||
|
.then(result => {
|
||||||
|
body.innerHTML = "";
|
||||||
|
const wrap = document.createElement("div");
|
||||||
|
wrap.className = "docx-preview";
|
||||||
|
// mammoth erzeugt kontrolliertes HTML aus Words eigenem,
|
||||||
|
// begrenztem Formatierungsmodell (Absätze/Tabellen/Listen/
|
||||||
|
// Bilder) -- kein beliebiges, aus der Datei uebernommenes
|
||||||
|
// Skript kann darin stecken, anders als bei generischem
|
||||||
|
// "fremdes HTML direkt einbetten".
|
||||||
|
wrap.innerHTML = result.value;
|
||||||
|
body.appendChild(wrap);
|
||||||
|
})
|
||||||
|
.catch(() => { body.textContent = "Vorschau konnte nicht geladen werden (Format evtl. nicht unterstützt)."; });
|
||||||
|
} else if (kind === "xlsx") {
|
||||||
|
body.textContent = "Lade …";
|
||||||
|
fetch(url).then(r => r.arrayBuffer())
|
||||||
|
.then(buf => {
|
||||||
|
const wb = XLSX.read(buf, { type: "array" });
|
||||||
|
body.innerHTML = "";
|
||||||
|
wb.SheetNames.forEach(function (sheetName, idx) {
|
||||||
|
const rows = XLSX.utils.sheet_to_json(wb.Sheets[sheetName], { header: 1, defval: "" });
|
||||||
|
const h4 = document.createElement("div");
|
||||||
|
h4.className = "xlsx-preview-sheet-title";
|
||||||
|
h4.textContent = sheetName;
|
||||||
|
body.appendChild(h4);
|
||||||
|
const wrap = document.createElement("div");
|
||||||
|
wrap.style.overflowX = "auto";
|
||||||
|
const table = document.createElement("table");
|
||||||
|
table.className = "data-table";
|
||||||
|
// Zellenwerte bewusst per textContent statt ueber die
|
||||||
|
// eingebaute HTML-Ausgabe von SheetJS gesetzt -- so ist
|
||||||
|
// die Vorschau unabhaengig von deren Escaping-Verhalten
|
||||||
|
// garantiert sicher gegen Inhalte in den Zellen.
|
||||||
|
rows.forEach(function (row) {
|
||||||
|
const tr = document.createElement("tr");
|
||||||
|
row.forEach(function (cell) {
|
||||||
|
const td = document.createElement("td");
|
||||||
|
td.textContent = (cell === null || cell === undefined) ? "" : String(cell);
|
||||||
|
tr.appendChild(td);
|
||||||
|
});
|
||||||
|
table.appendChild(tr);
|
||||||
|
});
|
||||||
|
wrap.appendChild(table);
|
||||||
|
body.appendChild(wrap);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(() => { body.textContent = "Vorschau konnte nicht geladen werden."; });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -104,8 +104,8 @@
|
|||||||
<td class="text-dim">{{ admin_virtual_group.member_names|length }}</td>
|
<td class="text-dim">{{ admin_virtual_group.member_names|length }}</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="row-actions">
|
<div class="row-actions">
|
||||||
<button class="icon-btn" title="Rechte anzeigen" onclick="toggleDetail('detail-admin')">
|
<button class="icon-btn" title="Rechte anzeigen" data-open-modal="adminGroupModal">
|
||||||
<svg id="chev-admin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9l6 6 6-6"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<button class="icon-btn" title="Mitglieder verwalten" data-open-modal="adminMembersModal">
|
<button class="icon-btn" title="Mitglieder verwalten" data-open-modal="adminMembersModal">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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"/></svg>
|
||||||
@@ -113,16 +113,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr class="group-detail-row hidden" id="detail-admin">
|
|
||||||
<td colspan="3">
|
|
||||||
{{ permission_tree(admin_virtual_group.permissions, true, true) }}
|
|
||||||
<p class="text-faint" style="font-size:11.5px; margin:12px 0 0;">Admins dürfen immer alles — diese Rechte sind fest und nicht änderbar.</p>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|
||||||
{% for g in groups %}
|
{% for g in groups %}
|
||||||
{% set can_edit_this = current_user.has_permission('groups.edit') and not g.is_system %}
|
{% set can_edit_this = current_user.has_permission('groups.edit') and not g.is_system %}
|
||||||
|
{% set can_unlock_system = g.is_system and current_user.is_admin %}
|
||||||
<tbody data-sort-name="{{ g.name|lower }}" data-sort-members="{{ g.member_names|length }}">
|
<tbody data-sort-name="{{ g.name|lower }}" data-sort-members="{{ g.member_names|length }}">
|
||||||
<tr>
|
<tr>
|
||||||
<td class="cell-name">
|
<td class="cell-name">
|
||||||
@@ -132,8 +127,12 @@
|
|||||||
<td class="text-dim">{{ g.member_names|length }}</td>
|
<td class="text-dim">{{ g.member_names|length }}</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="row-actions">
|
<div class="row-actions">
|
||||||
<button class="icon-btn" title="Rechte anzeigen{{ '/bearbeiten' if can_edit_this else '' }}" onclick="toggleDetail('detail-{{ g.id }}')">
|
<button class="icon-btn" title="{{ 'Bearbeiten' if (can_edit_this or can_unlock_system) else 'Anzeigen' }}" data-open-modal="editGroupModal{{ loop.index }}">
|
||||||
<svg id="chev-{{ g.id }}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9l6 6 6-6"/></svg>
|
{% if can_edit_this or can_unlock_system %}
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
||||||
|
{% else %}
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||||
|
{% endif %}
|
||||||
</button>
|
</button>
|
||||||
<button class="icon-btn" title="Mitglieder verwalten" data-open-modal="membersModal{{ loop.index }}">
|
<button class="icon-btn" title="Mitglieder verwalten" data-open-modal="membersModal{{ loop.index }}">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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"/></svg>
|
||||||
@@ -149,57 +148,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% set can_unlock_system = g.is_system and current_user.is_admin %}
|
|
||||||
<tr class="group-detail-row hidden" id="detail-{{ g.id }}">
|
|
||||||
<td colspan="3">
|
|
||||||
{% if can_edit_this %}
|
|
||||||
<form method="post">
|
|
||||||
<input type="hidden" name="save_group" value="1">
|
|
||||||
<input type="hidden" name="permissions_submitted" value="1">
|
|
||||||
<input type="hidden" name="group_id" value="{{ g.id }}">
|
|
||||||
<input type="hidden" name="name" value="{{ g.name }}">
|
|
||||||
{{ permission_tree(g.permissions, false, true) }}
|
|
||||||
<div class="flex" style="justify-content:flex-end; margin-top:16px;">
|
|
||||||
<button type="submit" class="btn btn-primary btn-sm">
|
|
||||||
<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>
|
|
||||||
Rechte speichern
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
{% elif can_unlock_system %}
|
|
||||||
<div id="readonly-{{ g.id }}">
|
|
||||||
{{ permission_tree(g.permissions, true, true) }}
|
|
||||||
<div class="flex" style="justify-content:space-between; align-items:center; margin-top:12px;">
|
|
||||||
<p class="text-faint" style="font-size:11.5px; margin:0;">Die Standardgruppe „Benutzer“ ist eine Systemgruppe — ihre Rechte sind normalerweise fest.</p>
|
|
||||||
<button type="button" class="btn btn-secondary btn-sm" onclick="unlockSystemGroup({{ g.id }})">Freischalten</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<form method="post" class="hidden" id="unlock-{{ g.id }}">
|
|
||||||
<input type="hidden" name="save_group" value="1">
|
|
||||||
<input type="hidden" name="permissions_submitted" value="1">
|
|
||||||
<input type="hidden" name="unlock_system_group" value="1">
|
|
||||||
<input type="hidden" name="group_id" value="{{ g.id }}">
|
|
||||||
<input type="hidden" name="name" value="{{ g.name }}">
|
|
||||||
{{ permission_tree(g.permissions, false, true) }}
|
|
||||||
<p class="text-faint" style="font-size:11.5px; margin:12px 0;">
|
|
||||||
⚠ Diese Gruppe ist die Standardgruppe für neue Benutzer (auch neu angelegte AD/LDAP-Konten). Zu restriktive
|
|
||||||
Rechte hier können den Erst-Login neuer Konten einschränken.
|
|
||||||
</p>
|
|
||||||
<div class="flex" style="justify-content:flex-end;">
|
|
||||||
<button type="submit" class="btn btn-primary btn-sm">
|
|
||||||
<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>
|
|
||||||
Rechte speichern
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
{% else %}
|
|
||||||
{{ permission_tree(g.permissions, true, true) }}
|
|
||||||
{% if g.is_system %}
|
|
||||||
<p class="text-faint" style="font-size:11.5px; margin:12px 0 0;">Die Standardgruppe „Benutzer“ ist eine Systemgruppe — ihre Rechte sind fest und nicht änderbar.</p>
|
|
||||||
{% endif %}
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
</tbody>
|
||||||
{% else %}
|
{% else %}
|
||||||
<tbody data-sort-pinned>
|
<tbody data-sort-pinned>
|
||||||
@@ -210,6 +158,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-overlay" id="adminGroupModal">
|
||||||
|
<div class="modal" style="max-width:1000px;">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>Admin — Rechte</h3>
|
||||||
|
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
{{ permission_tree(admin_virtual_group.permissions, true, true) }}
|
||||||
|
<p class="text-faint" style="font-size:11.5px; margin:12px 0 0;">Admins dürfen immer alles — diese Rechte sind fest und nicht änderbar.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="modal-overlay" id="adminMembersModal">
|
<div class="modal-overlay" id="adminMembersModal">
|
||||||
<div class="modal" style="max-width:380px;">
|
<div class="modal" style="max-width:380px;">
|
||||||
<form method="post">
|
<form method="post">
|
||||||
@@ -238,6 +199,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% for g in groups %}
|
{% for g in groups %}
|
||||||
|
{% set can_edit_this = current_user.has_permission('groups.edit') and not g.is_system %}
|
||||||
|
{% set can_unlock_system = g.is_system and current_user.is_admin %}
|
||||||
<div class="modal-overlay" id="membersModal{{ loop.index }}">
|
<div class="modal-overlay" id="membersModal{{ loop.index }}">
|
||||||
<div class="modal" style="max-width:380px;">
|
<div class="modal" style="max-width:380px;">
|
||||||
<form method="post">
|
<form method="post">
|
||||||
@@ -268,6 +231,80 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-overlay" id="editGroupModal{{ loop.index }}">
|
||||||
|
<div class="modal" style="max-width:1000px;">
|
||||||
|
{% if can_edit_this %}
|
||||||
|
<form method="post">
|
||||||
|
<input type="hidden" name="save_group" value="1">
|
||||||
|
<input type="hidden" name="permissions_submitted" value="1">
|
||||||
|
<input type="hidden" name="group_id" value="{{ g.id }}">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>Gruppe bearbeiten</h3>
|
||||||
|
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="field">
|
||||||
|
<label>Name</label>
|
||||||
|
<input type="text" name="name" value="{{ g.name }}" required>
|
||||||
|
</div>
|
||||||
|
{{ permission_tree(g.permissions, false, true) }}
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
|
||||||
|
<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>
|
||||||
|
</form>
|
||||||
|
{% elif can_unlock_system %}
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>Gruppe „{{ g.name }}“</h3>
|
||||||
|
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="field"><label>Name</label><input type="text" value="{{ g.name }}" disabled></div>
|
||||||
|
<div id="readonly-{{ g.id }}">
|
||||||
|
{{ permission_tree(g.permissions, true, true) }}
|
||||||
|
<div class="flex" style="justify-content:space-between; align-items:center; margin-top:12px; flex-wrap:wrap;">
|
||||||
|
<p class="text-faint" style="font-size:11.5px; margin:0;">Die Standardgruppe „Benutzer“ ist eine Systemgruppe — ihre Rechte sind normalerweise fest.</p>
|
||||||
|
<button type="button" class="btn btn-secondary btn-sm" onclick="unlockSystemGroup({{ g.id }})">Freischalten</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form method="post" class="hidden" id="unlock-{{ g.id }}">
|
||||||
|
<input type="hidden" name="save_group" value="1">
|
||||||
|
<input type="hidden" name="permissions_submitted" value="1">
|
||||||
|
<input type="hidden" name="unlock_system_group" value="1">
|
||||||
|
<input type="hidden" name="group_id" value="{{ g.id }}">
|
||||||
|
<input type="hidden" name="name" value="{{ g.name }}">
|
||||||
|
{{ permission_tree(g.permissions, false, true) }}
|
||||||
|
<p class="text-faint" style="font-size:11.5px; margin:12px 0;">
|
||||||
|
⚠ Diese Gruppe ist die Standardgruppe für neue Benutzer (auch neu angelegte AD/LDAP-Konten). Zu restriktive
|
||||||
|
Rechte hier können den Erst-Login neuer Konten einschränken.
|
||||||
|
</p>
|
||||||
|
<div class="flex" style="justify-content:flex-end;">
|
||||||
|
<button type="submit" class="btn btn-primary btn-sm">
|
||||||
|
<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>
|
||||||
|
Rechte speichern
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>Gruppe „{{ g.name }}“</h3>
|
||||||
|
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
{{ permission_tree(g.permissions, true, true) }}
|
||||||
|
{% if g.is_system %}
|
||||||
|
<p class="text-faint" style="font-size:11.5px; margin:12px 0 0;">Die Standardgruppe „Benutzer“ ist eine Systemgruppe — ihre Rechte sind fest und nicht änderbar.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
<div class="modal-overlay" id="addGroupModal">
|
<div class="modal-overlay" id="addGroupModal">
|
||||||
@@ -317,14 +354,6 @@ function unlockSystemGroup(id) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleDetail(id) {
|
|
||||||
const row = document.getElementById(id);
|
|
||||||
if (!row) return;
|
|
||||||
row.classList.toggle("hidden");
|
|
||||||
const chev = document.getElementById(id.replace("detail-", "chev-"));
|
|
||||||
if (chev) chev.style.transform = row.classList.contains("hidden") ? "" : "rotate(180deg)";
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyPermissionGating() {
|
function applyPermissionGating() {
|
||||||
document.querySelectorAll(".permission-group-col").forEach(function (area) {
|
document.querySelectorAll(".permission-group-col").forEach(function (area) {
|
||||||
const toggle = area.querySelector(".permission-area-toggle-cb");
|
const toggle = area.querySelector(".permission-area-toggle-cb");
|
||||||
|
|||||||
@@ -8,18 +8,31 @@
|
|||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
<div>
|
<div>
|
||||||
<h2 style="font-size:16px;">Live-Log</h2>
|
<h2 style="font-size:16px;">Live-Log</h2>
|
||||||
<div class="hint">Laufende Erreichbarkeitsprüfung von poe.sh, farblich markiert (online/offline).</div>
|
<div class="hint">Laufende Erreichbarkeitsprüfung von poe.sh, farblich markiert (online/offline). Zeigt aus Performance-Gründen nur die letzten {{ tail_lines }} Zeilen.</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
{% if current_user.can_view_log_history %}
|
||||||
|
<a href="{{ url_for('logs_history') }}" class="btn btn-secondary">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 3"/></svg>
|
||||||
|
Verlauf
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="openRawLogModal('{{ url_for('get_log_raw') }}', 'Komplettes Live-Log (RAW)')">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><path d="M14 2v6h6"/></svg>
|
||||||
|
Komplettes Log (RAW)
|
||||||
|
</button>
|
||||||
<button id="refresh-btn" class="btn btn-secondary">
|
<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>
|
<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
|
Aktualisieren
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="log-shell">
|
<div class="log-shell">
|
||||||
<div class="log-toolbar">
|
<div class="log-toolbar">
|
||||||
<div class="log-dots"><span></span><span></span><span></span></div>
|
<div class="log-dots"><span></span><span></span><span></span></div>
|
||||||
<span class="text-faint mono" style="font-size:11.5px;" data-log-name>{{ log_name or "" }}</span>
|
<span class="text-faint mono" style="font-size:11.5px;" data-log-name>{{ log_name or "" }}</span>
|
||||||
|
<span class="text-faint mono" style="font-size:11.5px;" id="log-line-info">{% if total_lines %}letzte {{ tail_lines }} von {{ total_lines }} Zeilen{% endif %}</span>
|
||||||
</div>
|
</div>
|
||||||
<div id="log-box">{{ log_content or "Keine Logfiles gefunden." }}</div>
|
<div id="log-box">{{ log_content or "Keine Logfiles gefunden." }}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -43,7 +56,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
const intervalMinutes = {{ global_check_interval | int }};
|
const intervalMinutes = {{ global_check_interval | int }};
|
||||||
const intervalMilliseconds = intervalMinutes * 60 * 1000;
|
const intervalMilliseconds = intervalMinutes * 60 * 1000;
|
||||||
|
|
||||||
function renderLog(text, logName) {
|
function renderLog(text, logName, totalLines) {
|
||||||
const box = document.getElementById("log-box");
|
const box = document.getElementById("log-box");
|
||||||
box.innerHTML = "";
|
box.innerHTML = "";
|
||||||
const lines = text.split("\n");
|
const lines = text.split("\n");
|
||||||
@@ -55,11 +68,15 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
if (logName) {
|
if (logName) {
|
||||||
document.querySelectorAll("[data-log-name]").forEach((el) => { el.textContent = logName; });
|
document.querySelectorAll("[data-log-name]").forEach((el) => { el.textContent = logName; });
|
||||||
}
|
}
|
||||||
|
const info = document.getElementById("log-line-info");
|
||||||
|
if (info && totalLines) {
|
||||||
|
info.textContent = "letzte {{ tail_lines }} von " + totalLines + " Zeilen";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function fetchLog() {
|
function fetchLog() {
|
||||||
fetch("{{ url_for('get_log') }}")
|
fetch("{{ url_for('get_log') }}")
|
||||||
.then(r => r.text().then((text) => renderLog(text, r.headers.get("X-Log-Name"))))
|
.then(r => r.text().then((text) => renderLog(text, r.headers.get("X-Log-Name"), r.headers.get("X-Total-Lines"))))
|
||||||
.catch(err => console.error(err));
|
.catch(err => console.error(err));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% set active_page = "logs" %}
|
||||||
|
{% block page_title %}Verlauf{% endblock %}
|
||||||
|
{% block page_sub %}<div class="topbar-sub">{{ selected_file.range_label if selected_file else "kein Log" }}</div>{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="section-head">
|
||||||
|
<div>
|
||||||
|
<h2 style="font-size:16px;">Log-Verlauf</h2>
|
||||||
|
<div class="hint">Aktuelles Live-Log sowie ältere, von logrotate rotierte Kopien davon — {{ files|length }} Stand{{ 'e' if files|length != 1 else '' }} verfügbar, auswählbar nach Zeitraum. Aus Performance-Gründen unformatiert (RAW) dargestellt, ohne farbliche Aufbereitung.</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<a href="{{ url_for('logs') }}" 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="M19 12H5M12 19l-7-7 7-7"/></svg>
|
||||||
|
Zurück zur Live-Ansicht
|
||||||
|
</a>
|
||||||
|
<button type="button" class="btn btn-secondary"
|
||||||
|
onclick="openRawLogModal('{{ url_for('logs_history_raw', file=selected_name) if selected_name else '' }}', 'Komplettes Log (RAW) — {{ selected_file.range_label if selected_file else '' }}')"
|
||||||
|
{% if not selected_name %}disabled{% endif %}>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><path d="M14 2v6h6"/></svg>
|
||||||
|
Komplettes Log (RAW)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if not files %}
|
||||||
|
<p class="text-faint" style="font-size:12.5px;">
|
||||||
|
Kein Live-Log gefunden.
|
||||||
|
</p>
|
||||||
|
{% else %}
|
||||||
|
|
||||||
|
<div class="flex gap-2" style="align-items:center; margin-bottom:14px;">
|
||||||
|
<label for="history-file-select" class="text-faint" style="font-size:12.5px; font-weight:600;">Zeitraum:</label>
|
||||||
|
<select id="history-file-select" onchange="window.location.href=this.value;" class="mono" style="max-width:460px;">
|
||||||
|
{% for f in files %}
|
||||||
|
<option value="{{ url_for('logs_history', file=f.filename) }}" {% if f.filename == selected_name %}selected{% endif %}>
|
||||||
|
{% if f.generation == 0 %}Aktuell — {{ f.range_label }}{% else %}{{ f.range_label }}{% endif %} ({{ f.size_str }})
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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;">{{ selected_file.range_label if selected_file else "" }}</span>
|
||||||
|
<span class="text-faint mono" style="font-size:11.5px;">{% if total_lines %}letzte {{ tail_lines }} von {{ total_lines }} Zeilen — RAW{% endif %}</span>
|
||||||
|
</div>
|
||||||
|
<pre id="log-box" class="raw-log-content" style="height:100%;">{{ log_content or "" }}</pre>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
const box = document.getElementById("log-box");
|
||||||
|
if (box) box.scrollTop = box.scrollHeight;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
{% block page_sub %}<div class="topbar-sub">Anmeldung mit dem Domänen-Passwort, zusätzlich zu lokalen Konten</div>{% endblock %}
|
{% block page_sub %}<div class="topbar-sub">Anmeldung mit dem Domänen-Passwort, zusätzlich zu lokalen Konten</div>{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="settings-grid" style="grid-template-columns: repeat(auto-fit, minmax(420px, 1fr));">
|
<div class="settings-grid settings-grid--wide">
|
||||||
|
|
||||||
<div class="card card-pad">
|
<div class="card card-pad">
|
||||||
<div class="section-head" style="margin-bottom:16px;">
|
<div class="section-head" style="margin-bottom:16px;">
|
||||||
@@ -70,6 +70,18 @@
|
|||||||
</select>
|
</select>
|
||||||
<div class="field-hint">Wird nur zugewiesen, wenn unten keine AD-Gruppenzuordnung greift — siehe Karte „AD-Gruppenzuordnungen“.</div>
|
<div class="field-hint">Wird nur zugewiesen, wenn unten keine AD-Gruppenzuordnung greift — siehe Karte „AD-Gruppenzuordnungen“.</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="field"><label>Erforderliche AD-Gruppe für Login (optional)</label>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<select name="ldap_required_login_group" id="requiredGroupSelect" style="flex:1;">
|
||||||
|
<option value="">Keine (jeder gültige AD-Benutzer darf sich anmelden)</option>
|
||||||
|
{% if ldap.required_login_group %}
|
||||||
|
<option value="{{ ldap.required_login_group }}" selected>{{ ldap.required_login_group }}</option>
|
||||||
|
{% endif %}
|
||||||
|
</select>
|
||||||
|
<button type="button" class="btn btn-secondary btn-sm" id="requiredGroupLoadBtn">Gruppen laden</button>
|
||||||
|
</div>
|
||||||
|
<div class="field-hint" id="requiredGroupLoadStatus">Ist hier eine Gruppe ausgewählt, scheitert die Anmeldung für alle Benutzer, die ihr NICHT angehören (rekursiv, auch über verschachtelte Gruppen) — wie bei falschen Zugangsdaten, ohne Hinweis auf den eigentlichen Grund.</div>
|
||||||
|
</div>
|
||||||
<div class="flex gap-2" style="flex-wrap:wrap;">
|
<div class="flex gap-2" style="flex-wrap:wrap;">
|
||||||
<button type="submit" name="save_ldap" value="1" class="btn btn-primary">
|
<button type="submit" name="save_ldap" value="1" 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>
|
<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>
|
||||||
@@ -123,12 +135,18 @@
|
|||||||
<td>{{ m.app_group_name or '—' }}</td>
|
<td>{{ m.app_group_name or '—' }}</td>
|
||||||
<td>
|
<td>
|
||||||
{% if can_edit %}
|
{% if can_edit %}
|
||||||
|
<div class="row-actions">
|
||||||
|
<button type="button" class="icon-btn" title="Bearbeiten"
|
||||||
|
onclick="openEditLdapMappingModal('{{ m.id }}','{{ m.ad_group_dn|e }}','{{ m.ad_group_name|e }}','{{ m.app_group_id }}')">
|
||||||
|
<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>
|
||||||
<form method="post" data-confirm="Zuordnung „{{ m.ad_group_name }} → {{ m.app_group_name }}“ löschen?">
|
<form method="post" data-confirm="Zuordnung „{{ m.ad_group_name }} → {{ m.app_group_name }}“ löschen?">
|
||||||
<input type="hidden" name="delete_ldap_group_mapping" value="{{ m.id }}">
|
<input type="hidden" name="delete_ldap_group_mapping" value="{{ m.id }}">
|
||||||
<button type="submit" class="icon-btn" style="color:var(--danger);" title="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>
|
<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>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -142,6 +160,63 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card card-pad">
|
||||||
|
<div class="section-head" style="margin-bottom:16px;">
|
||||||
|
<div>
|
||||||
|
<h2 style="font-size:16px;">Fileshare-Gruppen</h2>
|
||||||
|
<div class="hint">
|
||||||
|
Ist ein AD-Benutzer (rekursiv) Mitglied einer hier zugeordneten AD-Gruppe, wird die zugehörige
|
||||||
|
Freigabe beim Login für ihn gemountet — sofern er zusätzlich das TESM-Recht „Dateifreigaben lesen“
|
||||||
|
hat (siehe Gruppen → Rechte, Bereich „Dateifreigaben“). Fehlt das Recht, wird nicht gemountet und
|
||||||
|
der Menüpunkt „Dateifreigaben“ erscheint nicht, unabhängig von der AD-Gruppenmitgliedschaft.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if can_edit %}
|
||||||
|
<button type="button" class="btn btn-primary" data-open-modal="addFileshareMappingModal">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
|
||||||
|
Zuordnung hinzufügen
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if fileshare_mappings %}
|
||||||
|
<div class="table-wrap">
|
||||||
|
<div style="overflow-x:auto;">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead><tr><th>AD-Gruppe</th><th>Freigabe</th><th>Pfad</th><th style="width:1%;">Aktionen</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for m in fileshare_mappings %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ m.ad_group_name }}<div class="text-faint mono" style="font-size:11px;">{{ m.ad_group_dn }}</div></td>
|
||||||
|
<td>{{ m.share_label }}</td>
|
||||||
|
<td class="mono" style="font-size:12px;">{{ m.share_unc }}</td>
|
||||||
|
<td>
|
||||||
|
{% if can_edit %}
|
||||||
|
<div class="row-actions">
|
||||||
|
<button type="button" class="icon-btn" title="Bearbeiten"
|
||||||
|
onclick="openEditFileshareMappingModal('{{ m.id }}','{{ m.ad_group_dn|e }}','{{ m.ad_group_name|e }}','{{ m.share_label|e }}','{{ m.share_unc|e }}')">
|
||||||
|
<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>
|
||||||
|
<form method="post" data-confirm="Fileshare-Zuordnung „{{ m.ad_group_name }} → {{ m.share_label }}“ löschen?">
|
||||||
|
<input type="hidden" name="delete_fileshare_mapping" value="{{ m.id }}">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-faint" style="font-size:12.5px;">Noch keine Fileshare-Zuordnung angelegt — für niemanden wird eine Freigabe gemountet.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if can_edit %}
|
{% if can_edit %}
|
||||||
@@ -183,15 +258,129 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-overlay" id="addFileshareMappingModal">
|
||||||
|
<div class="modal">
|
||||||
|
<form method="post">
|
||||||
|
<input type="hidden" name="add_fileshare_mapping" value="1">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>Fileshare-Zuordnung hinzufügen</h3>
|
||||||
|
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="field">
|
||||||
|
<label>AD-Gruppe</label>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<select name="fs_ad_group_dn" id="fsMappingAdGroup" required style="flex:1;">
|
||||||
|
<option value="">— zuerst laden —</option>
|
||||||
|
</select>
|
||||||
|
<button type="button" class="btn btn-secondary btn-sm" id="fsMappingLoadGroupsBtn">Gruppen laden</button>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" name="fs_ad_group_name" id="fsMappingAdGroupName">
|
||||||
|
<div class="field-hint" id="fsMappingLoadStatus">Fragt live per Bind-Konto alle Gruppen aus dem Verzeichnis ab.</div>
|
||||||
|
</div>
|
||||||
|
<div class="field"><label>Bezeichnung</label>
|
||||||
|
<input type="text" name="fs_share_label" placeholder="z.B. Vertrieb" required>
|
||||||
|
<div class="field-hint">Anzeigename in der Freigaben-Auswahl — auch Ordnername unter dem Mount-Punkt.</div>
|
||||||
|
</div>
|
||||||
|
<div class="field"><label>Freigabe-Pfad (UNC)</label>
|
||||||
|
<input type="text" name="fs_share_unc" placeholder="//fileserver/freigabe" required>
|
||||||
|
<div class="field-hint">Beide Schreibweisen funktionieren — <code>\\server\freigabe</code> wird automatisch in das von Linux benötigte <code>//server/freigabe</code> umgewandelt.</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">Speichern</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-overlay" id="editLdapMappingModal">
|
||||||
|
<div class="modal">
|
||||||
|
<form method="post">
|
||||||
|
<input type="hidden" name="edit_ldap_group_mapping" id="editLdapMappingId" value="">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>AD-Gruppenzuordnung bearbeiten</h3>
|
||||||
|
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="field">
|
||||||
|
<label>AD-Gruppe</label>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<select name="ad_group_dn" id="editLdapMappingAdGroup" required style="flex:1;"></select>
|
||||||
|
<button type="button" class="btn btn-secondary btn-sm" id="editLdapMappingLoadGroupsBtn">Gruppen laden</button>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" name="ad_group_name" id="editLdapMappingAdGroupName">
|
||||||
|
<div class="field-hint" id="editLdapMappingLoadStatus">Fragt live per Bind-Konto alle Gruppen aus dem Verzeichnis ab.</div>
|
||||||
|
</div>
|
||||||
|
<div class="field"><label>App-Rechtegruppe</label>
|
||||||
|
<select name="app_group_id" id="editLdapMappingAppGroup" required>
|
||||||
|
<option value="">— auswählen —</option>
|
||||||
|
<option value="admin">Admin (alle Rechte)</option>
|
||||||
|
{% for g in ldap_groups %}
|
||||||
|
<option value="{{ g['id'] }}">{{ g['name'] }}</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>
|
||||||
|
|
||||||
|
<div class="modal-overlay" id="editFileshareMappingModal">
|
||||||
|
<div class="modal">
|
||||||
|
<form method="post">
|
||||||
|
<input type="hidden" name="edit_fileshare_mapping" id="editFsMappingId" value="">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>Fileshare-Zuordnung bearbeiten</h3>
|
||||||
|
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="field">
|
||||||
|
<label>AD-Gruppe</label>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<select name="fs_ad_group_dn" id="editFsMappingAdGroup" required style="flex:1;"></select>
|
||||||
|
<button type="button" class="btn btn-secondary btn-sm" id="editFsMappingLoadGroupsBtn">Gruppen laden</button>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" name="fs_ad_group_name" id="editFsMappingAdGroupName">
|
||||||
|
<div class="field-hint" id="editFsMappingLoadStatus">Fragt live per Bind-Konto alle Gruppen aus dem Verzeichnis ab.</div>
|
||||||
|
</div>
|
||||||
|
<div class="field"><label>Bezeichnung</label>
|
||||||
|
<input type="text" name="fs_share_label" id="editFsMappingLabel" placeholder="z.B. Vertrieb" required>
|
||||||
|
<div class="field-hint">Anzeigename in der Freigaben-Auswahl — auch Ordnername unter dem Mount-Punkt.</div>
|
||||||
|
</div>
|
||||||
|
<div class="field"><label>Freigabe-Pfad (UNC)</label>
|
||||||
|
<input type="text" name="fs_share_unc" id="editFsMappingUnc" placeholder="//fileserver/freigabe" required>
|
||||||
|
<div class="field-hint">Beide Schreibweisen funktionieren — <code>\\server\freigabe</code> wird automatisch in das von Linux benötigte <code>//server/freigabe</code> umgewandelt.</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">Speichern</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
var btn = document.getElementById('ldapMappingLoadGroupsBtn');
|
function wireAdGroupLoader(btnId, selectId, nameFieldId, statusId, includeEmptyOption) {
|
||||||
var select = document.getElementById('ldapMappingAdGroup');
|
var btn = document.getElementById(btnId);
|
||||||
var nameField = document.getElementById('ldapMappingAdGroupName');
|
var select = document.getElementById(selectId);
|
||||||
var status = document.getElementById('ldapMappingLoadStatus');
|
var nameField = nameFieldId ? document.getElementById(nameFieldId) : null;
|
||||||
|
var status = document.getElementById(statusId);
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
|
|
||||||
btn.addEventListener('click', function () {
|
btn.addEventListener('click', function () {
|
||||||
|
// Aktuellen Wert (z.B. die vorher gespeicherte, noch nicht per
|
||||||
|
// Klartext-Namen aufgeloeste Gruppe) merken -- bleibt nach dem
|
||||||
|
// Neuaufbau der Optionsliste ausgewaehlt, falls sie unter den
|
||||||
|
// geladenen Gruppen auftaucht.
|
||||||
|
var currentValue = select.value;
|
||||||
status.textContent = 'Lade Gruppen …';
|
status.textContent = 'Lade Gruppen …';
|
||||||
fetch("{{ url_for('settings_ldap_ad_groups') }}")
|
fetch("{{ url_for('settings_ldap_ad_groups') }}")
|
||||||
.then(function (r) { return r.json(); })
|
.then(function (r) { return r.json(); })
|
||||||
@@ -201,8 +390,14 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
select.innerHTML = '';
|
select.innerHTML = '';
|
||||||
|
if (includeEmptyOption) {
|
||||||
|
var emptyOpt = document.createElement('option');
|
||||||
|
emptyOpt.value = '';
|
||||||
|
emptyOpt.textContent = 'Keine (jeder gültige AD-Benutzer darf sich anmelden)';
|
||||||
|
select.appendChild(emptyOpt);
|
||||||
|
}
|
||||||
if (!groups.length) {
|
if (!groups.length) {
|
||||||
select.innerHTML = '<option value="">Keine Gruppen gefunden</option>';
|
if (!includeEmptyOption) select.innerHTML = '<option value="">Keine Gruppen gefunden</option>';
|
||||||
status.textContent = 'Keine Gruppen gefunden — Verbindung/Bind-Konto prüfen.';
|
status.textContent = 'Keine Gruppen gefunden — Verbindung/Bind-Konto prüfen.';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -211,19 +406,63 @@
|
|||||||
opt.value = g.dn;
|
opt.value = g.dn;
|
||||||
opt.textContent = g.name;
|
opt.textContent = g.name;
|
||||||
opt.dataset.name = g.name;
|
opt.dataset.name = g.name;
|
||||||
|
if (g.dn === currentValue) opt.selected = true;
|
||||||
select.appendChild(opt);
|
select.appendChild(opt);
|
||||||
});
|
});
|
||||||
nameField.value = select.options[select.selectedIndex].dataset.name || '';
|
if (nameField) nameField.value = (select.options[select.selectedIndex] && select.options[select.selectedIndex].dataset.name) || '';
|
||||||
status.textContent = groups.length + ' Gruppe(n) geladen.';
|
status.textContent = groups.length + ' Gruppe(n) geladen.';
|
||||||
})
|
})
|
||||||
.catch(function () { status.textContent = 'Fehler beim Laden — Verbindung/Bind-Konto prüfen.'; });
|
.catch(function () { status.textContent = 'Fehler beim Laden — Verbindung/Bind-Konto prüfen.'; });
|
||||||
});
|
});
|
||||||
|
|
||||||
select.addEventListener('change', function () {
|
select.addEventListener('change', function () {
|
||||||
|
if (!nameField) return;
|
||||||
var opt = select.options[select.selectedIndex];
|
var opt = select.options[select.selectedIndex];
|
||||||
nameField.value = (opt && opt.dataset.name) || '';
|
nameField.value = (opt && opt.dataset.name) || '';
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
wireAdGroupLoader('ldapMappingLoadGroupsBtn', 'ldapMappingAdGroup', 'ldapMappingAdGroupName', 'ldapMappingLoadStatus', false);
|
||||||
|
wireAdGroupLoader('fsMappingLoadGroupsBtn', 'fsMappingAdGroup', 'fsMappingAdGroupName', 'fsMappingLoadStatus', false);
|
||||||
|
wireAdGroupLoader('requiredGroupLoadBtn', 'requiredGroupSelect', null, 'requiredGroupLoadStatus', true);
|
||||||
|
wireAdGroupLoader('editLdapMappingLoadGroupsBtn', 'editLdapMappingAdGroup', 'editLdapMappingAdGroupName', 'editLdapMappingLoadStatus', false);
|
||||||
|
wireAdGroupLoader('editFsMappingLoadGroupsBtn', 'editFsMappingAdGroup', 'editFsMappingAdGroupName', 'editFsMappingLoadStatus', false);
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
// Bearbeiten-Modals vorbefuellen -- die AD-Gruppe steht dabei zunaechst nur
|
||||||
|
// als DN+Name aus der Datenbank zur Verfuegung (ohne erneute LDAP-Abfrage);
|
||||||
|
// "Gruppen laden" ersetzt die Auswahlliste bei Bedarf durch die vollstaendige,
|
||||||
|
// aktuelle AD-Gruppenliste und behaelt den bisherigen Wert dabei bei (siehe
|
||||||
|
// wireAdGroupLoader oben).
|
||||||
|
function seedMappingSelect(selectId, dn, name) {
|
||||||
|
var select = document.getElementById(selectId);
|
||||||
|
select.innerHTML = '';
|
||||||
|
var opt = document.createElement('option');
|
||||||
|
opt.value = dn;
|
||||||
|
opt.textContent = name || dn;
|
||||||
|
opt.dataset.name = name || dn;
|
||||||
|
opt.selected = true;
|
||||||
|
select.appendChild(opt);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditLdapMappingModal(id, dn, name, appGroupId) {
|
||||||
|
document.getElementById('editLdapMappingId').value = id;
|
||||||
|
seedMappingSelect('editLdapMappingAdGroup', dn, name);
|
||||||
|
document.getElementById('editLdapMappingAdGroupName').value = name;
|
||||||
|
document.getElementById('editLdapMappingAppGroup').value = appGroupId;
|
||||||
|
document.getElementById('editLdapMappingLoadStatus').textContent = 'Fragt live per Bind-Konto alle Gruppen aus dem Verzeichnis ab.';
|
||||||
|
PoeUI.openModal('editLdapMappingModal');
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditFileshareMappingModal(id, dn, name, label, unc) {
|
||||||
|
document.getElementById('editFsMappingId').value = id;
|
||||||
|
seedMappingSelect('editFsMappingAdGroup', dn, name);
|
||||||
|
document.getElementById('editFsMappingAdGroupName').value = name;
|
||||||
|
document.getElementById('editFsMappingLabel').value = label;
|
||||||
|
document.getElementById('editFsMappingUnc').value = unc;
|
||||||
|
document.getElementById('editFsMappingLoadStatus').textContent = 'Fragt live per Bind-Konto alle Gruppen aus dem Verzeichnis ab.';
|
||||||
|
PoeUI.openModal('editFileshareMappingModal');
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% set active_page = "settings_nginx" %}
|
||||||
|
{% set can_edit = current_user.has_permission('settings_nginx.edit') %}
|
||||||
|
{% block page_title %}NGINX{% endblock %}
|
||||||
|
{% block page_sub %}<div class="topbar-sub">Reverse-Proxy: Domain, Ports, SSL/HSTS und Zertifikat</div>{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="settings-grid settings-grid--wide">
|
||||||
|
|
||||||
|
<div class="card card-pad">
|
||||||
|
<div class="section-head" style="margin-bottom:16px;">
|
||||||
|
<div>
|
||||||
|
<h2 style="font-size:16px;">Reverse-Proxy</h2>
|
||||||
|
<div class="hint">Domain (server_name), Ports und HTTPS/HSTS für den nginx-Reverse-Proxy vor der App.</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-secondary btn-sm"
|
||||||
|
onclick="openRawLogModal('{{ url_for('settings_nginx_raw') }}', 'Aktuelle nginx-Konfiguration (RAW)')">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><path d="M14 2v6h6"/></svg>
|
||||||
|
RAW-Konfiguration
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if pending_nginx_token %}
|
||||||
|
<div class="card-pad" style="background:var(--warning-dim); border-radius:var(--radius-sm); margin-bottom:16px;">
|
||||||
|
<p style="margin:0 0 12px; font-size:13px;">
|
||||||
|
Neue nginx-Konfiguration wurde angewendet. Wenn diese Seite gerade noch lädt, funktioniert die Verbindung —
|
||||||
|
bitte bestätigen, bevor automatisch zurückgerollt wird (nach {{ nginx_revert_seconds }}s ohne Bestätigung).
|
||||||
|
</p>
|
||||||
|
<form method="post">
|
||||||
|
<input type="hidden" name="confirm_nginx" value="{{ pending_nginx_token }}">
|
||||||
|
<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>
|
||||||
|
Verbindung funktioniert — bestätigen
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% elif can_edit %}
|
||||||
|
<form method="post"
|
||||||
|
data-confirm="nginx-Konfiguration wirklich ändern? Falls die Verbindung danach abbricht, wird die vorherige Konfiguration automatisch nach {{ nginx_revert_seconds }} Sekunden wiederhergestellt.">
|
||||||
|
<div class="field">
|
||||||
|
<label for="server_name">Domain (server_name)</label>
|
||||||
|
<input type="text" name="server_name" id="server_name" value="{{ server_name }}" placeholder="_ (kein bestimmter Hostname) oder z.B. tesm.example.com">
|
||||||
|
<div class="field-hint">"_" ist nginx' Catch-all (Standard für interne Instanzen ohne eigene Domain) — für Let's Encrypt muss hier die tatsächliche, öffentlich auflösbare Domain stehen.</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<div class="field field--half">
|
||||||
|
<label for="http_port">HTTP-Port</label>
|
||||||
|
<input type="number" name="http_port" id="http_port" min="1" max="65535" value="{{ http_port }}" required>
|
||||||
|
</div>
|
||||||
|
<div class="field field--half">
|
||||||
|
<label for="https_port">HTTPS-Port</label>
|
||||||
|
<input type="number" name="https_port" id="https_port" min="1" max="65535" value="{{ https_port }}" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field-hint" style="margin-top:-8px; margin-bottom:14px;">
|
||||||
|
Let's Encrypt validiert IMMER über Port 80 (protokollbedingt, unabhängig vom hier eingestellten HTTP-Port) —
|
||||||
|
weicht der HTTP-Port von 80 ab, wird dafür automatisch zusätzlich ein minimaler Port-80-Block mitgeschrieben.
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="switch-check" style="display:inline-flex; align-items:center; gap:8px;">
|
||||||
|
<input type="checkbox" name="ssl_enabled" id="ssl_enabled_check" {% if ssl_enabled %}checked{% endif %} {% if not cert_info %}disabled{% endif %}>
|
||||||
|
<span class="track"></span>
|
||||||
|
<span>SSL/HTTPS aktivieren</span>
|
||||||
|
</label>
|
||||||
|
{% if not cert_info %}<div class="field-hint">Erst nach Hochladen oder Anfordern eines Zertifikats verfügbar.</div>{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="switch-check" style="display:inline-flex; align-items:center; gap:8px;">
|
||||||
|
<input type="checkbox" name="hsts_enabled" id="hsts_enabled_check" {% if hsts_enabled %}checked{% endif %} {% if not ssl_enabled %}disabled{% endif %}>
|
||||||
|
<span class="track"></span>
|
||||||
|
<span>HSTS (Strict-Transport-Security)</span>
|
||||||
|
</label>
|
||||||
|
<div class="field-hint">Weist Browser an, diese Instanz künftig NUR noch über HTTPS aufzurufen — bleibt auch bei einem späteren Zurückschalten auf HTTP im Browser für 1 Jahr bestehen. Nur aktivieren, wenn das Zertifikat dauerhaft gepflegt wird.</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" name="apply_nginx" value="1" 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="M9 12l2 2 4-4"/><path d="M12 2l8 4v6c0 5-3.5 8.5-8 10-4.5-1.5-8-5-8-10V6z"/></svg>
|
||||||
|
Anwenden
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-faint" style="font-size:12.5px;">Für Änderungen fehlt das Recht „NGINX ändern“.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card card-pad">
|
||||||
|
<div class="section-head" style="margin-bottom:16px;">
|
||||||
|
<div>
|
||||||
|
<h2 style="font-size:16px;">Zertifikat</h2>
|
||||||
|
<div class="hint">Hochladen (PEM) oder automatisch per Let's Encrypt anfordern — mit automatischer Verlängerung über certbots eigenen Timer.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if cert_info %}
|
||||||
|
<div class="detail-list" style="margin-bottom:18px;">
|
||||||
|
<div class="detail-row"><span class="k">Quelle</span><span class="v">{{ "Let's Encrypt" if cert_info.source == "letsencrypt" else "Hochgeladen" }}</span></div>
|
||||||
|
<div class="detail-row"><span class="k">Subject</span><span class="v" style="text-align:right; word-break:break-all;">{{ cert_info.subject }}</span></div>
|
||||||
|
<div class="detail-row"><span class="k">Aussteller</span><span class="v" style="text-align:right; word-break:break-all;">{{ cert_info.issuer }}</span></div>
|
||||||
|
<div class="detail-row">
|
||||||
|
<span class="k">Gültig bis</span>
|
||||||
|
<span class="v">
|
||||||
|
{{ cert_info.not_after.strftime('%d.%m.%Y') }}
|
||||||
|
{% if cert_info.days_left < 0 %}
|
||||||
|
<span class="pill offline" style="margin-left:6px;">abgelaufen</span>
|
||||||
|
{% elif cert_info.days_left <= 30 %}
|
||||||
|
<span class="pill unknown" style="margin-left:6px;">noch {{ cert_info.days_left }} Tage</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="pill online" style="margin-left:6px;">noch {{ cert_info.days_left }} Tage</span>
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% if cert_info.source == "letsencrypt" %}
|
||||||
|
<div class="detail-row">
|
||||||
|
<span class="k">Automatische Verlängerung</span>
|
||||||
|
<span class="v">
|
||||||
|
{% if certbot_timer and certbot_timer.active %}
|
||||||
|
<span class="pill online">aktiv</span>
|
||||||
|
{% if certbot_timer.next_run %}<span class="text-faint" style="font-size:11px; display:block; margin-top:2px;">nächster Lauf: {{ certbot_timer.next_run }}</span>{% endif %}
|
||||||
|
{% elif certbot_timer %}
|
||||||
|
<span class="pill offline">inaktiv</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="pill unknown">unbekannt</span>
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if cert_info.source == "letsencrypt" and can_edit %}
|
||||||
|
<div class="flex gap-2" style="margin-bottom:18px;">
|
||||||
|
<form method="post" style="flex:1;">
|
||||||
|
<button type="submit" name="test_renewal" value="1" class="btn btn-secondary btn-block btn-sm">Verlängerung testen (Dry-Run)</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" style="flex:1;" data-confirm="Zertifikat jetzt sofort erneuern (zählt gegen Let's Encrypts Rate-Limits)?">
|
||||||
|
<button type="submit" name="force_renew" value="1" class="btn btn-secondary btn-block btn-sm">Jetzt manuell verlängern</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<p class="text-faint" style="font-size:12.5px; margin-bottom:18px;">Noch kein Zertifikat hinterlegt.</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if can_edit %}
|
||||||
|
<div class="field">
|
||||||
|
<label style="font-weight:600; font-size:13px;">Let's Encrypt anfordern</label>
|
||||||
|
<div class="field-hint" style="margin-bottom:8px;">Setzt voraus, dass die Domain öffentlich auf diesen Host auflöst und Port 80 aus dem Internet erreichbar ist (HTTP-01-Validierung) — für rein interne Instanzen ohne öffentliche Domain nicht nutzbar, dann stattdessen hochladen. Verlängerung läuft danach automatisch über certbots eigenen Timer{% if certbot_timer %} ({{ "aktiv" if certbot_timer.active else "inaktiv!" }}){% endif %}, zweimal täglich.</div>
|
||||||
|
</div>
|
||||||
|
<form method="post" style="margin-bottom:18px;">
|
||||||
|
<div class="field">
|
||||||
|
<label for="le_domain">Domain</label>
|
||||||
|
<input type="text" name="le_domain" id="le_domain" placeholder="z.B. tesm.example.com" value="{{ server_name if server_name != '_' else '' }}">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="le_email">E-Mail (optional, für Ablauf-Erinnerungen)</label>
|
||||||
|
<input type="email" name="le_email" id="le_email" placeholder="admin@example.com">
|
||||||
|
</div>
|
||||||
|
<button type="submit" name="request_letsencrypt" value="1" class="btn btn-secondary btn-block">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2l8 4v6c0 5-3.5 8.5-8 10-4.5-1.5-8-5-8-10V6z"/><path d="M9 12l2 2 4-4"/></svg>
|
||||||
|
Zertifikat anfordern
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="field"><label style="font-weight:600; font-size:13px;">Manuell hochladen</label></div>
|
||||||
|
<form method="post" enctype="multipart/form-data" style="margin-bottom:10px;">
|
||||||
|
<div class="field">
|
||||||
|
<label for="cert_file">Zertifikat (PEM, .crt/.pem)</label>
|
||||||
|
<input type="file" name="cert_file" id="cert_file" accept=".pem,.crt,.cer" required>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="key_file">Privater Schlüssel (PEM, unverschlüsselt)</label>
|
||||||
|
<input type="file" name="key_file" id="key_file" accept=".pem,.key" required>
|
||||||
|
<div class="field-hint">Ein passwortgeschütztes Schlüssel wird abgelehnt — nginx könnte ihn beim Start ohnehin nicht ohne manuelle Passworteingabe laden.</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" name="upload_cert" value="1" class="btn btn-secondary btn-block">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><path d="M17 8l-5-5-5 5"/><path d="M12 3v12"/></svg>
|
||||||
|
Zertifikat + Schlüssel hochladen
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% if cert_info and not ssl_enabled %}
|
||||||
|
<form method="post" data-confirm="Hinterlegtes Zertifikat wirklich entfernen?" data-no-unsaved-guard>
|
||||||
|
<button type="submit" name="remove_cert" value="1" class="btn btn-danger btn-block">Zertifikat entfernen</button>
|
||||||
|
</form>
|
||||||
|
{% elif cert_info %}
|
||||||
|
<p class="text-faint" style="font-size:11.5px;">SSL muss zuerst deaktiviert werden, bevor das Zertifikat entfernt werden kann.</p>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<p class="text-faint" style="font-size:12.5px;">Für Anfordern/Upload/Entfernen fehlt das Recht „NGINX ändern“.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
const sslCheck = document.getElementById("ssl_enabled_check");
|
||||||
|
const hstsCheck = document.getElementById("hsts_enabled_check");
|
||||||
|
if (sslCheck && hstsCheck) {
|
||||||
|
sslCheck.addEventListener("change", () => {
|
||||||
|
hstsCheck.disabled = !sslCheck.checked;
|
||||||
|
if (!sslCheck.checked) hstsCheck.checked = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -9,16 +9,15 @@
|
|||||||
# automatischem Backup + Migrations-Sicherheitsnetz samt Rückroll-
|
# automatischem Backup + Migrations-Sicherheitsnetz samt Rückroll-
|
||||||
# Möglichkeit -- siehe install.sh für Details).
|
# Möglichkeit -- siehe install.sh für Details).
|
||||||
#
|
#
|
||||||
# Dieses Skript selbst ist nur noch für das Ablösen ÄLTERER, TESM
|
# Dieses Skript ist reiner Bootstrapper: Download + Entpacken + Aufruf von
|
||||||
# vorausgehender Installationsstufen auf diesem Host zuständig:
|
# install.sh. Die frühere Sonderbehandlung zweier TESM vorausgehender
|
||||||
# 1. Das ursprüngliche, reine Bash-Setup aus dem separaten Repo
|
# Installationsstufen (rpi-check.service aus dem ursprünglichen reinen
|
||||||
# https://gitea.int.eertmoed.net/WiS/Aruba-PoE (rpi-check.service +
|
# Bash-Setup, poe_web.service/poe_manager als Flask-Vorgänger von TESM)
|
||||||
# statische ips.list, keine Datenbank/Web-UI).
|
# wurde entfernt -- diese Stufen sind auf allen aktuellen Installationen
|
||||||
# 2. Die spätere Flask-App "PoE Manager" unter /srv/poe_manager
|
# längst migriert, die dazugehörigen Unit-Dateien wurden dort manuell
|
||||||
# (poe_web.service, Vorgänger von TESM in diesem Repo vor der
|
# entfernt. Ein heute noch unmigriertes System wäre ohnehin so alt, dass
|
||||||
# Umbenennung).
|
# ein manueller Blick vor dem Update angebracht ist statt automatischer
|
||||||
# Beide werden NICHT gelöscht, sondern nur gestoppt/deaktiviert und
|
# stillschweigender Bereinigung.
|
||||||
# (falls vorhanden) beiseite verschoben.
|
|
||||||
#
|
#
|
||||||
# Auszuführen als root auf dem Zielsystem:
|
# Auszuführen als root auf dem Zielsystem:
|
||||||
# sudo ./update.sh
|
# sudo ./update.sh
|
||||||
@@ -38,7 +37,6 @@ GITEA_TOKEN="${TESM_GITEA_TOKEN:-}"
|
|||||||
PACKAGE_NAME="tesm-${RELEASE_TAG}.tar.gz"
|
PACKAGE_NAME="tesm-${RELEASE_TAG}.tar.gz"
|
||||||
DOWNLOAD_URL="${GITEA_BASE}/${REPO_OWNER}/${REPO_NAME}/releases/download/${RELEASE_TAG}/${PACKAGE_NAME}"
|
DOWNLOAD_URL="${GITEA_BASE}/${REPO_OWNER}/${REPO_NAME}/releases/download/${RELEASE_TAG}/${PACKAGE_NAME}"
|
||||||
|
|
||||||
TIMESTAMP="$(date +%Y%m%d-%H%M%S)"
|
|
||||||
WORK_DIR="/tmp/tesm-update-$(date +%s)"
|
WORK_DIR="/tmp/tesm-update-$(date +%s)"
|
||||||
PACKAGE_FILE="$WORK_DIR/${PACKAGE_NAME}"
|
PACKAGE_FILE="$WORK_DIR/${PACKAGE_NAME}"
|
||||||
|
|
||||||
@@ -61,10 +59,6 @@ echo "install.sh daraus -- das erkennt selbst, ob es sich um eine frische"
|
|||||||
echo "Installation oder ein Update einer bestehenden handelt, und sichert"
|
echo "Installation oder ein Update einer bestehenden handelt, und sichert"
|
||||||
echo "die Datenbank bei einer Schema-Änderung automatisch vorsorglich ab."
|
echo "die Datenbank bei einer Schema-Änderung automatisch vorsorglich ab."
|
||||||
echo
|
echo
|
||||||
echo "Ältere, TESM vorausgehende Installationsstufen (PoE Manager unter"
|
|
||||||
echo "/srv/poe_manager, das ursprüngliche reine Bash-Setup) werden dabei"
|
|
||||||
echo "erkannt, gestoppt/deaktiviert und beiseite verschoben (nicht gelöscht)."
|
|
||||||
echo
|
|
||||||
|
|
||||||
read -r -p "Fortfahren? [y/N] " confirm
|
read -r -p "Fortfahren? [y/N] " confirm
|
||||||
if [[ "$confirm" != "y" && "$confirm" != "Y" ]]; then
|
if [[ "$confirm" != "y" && "$confirm" != "Y" ]]; then
|
||||||
@@ -86,22 +80,6 @@ mkdir -p "$WORK_DIR/pkg"
|
|||||||
tar xzf "$PACKAGE_FILE" -C "$WORK_DIR/pkg" --strip-components=1
|
tar xzf "$PACKAGE_FILE" -C "$WORK_DIR/pkg" --strip-components=1
|
||||||
echo -e "${GREEN}✔${NC} Paket entpackt."
|
echo -e "${GREEN}✔${NC} Paket entpackt."
|
||||||
|
|
||||||
echo -e "${RED}→${NC} Stoppe/deaktiviere ältere Installationsstufen (falls vorhanden)..."
|
|
||||||
# "|| true" macht nichts kaputt, falls einzelne Units gar nicht vorhanden
|
|
||||||
# sind. tesm.service selbst wird NICHT hier, sondern von install.sh
|
|
||||||
# gestoppt -- das entscheidet dort auch gleich, ob ein Backup nötig ist.
|
|
||||||
systemctl stop poe_web.service 2>/dev/null || true
|
|
||||||
systemctl disable poe_web.service 2>/dev/null || true
|
|
||||||
systemctl stop rpi-check.service rpi-check-restart.service rpi-check-restart.timer 2>/dev/null || true
|
|
||||||
systemctl disable rpi-check.service rpi-check-restart.service rpi-check-restart.timer 2>/dev/null || true
|
|
||||||
if [ -d /srv/poe_manager ]; then
|
|
||||||
dest="/srv/poe_manager-backup-${TIMESTAMP}"
|
|
||||||
echo -e "${RED}→${NC} Sichere /srv/poe_manager nach ${dest}..."
|
|
||||||
mv /srv/poe_manager "$dest"
|
|
||||||
echo -e "${GREEN}✔${NC} Gesichert (nicht gelöscht)."
|
|
||||||
fi
|
|
||||||
echo -e "${GREEN}✔${NC} Ältere Installationsstufen bereinigt."
|
|
||||||
|
|
||||||
echo -e "${RED}→${NC} Starte install.sh aus dem Paket..."
|
echo -e "${RED}→${NC} Starte install.sh aus dem Paket..."
|
||||||
bash "$WORK_DIR/pkg/install.sh"
|
bash "$WORK_DIR/pkg/install.sh"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user