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:
1 parent
3554c85f2f
commit
15e562cb04
11 files changed
+331
-2
No files matched your search
+134
-1
@@ -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)
|
||||
Reference in new issue
Block a user