Expand dashboard analytics and document production implementation
This commit is contained in:
217
start_dashboard.py
Normal file
217
start_dashboard.py
Normal file
@@ -0,0 +1,217 @@
|
||||
"""Windows development launcher: this terminal owns the backend and local DB.
|
||||
|
||||
Double-click start-dashboard.bat, or run python start_dashboard.py.
|
||||
Ctrl+C stops gracefully. A Windows Job Object also stops child processes if the
|
||||
terminal is closed abruptly. DATABASE_URL servers are external and never stopped.
|
||||
"""
|
||||
import argparse
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
import os
|
||||
from pathlib import Path
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from urllib.request import urlopen
|
||||
import webbrowser
|
||||
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
LOCAL = ROOT / '.local'
|
||||
PG_CTL = LOCAL / 'pgsql' / 'bin' / 'pg_ctl.exe'
|
||||
PG_DATA = LOCAL / 'pgdata'
|
||||
PYTHON = ROOT / '.venv' / 'Scripts' / 'python.exe'
|
||||
|
||||
|
||||
class TerminalJob:
|
||||
"""Include this process so all subsequently created children inherit the job."""
|
||||
def __init__(self):
|
||||
kernel = ctypes.WinDLL('kernel32', use_last_error=True)
|
||||
|
||||
class BasicLimits(ctypes.Structure):
|
||||
_fields_ = [('process_time', ctypes.c_longlong), ('job_time', ctypes.c_longlong),
|
||||
('flags', wintypes.DWORD), ('minimum_working_set', ctypes.c_size_t),
|
||||
('maximum_working_set', ctypes.c_size_t), ('active_processes', wintypes.DWORD),
|
||||
('affinity', ctypes.c_size_t), ('priority', wintypes.DWORD), ('scheduling', wintypes.DWORD)]
|
||||
|
||||
class IoCounters(ctypes.Structure):
|
||||
_fields_ = [(name, ctypes.c_ulonglong) for name in
|
||||
('reads', 'writes', 'other', 'read_bytes', 'write_bytes', 'other_bytes')]
|
||||
|
||||
class ExtendedLimits(ctypes.Structure):
|
||||
_fields_ = [('basic', BasicLimits), ('io', IoCounters),
|
||||
('process_memory', ctypes.c_size_t), ('job_memory', ctypes.c_size_t),
|
||||
('peak_process_memory', ctypes.c_size_t), ('peak_job_memory', ctypes.c_size_t)]
|
||||
|
||||
kernel.CreateJobObjectW.argtypes = [ctypes.c_void_p, wintypes.LPCWSTR]
|
||||
kernel.CreateJobObjectW.restype = wintypes.HANDLE
|
||||
kernel.SetInformationJobObject.argtypes = [wintypes.HANDLE, ctypes.c_int, ctypes.c_void_p, wintypes.DWORD]
|
||||
kernel.SetInformationJobObject.restype = wintypes.BOOL
|
||||
kernel.AssignProcessToJobObject.argtypes = [wintypes.HANDLE, wintypes.HANDLE]
|
||||
kernel.AssignProcessToJobObject.restype = wintypes.BOOL
|
||||
kernel.GetCurrentProcess.restype = wintypes.HANDLE
|
||||
self.handle = kernel.CreateJobObjectW(None, None)
|
||||
if not self.handle:
|
||||
raise ctypes.WinError(ctypes.get_last_error())
|
||||
limits = ExtendedLimits()
|
||||
limits.basic.flags = 0x2000 # JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
|
||||
if not kernel.SetInformationJobObject(self.handle, 9, ctypes.byref(limits), ctypes.sizeof(limits)):
|
||||
raise ctypes.WinError(ctypes.get_last_error())
|
||||
if not kernel.AssignProcessToJobObject(self.handle, kernel.GetCurrentProcess()):
|
||||
raise ctypes.WinError(ctypes.get_last_error())
|
||||
# Keep this non-inheritable handle open until process exit. Closing it
|
||||
# explicitly would terminate the launcher itself along with its children.
|
||||
self.kernel = kernel
|
||||
|
||||
|
||||
class Dashboard:
|
||||
def __init__(self, no_browser=False):
|
||||
self.stop = threading.Event()
|
||||
self.finished = threading.Event()
|
||||
self.backend = None
|
||||
self.local_db = False
|
||||
self.no_browser = no_browser
|
||||
self.port = int(os.environ.get('MOCKUP_PORT', '8011'))
|
||||
self.url = f'http://127.0.0.1:{self.port}/mockups/overview-mockup.html'
|
||||
self.job = None
|
||||
|
||||
def install_shutdown(self):
|
||||
self.job = TerminalJob()
|
||||
callback_type = ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.DWORD)
|
||||
|
||||
def console_event(event):
|
||||
if event not in (0, 1, 2, 5, 6):
|
||||
return False
|
||||
self.stop.set()
|
||||
if event in (2, 5, 6):
|
||||
self.finished.wait(4)
|
||||
return True
|
||||
|
||||
self.console_handler = callback_type(console_event)
|
||||
self.job.kernel.SetConsoleCtrlHandler.argtypes = [callback_type, wintypes.BOOL]
|
||||
self.job.kernel.SetConsoleCtrlHandler.restype = wintypes.BOOL
|
||||
if not self.job.kernel.SetConsoleCtrlHandler(self.console_handler, True):
|
||||
raise ctypes.WinError(ctypes.get_last_error())
|
||||
signal.signal(signal.SIGINT, lambda *_: self.stop.set())
|
||||
signal.signal(signal.SIGTERM, lambda *_: self.stop.set())
|
||||
|
||||
def pg(self, *args, timeout=35, quiet=False):
|
||||
return subprocess.run([str(PG_CTL), *args, '-D', str(PG_DATA)], cwd=ROOT,
|
||||
creationflags=subprocess.CREATE_NO_WINDOW, timeout=timeout,
|
||||
stdout=subprocess.DEVNULL if quiet else None,
|
||||
stderr=subprocess.DEVNULL if quiet else None)
|
||||
|
||||
def start(self):
|
||||
import msvcrt
|
||||
LOCAL.mkdir(exist_ok=True)
|
||||
self.lock_file = (LOCAL/'dashboard.lock').open('a+b')
|
||||
self.lock_file.write(b'0')
|
||||
self.lock_file.flush()
|
||||
self.lock_file.seek(0)
|
||||
try:
|
||||
msvcrt.locking(self.lock_file.fileno(), msvcrt.LK_NBLCK, 1)
|
||||
except OSError:
|
||||
raise RuntimeError('This dashboard launcher is already running. Use its terminal window.') from None
|
||||
if not 1 <= self.port <= 65535:
|
||||
raise RuntimeError('MOCKUP_PORT must be between 1 and 65535.')
|
||||
with socket.socket() as probe:
|
||||
try:
|
||||
probe.bind(('127.0.0.1', self.port))
|
||||
except OSError:
|
||||
raise RuntimeError(f'Port {self.port} is already in use. Close the existing backend terminal first.') from None
|
||||
self.install_shutdown()
|
||||
external_db = bool(os.environ.get('DATABASE_URL'))
|
||||
needs_setup = not PYTHON.exists() or (not external_db and
|
||||
(not PG_CTL.exists() or not (PG_DATA/'PG_VERSION').exists() or not (LOCAL/'database.json').exists()))
|
||||
if needs_setup:
|
||||
print('Preparing the development environment (first run may download PostgreSQL)...', flush=True)
|
||||
self.local_db = not external_db
|
||||
subprocess.run(['powershell.exe', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File',
|
||||
str(ROOT/'mockups'/'setup-database.ps1')], cwd=ROOT, check=True)
|
||||
if self.stop.is_set():
|
||||
return
|
||||
if not external_db:
|
||||
self.local_db = True
|
||||
if self.pg('status', quiet=True).returncode == 0:
|
||||
print('Restarting the local sample database under this terminal...', flush=True)
|
||||
self.pg('stop', '-m', 'fast', '-w', '-t', '15', timeout=20).check_returncode()
|
||||
self.pg('start', '-l', str(LOCAL/'postgres.log'), '-o', '-h 127.0.0.1 -p 55432',
|
||||
'-w', '-t', '30').check_returncode()
|
||||
else:
|
||||
print('Using DATABASE_URL; the external database is managed separately.', flush=True)
|
||||
if self.stop.is_set():
|
||||
return
|
||||
subprocess.run([str(PYTHON), str(ROOT/'mockups'/'manage_database.py'), 'init'], cwd=ROOT, check=True)
|
||||
print('\nStarting backend. Close this terminal or press Ctrl+C to stop everything.\n', flush=True)
|
||||
self.backend = subprocess.Popen([str(PYTHON), '-u', str(ROOT/'mockups'/'mockup_server.py')],
|
||||
cwd=ROOT, creationflags=subprocess.CREATE_NEW_PROCESS_GROUP)
|
||||
deadline = time.monotonic()+30
|
||||
while not self.stop.is_set():
|
||||
if self.backend.poll() is not None:
|
||||
raise RuntimeError(f'Backend exited with code {self.backend.returncode}.')
|
||||
try:
|
||||
with urlopen(f'http://127.0.0.1:{self.port}/api/games', timeout=1) as response:
|
||||
if response.status == 200:
|
||||
break
|
||||
except OSError:
|
||||
pass
|
||||
if time.monotonic()>deadline:
|
||||
raise RuntimeError('Backend did not become ready within 30 seconds.')
|
||||
self.stop.wait(.25)
|
||||
if self.stop.is_set():
|
||||
return
|
||||
print(f'Ready: {self.url}', flush=True)
|
||||
if not self.no_browser:
|
||||
try:
|
||||
webbrowser.open(self.url)
|
||||
except Exception as error:
|
||||
print(f'Open the URL above in your browser ({error}).', flush=True)
|
||||
while not self.stop.wait(.25):
|
||||
if self.backend.poll() is not None:
|
||||
raise RuntimeError(f'Backend exited with code {self.backend.returncode}.')
|
||||
|
||||
def shutdown(self):
|
||||
print('\nStopping backend and local PostgreSQL...', flush=True)
|
||||
try:
|
||||
if self.backend and self.backend.poll() is None:
|
||||
self.backend.terminate()
|
||||
self.backend.wait(timeout=1)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
if self.backend and self.backend.poll() is None:
|
||||
self.backend.kill()
|
||||
try:
|
||||
if self.local_db and PG_CTL.exists():
|
||||
self.pg('stop', '-m', 'fast', '-w', '-t', '2', timeout=3, quiet=True)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
# Closing the launcher still releases its Job Object and kills all
|
||||
# remaining descendants, including PostgreSQL's background processes.
|
||||
pass
|
||||
finally:
|
||||
self.finished.set()
|
||||
print('Stopped.', flush=True)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--no-browser', action='store_true', help='Do not open the dashboard in a browser.')
|
||||
args = parser.parse_args()
|
||||
if os.name != 'nt':
|
||||
parser.error('This launcher currently supports Windows.')
|
||||
os.chdir(ROOT)
|
||||
app = Dashboard(args.no_browser)
|
||||
try:
|
||||
app.start()
|
||||
return 0
|
||||
except KeyboardInterrupt:
|
||||
return 0
|
||||
except Exception as error:
|
||||
print(f'\nStartup error: {error}', file=sys.stderr, flush=True)
|
||||
return 1
|
||||
finally:
|
||||
app.shutdown()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user