Refactor Docker setup and enhance MongoDB connection handling

- Updated Dockerfile to use Gunicorn for serving the Flask application instead of the default Python command.
- Modified docker-compose.yml to set memory limits and reservations for MongoDB and the web service.
- Enhanced main.py with improved MongoDB connection management using a thread-safe singleton pattern and added environment variable configurations for connection pooling.
- Introduced new utility functions for system monitoring, including uptime, load averages, and disk usage.
- Added backup and restore functionality for instances, including routes for exporting and importing backups.
- Removed the live monitor section from admin_instances.html and updated admin_system.html to include a comprehensive server management dashboard with KPIs and usage charts.
- Added a new gunicorn configuration file for better performance tuning.
- Updated requirements.txt to include Gunicorn as a dependency.
This commit is contained in:
2026-04-14 23:04:51 +02:00
parent 57e200b877
commit 83abe0aab9
7 changed files with 869 additions and 188 deletions
+1 -1
View File
@@ -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"]
+10
View File
@@ -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:
+12
View File
@@ -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"
+273 -4
View File
@@ -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/<subdomain>')
@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/<subdomain>', 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():
+1
View File
@@ -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
-164
View File
@@ -54,55 +54,6 @@
</article>
</section>
<section class="live-monitor">
<div class="live-head">
<h2>Live Monitor (semi-live)</h2>
<p>Aktualisierung alle 15 Sekunden: RAM/CPU pro Instanz und Host-Auslastung.</p>
</div>
<div class="live-kpis">
<article class="live-kpi">
<p>Host RAM belegt</p>
<strong id="liveHostRam">-</strong>
</article>
<article class="live-kpi">
<p>Docker RAM (Invario)</p>
<strong id="liveDockerRam">-</strong>
</article>
<article class="live-kpi">
<p>Container (Invario)</p>
<strong id="liveContainerCount">-</strong>
</article>
<article class="live-kpi">
<p>Docker RAM (alle)</p>
<strong id="liveDockerRamAll">-</strong>
</article>
<article class="live-kpi">
<p>Container (alle)</p>
<strong id="liveContainerCountAll">-</strong>
</article>
<article class="live-kpi">
<p>Letztes Update</p>
<strong id="liveUpdatedAt">-</strong>
</article>
</div>
<div class="live-table-wrap">
<table>
<thead>
<tr>
<th>Instanz</th>
<th>Status</th>
<th>Container</th>
<th>RAM (MiB)</th>
<th>CPU (%)</th>
</tr>
</thead>
<tbody id="liveInstanceBody">
<tr><td colspan="5">Lade Live-Daten...</td></tr>
</tbody>
</table>
</div>
</section>
<section class="instance-form-wrap">
<form method="post" class="instance-form">
<input type="hidden" name="action" value="create">
@@ -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));
}
}
</style>
@@ -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 = '<tr><td colspan="5">Keine Instanzdaten vorhanden.</td></tr>';
return;
}
body.innerHTML = instances.map((item) => {
const status = `${item.status || '-'} / ${item.nginx_status || '-'}`;
return `
<tr>
<td>${item.domain || item.subdomain || '-'}</td>
<td>${status}</td>
<td>${item.container_count || 0}</td>
<td>${mib(item.mem_mib)}</td>
<td>${percent(item.cpu_percent)}</td>
</tr>
`;
}).join('');
} catch (error) {
const body = document.getElementById('liveInstanceBody');
if (body) {
body.innerHTML = '<tr><td colspan="5">Live-Daten konnten nicht geladen werden.</td></tr>';
}
}
};
updateLive();
window.setInterval(updateLive, 15000);
const ownerSelect = document.getElementById('owner_username');
const schoolInput = document.getElementById('school_name');
if (!ownerSelect || !schoolInput) {
+572 -19
View File
@@ -1,14 +1,120 @@
{% extends "base.html" %}
{% block title %}Admin | System-Tools{% endblock %}
{% block title %}Admin | Server-Verwaltung{% endblock %}
{% block content %}
<section class="head">
<h1>System-Tools</h1>
<p>Nützliche Admin-Funktionen für Betrieb, Backup, Update, Restart und Log-Download.</p>
<h1>Server-Verwaltung</h1>
<p>Zentrale Betriebsübersicht und Aktionen rund um Website, Instanzen, Ressourcen und Wartung.</p>
</section>
<section class="core-tools">
<section class="kpi-grid">
<article class="kpi-card">
<p class="kpi-label">Instanzen (Laufend)</p>
<p class="kpi-value" id="kpiInstancesRunning">{{ (system_snapshot.runtime.instances | selectattr('status', 'equalto', 'Läuft') | list | length) }}</p>
</article>
<article class="kpi-card">
<p class="kpi-label">Offene Tickets</p>
<p class="kpi-value" id="kpiTicketsOpen">{{ system_snapshot.ops.tickets_open }}</p>
</article>
<article class="kpi-card">
<p class="kpi-label">Angefragte Termine</p>
<p class="kpi-value" id="kpiAppointmentsPending">{{ system_snapshot.ops.appointments_pending }}</p>
</article>
<article class="kpi-card">
<p class="kpi-label">Nutzer gesamt</p>
<p class="kpi-value" id="kpiUsersTotal">{{ system_snapshot.ops.users_total }}</p>
</article>
<article class="kpi-card">
<p class="kpi-label">Host RAM</p>
<p class="kpi-value" id="kpiHostRam">{{ system_snapshot.runtime.host.used_mem_mib }} MiB</p>
</article>
<article class="kpi-card">
<p class="kpi-label">Docker RAM (Invario)</p>
<p class="kpi-value" id="kpiDockerManaged">{{ system_snapshot.runtime.docker.managed_used_mem_mib }} MiB</p>
</article>
</section>
<section class="overview-grid">
<article class="panel">
<h2>Systemstatus</h2>
<dl class="kv-grid">
<dt>Uptime</dt><dd id="sysUptime">{{ system_snapshot.system.uptime_seconds }} s</dd>
<dt>Load 1/5/15</dt><dd id="sysLoad">{{ system_snapshot.system.load_1 }} / {{ system_snapshot.system.load_5 }} / {{ system_snapshot.system.load_15 }}</dd>
<dt>Letztes Update</dt><dd id="sysUpdatedAt">{{ system_snapshot.generated_at }}</dd>
</dl>
</article>
<article class="panel">
<h2>Speicher (Root)</h2>
<dl class="kv-grid">
<dt>Belegt</dt><dd id="rootUsed">{{ system_snapshot.root_disk.used_gib }} GiB</dd>
<dt>Frei</dt><dd id="rootFree">{{ system_snapshot.root_disk.free_gib }} GiB</dd>
<dt>Auslastung</dt><dd id="rootPct">{{ system_snapshot.root_disk.used_pct }} %</dd>
</dl>
</article>
<article class="panel">
<h2>Instanz-Speicher</h2>
<dl class="kv-grid">
<dt>Pfad</dt><dd>{{ system_snapshot.instance_disk.path }}</dd>
<dt>Belegt</dt><dd id="instUsed">{{ system_snapshot.instance_disk.used_gib }} GiB</dd>
<dt>Frei</dt><dd id="instFree">{{ system_snapshot.instance_disk.free_gib }} GiB</dd>
<dt>Auslastung</dt><dd id="instPct">{{ system_snapshot.instance_disk.used_pct }} %</dd>
</dl>
</article>
</section>
<section class="usage-grid">
<article class="panel usage-panel">
<h2>Usage-Diagramme</h2>
<div class="usage-row">
<div class="usage-top">
<span>Host RAM</span>
<strong id="usageHostRamLabel">0.0 %</strong>
</div>
<div class="usage-bar"><span id="usageHostRamFill"></span></div>
</div>
<div class="usage-row">
<div class="usage-top">
<span>Host CPU</span>
<strong id="usageHostCpuLabel">0.0 %</strong>
</div>
<div class="usage-bar"><span id="usageHostCpuFill"></span></div>
</div>
<div class="usage-row">
<div class="usage-top">
<span>Root-Disk</span>
<strong id="usageRootDiskLabel">0.0 %</strong>
</div>
<div class="usage-bar"><span id="usageRootDiskFill"></span></div>
</div>
<div class="usage-row">
<div class="usage-top">
<span>Instanz-Disk</span>
<strong id="usageInstDiskLabel">0.0 %</strong>
</div>
<div class="usage-bar"><span id="usageInstDiskFill"></span></div>
</div>
</article>
</section>
<section class="charts-grid">
<article class="panel chart-panel">
<h2>Verlauf: RAM</h2>
<canvas id="ramHistoryChart" height="140"></canvas>
</article>
<article class="panel chart-panel">
<h2>Verlauf: CPU / Load</h2>
<canvas id="cpuLoadHistoryChart" height="140"></canvas>
</article>
<article class="panel chart-panel">
<h2>Verlauf: Docker-Container</h2>
<canvas id="containerHistoryChart" height="140"></canvas>
</article>
</section>
<section class="core-tools panel">
<h2>Core-Services</h2>
<div class="actions">
<form method="post">
@@ -17,9 +123,18 @@
</form>
<a class="action-link" href="{{ url_for('admin_download_core_logs') }}">Core-Logs herunterladen</a>
</div>
<h2 style="margin-top: 1rem;">Backup-Export / Einspielen</h2>
<div class="actions">
<a class="action-link" href="{{ url_for('download_backup') }}">Lizenz-Backup exportieren</a>
<form method="post" action="{{ url_for('upload_backup') }}" enctype="multipart/form-data" class="inline-restore-form">
<input type="file" name="file" accept=".json,application/json" required>
<button type="submit">Lizenz-Backup einspielen</button>
</form>
</div>
</section>
<section class="instance-tools">
<section class="instance-tools panel">
<h2>Instanz-Operationen</h2>
{% if instances %}
<table>
@@ -28,15 +143,17 @@
<th>Schule</th>
<th>Subdomain</th>
<th>Status</th>
<th>Live</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
<tbody id="instanceMgmtBody">
{% for item in instances %}
<tr>
<tr data-subdomain="{{ item.subdomain }}">
<td>{{ item.school_name }}</td>
<td>{{ item.subdomain }}</td>
<td>{{ item.status }} / {{ item.nginx_status }}</td>
<td class="live-cell">RAM: - | CPU: -</td>
<td>
<div class="actions row-actions">
<form method="post">
@@ -44,6 +161,7 @@
<input type="hidden" name="subdomain" value="{{ item.subdomain }}">
<button type="submit">Backup</button>
</form>
<a class="action-link" href="{{ url_for('admin_export_instance_backup', subdomain=item.subdomain) }}">Backup exportieren</a>
<form method="post">
<input type="hidden" name="action" value="update_instance">
<input type="hidden" name="subdomain" value="{{ item.subdomain }}">
@@ -56,6 +174,10 @@
</form>
<a class="action-link" href="{{ url_for('admin_download_instance_logs', subdomain=item.subdomain) }}">Logs</a>
</div>
<form method="post" action="{{ url_for('admin_import_instance_backup', subdomain=item.subdomain) }}" enctype="multipart/form-data" class="inline-restore-form">
<input type="file" name="backup_file" accept=".tar.gz,.tgz,application/gzip" required>
<button type="submit">Backup einspielen</button>
</form>
<form method="post" class="inline-admin-form">
<input type="hidden" name="action" value="create_instance_admin">
<input type="hidden" name="subdomain" value="{{ item.subdomain }}">
@@ -76,15 +198,162 @@
</section>
<style>
.head { margin-bottom: 1rem; }
.core-tools,
.instance-tools {
border: 1px solid #d8e1e8;
background: #ffffff;
:root {
--term-bg: #0b1114;
--term-bg-soft: #0f181d;
--term-border: #1f323a;
--term-text: #d6f7d8;
--term-muted: #8db49f;
--term-accent: #5af78e;
--term-accent-soft: #2fd06f;
--term-warn: #f1c75b;
}
.head {
margin-bottom: 1rem;
background: radial-gradient(circle at top left, #12311f 0%, #0b1114 58%);
border: 1px solid var(--term-border);
border-radius: 12px;
padding: 1rem;
}
.head h1,
.head p,
.panel h2,
.kpi-value,
.kpi-label,
.kv-grid dt,
.kv-grid dd,
th,
td,
button,
.action-link,
.inline-admin-form input {
font-family: "JetBrains Mono", "Fira Code", "Consolas", monospace;
}
.head h1 {
margin: 0;
color: var(--term-accent);
font-size: 1.34rem;
}
.head p {
margin: 0.45rem 0 0;
color: var(--term-muted);
}
.kpi-grid {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 0.65rem;
margin-bottom: 1rem;
}
.kpi-card {
border: 1px solid var(--term-border);
background: linear-gradient(160deg, var(--term-bg) 0%, var(--term-bg-soft) 100%);
border-radius: 12px;
padding: 0.75rem;
box-shadow: inset 0 0 0 1px rgba(90, 247, 142, 0.04);
}
.kpi-label {
margin: 0;
color: var(--term-muted);
font-size: 0.82rem;
font-weight: 600;
}
.kpi-value {
margin: 0.28rem 0 0;
font-size: 1.35rem;
color: var(--term-accent);
font-weight: 800;
}
.overview-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.8rem;
margin-bottom: 1rem;
}
.charts-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.8rem;
margin-bottom: 1rem;
}
.usage-grid {
display: grid;
grid-template-columns: 1fr;
gap: 0.8rem;
margin-bottom: 1rem;
}
.panel {
border: 1px solid var(--term-border);
background: linear-gradient(180deg, #0f171b 0%, #0b1114 100%);
border-radius: 14px;
padding: 1rem;
margin-bottom: 1rem;
}
.chart-panel {
min-height: 235px;
}
.chart-panel canvas {
width: 100% !important;
height: 220px !important;
display: block;
}
.panel h2 {
margin: 0 0 0.65rem;
color: var(--term-accent);
font-size: 1rem;
}
.kv-grid {
display: grid;
grid-template-columns: 150px 1fr;
gap: 0.35rem 0.55rem;
margin: 0;
}
.kv-grid dt { color: var(--term-muted); font-weight: 600; }
.kv-grid dd { margin: 0; font-weight: 700; color: var(--term-text); }
.usage-panel {
margin-bottom: 0;
}
.usage-row + .usage-row {
margin-top: 0.7rem;
}
.usage-top {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.3rem;
color: var(--term-muted);
}
.usage-top strong {
color: var(--term-accent);
}
.usage-bar {
position: relative;
height: 11px;
border: 1px solid var(--term-border);
border-radius: 999px;
background: #0b1114;
overflow: hidden;
}
.usage-bar > span {
position: absolute;
top: 0;
left: 0;
height: 100%;
width: 0%;
background: linear-gradient(90deg, #2fa96e 0%, #63ff9a 100%);
transition: width 0.25s ease;
}
.actions { display: flex; flex-wrap: wrap; gap: 0.55rem; align-items: center; }
.row-actions { gap: 0.4rem; }
.actions form { margin: 0; }
@@ -94,38 +363,322 @@
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 0.38rem;
}
.inline-restore-form {
margin-top: 0.55rem;
display: flex;
flex-wrap: wrap;
gap: 0.38rem;
align-items: center;
}
.inline-admin-form input {
border: 1px solid #bcd0de;
border: 1px solid var(--term-border);
border-radius: 8px;
padding: 0.38rem 0.5rem;
font: inherit;
min-width: 0;
color: var(--term-text);
background: #0b1114;
}
.inline-restore-form input[type="file"] {
border: 1px solid var(--term-border);
border-radius: 8px;
padding: 0.35rem 0.45rem;
color: var(--term-text);
background: #0b1114;
}
button,
.action-link {
border: 1px solid #bcd0de;
background: #ffffff;
border: 1px solid #2f6148;
background: #0e2218;
padding: 0.42rem 0.72rem;
border-radius: 999px;
border-radius: 7px;
font-weight: 700;
cursor: pointer;
text-decoration: none;
color: #173f5a;
color: var(--term-accent);
display: inline-flex;
align-items: center;
}
button:hover,
.action-link:hover { background: #f2f8fc; }
.action-link:hover { background: #133425; }
.live-cell { font-weight: 700; color: var(--term-warn); }
table { width: 100%; border-collapse: collapse; }
th, td {
text-align: left;
border-bottom: 1px solid #e7edf3;
border-bottom: 1px solid #1f323a;
padding: 0.62rem;
vertical-align: top;
}
th {
color: var(--term-muted);
font-weight: 700;
}
td {
color: var(--term-text);
}
@media (max-width: 1180px) {
.kpi-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.overview-grid { grid-template-columns: 1fr; }
.charts-grid { grid-template-columns: 1fr; }
}
@media (max-width: 860px) {
.actions { display: grid; }
.inline-admin-form { grid-template-columns: 1fr; }
.kpi-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
</style>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script>
(function () {
const mib = (value) => `${Number(value || 0).toFixed(1)} MiB`;
const gib = (value) => `${Number(value || 0).toFixed(2)} GiB`;
const pct = (value) => `${Number(value || 0).toFixed(1)} %`;
const clampPct = (value) => Math.max(0, Math.min(100, Number(value || 0)));
const historyLimit = 24;
const history = {
labels: [],
hostRam: [],
dockerManagedRam: [],
hostCpu: [],
avgInstanceCpu: [],
load1: [],
managedContainers: [],
allContainers: []
};
const chartDefaults = {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: false
},
plugins: {
legend: {
position: 'bottom'
}
},
scales: {
x: {
ticks: { maxRotation: 0, autoSkip: true }
}
}
};
const makeLineChart = (canvasId, datasets, yTitle) => {
const el = document.getElementById(canvasId);
if (!el || typeof Chart === 'undefined') {
return null;
}
return new Chart(el, {
type: 'line',
data: {
labels: history.labels,
datasets
},
options: {
...chartDefaults,
scales: {
...chartDefaults.scales,
y: {
beginAtZero: true,
title: {
display: true,
text: yTitle
}
}
}
}
});
};
const ramChart = makeLineChart('ramHistoryChart', [
{
label: 'Host RAM (MiB)',
data: history.hostRam,
borderColor: '#1f7a8c',
backgroundColor: 'rgba(31,122,140,0.16)',
tension: 0.25,
fill: true
},
{
label: 'Docker RAM Invario (MiB)',
data: history.dockerManagedRam,
borderColor: '#2a9d8f',
backgroundColor: 'rgba(42,157,143,0.15)',
tension: 0.25,
fill: true
}
], 'MiB');
const cpuLoadChart = makeLineChart('cpuLoadHistoryChart', [
{
label: 'Host CPU (%)',
data: history.hostCpu,
borderColor: '#c16200',
backgroundColor: 'rgba(193,98,0,0.15)',
tension: 0.25,
fill: true
},
{
label: 'Instanzen CPU Mittel (%)',
data: history.avgInstanceCpu,
borderColor: '#355070',
backgroundColor: 'rgba(53,80,112,0.15)',
tension: 0.25,
fill: true
},
{
label: 'Load 1m',
data: history.load1,
borderColor: '#8a5a44',
backgroundColor: 'rgba(138,90,68,0.13)',
tension: 0.25,
fill: true
}
], 'Wert');
const containerChart = makeLineChart('containerHistoryChart', [
{
label: 'Container (Invario)',
data: history.managedContainers,
borderColor: '#3f37c9',
backgroundColor: 'rgba(63,55,201,0.15)',
tension: 0.2,
fill: true
},
{
label: 'Container (alle)',
data: history.allContainers,
borderColor: '#6c757d',
backgroundColor: 'rgba(108,117,125,0.13)',
tension: 0.2,
fill: true
}
], 'Anzahl');
const pushHistory = (data) => {
const runtime = data.runtime || {};
const host = runtime.host || {};
const docker = runtime.docker || {};
const sys = data.system || {};
const instances = runtime.instances || [];
const avgInstanceCpu = instances.length
? instances.reduce((sum, item) => sum + Number(item.cpu_percent || 0), 0) / instances.length
: 0;
const now = new Date();
const timeLabel = now.toLocaleTimeString('de-DE', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
history.labels.push(timeLabel);
history.hostRam.push(Number(host.used_mem_mib || 0));
history.dockerManagedRam.push(Number(docker.managed_used_mem_mib || 0));
history.hostCpu.push(Number(host.cpu_percent || 0));
history.avgInstanceCpu.push(Number(avgInstanceCpu.toFixed(2)));
history.load1.push(Number(sys.load_1 || 0));
history.managedContainers.push(Number(docker.managed_container_count || 0));
history.allContainers.push(Number(docker.all_container_count || 0));
if (history.labels.length > historyLimit) {
Object.keys(history).forEach((key) => {
history[key].shift();
});
}
[ramChart, cpuLoadChart, containerChart].forEach((chart) => {
if (chart) {
chart.update();
}
});
};
const setText = (id, value) => {
const el = document.getElementById(id);
if (el) {
el.textContent = value;
}
};
const setUsageBar = (fillId, labelId, valuePct) => {
const pctValue = clampPct(valuePct);
const fill = document.getElementById(fillId);
const label = document.getElementById(labelId);
if (fill) {
fill.style.width = `${pctValue.toFixed(1)}%`;
}
if (label) {
label.textContent = `${pctValue.toFixed(1)} %`;
}
};
const updateSystem = async () => {
try {
const response = await fetch('/admin/system/stats', { cache: 'no-store' });
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
const runtime = data.runtime || {};
const host = runtime.host || {};
const docker = runtime.docker || {};
const ops = data.ops || {};
const rootDisk = data.root_disk || {};
const instDisk = data.instance_disk || {};
const sys = data.system || {};
setText('kpiInstancesRunning', String((runtime.instances || []).filter(i => (i.status || '') === 'Läuft').length));
setText('kpiTicketsOpen', String(ops.tickets_open || 0));
setText('kpiAppointmentsPending', String(ops.appointments_pending || 0));
setText('kpiUsersTotal', String(ops.users_total || 0));
setText('kpiHostRam', mib(host.used_mem_mib));
setText('kpiDockerManaged', mib(docker.managed_used_mem_mib));
setText('sysUptime', `${Number(sys.uptime_seconds || 0).toFixed(0)} s`);
setText('sysLoad', `${sys.load_1 || 0} / ${sys.load_5 || 0} / ${sys.load_15 || 0}`);
setText('sysUpdatedAt', data.generated_at || '-');
setText('rootUsed', gib(rootDisk.used_gib));
setText('rootFree', gib(rootDisk.free_gib));
setText('rootPct', pct(rootDisk.used_pct));
setText('instUsed', gib(instDisk.used_gib));
setText('instFree', gib(instDisk.free_gib));
setText('instPct', pct(instDisk.used_pct));
setUsageBar('usageHostRamFill', 'usageHostRamLabel', host.used_mem_pct);
setUsageBar('usageHostCpuFill', 'usageHostCpuLabel', host.cpu_percent);
setUsageBar('usageRootDiskFill', 'usageRootDiskLabel', rootDisk.used_pct);
setUsageBar('usageInstDiskFill', 'usageInstDiskLabel', instDisk.used_pct);
pushHistory(data);
const bySubdomain = new Map((runtime.instances || []).map(item => [item.subdomain, item]));
document.querySelectorAll('#instanceMgmtBody tr[data-subdomain]').forEach((row) => {
const subdomain = row.getAttribute('data-subdomain') || '';
const cell = row.querySelector('.live-cell');
if (!cell) {
return;
}
const stats = bySubdomain.get(subdomain);
if (!stats) {
cell.textContent = 'RAM: - | CPU: -';
return;
}
cell.textContent = `RAM: ${mib(stats.mem_mib)} | CPU: ${pct(stats.cpu_percent)}`;
});
} catch (error) {
// Keep last values if refresh fails.
}
};
updateSystem();
window.setInterval(updateSystem, 15000);
})();
</script>
{% endblock %}