diff --git a/Website/Dockerfile b/Website/Dockerfile index 859792c..8da6992 100644 --- a/Website/Dockerfile +++ b/Website/Dockerfile @@ -40,4 +40,4 @@ RUN chmod +x /app/provision_instance.sh EXPOSE 4999 -CMD ["python", "main.py"] +CMD ["gunicorn", "-c", "gunicorn.conf.py", "main:app"] diff --git a/Website/docker-compose.yml b/Website/docker-compose.yml index ae6cca3..777a169 100644 --- a/Website/docker-compose.yml +++ b/Website/docker-compose.yml @@ -2,6 +2,8 @@ services: mongodb: image: mongo:7 restart: unless-stopped + mem_limit: 512m + mem_reservation: 256m environment: MONGO_INITDB_DATABASE: Invario_Website volumes: @@ -23,6 +25,12 @@ services: environment: MONGO_URI: mongodb://mongodb:27017 MONGO_DB_NAME: Invario_Website + MONGO_MAX_POOL_SIZE: ${MONGO_MAX_POOL_SIZE:-24} + MONGO_MIN_POOL_SIZE: ${MONGO_MIN_POOL_SIZE:-0} + MONGO_MAX_IDLE_MS: ${MONGO_MAX_IDLE_MS:-60000} + MONGO_CONNECT_TIMEOUT_MS: ${MONGO_CONNECT_TIMEOUT_MS:-1500} + MONGO_SOCKET_TIMEOUT_MS: ${MONGO_SOCKET_TIMEOUT_MS:-30000} + MONGO_WAIT_QUEUE_TIMEOUT_MS: ${MONGO_WAIT_QUEUE_TIMEOUT_MS:-2000} SESSION_COOKIE_SECURE: ${SESSION_COOKIE_SECURE:-0} JWT_SECRET_KEY: change-this-in-production INSTANCE_PROVISION_SCRIPT: /app/provision_instance.sh @@ -40,6 +48,8 @@ services: - /etc/nginx/certs:/etc/nginx/certs ports: - "4999:4999" + mem_limit: 384m + mem_reservation: 192m volumes: mongo_data: diff --git a/Website/gunicorn.conf.py b/Website/gunicorn.conf.py new file mode 100644 index 0000000..5bf5028 --- /dev/null +++ b/Website/gunicorn.conf.py @@ -0,0 +1,12 @@ +bind = "0.0.0.0:4999" +worker_class = "gthread" +workers = 1 +threads = 8 +timeout = 45 +keepalive = 2 +max_requests = 400 +max_requests_jitter = 60 +preload_app = False +accesslog = "-" +errorlog = "-" +loglevel = "warning" diff --git a/Website/main.py b/Website/main.py index 7786cfc..2ca88f6 100644 --- a/Website/main.py +++ b/Website/main.py @@ -1,12 +1,16 @@ from flask_jwt_extended import JWTManager, create_access_token, jwt_required -from flask import Flask, render_template, request, jsonify, flash, redirect, url_for, get_flashed_messages, session, send_file +from flask import Flask, render_template, request, jsonify, flash, redirect, url_for, get_flashed_messages, session, send_file, after_this_request import os import json +import atexit import calendar import re import subprocess import hashlib import shutil +import tarfile +import tempfile +import threading from datetime import timedelta, datetime, date from functools import wraps from io import BytesIO @@ -66,6 +70,33 @@ INSTANCE_PROVISION_SCRIPT = os.environ.get( ) +def _env_int(name: str, default: int) -> int: + value = os.environ.get(name) + if value is None: + return default + try: + return int(value) + except (TypeError, ValueError): + return default + + +MONGO_MAX_POOL_SIZE = max(_env_int("MONGO_MAX_POOL_SIZE", 24), 1) +MONGO_MIN_POOL_SIZE = max(_env_int("MONGO_MIN_POOL_SIZE", 0), 0) +MONGO_MAX_IDLE_MS = max(_env_int("MONGO_MAX_IDLE_MS", 60000), 1000) +MONGO_CONNECT_TIMEOUT_MS = max(_env_int("MONGO_CONNECT_TIMEOUT_MS", 1500), 500) +MONGO_SOCKET_TIMEOUT_MS = max(_env_int("MONGO_SOCKET_TIMEOUT_MS", 30000), 1000) +MONGO_WAIT_QUEUE_TIMEOUT_MS = max(_env_int("MONGO_WAIT_QUEUE_TIMEOUT_MS", 2000), 500) + +_MONGO_CLIENT: MongoClient | None = None +_MONGO_LOCK = threading.Lock() + + +class _NoopMongoClientHandle: + def close(self): + # Backward-compatible no-op so existing finally blocks stay harmless. + return None + + def _issue_access_token() -> str: return create_access_token(identity="license-validation-client") @@ -115,12 +146,37 @@ def _save_team_photo(file_obj, identifier: str) -> str | None: def _get_mongo_client() -> MongoClient: - return MongoClient(MONGO_URI, serverSelectionTimeoutMS=1200) + global _MONGO_CLIENT + if _MONGO_CLIENT is not None: + return _MONGO_CLIENT + + with _MONGO_LOCK: + if _MONGO_CLIENT is None: + _MONGO_CLIENT = MongoClient( + MONGO_URI, + serverSelectionTimeoutMS=MONGO_CONNECT_TIMEOUT_MS, + connectTimeoutMS=MONGO_CONNECT_TIMEOUT_MS, + socketTimeoutMS=MONGO_SOCKET_TIMEOUT_MS, + maxPoolSize=MONGO_MAX_POOL_SIZE, + minPoolSize=MONGO_MIN_POOL_SIZE, + maxIdleTimeMS=MONGO_MAX_IDLE_MS, + waitQueueTimeoutMS=MONGO_WAIT_QUEUE_TIMEOUT_MS, + ) + return _MONGO_CLIENT + + +@atexit.register +def _shutdown_mongo_client() -> None: + global _MONGO_CLIENT + client = _MONGO_CLIENT + _MONGO_CLIENT = None + if client is not None: + client.close() def _get_mongo_db(): client = _get_mongo_client() - return client, client[MONGO_DB_NAME] + return _NoopMongoClientHandle(), client[MONGO_DB_NAME] def _normalize_user_doc(doc: dict | None) -> dict | None: @@ -484,6 +540,108 @@ def _collect_runtime_stats(instances: list[dict]) -> dict: } +def _read_uptime_load() -> dict: + uptime_seconds = 0.0 + load_1 = 0.0 + load_5 = 0.0 + load_15 = 0.0 + + try: + with open("/proc/uptime", "r", encoding="utf-8") as handle: + uptime_seconds = float((handle.read().strip().split() or ["0"])[0]) + except Exception: + uptime_seconds = 0.0 + + try: + with open("/proc/loadavg", "r", encoding="utf-8") as handle: + parts = handle.read().strip().split() + load_1 = float(parts[0]) + load_5 = float(parts[1]) + load_15 = float(parts[2]) + except Exception: + pass + + return { + "uptime_seconds": round(uptime_seconds, 1), + "load_1": round(load_1, 2), + "load_5": round(load_5, 2), + "load_15": round(load_15, 2), + } + + +def _read_disk_usage(path: str) -> dict: + try: + usage = shutil.disk_usage(path) + except Exception: + return {"path": path, "total_gib": 0.0, "used_gib": 0.0, "free_gib": 0.0, "used_pct": 0.0} + + total_gib = usage.total / (1024.0 ** 3) + used_gib = usage.used / (1024.0 ** 3) + free_gib = usage.free / (1024.0 ** 3) + used_pct = (used_gib / total_gib * 100.0) if total_gib > 0 else 0.0 + return { + "path": path, + "total_gib": round(total_gib, 2), + "used_gib": round(used_gib, 2), + "free_gib": round(free_gib, 2), + "used_pct": round(used_pct, 2), + } + + +def _collect_ops_counts() -> dict: + appointments_pending = 0 + tickets_open = 0 + users_total = 0 + + client = None + try: + client, col = _get_collection("appointments") + appointments_pending = col.count_documents({"status": "Angefragt"}) + except PyMongoError: + appointments_pending = 0 + finally: + if client: + client.close() + + client = None + try: + client, col = _get_collection("support_tickets") + tickets_open = col.count_documents({"status": {"$in": ["Offen", "In Bearbeitung"]}}) + except PyMongoError: + tickets_open = 0 + finally: + if client: + client.close() + + try: + users_total = len(_list_users_for_admin()) + except Exception: + users_total = 0 + + return { + "appointments_pending": appointments_pending, + "tickets_open": tickets_open, + "users_total": users_total, + } + + +def _build_server_management_snapshot(instances: list[dict]) -> dict: + runtime = _collect_runtime_stats(instances) + root_disk = _read_disk_usage("/") + instance_disk = _read_disk_usage(INSTANCE_BASE_DIR) + ops_counts = _collect_ops_counts() + uptime_load = _read_uptime_load() + + return { + "generated_at": _utc_now_iso(), + "runtime": runtime, + "root_disk": root_disk, + "instance_disk": instance_disk, + "ops": ops_counts, + "system": uptime_load, + } + + def _upsert_school_instance(data: dict) -> None: subdomain = _sanitize_text(data.get("subdomain") or "", 63) if not subdomain: @@ -677,6 +835,40 @@ def _collect_instance_logs(subdomain: str) -> tuple[bool, str]: return _run_command(command, cwd=instance_dir, timeout=180) +def _build_instance_backup_archive(subdomain: str) -> tuple[bool, str, str | None]: + instance_dir = _resolve_instance_dir(subdomain) + if not instance_dir: + return False, "Instanzverzeichnis nicht gefunden.", None + + safe_name = _slugify_subdomain(subdomain) + stamp = datetime.utcnow().strftime("%Y%m%d-%H%M%S") + archive_base = os.path.join(tempfile.gettempdir(), f"instance-backup-{safe_name}-{stamp}") + + try: + archive_path = shutil.make_archive(archive_base, "gztar", root_dir=instance_dir) + except Exception as exc: + return False, f"Backup-Archiv konnte nicht erstellt werden: {exc}", None + + filename = f"instance-{safe_name}-backup-{stamp}.tar.gz" + return True, filename, archive_path + + +def _safe_extract_tar_archive(archive_path: str, target_dir: str) -> tuple[bool, str]: + target_abs = os.path.abspath(target_dir) + + try: + with tarfile.open(archive_path, "r:gz") as tf: + for member in tf.getmembers(): + member_path = os.path.abspath(os.path.join(target_abs, member.name)) + if not member_path.startswith(target_abs + os.sep) and member_path != target_abs: + return False, "Unsicherer Pfad im Backup-Archiv erkannt." + tf.extractall(path=target_abs) + except Exception as exc: + return False, f"Backup konnte nicht entpackt werden: {exc}" + + return True, "Backup erfolgreich entpackt." + + def _set_instance_library_enabled(instance_dir: str, enabled: bool) -> tuple[bool, str]: config_path = os.path.join(instance_dir, "config.json") if not os.path.isfile(config_path): @@ -1794,7 +1986,15 @@ def admin_system_tools(): return redirect(url_for("admin_system_tools")) instances = _list_school_instances() - return render_template("admin_system.html", instances=instances) + system_snapshot = _build_server_management_snapshot(instances) + return render_template("admin_system.html", instances=instances, system_snapshot=system_snapshot) + + +@app.route('/admin/system/stats') +@admin_required +def admin_system_stats(): + instances = _list_school_instances() + return jsonify(_build_server_management_snapshot(instances)) @app.route('/admin/system/logs/core') @@ -1832,6 +2032,75 @@ def admin_download_instance_logs(subdomain): ) +@app.route('/admin/system/backup/export/') +@admin_required +def admin_export_instance_backup(subdomain): + ok, filename, archive_path = _build_instance_backup_archive(subdomain) + if not ok or not archive_path: + flash(filename or "Backup-Export fehlgeschlagen.", "error") + return redirect(url_for("admin_system_tools")) + + @after_this_request + def _cleanup_archive(response): + try: + os.remove(archive_path) + except OSError: + pass + return response + + return send_file( + archive_path, + mimetype="application/gzip", + as_attachment=True, + download_name=filename, + ) + + +@app.route('/admin/system/backup/import/', methods=['POST']) +@admin_required +def admin_import_instance_backup(subdomain): + instance_dir = _resolve_instance_dir(subdomain) + if not instance_dir: + flash("Instanzverzeichnis nicht gefunden.", "error") + return redirect(url_for("admin_system_tools")) + + upload = request.files.get("backup_file") + if not upload or not upload.filename: + flash("Bitte eine Backup-Datei auswählen.", "error") + return redirect(url_for("admin_system_tools")) + + filename = secure_filename(upload.filename) + if not (filename.endswith(".tar.gz") or filename.endswith(".tgz")): + flash("Nur .tar.gz oder .tgz Backups sind erlaubt.", "error") + return redirect(url_for("admin_system_tools")) + + fd, temp_path = tempfile.mkstemp(prefix="instance-restore-", suffix=".tar.gz") + os.close(fd) + try: + upload.save(temp_path) + ok, message = _safe_extract_tar_archive(temp_path, instance_dir) + if not ok: + flash(message, "error") + return redirect(url_for("admin_system_tools")) + + restart_ok, restart_output = _restart_instance_stack(instance_dir) + if restart_ok: + flash(f"Backup für {subdomain} erfolgreich eingespielt und Instanz neu gestartet.", "success") + if restart_output: + flash(_tail_output(restart_output, 10), "info") + else: + flash(f"Backup eingespielt, aber Neustart fehlgeschlagen:\n{_tail_output(restart_output, 14)}", "error") + except Exception as exc: + flash(f"Backup konnte nicht eingespielt werden: {exc}", "error") + finally: + try: + os.remove(temp_path) + except OSError: + pass + + return redirect(url_for("admin_system_tools")) + + @app.route('/admin/appointments/block-day', methods=['POST']) @admin_required def admin_block_day(): diff --git a/Website/requirements.txt b/Website/requirements.txt index c131048..a3ab843 100644 --- a/Website/requirements.txt +++ b/Website/requirements.txt @@ -2,3 +2,4 @@ Flask>=3.0,<4.0 Flask-JWT-Extended>=4.6,<5.0 bleach>=6.1,<7.0 pymongo>=4.8,<5.0 +gunicorn>=22.0,<23.0 diff --git a/Website/templates/admin_instances.html b/Website/templates/admin_instances.html index de6bfe4..8d42f84 100644 --- a/Website/templates/admin_instances.html +++ b/Website/templates/admin_instances.html @@ -54,55 +54,6 @@ -
-
-

Live Monitor (semi-live)

-

Aktualisierung alle 15 Sekunden: RAM/CPU pro Instanz und Host-Auslastung.

-
-
-
-

Host RAM belegt

- - -
-
-

Docker RAM (Invario)

- - -
-
-

Container (Invario)

- - -
-
-

Docker RAM (alle)

- - -
-
-

Container (alle)

- - -
-
-

Letztes Update

- - -
-
-
- - - - - - - - - - - - - -
InstanzStatusContainerRAM (MiB)CPU (%)
Lade Live-Daten...
-
-
-
@@ -294,54 +245,6 @@ margin-bottom: 1.2rem; } -.live-monitor { - border: 1px solid #d8e1e8; - background: #ffffff; - border-radius: 14px; - padding: 1rem; - margin-bottom: 1.2rem; -} - -.live-head h2 { - margin: 0; -} - -.live-head p { - margin: 0.25rem 0 0.75rem; - color: #4f6573; -} - -.live-kpis { - display: grid; - grid-template-columns: repeat(6, minmax(0, 1fr)); - gap: 0.6rem; - margin-bottom: 0.8rem; -} - -.live-kpi { - border: 1px solid #d8e1e8; - border-radius: 12px; - background: #f7fafc; - padding: 0.65rem; -} - -.live-kpi p { - margin: 0; - font-size: 0.82rem; - color: #4b6170; -} - -.live-kpi strong { - display: block; - margin-top: 0.3rem; - font-size: 1.08rem; - color: #11364a; -} - -.live-table-wrap { - overflow-x: auto; -} - .instance-form, .instance-meta, .instance-table-wrap { @@ -503,10 +406,6 @@ td { .instance-form-wrap { grid-template-columns: 1fr; } - - .live-kpis { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } } @@ -601,69 +500,6 @@ td { } }); - const mib = (value) => { - const num = Number(value || 0); - return `${num.toFixed(1)} MiB`; - }; - - const percent = (value) => { - const num = Number(value || 0); - return `${num.toFixed(1)} %`; - }; - - const updateLive = async () => { - try { - const response = await fetch('/admin/instances/stats', { cache: 'no-store' }); - if (!response.ok) { - throw new Error(`HTTP ${response.status}`); - } - - const payload = await response.json(); - const host = payload.host || {}; - const docker = payload.docker || {}; - const instances = payload.instances || []; - - const hostRam = `${mib(host.used_mem_mib)} / ${mib(host.total_mem_mib)} (${percent(host.used_mem_pct)})`; - document.getElementById('liveHostRam').textContent = hostRam; - document.getElementById('liveDockerRam').textContent = mib(docker.managed_used_mem_mib); - document.getElementById('liveContainerCount').textContent = String(docker.managed_container_count || 0); - document.getElementById('liveDockerRamAll').textContent = mib(docker.all_used_mem_mib); - document.getElementById('liveContainerCountAll').textContent = String(docker.all_container_count || 0); - document.getElementById('liveUpdatedAt').textContent = payload.generated_at || '-'; - - const body = document.getElementById('liveInstanceBody'); - if (!body) { - return; - } - - if (!instances.length) { - body.innerHTML = 'Keine Instanzdaten vorhanden.'; - return; - } - - body.innerHTML = instances.map((item) => { - const status = `${item.status || '-'} / ${item.nginx_status || '-'}`; - return ` - - ${item.domain || item.subdomain || '-'} - ${status} - ${item.container_count || 0} - ${mib(item.mem_mib)} - ${percent(item.cpu_percent)} - - `; - }).join(''); - } catch (error) { - const body = document.getElementById('liveInstanceBody'); - if (body) { - body.innerHTML = 'Live-Daten konnten nicht geladen werden.'; - } - } - }; - - updateLive(); - window.setInterval(updateLive, 15000); - const ownerSelect = document.getElementById('owner_username'); const schoolInput = document.getElementById('school_name'); if (!ownerSelect || !schoolInput) { diff --git a/Website/templates/admin_system.html b/Website/templates/admin_system.html index d8a917f..6105e0d 100644 --- a/Website/templates/admin_system.html +++ b/Website/templates/admin_system.html @@ -1,14 +1,120 @@ {% extends "base.html" %} -{% block title %}Admin | System-Tools{% endblock %} +{% block title %}Admin | Server-Verwaltung{% endblock %} {% block content %}
-

System-Tools

-

Nützliche Admin-Funktionen für Betrieb, Backup, Update, Restart und Log-Download.

+

Server-Verwaltung

+

Zentrale Betriebsübersicht und Aktionen rund um Website, Instanzen, Ressourcen und Wartung.

-
+
+
+

Instanzen (Laufend)

+

{{ (system_snapshot.runtime.instances | selectattr('status', 'equalto', 'Läuft') | list | length) }}

+
+
+

Offene Tickets

+

{{ system_snapshot.ops.tickets_open }}

+
+
+

Angefragte Termine

+

{{ system_snapshot.ops.appointments_pending }}

+
+
+

Nutzer gesamt

+

{{ system_snapshot.ops.users_total }}

+
+
+

Host RAM

+

{{ system_snapshot.runtime.host.used_mem_mib }} MiB

+
+
+

Docker RAM (Invario)

+

{{ system_snapshot.runtime.docker.managed_used_mem_mib }} MiB

+
+
+ +
+
+

Systemstatus

+
+
Uptime
{{ system_snapshot.system.uptime_seconds }} s
+
Load 1/5/15
{{ system_snapshot.system.load_1 }} / {{ system_snapshot.system.load_5 }} / {{ system_snapshot.system.load_15 }}
+
Letztes Update
{{ system_snapshot.generated_at }}
+
+
+ +
+

Speicher (Root)

+
+
Belegt
{{ system_snapshot.root_disk.used_gib }} GiB
+
Frei
{{ system_snapshot.root_disk.free_gib }} GiB
+
Auslastung
{{ system_snapshot.root_disk.used_pct }} %
+
+
+ +
+

Instanz-Speicher

+
+
Pfad
{{ system_snapshot.instance_disk.path }}
+
Belegt
{{ system_snapshot.instance_disk.used_gib }} GiB
+
Frei
{{ system_snapshot.instance_disk.free_gib }} GiB
+
Auslastung
{{ system_snapshot.instance_disk.used_pct }} %
+
+
+
+ +
+
+

Usage-Diagramme

+
+
+ Host RAM + 0.0 % +
+
+
+
+
+ Host CPU + 0.0 % +
+
+
+
+
+ Root-Disk + 0.0 % +
+
+
+
+
+ Instanz-Disk + 0.0 % +
+
+
+
+
+ +
+
+

Verlauf: RAM

+ +
+
+

Verlauf: CPU / Load

+ +
+
+

Verlauf: Docker-Container

+ +
+
+ +

Core-Services

@@ -17,9 +123,18 @@ Core-Logs herunterladen
+ +

Backup-Export / Einspielen

+
+ Lizenz-Backup exportieren +
+ + +
+
-
+

Instanz-Operationen

{% if instances %} @@ -28,15 +143,17 @@ + - + {% for item in instances %} - + +
Schule Subdomain StatusLive Aktionen
{{ item.school_name }} {{ item.subdomain }} {{ item.status }} / {{ item.nginx_status }}RAM: - | CPU: -
@@ -44,6 +161,7 @@
+ Backup exportieren
@@ -56,6 +174,10 @@
Logs
+
+ + +
@@ -76,15 +198,162 @@ + + + {% endblock %}