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:
+1
-1
@@ -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"]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
Reference in New Issue
Block a user