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
@@ -403,8 +403,16 @@ PVOID VisibilityProcess(PVOID TS)
|
||||
// World Process
|
||||
PVOID WorldProcess( PVOID TS )
|
||||
{
|
||||
int adminQueueTicks = 0;
|
||||
while( GServer->ServerOnline )
|
||||
{
|
||||
// Poll roughly every 2 seconds regardless of WorldDelay, instead of
|
||||
// hitting the DB on every tick.
|
||||
if( ++adminQueueTicks * GServer->Config.WorldDelay >= 2000 )
|
||||
{
|
||||
adminQueueTicks = 0;
|
||||
GServer->PollAdminQueue( );
|
||||
}
|
||||
pthread_mutex_lock( &GServer->MapMutex );
|
||||
for(UINT i=0;i<GServer->MapList.Map.size();i++)
|
||||
{
|
||||
|
||||
@@ -66,6 +66,48 @@ bool CWorldServer::NPCAnnounce( const char msg[200], const char npc[50])
|
||||
return true;
|
||||
}
|
||||
|
||||
// Poll the admin_queue table (written by the webadmin panel) for pending
|
||||
// broadcast/kick actions and execute them. There's no direct socket between
|
||||
// webadmin and the world server, so this DB-polling approach reuses the
|
||||
// existing MYSQL connection instead of adding a second IPC channel.
|
||||
void CWorldServer::PollAdminQueue( )
|
||||
{
|
||||
// QStore holds SQLMutex locked until QFree() -- copy the rows out and
|
||||
// free the result set first, since NPCAnnounce/pakGMKick/QExecute below
|
||||
// issue their own queries and would deadlock on the same (non-recursive)
|
||||
// mutex if called while this result set is still open.
|
||||
struct QueueItem { int id; char type[20]; char payload[255]; };
|
||||
QueueItem items[20];
|
||||
int count = 0;
|
||||
|
||||
MYSQL_RES* result = DB->QStore("SELECT id, type, payload FROM admin_queue WHERE executed=0 ORDER BY id LIMIT 20");
|
||||
if(result != NULL)
|
||||
{
|
||||
MYSQL_ROW row;
|
||||
while(count < 20 && (row = mysql_fetch_row(result)) != NULL)
|
||||
{
|
||||
items[count].id = atoi(row[0]);
|
||||
strncpy_s(items[count].type, row[1], sizeof(items[count].type)-1);
|
||||
strncpy_s(items[count].payload, row[2], sizeof(items[count].payload)-1);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
DB->QFree( );
|
||||
|
||||
for(int i = 0; i < count; i++)
|
||||
{
|
||||
if(strcmp(items[i].type, "broadcast") == 0)
|
||||
{
|
||||
NPCAnnounce(items[i].payload, "[Admin]");
|
||||
}
|
||||
else if(strcmp(items[i].type, "kick") == 0)
|
||||
{
|
||||
pakGMKick(NULL, items[i].payload);
|
||||
}
|
||||
DB->QExecute("UPDATE admin_queue SET executed=1 WHERE id=%i", items[i].id);
|
||||
}
|
||||
}
|
||||
|
||||
// NPC Whisper to a character
|
||||
bool CWorldServer::NPCWhisper( CPlayer* thisclient, CMonster* thismon, const char msg[200], const char npc[50] )
|
||||
{
|
||||
|
||||
@@ -342,6 +342,7 @@ class CWorldServer : public CServerSocket
|
||||
bool NPCMessage( CMonster* thismon, const char msg[200], const char npc[50] );
|
||||
bool NPCWhisper( CPlayer* thisclient, CMonster* thismon, const char msg[200], const char npc[50] );
|
||||
bool NPCAnnounce( const char msg[200], const char npc[50] );
|
||||
void PollAdminQueue( );
|
||||
UINT GetMaxPartyExp( UINT partylevel );
|
||||
bool DoSkillScript( CCharacter* character, CSkills* thisskill );
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
--
|
||||
-- Queue table the webadmin panel writes to and the world server polls
|
||||
-- (in WorldProcess, MainProcess.cpp) to execute broadcast/kick actions
|
||||
-- without needing a direct socket between the two processes.
|
||||
--
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `admin_queue` (
|
||||
`id` int(11) NOT NULL auto_increment,
|
||||
`type` varchar(20) NOT NULL,
|
||||
`payload` varchar(255) NOT NULL,
|
||||
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`executed` tinyint(1) NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=latin1 COLLATE=latin1_general_ci;
|
||||
@@ -104,6 +104,13 @@ services:
|
||||
DB_NAME: ${DB_NAME:-ktrose}
|
||||
ADMIN_SECRET_KEY: ${ADMIN_SECRET_KEY:-change-me-too}
|
||||
ADMIN_MIN_ACCESSLEVEL: ${ADMIN_MIN_ACCESSLEVEL:-900}
|
||||
COMPOSE_PROJECT_NAME: ${COMPOSE_PROJECT_NAME:-ross}
|
||||
volumes:
|
||||
# Lets the panel start/stop/restart the login/char/world containers and
|
||||
# stream their logs via the Docker API. Grants the container
|
||||
# effectively root-equivalent access to the host -- acceptable here
|
||||
# since the panel sits behind GM-only login and isn't internet-exposed.
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
expose:
|
||||
- "8080"
|
||||
depends_on:
|
||||
|
||||
@@ -11,6 +11,10 @@ server {
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Prefix /admin;
|
||||
# /server/<service>/logs is an SSE stream -- buffering would delay
|
||||
# lines until nginx's buffer fills instead of forwarding them live.
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 1h;
|
||||
}
|
||||
|
||||
location / {
|
||||
|
||||
+1
-1
@@ -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
@@ -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,3 +1,4 @@
|
||||
Flask==3.0.3
|
||||
PyMySQL==1.1.1
|
||||
gunicorn==22.0.0
|
||||
docker==7.1.0
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
Reference in new issue
Block a user