Spaces:
Paused
Paused
Download control.py from Ntk123/HuggingClaw: direct link, hf CLI and curl.
- Browser
- Download file 26.2 kB
-
https://huggingface.co/spaces/Ntk123/HuggingClaw/resolve/main/control.py
- Command line
-
hf download hf://spaces/Ntk123/HuggingClaw/control.py
-
curl -L -o control.py https://huggingface.co/spaces/Ntk123/HuggingClaw/resolve/main/control.py
26.2 kB
| #!/usr/bin/env python3 | |
| import hashlib | |
| import hmac | |
| import json | |
| import os | |
| import shutil | |
| import signal | |
| import socket | |
| import subprocess | |
| import sys | |
| import threading | |
| import time | |
| import traceback | |
| from collections import deque | |
| from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer | |
| from urllib.parse import parse_qs, urlparse | |
| BUILD = "v11" | |
| DATA_DIR = os.environ.get("MC_DATA", "/data") | |
| JAR = os.environ.get("MC_JAR", "/server/server.jar") | |
| MEM = os.environ.get("MC_MEM", "4G") | |
| HTTP_PORT = int(os.environ.get("STATUS_PORT", "7860")) | |
| CONTROL_PASSWORD = os.environ.get("CONTROL_PASSWORD", "") | |
| PLAYIT_SECRET = os.environ.get("PLAYIT_SECRET", "") | |
| MAX_LOG = 4000 | |
| PLAYIT_TRAIL = 40 | |
| class MCController: | |
| def __init__(self): | |
| self.lock = threading.RLock() | |
| self.lines = deque(maxlen=MAX_LOG) | |
| self.ids = deque(maxlen=MAX_LOG) | |
| self.next_id = 0 | |
| self.playit_trail = deque(maxlen=PLAYIT_TRAIL) | |
| self.proc = None | |
| self.playit = None | |
| self.stopped = False | |
| self.pending_restart = False | |
| self.crashes = 0 | |
| self.started_at = None | |
| self._kill_timer = None | |
| def push(self, text, source="mc"): | |
| line = str(text) | |
| with self.lock: | |
| self.lines.append(line) | |
| self.ids.append(self.next_id) | |
| self.next_id += 1 | |
| if source == "playit": | |
| self.playit_trail.append(line) | |
| def log_from(self, after, limit=500): | |
| with self.lock: | |
| pairs = list(zip(self.ids, self.lines)) | |
| if after < 0: | |
| pairs = pairs[-limit:] | |
| out = [line for _, line in pairs] | |
| else: | |
| out = [line for i, line in pairs if i > after][:limit] | |
| last = pairs[-1][0] if pairs else after | |
| return out, last | |
| def start_playit(self): | |
| if self.playit is not None and self.playit.poll() is None: | |
| return | |
| if not PLAYIT_SECRET: | |
| self.push("[panel] PLAYIT_SECRET unset - tunnel disabled, server is LAN-only", "playit") | |
| return | |
| self.push("[panel] starting playitd", "playit") | |
| env = dict(os.environ) | |
| runtime_dir = "/tmp/playitd" | |
| try: | |
| os.makedirs(runtime_dir, mode=0o700, exist_ok=True) | |
| except OSError as e: | |
| self.push(f"[panel] playitd runtime dir: {e}", "playit") | |
| env["XDG_RUNTIME_DIR"] = runtime_dir | |
| try: | |
| self.playit = subprocess.Popen( | |
| ["playitd", "--secret", PLAYIT_SECRET, "--platform-docker", | |
| "--socket-path", os.path.join(runtime_dir, "ipc.sock")], | |
| stdout=subprocess.PIPE, stderr=subprocess.STDOUT, | |
| text=True, encoding="utf-8", errors="replace", env=env, | |
| ) | |
| except FileNotFoundError: | |
| self.push("[panel] playitd binary missing - tunnel disabled", "playit") | |
| self.playit = None | |
| return | |
| threading.Thread(target=self._playit_reader, args=(self.playit,), daemon=True).start() | |
| def _playit_reader(self, proc): | |
| for line in proc.stdout: | |
| self.push(line.rstrip(), "playit") | |
| code = proc.wait() | |
| self.push(f"[playit] exited with code {code}", "playit") | |
| def playit_state(self): | |
| if not PLAYIT_SECRET: | |
| return "disabled" | |
| p = self.playit | |
| if p is None: | |
| return "offline" | |
| if p.poll() is not None: | |
| return "offline" | |
| text = " ".join(self.playit_trail).lower() | |
| if "connected" in text: | |
| return "connected" | |
| if "error" in text or "refused" in text: | |
| return "error" | |
| return "connecting" | |
| def start_java(self): | |
| with self.lock: | |
| if self.proc is not None and self.proc.poll() is None: | |
| return | |
| self.proc = None | |
| if not os.path.exists(os.path.join(DATA_DIR, "eula.txt")): | |
| self.ensure_conf() | |
| self.push("[panel] starting Minecraft server") | |
| env = dict(os.environ) | |
| args = ["java", "-Xms" + MEM, "-Xmx" + MEM, "-jar", JAR, "nogui"] | |
| self.proc = subprocess.Popen( | |
| args, cwd=DATA_DIR, | |
| stdin=subprocess.PIPE, stdout=subprocess.PIPE, | |
| stderr=subprocess.STDOUT, | |
| text=True, encoding="utf-8", errors="replace", bufsize=1, env=env, | |
| ) | |
| self.started_at = time.time() | |
| self._kill_timer = None | |
| threading.Thread(target=self._mc_reader, args=(self.proc,), daemon=True).start() | |
| def ensure_conf(self): | |
| eula_path = os.path.join(DATA_DIR, "eula.txt") | |
| props_path = os.path.join(DATA_DIR, "server.properties") | |
| if not os.path.exists(eula_path): | |
| with open(eula_path, "w") as f: | |
| f.write("eula=true\n") | |
| if not os.path.exists(props_path): | |
| with open(props_path, "w") as f: | |
| f.write("server-port=25565\nonline-mode=false\nmax-players=20\nview-distance=4\nsimulation-distance=4\n") | |
| def _mc_reader(self, proc): | |
| for line in proc.stdout: | |
| self.push(line.rstrip()) | |
| code = proc.wait() | |
| self._on_mc_exit(code) | |
| def _on_mc_exit(self, code): | |
| with self.lock: | |
| self.proc = None | |
| if self.stopped: | |
| self.push(f"[panel] server stopped (exit {code})") | |
| return | |
| if self.pending_restart: | |
| self.pending_restart = False | |
| self.push(f"[panel] restarted by panel (exit {code})") | |
| restart_delay = 0.0 | |
| else: | |
| self.crashes += 1 | |
| self.push(f"[panel] server exited ({code}) - restarting in 3s") | |
| restart_delay = 3.0 | |
| if restart_delay <= 0: | |
| self.start_java() | |
| else: | |
| timer = threading.Timer(restart_delay, self.start_java) | |
| timer.daemon = True | |
| timer.start() | |
| def send_command(self, cmd): | |
| cmd = str(cmd).strip() | |
| if not cmd: | |
| return False, "empty command" | |
| with self.lock: | |
| proc = self.proc | |
| if proc is None or proc.poll() is not None: | |
| return False, "server is not running" | |
| try: | |
| proc.stdin.write(cmd + "\n") | |
| proc.stdin.flush() | |
| except (BrokenPipeError, ValueError) as e: | |
| return False, str(e) | |
| self.push(f"[panel] > {cmd}") | |
| return True, "sent" | |
| def restart(self): | |
| with self.lock: | |
| self.stopped = False | |
| self.pending_restart = True | |
| proc = self.proc if self.proc is not None and self.proc.poll() is None else None | |
| if proc is not None: | |
| self._arm_kill_timer() | |
| self._write_stdin(proc, "stop") | |
| if proc is None: | |
| self.start_java() | |
| return "restart requested" | |
| def _arm_kill_timer(self): | |
| def kill(): | |
| with self.lock: | |
| p = self.proc | |
| if p is not None and self.pending_restart and p.poll() is None: | |
| self.push("[panel] force-stopping server") | |
| p.terminate() | |
| self._kill_timer = threading.Timer(45.0, kill) | |
| self._kill_timer.daemon = True | |
| self._kill_timer.start() | |
| def wipe(self): | |
| with self.lock: | |
| world = os.path.join(DATA_DIR, "world") | |
| if os.path.isdir(world): | |
| shutil.rmtree(world, ignore_errors=True) | |
| self.push("[panel] world wiped - fresh world will generate") | |
| else: | |
| self.push("[panel] no world dir to wipe") | |
| self.stopped = False | |
| self.pending_restart = False | |
| running = self.proc is not None and self.proc.poll() is None | |
| if not running: | |
| self.start_java() | |
| return "world reset" | |
| def stop(self): | |
| with self.lock: | |
| self.stopped = True | |
| self.pending_restart = False | |
| proc = self.proc if self.proc is not None and self.proc.poll() is None else None | |
| if proc is not None: | |
| self._write_stdin(proc, "stop") | |
| if proc is None: | |
| return "server not running" | |
| return "server stopping" | |
| def _write_stdin(proc, cmd): | |
| try: | |
| proc.stdin.write(cmd + "\n") | |
| proc.stdin.flush() | |
| except (BrokenPipeError, ValueError): | |
| pass | |
| def status(self): | |
| with self.lock: | |
| proc = self.proc | |
| proc_alive = proc is not None and proc.poll() is None | |
| up = int(time.time() - self.started_at) if proc_alive and self.started_at else 0 | |
| rss = self._rss_kb(proc.pid) if proc_alive else 0 | |
| world = os.path.isdir(os.path.join(DATA_DIR, "world")) | |
| return { | |
| "world": "exists" if world else "none", | |
| "probe": getattr(self, "boot_probe", 0), | |
| "mc": "running" if proc_alive else "stopped", | |
| "uptime": up, | |
| "crashes": self.crashes, | |
| "mem_kb": rss, | |
| "mem_gb": round(rss / (1024 * 1024), 2), | |
| "playit": self.playit_state(), | |
| "mem_heap": MEM, | |
| "ver": BUILD, | |
| } | |
| def _rss_kb(pid): | |
| try: | |
| with open(f"/proc/{pid}/status") as f: | |
| for line in f: | |
| if line.startswith("VmRSS:"): | |
| return int(line.split()[1]) | |
| except (OSError, ValueError, IndexError): | |
| pass | |
| return 0 | |
| def graceful(self): | |
| with self.lock: | |
| self.stopped = True | |
| self.pending_restart = False | |
| p = self.proc | |
| q = self.playit | |
| self.push("[panel] shutting down") | |
| for target in (p, q): | |
| if target is not None and target.poll() is None: | |
| try: | |
| os.kill(target.pid, signal.SIGTERM) | |
| except OSError: | |
| pass | |
| for target, timeout in ((p, 20), (q, 10)): | |
| if target is not None: | |
| try: | |
| target.wait(timeout=timeout) | |
| except subprocess.TimeoutExpired: | |
| try: | |
| target.kill() | |
| except OSError: | |
| pass | |
| controller = MCController() | |
| def token_for(password): | |
| return hashlib.sha256(("panel-control:" + password).encode("utf-8")).hexdigest() | |
| def auth_ok(tok): | |
| if not CONTROL_PASSWORD: | |
| return True | |
| if not tok: | |
| return False | |
| return hmac.compare_digest(tok, token_for(CONTROL_PASSWORD)) | |
| class Handler(BaseHTTPRequestHandler): | |
| server_version = "MCPanel/1.0" | |
| def _send(self, code, body, ctype): | |
| if isinstance(body, str): | |
| body = body.encode("utf-8") | |
| self.send_response(code) | |
| self.send_header("Content-Type", ctype) | |
| self.send_header("Content-Length", str(len(body))) | |
| self.send_header("Cache-Control", "no-store") | |
| self.end_headers() | |
| self.wfile.write(body) | |
| def _json(self, code, obj): | |
| self._send(code, json.dumps(obj, separators=(",", ":")), "application/json") | |
| def _read_post(self): | |
| raw = self._read_body() or b"" | |
| try: | |
| obj = json.loads(raw.decode("utf-8")) if raw else None | |
| except (ValueError, json.JSONDecodeError): | |
| obj = None | |
| return obj, raw | |
| def _read_body(self): | |
| te = (self.headers.get("Transfer-Encoding") or "").lower() | |
| if te == "chunked": | |
| data = bytearray() | |
| while True: | |
| size_line = self.rfile.readline().strip() | |
| try: | |
| size = int(size_line.split(b";")[0], 16) | |
| except (ValueError, IndexError): | |
| break | |
| if size == 0: | |
| self.rfile.readline() | |
| break | |
| data += self.rfile.read(size) | |
| self.rfile.readline() | |
| return bytes(data) | |
| cl = int(self.headers.get("Content-Length") or 0) | |
| if 0 < cl <= 1 << 20: | |
| return self.rfile.read(cl) | |
| sock = self.connection | |
| sock.settimeout(0.5) | |
| try: | |
| data = bytearray() | |
| while True: | |
| try: | |
| chunk = self.rfile.read(4096) | |
| except (socket.timeout, OSError): | |
| break | |
| if not chunk: | |
| break | |
| data += chunk | |
| if len(data) > 1 << 20: | |
| break | |
| return bytes(data) if data else None | |
| finally: | |
| sock.settimeout(None) | |
| def log_message(self, fmt, *args): | |
| pass | |
| def handle(self): | |
| try: | |
| super().handle() | |
| except Exception: | |
| controller.push("[panel] conn error: " + traceback.format_exc().splitlines()[-1]) | |
| raise | |
| def do_GET(self): | |
| u = urlparse(self.path) | |
| path = u.path | |
| params = {k: v[-1] for k, v in parse_qs(u.query).items()} | |
| if path == "/api/status": | |
| self._json(200, controller.status()) | |
| elif path == "/api/log": | |
| try: | |
| after = int(params.get("after", "-1")) | |
| limit = int(params.get("limit", "500")) | |
| except ValueError: | |
| after, limit = -1, 500 | |
| lines, last = controller.log_from(after, limit) | |
| self._json(200, {"lines": lines, "last": last}) | |
| elif path == "/api/command": | |
| if not auth_ok(self.headers.get("X-Token", "")): | |
| self._json(401, {"ok": False, "error": "auth required"}) | |
| return | |
| ok, msg = controller.send_command(params.get("cmd", "")) | |
| self._json(200 if ok else 400, {"ok": ok, "error": msg}) | |
| elif path == "/api/action": | |
| if not auth_ok(self.headers.get("X-Token", "")): | |
| self._json(401, {"ok": False, "error": "auth required"}) | |
| return | |
| action = params.get("action") | |
| if action == "restart": | |
| self._json(200, {"ok": True, "error": controller.restart()}) | |
| elif action == "stop": | |
| self._json(200, {"ok": True, "error": controller.stop()}) | |
| elif action == "wipe": | |
| self._json(200, {"ok": True, "error": controller.wipe()}) | |
| else: | |
| self._json(400, {"ok": False, "error": "unknown action"}) | |
| else: | |
| self._send(200, PAGE, "text/html; charset=utf-8") | |
| def do_POST(self): | |
| controller.push("[panel] POST " + self.path + " cl=" + str(self.headers.get("Content-Length")) + " te=" + str(self.headers.get("Transfer-Encoding"))) | |
| u = urlparse(self.path) | |
| try: | |
| self._do_post(u) | |
| except Exception as e: | |
| controller.push("[panel] handler error: " + traceback.format_exc().splitlines()[-1]) | |
| self._json(500, {"ok": False, "error": "internal: " + repr(e)}) | |
| def _do_post(self, u): | |
| raw = self._read_body() or b"" | |
| params = self._params(u, raw) | |
| if u.path == "/api/login": | |
| if CONTROL_PASSWORD and params.get("password") == CONTROL_PASSWORD: | |
| self._json(200, {"ok": True, "token": token_for(CONTROL_PASSWORD)}) | |
| elif CONTROL_PASSWORD: | |
| self._json(401, {"ok": False, "error": "wrong password"}) | |
| else: | |
| self._json(200, {"ok": True, "token": ""}) | |
| return | |
| if u.path == "/api/command": | |
| if not auth_ok(self.headers.get("X-Token", "")): | |
| self._json(401, {"ok": False, "error": "auth required"}) | |
| return | |
| ok, msg = controller.send_command(params.get("cmd", "")) | |
| self._json(200 if ok else 400, {"ok": ok, "error": msg}) | |
| elif u.path == "/api/action": | |
| if not auth_ok(self.headers.get("X-Token", "")): | |
| self._json(401, {"ok": False, "error": "auth required"}) | |
| return | |
| action = params.get("action") | |
| if action == "restart": | |
| self._json(200, {"ok": True, "error": controller.restart()}) | |
| elif action == "stop": | |
| self._json(200, {"ok": True, "error": controller.stop()}) | |
| elif action == "wipe": | |
| self._json(200, {"ok": True, "error": controller.wipe()}) | |
| else: | |
| self._json(400, {"ok": False, "error": "unknown action"}) | |
| else: | |
| self._json(404, {"ok": False, "error": "not found"}) | |
| def _params(self, u, raw): | |
| params = {} | |
| for key, vals in parse_qs(u.query).items(): | |
| params[key] = vals[-1] | |
| ctype = (self.headers.get("Content-Type") or "").split(";")[0].strip().lower() | |
| if ctype == "application/x-www-form-urlencoded" and raw: | |
| for key, vals in parse_qs(raw.decode("utf-8", "replace")).items(): | |
| params[key] = vals[-1] | |
| if raw: | |
| try: | |
| obj = json.loads(raw.decode("utf-8")) | |
| if isinstance(obj, dict): | |
| for key, val in obj.items(): | |
| params[str(key)] = val | |
| except (ValueError, json.JSONDecodeError): | |
| pass | |
| return params | |
| PAGE = r"""<!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| <title>Minecraft Server Control</title> | |
| <style> | |
| :root{color-scheme:dark} | |
| *{box-sizing:border-box} | |
| body{margin:0;background:#101318;color:#e8e8e8;font-family:ui-monospace,Consolas,Menlo,monospace;font-size:14px} | |
| header{display:flex;align-items:center;gap:14px;padding:10px 16px;border-bottom:1px solid #262c35;background:#171b21;position:sticky;top:0} | |
| h1{font-size:15px;margin:0;font-weight:600} | |
| .badge{font-size:12px;padding:3px 8px;border-radius:999px;border:1px solid #2b313a;color:#9aa4b0} | |
| .badge b{color:#e8e8e8} | |
| .pill{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:6px;vertical-align:baseline} | |
| .ok{background:#3fb950} | |
| .warn{background:#d29922} | |
| .err{background:#f85149} | |
| .off{background:#595f66} | |
| .controls{display:flex;align-items:center;gap:8px;color:#9aa4b0;font-size:12px;margin-left:auto} | |
| button{background:#242a33;color:#e8e8e8;border:1px solid #333b47;border-radius:6px;padding:6px 12px;font-family:inherit;font-size:12px;cursor:pointer} | |
| button:hover:not(:disabled){background:#2d3540} | |
| button[data-danger]{background:#3a1f22;border-color:#6b2a30} | |
| button:disabled{opacity:.5;cursor:not-allowed} | |
| main{display:flex;flex-direction:column} | |
| #logbox{margin:0;padding:12px 16px;white-space:pre-wrap;word-break:break-word;flex:1;font-size:13px;line-height:1.55;max-height:calc(100vh - 150px);overflow-y:auto} | |
| .cmdline{display:flex;gap:8px;padding:10px 16px;border-top:1px solid #262c35;background:#171b21} | |
| .cmdline input{flex:1;background:#0d0f12;border:1px solid #333b47;color:#e8e8e8;border-radius:6px;padding:9px 12px;font-family:inherit} | |
| .quick{display:flex;gap:6px;padding:0 16px 10px;flex-wrap:wrap} | |
| .quick button{padding:4px 9px;font-size:11px;color:#9aa4b0} | |
| .faint{color:#7d8792} | |
| #auth{position:fixed;inset:0;display:none;align-items:center;justify-content:center;background:rgba(5,7,9,.82);z-index:5} | |
| #auth .card{background:#171b21;border:1px solid #2b313a;padding:22px;border-radius:10px;display:flex;flex-direction:column;gap:10px} | |
| #auth input,#auth button{font-family:inherit;padding:8px 10px;border-radius:6px;border:1px solid #333b47} | |
| .row{display:flex;gap:14px;align-items:center;flex-wrap:wrap;padding:8px 16px;border-bottom:1px solid #1c2128;font-size:12px;color:#9aa4b0} | |
| .row span b{color:#e8e8e8;font-weight:600} | |
| </style> | |
| </head> | |
| <body> | |
| <div id="auth"><div class="card"> | |
| <div style="font-size:14px">Enter control password</div> | |
| <input id="pw" type="password" placeholder="password" autocomplete="current-password"> | |
| <button onclick="doLogin()">Unlock</button> | |
| <div id="pw_err" class="err-row" style="font-size:12px"> </div> | |
| </div></div> | |
| <header> | |
| <h1>Minecraft Server</h1> | |
| <span class="badge">MC <b id="mc_state">?</b></span> | |
| <span class="badge">Tunnel <b id="tun_state">?</b></span> | |
| <span class="badge">Heap <b id="heap_badge">?</b></span> | |
| <span class="badge">RAM <b id="ram_badge">?</b></span> | |
| <span class="controls">uptime <b id="uptime"></b> · crashes <b id="crashes"></b></span> | |
| </header> | |
| <div class="row"> | |
| <span>Command: <b id="cmd_result" class="faint"> </b></span> | |
| </div> | |
| <pre id="logbox"></pre> | |
| <div class="quick"> | |
| <button onclick="quick('list')">list</button> | |
| <button onclick="quick('help')">help</button> | |
| <button onclick="quick('save-all')">save-all</button> | |
| <button onclick="quick('say test 123')">say hi</button> | |
| <button onclick="quick('whitelist list')">whitelist list</button> | |
| <button onclick="quick('seed')">seed</button> | |
| <button onclick="quick('tps')">tps</button> | |
| <button style="margin-left:auto" data-danger onclick="doAction('stop')">Stop</button> | |
| <button data-danger onclick="doAction('restart')">Restart</button> | |
| </div> | |
| <div class="cmdline"> | |
| <input id="cmd" placeholder="send a server command (e.g. say hello)" autocomplete="off"> | |
| <button onclick="sendCmd()">Send</button> | |
| </div> | |
| <script> | |
| let token = sessionStorage.getItem('tok') || ''; | |
| let lastId = -1; | |
| const $ = id => document.getElementById(id); | |
| const state = { shouldAutoScroll: true }; | |
| function setBadge(id, text, cls) { | |
| const el = $(id); | |
| el.innerHTML = '<i class="pill ' + (cls || 'off') + '"></i>' + text; | |
| } | |
| $('logbox').addEventListener('scroll', () => { | |
| const l = $('logbox'); | |
| state.shouldAutoScroll = (l.scrollTop + l.clientHeight) >= l.scrollHeight - 40; | |
| }); | |
| function doLogin() { | |
| fetch('/api/login', { | |
| method: 'POST', | |
| headers: {'Content-Type': 'application/x-www-form-urlencoded'}, | |
| body: 'password=' + encodeURIComponent($('pw').value) | |
| }).then(r => { | |
| if (r.status === 401) { $('pw_err').textContent = 'wrong password'; throw 0; } | |
| return r.json(); | |
| }).then(d => { | |
| token = d.token || ''; | |
| sessionStorage.setItem('tok', token); | |
| if (!token) sessionStorage.setItem('noauth', '1'); | |
| $('auth').style.display = 'none'; | |
| }).catch(() => {}); | |
| } | |
| async function refreshStatus() { | |
| try { | |
| const s = await (await fetch('/api/status?ts=' + Date.now())).json(); | |
| setBadge('mc_state', s.mc, s.mc === 'running' ? 'ok' : 'err'); | |
| const cls = s.playit === 'connected' ? 'ok' : (s.playit === 'disabled' || s.playit === 'offline' ? 'off' : 'warn'); | |
| setBadge('tun_state', s.playit, cls); | |
| $('heap_badge').textContent = s.mem_heap; | |
| $('ram_badge').textContent = s.mem_gb + ' GB'; | |
| $('uptime').textContent = fmtUptime(s.uptime); | |
| $('crashes').textContent = s.crashes; | |
| } catch (e) {} | |
| } | |
| function fmtUptime(sec) { | |
| const h = Math.floor(sec / 3600), m = Math.floor(sec % 3600 / 60), s = sec % 60; | |
| return (h ? h + 'h ' : '') + m + 'm ' + s + 's'; | |
| } | |
| async function refreshLog() { | |
| try { | |
| const d = await (await fetch('/api/log?after=' + lastId + '&ts=' + Date.now())).json(); | |
| lastId = d.last; | |
| if (d.lines.length) { | |
| const l = $('logbox'); | |
| for (const ln of d.lines) { | |
| l.appendChild(renderLine(ln)); | |
| } | |
| if (state.shouldAutoScroll) l.scrollTop = l.scrollHeight; | |
| } | |
| } catch (e) {} | |
| } | |
| function renderLine(txt) { | |
| const div = document.createElement('div'); | |
| div.textContent = txt; | |
| if (txt.startsWith('[panel] ')) div.style.color = '#7dd3fc'; | |
| else if (txt.startsWith('[playit')) div.style.color = '#c4b5fd'; | |
| else if (txt.includes('ERROR') || txt.includes('FATAL')) div.style.color = '#f85149'; | |
| else if (txt.includes('Done (')) div.style.color = '#3fb950'; | |
| return div; | |
| } | |
| function api(url, data) { | |
| const qs = Object.keys(data || {}).map(k => encodeURIComponent(k) + '=' + encodeURIComponent(data[k])).join('&'); | |
| return fetch(url + '?' + qs + '&ts=' + Date.now(), { | |
| method: 'GET', | |
| headers: {'X-Token': token} | |
| }).then(r => r.json().then(d => ({status: r.status, d}))); | |
| } | |
| async function sendCmd() { | |
| const v = $('cmd').value.trim(); | |
| if (!v) return; | |
| $('cmd').value = ''; | |
| return runCommand(v); | |
| } | |
| async function quick(c) { return runCommand(c); } | |
| async function runCommand(c) { | |
| const {status, d} = await api('/api/command', {cmd: c}); | |
| $('cmd_result').textContent = (status === 200 ? 'sent: ' : 'error: ') + (d.error || c); | |
| if (status === 401) $('auth').style.display = 'flex'; | |
| } | |
| async function doAction(a) { | |
| const {status, d} = await api('/api/action', {action: a}); | |
| if (status === 401) { $('auth').style.display = 'flex'; return; } | |
| $('cmd_result').textContent = d.error || a; | |
| } | |
| setInterval(refreshStatus, 3000); | |
| setInterval(refreshLog, 1200); | |
| refreshStatus(); refreshLog(); | |
| (async () => { | |
| if (token || sessionStorage.getItem('noauth')) return; | |
| $('auth').style.display = 'flex'; | |
| })(); | |
| $('cmd').addEventListener('keydown', e => { if (e.key === 'Enter') sendCmd(); }); | |
| </script> | |
| </body> | |
| </html>""" | |
| def main(): | |
| probe_path = os.path.join(DATA_DIR, ".boot_probe") | |
| try: | |
| prev = int(open(probe_path).read()) if os.path.exists(probe_path) else 0 | |
| except ValueError: | |
| prev = 0 | |
| controller.boot_probe = prev + 1 | |
| try: | |
| with open(probe_path, "w") as f: | |
| f.write(str(controller.boot_probe)) | |
| except OSError as e: | |
| controller.push(f"[panel] cannot write to {DATA_DIR}: {e}") | |
| controller.start_playit() | |
| controller.start_java() | |
| httpd = ThreadingHTTPServer(("0.0.0.0", HTTP_PORT), Handler) | |
| httpd.daemon_threads = True | |
| stop = threading.Event() | |
| def on_signal(signum, frame): | |
| stop.set() | |
| signal.signal(signal.SIGTERM, on_signal) | |
| signal.signal(signal.SIGINT, on_signal) | |
| server_thread = threading.Thread(target=httpd.serve_forever, daemon=True) | |
| server_thread.start() | |
| stop.wait() | |
| httpd.shutdown() | |
| server_thread.join(timeout=5) | |
| sys.stderr.write("[panel] shutting down\n") | |
| controller.graceful() | |
| if __name__ == "__main__": | |
| main() |