Add server control (start/stop/restart, live logs, broadcast, kick) to webadmin

Docker SDK drives login/char/worldserver containers via docker.sock (mounted
read-only into webadmin); live logs stream over SSE with nginx buffering
disabled so lines arrive in real time. Broadcast/kick go through a new
admin_queue DB table that the world server polls every ~2s in its existing
WorldProcess tick loop, avoiding a second IPC channel to the game server.
This commit is contained in:
Thomas committed 2026-07-10 14:19:14 +02:00
1 parent 3554c85f2f
commit 15e562cb04
11 files changed
+331 -2

No files matched your search

+1 -1
View File
@@ -9,4 +9,4 @@ COPY app.py ./
COPY templates ./templates
EXPOSE 8080
CMD ["gunicorn", "--bind", "0.0.0.0:8080", "--workers", "2", "app:app"]
CMD ["gunicorn", "--bind", "0.0.0.0:8080", "--workers", "2", "--worker-class", "gthread", "--threads", "8", "app:app"]
+134 -1
View File
@@ -1,10 +1,14 @@
import os
import functools
import hashlib
import json
import time
import docker
import docker.errors
import pymysql
import pymysql.cursors
from flask import Flask, request, session, redirect, url_for, render_template, flash
from flask import Flask, Response, request, session, redirect, url_for, render_template, flash
from werkzeug.middleware.proxy_fix import ProxyFix
app = Flask(__name__)
@@ -30,6 +34,33 @@ DB_NAME = os.environ.get("DB_NAME", "ktrose")
# Only accounts with accesslevel >= this may log in to the admin panel.
ADMIN_MIN_ACCESSLEVEL = int(os.environ.get("ADMIN_MIN_ACCESSLEVEL", "900"))
COMPOSE_PROJECT_NAME = os.environ.get("COMPOSE_PROJECT_NAME", "ross")
GAME_SERVICES = ["loginserver", "charserver", "worldserver"]
_docker_client = None
def get_docker():
global _docker_client
if _docker_client is None:
_docker_client = docker.from_env()
return _docker_client
def get_service_container(service):
# Looked up by compose labels rather than a hardcoded "<project>-<service>-1"
# name, since that naming scheme isn't guaranteed across compose versions.
containers = get_docker().containers.list(
all=True,
filters={
"label": [
f"com.docker.compose.project={COMPOSE_PROJECT_NAME}",
f"com.docker.compose.service={service}",
]
},
)
return containers[0] if containers else None
CHAR_STAT_FIELDS = [
"level", "zuly", "str", "con", "dex", "_int", "cha", "sen",
"curHp", "curMp", "statp", "skillp", "stamina", "exp", "classid",
@@ -387,5 +418,107 @@ def kick_account(account_id):
return redirect(url_for("edit_account", account_id=account_id))
def queue_admin_command(cmd_type, payload):
db = get_db()
try:
with db.cursor() as cur:
cur.execute(
"INSERT INTO admin_queue (type, payload) VALUES (%s, %s)",
(cmd_type, payload),
)
finally:
db.close()
@app.route("/server")
@login_required
def server_control():
statuses = {}
for service in GAME_SERVICES:
container = get_service_container(service)
if container is None:
statuses[service] = {"status": "missing", "started_at": None}
else:
statuses[service] = {
"status": container.status,
"started_at": container.attrs.get("State", {}).get("StartedAt"),
}
db = get_db()
try:
with db.cursor() as cur:
cur.execute(
"SELECT username, lastchar FROM accounts WHERE online=1 ORDER BY username"
)
online = cur.fetchall()
finally:
db.close()
return render_template(
"server.html", statuses=statuses, services=GAME_SERVICES, online=online
)
@app.route("/server/<service>/<action>", methods=["POST"])
@login_required
def server_action(service, action):
if service not in GAME_SERVICES or action not in ("start", "stop", "restart"):
flash("Ungültige Aktion.")
return redirect(url_for("server_control"))
container = get_service_container(service)
if container is None:
flash(f"Container für '{service}' nicht gefunden.")
return redirect(url_for("server_control"))
try:
if action == "start":
container.start()
else:
getattr(container, action)(timeout=15)
except docker.errors.APIError as exc:
flash(f"Aktion fehlgeschlagen: {exc}")
else:
flash(f"{service}: {action} ausgeführt.")
return redirect(url_for("server_control"))
@app.route("/server/<service>/logs")
@login_required
def server_logs(service):
if service not in GAME_SERVICES:
return "unknown service", 404
container = get_service_container(service)
if container is None:
return "container not found", 404
def stream():
for line in container.logs(stream=True, follow=True, tail=200):
text = line.decode("utf-8", errors="replace").rstrip("\n")
yield f"data: {json.dumps(text)}\n\n"
return Response(stream(), mimetype="text/event-stream")
@app.route("/server/broadcast", methods=["POST"])
@login_required
def server_broadcast():
message = request.form.get("message", "").strip()
if not message:
flash("Nachricht darf nicht leer sein.")
else:
queue_admin_command("broadcast", message[:250])
flash("Broadcast eingereiht (wird binnen ~2s vom Worldserver gesendet).")
return redirect(url_for("server_control"))
@app.route("/server/kick", methods=["POST"])
@login_required
def server_kick():
charname = request.form.get("charname", "").strip()
if not charname:
flash("Kein Charakter angegeben.")
else:
queue_admin_command("kick", charname[:64])
flash(f"Kick für '{charname}' eingereiht.")
return redirect(url_for("server_control"))
if __name__ == "__main__":
app.run(host="0.0.0.0", port=8080, debug=False)
+1
View File
@@ -1,3 +1,4 @@
Flask==3.0.3
PyMySQL==1.1.1
gunicorn==22.0.0
docker==7.1.0
+16
View File
@@ -71,6 +71,21 @@
.badge.ok { background: #163a26; color: var(--ok); }
.badge.off { background: #3a1620; color: var(--danger); }
.row-actions a { margin-right: 8px; }
.logbox {
background: #0a0b10;
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px;
height: 360px;
overflow-y: auto;
font-family: "SF Mono", Consolas, Menlo, monospace;
font-size: 12px;
white-space: pre-wrap;
word-break: break-all;
}
.service-tabs { display: flex; gap: 8px; margin-bottom: 12px; }
.service-tabs button.tab { background: #33384a; }
.service-tabs button.tab.active { background: var(--accent); }
</style>
</head>
<body>
@@ -80,6 +95,7 @@
<nav>
<a href="{{ url_for('accounts') }}">Accounts</a>
<a href="{{ url_for('characters') }}">Charaktere</a>
<a href="{{ url_for('server_control') }}">Server</a>
<a href="{{ url_for('logout') }}">Logout ({{ session['username'] }})</a>
</nav>
{% endif %}
+103
View File
@@ -0,0 +1,103 @@
{% extends "base.html" %}
{% block title %}Server – ROSS Admin{% endblock %}
{% block content %}
<h2>Serversteuerung</h2>
<div class="card">
<table>
<thead><tr><th>Dienst</th><th>Status</th><th>Gestartet</th><th></th></tr></thead>
<tbody>
{% for service in services %}
<tr>
<td>{{ service }}</td>
<td>
{% set st = statuses[service]['status'] %}
<span class="badge {{ 'ok' if st == 'running' else 'off' }}">{{ st }}</span>
</td>
<td>{{ statuses[service]['started_at'] or '-' }}</td>
<td class="row-actions">
<form class="inline" method="post" action="{{ url_for('server_action', service=service, action='start') }}">
<button type="submit">Start</button>
</form>
<form class="inline" method="post" action="{{ url_for('server_action', service=service, action='restart') }}">
<button type="submit" class="secondary">Restart</button>
</form>
<form class="inline" method="post" action="{{ url_for('server_action', service=service, action='stop') }}"
onsubmit="return confirm('{{ service }} wirklich stoppen?');">
<button type="submit" class="secondary" style="background:var(--danger)">Stop</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="card">
<h3 style="margin-top:0">Live-Logs</h3>
<div class="service-tabs">
{% for service in services %}
<button type="button" class="tab {{ 'active' if loop.first else '' }}" data-service="{{ service }}" onclick="switchLog('{{ service }}')">{{ service }}</button>
{% endfor %}
</div>
<div id="logbox" class="logbox"></div>
</div>
<div class="grid">
<div class="card">
<h3 style="margin-top:0">Broadcast</h3>
<form method="post" action="{{ url_for('server_broadcast') }}">
<label>Nachricht an alle Spieler</label>
<input type="text" name="message" maxlength="250" required>
<div style="margin-top:12px"><button type="submit">Senden</button></div>
</form>
</div>
<div class="card">
<h3 style="margin-top:0">Online-Spieler</h3>
{% if online %}
<table>
<thead><tr><th>Account</th><th>Charakter</th><th></th></tr></thead>
<tbody>
{% for row in online %}
<tr>
<td>{{ row['username'] }}</td>
<td>{{ row['lastchar'] }}</td>
<td class="row-actions">
<form class="inline" method="post" action="{{ url_for('server_kick') }}"
onsubmit="return confirm('{{ row['lastchar'] }} kicken?');">
<input type="hidden" name="charname" value="{{ row['lastchar'] }}">
<button type="submit" class="secondary" style="background:var(--danger)">Kick</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p style="color:var(--muted)">Niemand online.</p>
{% endif %}
</div>
</div>
<script>
let currentSource = null;
function switchLog(service) {
document.querySelectorAll('.service-tabs .tab').forEach(function(btn) {
btn.classList.toggle('active', btn.dataset.service === service);
});
const box = document.getElementById('logbox');
box.textContent = '';
if (currentSource) currentSource.close();
currentSource = new EventSource('{{ url_for("server_logs", service="__SERVICE__") }}'.replace('__SERVICE__', service));
currentSource.onmessage = function(ev) {
const line = JSON.parse(ev.data);
const atBottom = box.scrollHeight - box.scrollTop - box.clientHeight < 40;
box.textContent += line + '\n';
if (atBottom) box.scrollTop = box.scrollHeight;
};
}
switchLog('{{ services[0] }}');
window.addEventListener('beforeunload', function() { if (currentSource) currentSource.close(); });
</script>
{% endblock %}