mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Server panel: the launcher's Glass look, a page for each part, in-page dialogs
The panel takes the Glass look from docs/design-system.md: the sunset with pixel ridges, frosted plates, Geist and Bricolage Grotesque Heading, the theme's Hugeicons. What it already did gets a page each in a sidebar (overview, players, console, backups, crash reports), and confirm()/prompt() become in-page dialogs. The supervisor serves the theme's fonts and the mark; the CSP gains font-src 'self'. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Jh5A2PzdusBChqE38VZ3zD
This commit is contained in:
4 files changed
+1205
-433
No files matched your search
@@ -4,101 +4,210 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="referrer" content="no-referrer">
|
||||
<meta name="color-scheme" content="dark light">
|
||||
<title>Horizon server</title>
|
||||
<link rel="icon" href="/mark.png">
|
||||
<link rel="stylesheet" href="/panel.css">
|
||||
<script src="/panel.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="title">
|
||||
<h1>Horizon server</h1>
|
||||
<span id="phase" class="pill">…</span>
|
||||
<span id="summary" class="muted"></span>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button id="restart" type="button">Restart</button>
|
||||
<button id="stop" type="button" class="danger">Stop</button>
|
||||
</div>
|
||||
</header>
|
||||
<!-- The Horizon sky, drawn once: a sunset and three pixel ridges (docs/design-system.md, Glass). -->
|
||||
<div class="scene" aria-hidden="true">
|
||||
<svg class="ridges" viewBox="0 0 320 90" preserveAspectRatio="none" shape-rendering="crispEdges">
|
||||
<path class="ridge far" d="M0 50h8V56h8V57h14V51h6V49h12V45h14V39h8V45h12V43h12V49h8V55h6V57h8V55h8V53h14V57h12V57h8V51h6V47h10V41h14V37h14V36h6V40h14V46h14V48h10V54h12V57h8V53h10V51h14V55h12V49h14V47h4V90H0z"/>
|
||||
<path class="ridge mid" d="M0 62h8V58h10V52h16V58h8V54h14V52h32V52h28V58h14V52h38V52h8V52h18V54h12V52h18V58h14V60h16V64h16V67h18V63h8V61h16V57h8V90H0z"/>
|
||||
<path class="ridge near" d="M0 74h12V68h48V68h10V74h10V77h32V75h12V77h14V73h12V68h32V68h10V70h24V72h22V68h28V72h16V74h16V72h22V76h10V90H0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<p id="update" class="notice" role="status" hidden>
|
||||
<span id="update-text"></span>
|
||||
<button id="update-button" type="button" hidden>Update</button>
|
||||
</p>
|
||||
<svg class="sprite" aria-hidden="true">
|
||||
<!-- Hugeicons (MIT), from crates/horizon-theme/icons/glass -->
|
||||
<symbol id="i-play" viewBox="0 0 24 24" fill="none"><path d="M18.8906 12.846C18.5371 14.189 16.8667 15.138 13.5257 17.0361C10.296 18.8709 8.6812 19.7884 7.37983 19.4196C6.8418 19.2671 6.35159 18.9776 5.95624 18.5787C5 17.6139 5 15.7426 5 12C5 8.2574 5 6.3861 5.95624 5.42132C6.35159 5.02245 6.8418 4.73288 7.37983 4.58042C8.6812 4.21165 10.296 5.12907 13.5257 6.96393C16.8667 8.86197 18.5371 9.811 18.8906 11.154C19.0365 11.7084 19.0365 12.2916 18.8906 12.846Z" stroke="currentColor" stroke-linejoin="round" stroke-width="1.6"/></symbol>
|
||||
<symbol id="i-stop" viewBox="0 0 24 24" fill="none"><path d="M4 12C4 8.72077 4 7.08116 4.81382 5.91891C5.1149 5.48891 5.48891 5.1149 5.91891 4.81382C7.08116 4 8.72077 4 12 4C15.2792 4 16.9188 4 18.0811 4.81382C18.5111 5.1149 18.8851 5.48891 19.1862 5.91891C20 7.08116 20 8.72077 20 12C20 15.2792 20 16.9188 19.1862 18.0811C18.8851 18.5111 18.5111 18.8851 18.0811 19.1862C16.9188 20 15.2792 20 12 20C8.72077 20 7.08116 20 5.91891 19.1862C5.48891 18.8851 5.1149 18.5111 4.81382 18.0811C4 16.9188 4 15.2792 4 12Z" stroke="currentColor" stroke-width="1.6"/></symbol>
|
||||
<symbol id="i-refresh" viewBox="0 0 24 24" fill="none"><path d="M20.0092 2V5.13219C20.0092 5.42605 19.6418 5.55908 19.4537 5.33333C17.6226 3.2875 14.9617 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22C17.5228 22 22 17.5228 22 12" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/></symbol>
|
||||
<symbol id="i-dashboard-square-01" viewBox="0 0 24 24" fill="none"><path d="M13.6903 19.4567C13.5 18.9973 13.5 18.4149 13.5 17.25C13.5 16.0851 13.5 15.5027 13.6903 15.0433C13.944 14.4307 14.4307 13.944 15.0433 13.6903C15.5027 13.5 16.0851 13.5 17.25 13.5C18.4149 13.5 18.9973 13.5 19.4567 13.6903C20.0693 13.944 20.556 14.4307 20.8097 15.0433C21 15.5027 21 16.0851 21 17.25C21 18.4149 21 18.9973 20.8097 19.4567C20.556 20.0693 20.0693 20.556 19.4567 20.8097C18.9973 21 18.4149 21 17.25 21C16.0851 21 15.5027 21 15.0433 20.8097C14.4307 20.556 13.944 20.0693 13.6903 19.4567Z" stroke="currentColor" stroke-linecap="square" stroke-linejoin="round" stroke-width="1.6"/><path d="M13.6903 8.95671C13.5 8.49728 13.5 7.91485 13.5 6.75C13.5 5.58515 13.5 5.00272 13.6903 4.54329C13.944 3.93072 14.4307 3.44404 15.0433 3.1903C15.5027 3 16.0851 3 17.25 3C18.4149 3 18.9973 3 19.4567 3.1903C20.0693 3.44404 20.556 3.93072 20.8097 4.54329C21 5.00272 21 5.58515 21 6.75C21 7.91485 21 8.49728 20.8097 8.95671C20.556 9.56928 20.0693 10.056 19.4567 10.3097C18.9973 10.5 18.4149 10.5 17.25 10.5C16.0851 10.5 15.5027 10.5 15.0433 10.3097C14.4307 10.056 13.944 9.56928 13.6903 8.95671Z" stroke="currentColor" stroke-linecap="square" stroke-linejoin="round" stroke-width="1.6"/><path d="M3.1903 19.4567C3 18.9973 3 18.4149 3 17.25C3 16.0851 3 15.5027 3.1903 15.0433C3.44404 14.4307 3.93072 13.944 4.54329 13.6903C5.00272 13.5 5.58515 13.5 6.75 13.5C7.91485 13.5 8.49728 13.5 8.95671 13.6903C9.56928 13.944 10.056 14.4307 10.3097 15.0433C10.5 15.5027 10.5 16.0851 10.5 17.25C10.5 18.4149 10.5 18.9973 10.3097 19.4567C10.056 20.0693 9.56928 20.556 8.95671 20.8097C8.49728 21 7.91485 21 6.75 21C5.58515 21 5.00272 21 4.54329 20.8097C3.93072 20.556 3.44404 20.0693 3.1903 19.4567Z" stroke="currentColor" stroke-linecap="square" stroke-linejoin="round" stroke-width="1.6"/><path d="M3.1903 8.95671C3 8.49728 3 7.91485 3 6.75C3 5.58515 3 5.00272 3.1903 4.54329C3.44404 3.93072 3.93072 3.44404 4.54329 3.1903C5.00272 3 5.58515 3 6.75 3C7.91485 3 8.49728 3 8.95671 3.1903C9.56928 3.44404 10.056 3.93072 10.3097 4.54329C10.5 5.00272 10.5 5.58515 10.5 6.75C10.5 7.91485 10.5 8.49728 10.3097 8.95671C10.056 9.56928 9.56928 10.056 8.95671 10.3097C8.49728 10.5 7.91485 10.5 6.75 10.5C5.58515 10.5 5.00272 10.5 4.54329 10.3097C3.93072 10.056 3.44404 9.56928 3.1903 8.95671Z" stroke="currentColor" stroke-linecap="square" stroke-linejoin="round" stroke-width="1.6"/></symbol>
|
||||
<symbol id="i-user-multiple-02" viewBox="0 0 24 24" fill="none"><path d="M18.5 7C18.5 9.20914 16.7091 11 14.5 11C12.2909 11 10.5 9.20914 10.5 7C10.5 4.79086 12.2909 3 14.5 3C16.7091 3 18.5 4.79086 18.5 7Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/><path d="M8 11C6.34315 11 5 9.65685 5 8C5 6.34315 6.34315 5 8 5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/><path d="M14.5 14C9.8125 14 7 16.5 7 19C7 20.1046 7.83947 21 8.875 21H20.125C21.1605 21 22 20.1046 22 19C22 16.5 19.1875 14 14.5 14Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/><path d="M4 19.9992H3.5C2.67157 19.9992 2 19.2317 2 18.2849C2 16.673 3.27307 15.0612 5.5 14.3613" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/></symbol>
|
||||
<symbol id="i-computer-terminal-01" viewBox="0 0 24 24" fill="none"><path d="M7.5 7.5L8.72654 8.55719C9.24218 9.00163 9.5 9.22386 9.5 9.5C9.5 9.77614 9.24218 9.99836 8.72654 10.4428L7.5 11.5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/><path d="M11.5 12.5H15.5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/><path d="M12 21C15.7497 21 17.6246 21 18.9389 20.0451C19.3634 19.7367 19.7367 19.3634 20.0451 18.9389C21 17.6246 21 15.7497 21 12C21 8.25027 21 6.3754 20.0451 5.06107C19.7367 4.6366 19.3634 4.26331 18.9389 3.95491C17.6246 3 15.7497 3 12 3C8.25027 3 6.3754 3 5.06107 3.95491C4.6366 4.26331 4.26331 4.6366 3.95491 5.06107C3 6.3754 3 8.25027 3 12C3 15.7497 3 17.6246 3.95491 18.9389C4.26331 19.3634 4.6366 19.7367 5.06107 20.0451C6.3754 21 8.25027 21 12 21Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/></symbol>
|
||||
<symbol id="i-sliders-horizontal" viewBox="0 0 24 24" fill="none"><path d="M2.99982 12.0002H10.9998" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/><path d="M13.9998 5.00015L20.9998 5.00015" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/><path d="M11.9998 19.0002L20.9998 19.0002" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/><path d="M2.99982 5.00015H9.99982M9.99982 5.00015V3.00015M9.99982 5.00015V7.00015" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/><path d="M2.99982 19.0002H7.99982M7.99982 19.0002V17.0002M7.99982 19.0002V21.0002" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/><path d="M14.9998 12.0002L20.9998 12.0002M14.9998 12.0002V10.0002M14.9998 12.0002V14.0002" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/></symbol>
|
||||
<symbol id="i-folder-01" viewBox="0 0 24 24" fill="none"><path d="M13.6656 6.49446H16C18.8284 6.49446 20.2426 6.49446 21.1213 7.37314C22 8.25182 22 9.66603 22 12.4945V14.5C22 17.3284 22 18.7426 21.1213 19.6213C20.2426 20.5 18.8284 20.5 16 20.5H8C5.17157 20.5 3.75736 20.5 2.87868 19.6213C2 18.7426 2 17.3284 2 14.5V8.99183C2 6.65409 2 5.48521 2.61604 4.67881C2.7778 4.46706 2.96706 4.2778 3.17881 4.11604C3.98521 3.5 5.15409 3.5 7.49183 3.5C8.16633 3.5 8.50358 3.5 8.81392 3.60442C8.8976 3.63257 8.97932 3.66623 9.05854 3.70518C9.35239 3.84965 9.59179 4.08719 10.0706 4.56227L10.8483 5.33389C11.4254 5.9066 11.714 6.19295 12.08 6.3437C12.446 6.49446 12.8525 6.49446 13.6656 6.49446Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/></symbol>
|
||||
<symbol id="i-puzzle" viewBox="0 0 24 24" fill="none"><path d="M12.828 6.00096C12.9388 5.68791 12.999 5.35099 12.999 5C12.999 3.34315 11.6559 2 9.99904 2C8.34219 2 6.99904 3.34315 6.99904 5C6.99904 5.35099 7.05932 5.68791 7.17008 6.00096C4.88532 6.0093 3.66601 6.09039 2.87772 6.87868C2.08951 7.66689 2.00836 8.88603 2 11.1704C2.31251 11.06 2.64876 11 2.99904 11C4.6559 11 5.99904 12.3431 5.99904 14C5.99904 15.6569 4.6559 17 2.99904 17C2.64876 17 2.31251 16.94 2 16.8296C2.00836 19.114 2.08951 20.3331 2.87772 21.1213C3.66593 21.9095 4.88508 21.9907 7.16941 21.999C7.05908 21.6865 6.99904 21.3503 6.99904 21C6.99904 19.3431 8.34219 18 9.99904 18C11.6559 18 12.999 19.3431 12.999 21C12.999 21.3503 12.939 21.6865 12.8287 21.999C15.113 21.9907 16.3322 21.9095 17.1204 21.1213C17.9086 20.333 17.9897 19.1137 17.9981 16.829C18.3111 16.9397 18.648 17 18.999 17C20.6559 17 21.999 15.6569 21.999 14C21.999 12.3431 20.6559 11 18.999 11C18.648 11 18.3111 11.0603 17.9981 11.171C17.9897 8.88627 17.9086 7.66697 17.1204 6.87868C16.3321 6.09039 15.1128 6.0093 12.828 6.00096Z" stroke="currentColor" stroke-linejoin="round" stroke-width="1.6"/></symbol>
|
||||
<symbol id="i-information-circle" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="10" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/><path d="M12 16V12" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/><path d="M12.125 8.25H12M12.25 8.25C12.25 8.11193 12.1381 8 12 8C11.8619 8 11.75 8.11193 11.75 8.25C11.75 8.38807 11.8619 8.5 12 8.5C12.1381 8.5 12.25 8.38807 12.25 8.25Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/></symbol>
|
||||
<symbol id="i-download-04" viewBox="0 0 24 24" fill="none"><path d="M16.9504 12.1817C17.1981 12.814 16.5076 13.5726 15.1267 15.0899C13.6702 16.6902 12.9201 17.4904 12 17.5C11.0799 17.4904 10.3298 16.6902 8.87331 15.0899C7.49239 13.5726 6.80193 12.814 7.04964 12.1817C7.05868 12.1586 7.06851 12.1359 7.0791 12.1135C7.34928 11.542 8.24477 11.5029 10 11.5002V4.99998C10 4.53501 10 4.30253 10.0511 4.11179C10.1898 3.59414 10.5941 3.1898 11.1118 3.05111C11.3025 3 11.535 3 12 3C12.4649 3 12.6974 3 12.8882 3.05111C13.4058 3.1898 13.8102 3.59414 13.9489 4.11179C14 4.30253 14 4.53501 14 4.99998V11.5002C15.7552 11.5029 16.6507 11.542 16.9209 12.1135C16.9315 12.1359 16.9413 12.1586 16.9504 12.1817Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/><path d="M5.00006 21H19.0001" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/></symbol>
|
||||
<symbol id="i-delete-02" viewBox="0 0 24 24" fill="none"><path d="M18.8516 16.3743L19.5 6H4.5L5.14839 16.3743C5.315 19.0401 5.39831 20.373 6.26426 21.1865C7.13021 22 8.46571 22 11.1367 22H12.8633C15.5343 22 16.8698 22 17.7357 21.1865C18.6017 20.373 18.685 19.0401 18.8516 16.3743Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/><path d="M16 6H21M16 6L15.3778 4.34082C15.0007 3.33524 14.8122 2.83245 14.4402 2.50292C14.3213 2.39765 14.1904 2.3069 14.0501 2.2326C13.6109 2 13.074 2 12 2C10.926 2 10.3891 2 9.9499 2.2326C9.8096 2.3069 9.67865 2.39765 9.55982 2.50292C9.18783 2.83245 8.99928 3.33524 8.62219 4.34082L8 6M16 6H8M3 6H8" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/><path d="M9.5 11V17M14.5 11V17" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/></symbol>
|
||||
<symbol id="i-plus-sign" viewBox="0 0 24 24" fill="none"><path d="M12 4V20M20 12H4" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/></symbol>
|
||||
<symbol id="i-search-01" viewBox="0 0 24 24" fill="none"><path d="M17 17L21 21" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/><path d="M19 11C19 6.58172 15.4183 3 11 3C6.58172 3 3 6.58172 3 11C3 15.4183 6.58172 19 11 19C15.4183 19 19 15.4183 19 11Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/></symbol>
|
||||
<symbol id="i-cancel-01" viewBox="0 0 24 24" fill="none"><path d="M18.9991 19L5 5.00098" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/><path d="M19 5L5.00092 18.999" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/></symbol>
|
||||
<symbol id="i-tick-02" viewBox="0 0 24 24" fill="none"><path d="M5 14L8.5 17.5L19 6.5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/></symbol>
|
||||
<symbol id="i-copy-01" viewBox="0 0 24 24" fill="none"><path d="M7.5 14.5C7.5 11.2002 7.5 9.55025 8.52513 8.52513C9.55025 7.5 11.2002 7.5 14.5 7.5C17.7998 7.5 19.4497 7.5 20.4749 8.52513C21.5 9.55025 21.5 11.2002 21.5 14.5C21.5 17.7998 21.5 19.4497 20.4749 20.4749C19.4497 21.5 17.7998 21.5 14.5 21.5C11.2002 21.5 9.55025 21.5 8.52513 20.4749C7.5 19.4497 7.5 17.7998 7.5 14.5Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/><path d="M7.5 16.5C6.10355 16.5 5.40533 16.5 4.84402 16.3036C3.83866 15.9518 3.0482 15.1613 2.69641 14.156C2.5 13.5947 2.5 12.8964 2.5 11.5V9.5C2.5 6.20017 2.5 4.55025 3.52513 3.52513C4.55025 2.5 6.20017 2.5 9.5 2.5H11.5C12.8964 2.5 13.5947 2.5 14.156 2.69641C15.1613 3.0482 15.9518 3.83866 16.3036 4.84402C16.5 5.40533 16.5 6.10355 16.5 7.5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/></symbol>
|
||||
<symbol id="i-globe-02" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="1.6"/><path d="M8 12C8 18 12 22 12 22C12 22 16 18 16 12C16 6 12 2 12 2C12 2 8 6 8 12Z" stroke="currentColor" stroke-linejoin="round" stroke-width="1.6"/><path d="M21 15H3" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/><path d="M21 9H3" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/></symbol>
|
||||
<symbol id="i-arrow-down-01" viewBox="0 0 24 24" fill="none"><path d="M18 9.00005C18 9.00005 13.5811 15 12 15C10.4188 15 6 9 6 9" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/></symbol>
|
||||
<symbol id="i-user" viewBox="0 0 24 24" fill="none"><path d="M20 21.0001C19.713 17.269 16.7289 14.3151 12.995 14.0662L12 13.9999C11.6446 14.0096 11.3134 14.0225 11.0008 14.0378C7.3 14.2192 4.28417 17.3057 4 21.0001" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/><circle cx="12" cy="6.99988" r="4" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/></symbol>
|
||||
</svg>
|
||||
|
||||
<main id="app" hidden>
|
||||
<section class="charts" aria-label="Last 10 minutes">
|
||||
<figure><figcaption>TPS <b id="tps-now">–</b></figcaption><canvas id="tps" width="320" height="96"></canvas></figure>
|
||||
<figure><figcaption>MSPT <b id="mspt-now">–</b></figcaption><canvas id="mspt" width="320" height="96"></canvas></figure>
|
||||
<figure><figcaption>RAM <b id="ram-now">–</b></figcaption><canvas id="ram" width="320" height="96"></canvas></figure>
|
||||
<figure><figcaption>CPU <b id="cpu-now">–</b></figcaption><canvas id="cpu" width="320" height="96"></canvas></figure>
|
||||
</section>
|
||||
<div class="shell" id="app" hidden>
|
||||
<aside class="side">
|
||||
<div class="brand">
|
||||
<img src="/mark.png" alt="" width="28" height="28">
|
||||
<span>Horizon <b>server</b></span>
|
||||
</div>
|
||||
|
||||
<div class="columns">
|
||||
<section>
|
||||
<h2>Players <span id="player-count" class="muted"></span></h2>
|
||||
<table>
|
||||
<thead><tr><th>Name</th><th>Ping</th><th></th></tr></thead>
|
||||
<tbody id="players"></tbody>
|
||||
</table>
|
||||
<p id="no-players" class="muted">Nobody is online.</p>
|
||||
<div class="server-card plate">
|
||||
<div class="server-name" id="server-name">Server</div>
|
||||
<div class="server-address" id="server-address"></div>
|
||||
<div class="server-state"><span id="phase" class="state">…</span><span id="uptime" class="faint"></span></div>
|
||||
</div>
|
||||
|
||||
<nav class="nav" aria-label="Pages">
|
||||
<a href="#overview" data-page="overview"><svg><use href="#i-dashboard-square-01"/></svg>Overview</a>
|
||||
<a href="#players" data-page="players"><svg><use href="#i-user-multiple-02"/></svg>Players<span class="count" id="nav-players"></span></a>
|
||||
<a href="#console" data-page="console"><svg><use href="#i-computer-terminal-01"/></svg>Console</a>
|
||||
<a href="#backups" data-page="backups"><svg><use href="#i-folder-01"/></svg>Backups</a>
|
||||
<a href="#reports" data-page="reports"><svg><use href="#i-information-circle"/></svg>Crash reports<span class="count warn" id="nav-reports"></span></a>
|
||||
</nav>
|
||||
|
||||
<div class="side-foot">
|
||||
<span id="version" class="faint"></span>
|
||||
<button type="button" class="ghost small" id="sign-out">Sign out</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="content">
|
||||
<header class="top">
|
||||
<div>
|
||||
<h1 id="page-title">Overview</h1>
|
||||
<p id="page-lead" class="lead"></p>
|
||||
</div>
|
||||
<div class="power" role="group" aria-label="Server">
|
||||
<button type="button" id="start" class="primary" hidden><svg><use href="#i-play"/></svg>Start</button>
|
||||
<button type="button" id="restart"><svg><use href="#i-refresh"/></svg>Restart</button>
|
||||
<button type="button" id="stop" class="destructive"><svg><use href="#i-stop"/></svg>Stop</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div id="update" class="banner plate" role="status" hidden>
|
||||
<span class="dot accent"></span>
|
||||
<span id="update-text"></span>
|
||||
<button id="update-button" type="button" class="primary small" hidden>Update now</button>
|
||||
</div>
|
||||
|
||||
<!-- Overview -->
|
||||
<section class="page" data-page="overview">
|
||||
<div class="stats">
|
||||
<article class="stat plate"><header><span>TPS</span><b id="tps-now">–</b></header><canvas id="tps"></canvas><footer id="tps-note" class="faint">target 20</footer></article>
|
||||
<article class="stat plate"><header><span>Tick time</span><b id="mspt-now">–</b></header><canvas id="mspt"></canvas><footer id="mspt-note" class="faint">budget 50 ms</footer></article>
|
||||
<article class="stat plate"><header><span>Players</span><b id="players-now">–</b></header><canvas id="players-chart"></canvas><footer id="players-note" class="faint"></footer></article>
|
||||
<article class="stat plate"><header><span>Memory</span><b id="ram-now">–</b></header><canvas id="ram"></canvas><footer id="cpu-note" class="faint"></footer></article>
|
||||
</div>
|
||||
|
||||
<div class="grid-2">
|
||||
<article class="plate card">
|
||||
<header class="card-head"><h2>Online now</h2><a href="#players" class="link">All players</a></header>
|
||||
<ul class="rows" id="overview-players"></ul>
|
||||
<p class="empty" id="overview-no-players">Nobody is online.</p>
|
||||
</article>
|
||||
<article class="plate card">
|
||||
<header class="card-head"><h2>This server</h2></header>
|
||||
<dl class="facts" id="facts"></dl>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<article class="plate card">
|
||||
<header class="card-head"><h2>Latest in the console</h2><a href="#console" class="link">Open console</a></header>
|
||||
<pre class="log short" id="log-tail"></pre>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Backups</h2>
|
||||
<form id="backup-form" class="row">
|
||||
<input id="backup-name" placeholder="Name (optional)" maxlength="40" pattern="[A-Za-z0-9_.\-]*" autocomplete="off">
|
||||
<button type="submit">Back up now</button>
|
||||
</form>
|
||||
<table>
|
||||
<thead><tr><th>Backup</th><th>Size</th><th></th></tr></thead>
|
||||
<tbody id="backups"></tbody>
|
||||
</table>
|
||||
<p id="no-backups" class="muted">No backups yet.</p>
|
||||
<!-- Players -->
|
||||
<section class="page" data-page="players" hidden>
|
||||
<article class="plate card">
|
||||
<header class="card-head"><h2>Online</h2></header>
|
||||
<ul class="rows" id="players-online"></ul>
|
||||
<p class="empty" id="players-none">Nobody is online. Players show up here as they join.</p>
|
||||
</article>
|
||||
|
||||
<article class="plate card">
|
||||
<header class="card-head"><h2>Banned players</h2></header>
|
||||
<form class="inline-form" id="ban-form">
|
||||
<input name="target" placeholder="Player name" maxlength="16" required autocomplete="off" spellcheck="false" aria-label="Player name">
|
||||
<input name="reason" placeholder="Reason (optional)" maxlength="200" autocomplete="off" aria-label="Reason">
|
||||
<button type="submit" class="destructive">Ban</button>
|
||||
</form>
|
||||
<ul class="rows" id="bans"></ul>
|
||||
<p class="empty" id="bans-none">Nobody is banned.</p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<!-- Console -->
|
||||
<section class="page console-page" data-page="console" hidden>
|
||||
<article class="plate console">
|
||||
<pre class="log" id="log" tabindex="0" aria-live="off"></pre>
|
||||
<button type="button" class="jump small" id="jump" hidden><svg><use href="#i-arrow-down-01"/></svg>Latest</button>
|
||||
<form class="command" id="command-form">
|
||||
<span class="prompt" aria-hidden="true">/</span>
|
||||
<input id="command" placeholder="A command, like list or say Hello" autocomplete="off" spellcheck="false" aria-label="Console command">
|
||||
<button type="submit" class="primary">Run</button>
|
||||
</form>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<!-- Backups -->
|
||||
<section class="page" data-page="backups" hidden>
|
||||
<article class="plate card">
|
||||
<form class="inline-form" id="backup-form">
|
||||
<input id="backup-name" placeholder="Name (optional)" maxlength="40" pattern="[A-Za-z0-9_.\-]*" autocomplete="off" aria-label="Backup name">
|
||||
<button type="submit" class="primary"><svg><use href="#i-folder-01"/></svg>Back up now</button>
|
||||
</form>
|
||||
<p class="faint note" id="backup-schedule"></p>
|
||||
<ul class="rows" id="backups"></ul>
|
||||
<p class="empty" id="no-backups">No backups yet.</p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<!-- Crash reports -->
|
||||
<section class="page" data-page="reports" hidden>
|
||||
<div class="split">
|
||||
<article class="plate card">
|
||||
<ul class="rows" id="crashes"></ul>
|
||||
<p class="empty" id="no-crashes">No crash reports.</p>
|
||||
</article>
|
||||
<article class="plate card viewer" id="crash-viewer" hidden>
|
||||
<header class="card-head"><h2 id="crash-name"></h2><button type="button" class="ghost small" id="crash-copy"><svg><use href="#i-copy-01"/></svg>Copy</button></header>
|
||||
<pre class="log" id="crash-text" tabindex="0"></pre>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="columns">
|
||||
<section>
|
||||
<h2>Banned players</h2>
|
||||
<table>
|
||||
<thead><tr><th>Name</th><th>Reason</th><th></th></tr></thead>
|
||||
<tbody id="bans"></tbody>
|
||||
</table>
|
||||
<p id="no-bans" class="muted">Nobody is banned.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Crash reports</h2>
|
||||
<table>
|
||||
<thead><tr><th>Report</th><th>Size</th><th></th></tr></thead>
|
||||
<tbody id="crashes"></tbody>
|
||||
</table>
|
||||
<p id="no-crashes" class="muted">No crash reports.</p>
|
||||
<pre id="crash-text" tabindex="0" hidden></pre>
|
||||
</section>
|
||||
<main id="signed-out" class="gate" hidden>
|
||||
<div class="plate gate-card">
|
||||
<img src="/mark.png" alt="" width="48" height="48">
|
||||
<h1>Sign in to your server</h1>
|
||||
<p>Run <code>horizon-server panel</code> in the server's folder and open the link it prints.
|
||||
A link works once, for 10 minutes.</p>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<h2>Console</h2>
|
||||
<pre id="log" tabindex="0" aria-live="off"></pre>
|
||||
<form id="command-form" class="row">
|
||||
<input id="command" placeholder="A console command, like list or say Hello" autocomplete="off" spellcheck="false">
|
||||
<button type="submit">Run</button>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<main id="signed-out" class="message" hidden>
|
||||
<p>Sign in with a link from the server: run <code>horizon-server panel</code> in the server's folder and open
|
||||
the link it prints. Links work once, for 10 minutes.</p>
|
||||
<main id="gone" class="gate" hidden>
|
||||
<div class="plate gate-card">
|
||||
<img src="/mark.png" alt="" width="48" height="48">
|
||||
<h1>The server isn't answering</h1>
|
||||
<p>It may have stopped. Start it again with <code>horizon-server start</code>; this page reconnects by itself.</p>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<main id="gone" class="message" hidden>
|
||||
<p>The server isn't answering. It may have stopped: start it again with <code>horizon-server start</code>,
|
||||
then reload this page.</p>
|
||||
</main>
|
||||
<dialog id="dialog" class="plate">
|
||||
<form method="dialog">
|
||||
<h2 id="dialog-title"></h2>
|
||||
<p id="dialog-text"></p>
|
||||
<div id="dialog-fields"></div>
|
||||
<div class="dialog-actions">
|
||||
<button value="cancel" type="submit" class="ghost" formnovalidate>Cancel</button>
|
||||
<button value="ok" type="submit" id="dialog-ok" class="primary">OK</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<div id="toast" role="status" hidden></div>
|
||||
<div id="toasts" class="toasts" role="status" aria-live="polite"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,142 +1,419 @@
|
||||
/* The Horizon server panel. Colours and spacing are horizon-theme's tokens (crates/horizon-theme/tokens.css). */
|
||||
/* The Horizon server panel, in the launcher's Glass look (docs/design-system.md, Glass). The colours are
|
||||
horizon-theme's `--glass-*` and night-sky tokens (crates/horizon-theme/tokens.css); dark by default, the
|
||||
dawn when the system asks for light. */
|
||||
@font-face { font-family: "Geist"; font-weight: 400; font-display: swap; src: local("Geist"), url("/fonts/Geist-Regular.ttf") format("truetype"); }
|
||||
@font-face { font-family: "Geist"; font-weight: 500; font-display: swap; src: local("Geist Medium"), url("/fonts/Geist-Medium.ttf") format("truetype"); }
|
||||
@font-face { font-family: "Geist"; font-weight: 600; font-display: swap; src: local("Geist SemiBold"), url("/fonts/Geist-SemiBold.ttf") format("truetype"); }
|
||||
@font-face { font-family: "Geist Mono"; font-weight: 400; font-display: swap; src: local("Geist Mono"), url("/fonts/GeistMono-Regular.ttf") format("truetype"); }
|
||||
@font-face { font-family: "Bricolage Grotesque Heading"; font-weight: 600; font-display: swap; src: url("/fonts/BricolageGrotesqueHeading-SemiBold.ttf") format("truetype"); }
|
||||
|
||||
:root {
|
||||
--page: #fafafc;
|
||||
--plate-soft: #f3f3f8;
|
||||
--plate: #ebecf4;
|
||||
--surface: #ffffff;
|
||||
--rule: #e2e3ea;
|
||||
--ink: #16181f;
|
||||
--ink-soft: #3b3e49;
|
||||
--muted: #646875;
|
||||
--accent: #5b3fd6;
|
||||
--accent: #6b4bff;
|
||||
--accent-soft: #a9a9ff;
|
||||
--on-accent: #ffffff;
|
||||
--danger: #b4372a;
|
||||
--success: #1d7a48;
|
||||
--base: #1b1830;
|
||||
--page: #0a0919;
|
||||
--live: #4ade80;
|
||||
--warn: #f2c46b;
|
||||
--danger: #ff8a8a;
|
||||
--plate: rgba(78, 76, 95, 0.267);
|
||||
--plate-float: rgba(32, 28, 54, 0.82);
|
||||
--ink: #ffffff;
|
||||
--ink-80: rgba(255, 255, 255, 0.8);
|
||||
--ink-60: rgba(255, 255, 255, 0.6);
|
||||
--ink-45: rgba(255, 255, 255, 0.45);
|
||||
--fill-hover: rgba(255, 255, 255, 0.05);
|
||||
--fill-field: rgba(255, 255, 255, 0.07);
|
||||
--fill-button: rgba(255, 255, 255, 0.08);
|
||||
--fill-selected: rgba(255, 255, 255, 0.1);
|
||||
--fill-active: rgba(255, 255, 255, 0.12);
|
||||
--rule: rgba(255, 255, 255, 0.07);
|
||||
--well: rgba(6, 5, 16, 0.82);
|
||||
--sky-0: #07061a; --sky-1: #140f38; --sky-2: #2a1656; --sky-3: #5b2a78; --sky-4: #b0477d; --sky-5: #f08a5d;
|
||||
--ridge-far: #2a1a55; --ridge-mid: #1c1240; --ridge-near: #110b2a;
|
||||
--font: "Geist", ui-sans-serif, system-ui, sans-serif;
|
||||
--mono: "Geist Mono", ui-monospace, monospace;
|
||||
--radius: 8px;
|
||||
--radius-plate: 12px;
|
||||
color-scheme: light;
|
||||
--mono: "Geist Mono", ui-monospace, "SFMono-Regular", Menlo, monospace;
|
||||
--heading: "Bricolage Grotesque Heading", "Geist", ui-sans-serif, sans-serif;
|
||||
--blur: 24px;
|
||||
--r-panel: 20px;
|
||||
--r-menu: 16px;
|
||||
--r-row: 12px;
|
||||
--r-button: 10px;
|
||||
--quick: 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--pop: 200ms cubic-bezier(0.2, 0.7, 0.2, 1);
|
||||
color-scheme: dark;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--page: #0d0e13;
|
||||
--plate-soft: #181a22;
|
||||
--plate: #20222c;
|
||||
--surface: #16181f;
|
||||
--rule: #24262f;
|
||||
--ink: #ecedf3;
|
||||
--ink-soft: #c5c7d1;
|
||||
--muted: #8b8f9c;
|
||||
--accent: #9a86ff;
|
||||
--on-accent: #0d0e13;
|
||||
--danger: #f07a68;
|
||||
--success: #45c27a;
|
||||
color-scheme: dark;
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root:not([data-theme="dark"]) {
|
||||
--accent: #5b3fd6;
|
||||
--accent-soft: #5b3fd6;
|
||||
--page: #ece7f7;
|
||||
--live: #136336;
|
||||
--warn: #7a4f00;
|
||||
--danger: #a3301d;
|
||||
--plate: rgba(255, 255, 255, 0.84);
|
||||
--plate-float: rgba(255, 255, 255, 0.96);
|
||||
--ink: #1b1530;
|
||||
--ink-80: #3b3354;
|
||||
--ink-60: #585071;
|
||||
--ink-45: #6a6284;
|
||||
--fill-hover: rgba(27, 21, 48, 0.04);
|
||||
--fill-field: rgba(27, 21, 48, 0.05);
|
||||
--fill-button: rgba(27, 21, 48, 0.06);
|
||||
--fill-selected: rgba(91, 63, 214, 0.1);
|
||||
--fill-active: rgba(27, 21, 48, 0.1);
|
||||
--rule: rgba(59, 47, 120, 0.1);
|
||||
--well: rgba(243, 240, 251, 0.9);
|
||||
--sky-0: #c9d3f2; --sky-1: #dcd8f3; --sky-2: #ead5ec; --sky-3: #f5d3d8; --sky-4: #f9d6bd; --sky-5: #fbe4c6;
|
||||
--ridge-far: #cfc4ec; --ridge-mid: #bdb0e4; --ridge-near: #a99bd8;
|
||||
color-scheme: light;
|
||||
}
|
||||
}
|
||||
:root[data-theme="light"] {
|
||||
--accent: #5b3fd6;
|
||||
--accent-soft: #5b3fd6;
|
||||
--page: #ece7f7;
|
||||
--live: #136336;
|
||||
--warn: #7a4f00;
|
||||
--danger: #a3301d;
|
||||
--plate: rgba(255, 255, 255, 0.84);
|
||||
--plate-float: rgba(255, 255, 255, 0.96);
|
||||
--ink: #1b1530;
|
||||
--ink-80: #3b3354;
|
||||
--ink-60: #585071;
|
||||
--ink-45: #6a6284;
|
||||
--fill-hover: rgba(27, 21, 48, 0.04);
|
||||
--fill-field: rgba(27, 21, 48, 0.05);
|
||||
--fill-button: rgba(27, 21, 48, 0.06);
|
||||
--fill-selected: rgba(91, 63, 214, 0.1);
|
||||
--fill-active: rgba(27, 21, 48, 0.1);
|
||||
--rule: rgba(59, 47, 120, 0.1);
|
||||
--well: rgba(243, 240, 251, 0.9);
|
||||
--sky-0: #c9d3f2; --sky-1: #dcd8f3; --sky-2: #ead5ec; --sky-3: #f5d3d8; --sky-4: #f9d6bd; --sky-5: #fbe4c6;
|
||||
--ridge-far: #cfc4ec; --ridge-mid: #bdb0e4; --ridge-near: #a99bd8;
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
[hidden] { display: none !important; }
|
||||
html, body { background: var(--page); }
|
||||
body { position: relative; z-index: 0; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--page);
|
||||
min-height: 100vh;
|
||||
color: var(--ink);
|
||||
font: 14px/1.5 var(--font);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
background: var(--surface);
|
||||
code { font: 12.5px var(--mono); background: var(--fill-field); padding: 1px 6px; border-radius: 6px; }
|
||||
a { color: var(--accent-soft); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
.faint { color: var(--ink-60); }
|
||||
.sprite { display: none; }
|
||||
svg { width: 18px; height: 18px; flex: none; }
|
||||
|
||||
/* The sky behind everything. */
|
||||
.scene {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
background: linear-gradient(180deg, var(--sky-0) 0%, var(--sky-1) 38%, var(--sky-2) 62%, var(--sky-3) 78%, var(--sky-4) 90%, var(--sky-5) 100%);
|
||||
}
|
||||
.title { display: flex; flex-wrap: wrap; gap: 12px; align-items: baseline; }
|
||||
h1 { font-size: 18px; font-weight: 500; margin: 0; letter-spacing: -0.01em; }
|
||||
h2 { font-size: 13px; font-weight: 500; margin: 0 0 8px; color: var(--ink-soft); }
|
||||
main { padding: 24px; max-width: 1200px; margin: 0 auto; display: grid; gap: 24px; }
|
||||
section { background: var(--surface); border: 1px solid var(--rule); border-radius: var(--radius-plate); padding: 16px; }
|
||||
.muted { color: var(--muted); }
|
||||
.pill {
|
||||
font-size: 12px;
|
||||
padding: 2px 10px;
|
||||
border-radius: 999px;
|
||||
.ridges { position: absolute; left: 0; right: 0; bottom: 0; width: 100%; height: 26vh; opacity: 0.9; }
|
||||
.ridge.far { fill: var(--ridge-far); }
|
||||
.ridge.mid { fill: var(--ridge-mid); }
|
||||
.ridge.near { fill: var(--ridge-near); }
|
||||
|
||||
.plate {
|
||||
background: var(--plate);
|
||||
color: var(--ink-soft);
|
||||
-webkit-backdrop-filter: blur(var(--blur)) saturate(140%);
|
||||
backdrop-filter: blur(var(--blur)) saturate(140%);
|
||||
border-radius: var(--r-panel);
|
||||
}
|
||||
.pill.running { background: color-mix(in srgb, var(--success) 18%, transparent); color: var(--success); }
|
||||
.pill.stopping, .pill.restarting, .pill.starting { background: color-mix(in srgb, var(--accent) 16%, transparent); color: var(--accent); }
|
||||
.pill.down { background: color-mix(in srgb, var(--danger) 16%, transparent); color: var(--danger); }
|
||||
|
||||
.charts { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 16px; }
|
||||
figure { margin: 0; }
|
||||
figcaption { font-size: 12px; color: var(--muted); display: flex; justify-content: space-between; }
|
||||
figcaption b { color: var(--ink); font-weight: 500; font-variant-numeric: tabular-nums; }
|
||||
canvas { width: 100%; height: 96px; display: block; }
|
||||
/* Layout: a sidebar and the page. */
|
||||
.shell { display: grid; grid-template-columns: 248px minmax(0, 1fr); min-height: 100vh; }
|
||||
.side {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 20px 16px;
|
||||
}
|
||||
.brand { display: flex; align-items: center; gap: 10px; font-size: 15px; font-weight: 500; padding: 4px 8px; }
|
||||
.brand img { image-rendering: pixelated; border-radius: 6px; }
|
||||
.brand b { font-weight: 500; color: var(--ink-60); }
|
||||
.server-card { padding: 14px 16px; border-radius: var(--r-menu); }
|
||||
.server-name { font: 600 16px/1.3 var(--heading); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.server-address { font: 12px var(--mono); color: var(--ink-60); margin-top: 2px; overflow-wrap: anywhere; }
|
||||
.server-state { display: flex; align-items: center; gap: 8px; margin-top: 10px; font-size: 12px; }
|
||||
.state { display: inline-flex; align-items: center; gap: 6px; font-weight: 500; text-transform: capitalize; color: var(--ink-80); }
|
||||
.state::before { content: ""; width: 8px; height: 8px; border-radius: 50%; background: var(--ink-45); }
|
||||
.state.running { color: var(--live); }
|
||||
.state.running::before { background: var(--live); box-shadow: 0 0 0 4px color-mix(in srgb, var(--live) 22%, transparent); }
|
||||
.state.starting, .state.restarting, .state.stopping, .state.updating { color: var(--warn); }
|
||||
.state.starting::before, .state.restarting::before, .state.stopping::before, .state.updating::before { background: var(--warn); animation: pulse 1.2s ease-in-out infinite; }
|
||||
.state.down, .state.crashed { color: var(--danger); }
|
||||
.state.down::before, .state.crashed::before { background: var(--danger); }
|
||||
@keyframes pulse { 50% { opacity: 0.35; } }
|
||||
|
||||
.columns { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 24px; }
|
||||
table { width: 100%; border-collapse: collapse; font-variant-numeric: tabular-nums; }
|
||||
th { text-align: left; font-weight: 400; font-size: 12px; color: var(--muted); padding: 4px 0; }
|
||||
td { padding: 6px 0; border-top: 1px solid var(--rule); }
|
||||
td:last-child { text-align: right; white-space: nowrap; }
|
||||
td button { margin-left: 4px; }
|
||||
|
||||
button, input {
|
||||
font: inherit;
|
||||
height: 32px;
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--rule);
|
||||
background: var(--plate-soft);
|
||||
color: var(--ink);
|
||||
.nav { display: flex; flex-direction: column; gap: 2px; }
|
||||
.nav a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
height: 38px;
|
||||
padding: 0 12px;
|
||||
border-radius: var(--r-row);
|
||||
color: var(--ink-60);
|
||||
font-weight: 500;
|
||||
transition: background var(--quick), color var(--quick);
|
||||
}
|
||||
button { cursor: pointer; }
|
||||
button:hover { background: var(--plate); }
|
||||
button:disabled { opacity: 0.5; cursor: default; }
|
||||
button[type="submit"] { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
|
||||
button.danger { color: var(--danger); }
|
||||
input { background: var(--surface); flex: 1; min-width: 0; }
|
||||
input:focus, button:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||
.row { display: flex; gap: 8px; margin: 8px 0; }
|
||||
.nav a:hover { background: var(--fill-hover); color: var(--ink); text-decoration: none; }
|
||||
.nav a[aria-current="page"] { background: var(--fill-selected); color: var(--ink); }
|
||||
.nav a[aria-current="page"] svg { color: var(--accent-soft); }
|
||||
.count { margin-left: auto; font: 11px var(--mono); color: var(--ink-60); }
|
||||
.count:empty { display: none; }
|
||||
.count.warn { color: var(--danger); }
|
||||
.count.dot { width: 7px; height: 7px; border-radius: 50%; background: var(--accent-soft); }
|
||||
.side-foot { margin-top: auto; display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 0 8px; font-size: 12px; }
|
||||
|
||||
pre {
|
||||
font: 12px/1.5 var(--mono);
|
||||
background: var(--plate-soft);
|
||||
border-radius: var(--radius);
|
||||
padding: 12px;
|
||||
.content { padding: 28px 40px 48px 24px; display: flex; flex-direction: column; gap: 20px; min-width: 0; max-width: 1240px; }
|
||||
.top { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; flex-wrap: wrap; }
|
||||
.top h1 { font: 600 30px/1.15 var(--heading); letter-spacing: -0.01em; margin: 0; }
|
||||
.lead { margin: 4px 0 0; color: var(--ink-60); max-width: 60ch; }
|
||||
.lead:empty { display: none; }
|
||||
.power { display: flex; gap: 8px; }
|
||||
|
||||
.page { display: flex; flex-direction: column; gap: 20px; animation: arrive var(--pop) both; }
|
||||
@keyframes arrive { from { opacity: 0; transform: translateY(6px); } }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.page, .toast, dialog[open] { animation: none; }
|
||||
.state::before { animation: none !important; }
|
||||
}
|
||||
|
||||
/* Controls. */
|
||||
button, input, select {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
height: 36px;
|
||||
padding: 0 14px;
|
||||
border: 0;
|
||||
border-radius: var(--r-button);
|
||||
background: var(--fill-button);
|
||||
color: var(--ink);
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: background var(--quick), color var(--quick), opacity var(--quick);
|
||||
}
|
||||
button svg { width: 16px; height: 16px; }
|
||||
button:hover { background: var(--fill-active); }
|
||||
button:disabled { opacity: 0.4; cursor: default; }
|
||||
button.primary { background: var(--accent); color: var(--on-accent); }
|
||||
button.primary:hover { background: color-mix(in srgb, var(--accent) 86%, white); }
|
||||
button.destructive { color: var(--danger); }
|
||||
button.destructive:hover { background: color-mix(in srgb, var(--danger) 14%, transparent); }
|
||||
button.ghost { background: transparent; color: var(--ink-60); }
|
||||
button.ghost:hover { background: var(--fill-hover); color: var(--ink); }
|
||||
button.small { height: 30px; padding: 0 10px; font-size: 12px; }
|
||||
button.icon { width: 32px; height: 32px; padding: 0; }
|
||||
:focus-visible { outline: 2px solid var(--accent-soft); outline-offset: 2px; }
|
||||
|
||||
input, select {
|
||||
height: 38px;
|
||||
min-width: 0;
|
||||
padding: 0 12px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--r-button);
|
||||
background: var(--fill-field);
|
||||
transition: border-color var(--quick), background var(--quick);
|
||||
}
|
||||
input::placeholder { color: var(--ink-45); }
|
||||
input:focus, select:focus { outline: none; border-color: var(--accent-soft); background: var(--fill-button); }
|
||||
input:invalid:not(:placeholder-shown) { border-color: var(--danger); }
|
||||
select { padding-right: 32px; appearance: none; background-image: linear-gradient(45deg, transparent 50%, var(--ink-60) 50%), linear-gradient(135deg, var(--ink-60) 50%, transparent 50%); background-position: calc(100% - 17px) 17px, calc(100% - 12px) 17px; background-size: 5px 5px; background-repeat: no-repeat; }
|
||||
select option { background: var(--base); color: #fff; }
|
||||
|
||||
|
||||
/* Cards and rows. */
|
||||
.card { padding: 20px; display: flex; flex-direction: column; gap: 14px; min-width: 0; }
|
||||
.card-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.card-head h2 { margin: 0; font-size: 14px; font-weight: 600; }
|
||||
.link { font-size: 12px; font-weight: 500; }
|
||||
.grid-2 { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 20px; }
|
||||
.split { display: grid; grid-template-columns: minmax(280px, 380px) minmax(0, 1fr); gap: 20px; align-items: start; }
|
||||
.split:has(> .viewer[hidden]) { grid-template-columns: minmax(0, 1fr); }
|
||||
.empty { margin: 0; padding: 18px 4px; color: var(--ink-60); text-align: center; }
|
||||
.note { margin: -4px 0 0; font-size: 12px; }
|
||||
.note:empty { display: none; }
|
||||
|
||||
.rows { list-style: none; margin: 0 -8px; padding: 0; display: flex; flex-direction: column; }
|
||||
.rows:empty { display: none; }
|
||||
.rows li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-height: 52px;
|
||||
padding: 8px;
|
||||
border-radius: var(--r-row);
|
||||
transition: background var(--quick);
|
||||
}
|
||||
.rows li + li { box-shadow: inset 0 1px 0 var(--rule); }
|
||||
.rows li:hover { background: var(--fill-hover); box-shadow: none; }
|
||||
.rows li:hover + li { box-shadow: none; }
|
||||
.rows li.selected { background: var(--fill-selected); }
|
||||
.row-main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
|
||||
.row-main b { font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.row-main small { color: var(--ink-60); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.row-side { font: 12px var(--mono); color: var(--ink-60); white-space: nowrap; }
|
||||
.row-actions { display: flex; gap: 4px; opacity: 0.85; }
|
||||
.rows li:hover .row-actions, .rows li:focus-within .row-actions { opacity: 1; }
|
||||
.avatar {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 9px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font: 600 14px var(--heading);
|
||||
color: #fff;
|
||||
flex: none;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.file-icon { width: 34px; height: 34px; border-radius: 9px; display: grid; place-items: center; background: var(--fill-button); color: var(--ink-80); flex: none; }
|
||||
.ping { display: inline-flex; align-items: flex-end; gap: 2px; height: 12px; margin-right: 6px; vertical-align: -1px; }
|
||||
.ping i { width: 3px; border-radius: 1px; background: var(--fill-active); }
|
||||
.ping i:nth-child(1) { height: 4px; } .ping i:nth-child(2) { height: 8px; } .ping i:nth-child(3) { height: 12px; }
|
||||
.ping.good i { background: var(--live); }
|
||||
.ping.fair i:nth-child(-n+2) { background: var(--warn); }
|
||||
.ping.poor i:nth-child(1) { background: var(--danger); }
|
||||
.tag { font: 500 11px var(--font); padding: 2px 8px; border-radius: 999px; background: var(--fill-button); color: var(--ink-80); }
|
||||
.tag.danger { color: var(--danger); background: color-mix(in srgb, var(--danger) 14%, transparent); }
|
||||
|
||||
.facts { display: grid; grid-template-columns: max-content 1fr; gap: 10px 20px; margin: 0; }
|
||||
.facts dt { color: var(--ink-60); }
|
||||
.facts dd { margin: 0; font-weight: 500; overflow-wrap: anywhere; }
|
||||
.facts dd.mono { font: 12.5px var(--mono); font-weight: 400; }
|
||||
|
||||
.inline-form { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.inline-form input { flex: 1 1 180px; }
|
||||
|
||||
.banner { display: flex; align-items: center; gap: 12px; padding: 12px 16px; border-radius: var(--r-menu); }
|
||||
.banner.failed { color: var(--danger); }
|
||||
.banner #update-button { margin-left: auto; }
|
||||
.dot { width: 8px; height: 8px; border-radius: 50%; flex: none; }
|
||||
.dot.accent { background: var(--accent-soft); }
|
||||
|
||||
/* Overview statistics. */
|
||||
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 20px; }
|
||||
.stat { padding: 16px 18px 14px; display: flex; flex-direction: column; gap: 6px; min-width: 0; }
|
||||
.stat header { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; color: var(--ink-60); font-size: 12px; font-weight: 500; }
|
||||
.stat b { color: var(--ink); font: 600 26px/1.1 var(--heading); font-variant-numeric: tabular-nums; letter-spacing: -0.01em; }
|
||||
.stat b.bad { color: var(--danger); }
|
||||
.stat b.meh { color: var(--warn); }
|
||||
.stat b.none { color: var(--ink-45); font-weight: 500; }
|
||||
.stat canvas { width: 100%; height: 56px; display: block; }
|
||||
.stat footer { font-size: 11.5px; }
|
||||
|
||||
/* The console. */
|
||||
.console-page { flex: 1; }
|
||||
.console { display: flex; flex-direction: column; min-height: calc(100vh - 180px); position: relative; overflow: hidden; }
|
||||
.log {
|
||||
font: 12.5px/1.65 var(--mono);
|
||||
color: var(--ink-80);
|
||||
background: var(--well);
|
||||
margin: 0;
|
||||
height: 320px;
|
||||
padding: 14px 18px;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
flex: 1;
|
||||
min-height: 240px;
|
||||
max-height: calc(100vh - 300px);
|
||||
}
|
||||
.message { max-width: 560px; }
|
||||
code { font-family: var(--mono); font-size: 13px; }
|
||||
#toast {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--ink);
|
||||
color: var(--page);
|
||||
padding: 8px 16px;
|
||||
border-radius: var(--radius);
|
||||
max-width: 90vw;
|
||||
.console .log { border-radius: 0; height: 0; max-height: none; }
|
||||
.log.short { border-radius: var(--r-row); min-height: 0; max-height: 220px; }
|
||||
.card .log { border-radius: var(--r-row); }
|
||||
.log .line { display: block; }
|
||||
.log .t { color: var(--ink-45); }
|
||||
.log .warn { color: var(--warn); }
|
||||
.log .error { color: var(--danger); }
|
||||
.log .chat { color: var(--ink); }
|
||||
.log .you { color: var(--accent-soft); }
|
||||
.log .sup { color: var(--ink-60); }
|
||||
.jump { position: absolute; right: 20px; bottom: 76px; background: var(--plate-float); box-shadow: 0 6px 20px rgba(0, 0, 0, 0.35); }
|
||||
.command { display: flex; align-items: center; gap: 10px; padding: 12px 12px 12px 18px; box-shadow: inset 0 1px 0 var(--rule); }
|
||||
.command .prompt { font: 600 14px var(--mono); color: var(--accent-soft); }
|
||||
.command input { flex: 1; font-family: var(--mono); font-size: 13px; background: transparent; padding-left: 0; }
|
||||
.command input:focus { background: transparent; border-color: transparent; }
|
||||
|
||||
/* Sign-in and gone. */
|
||||
.gate { min-height: 100vh; display: grid; place-items: center; padding: 16px; }
|
||||
.gate-card { max-width: 440px; padding: 32px; text-align: center; display: flex; flex-direction: column; align-items: center; gap: 12px; }
|
||||
.gate-card img { image-rendering: pixelated; border-radius: 10px; }
|
||||
.gate-card h1 { font: 600 24px/1.2 var(--heading); margin: 4px 0 0; }
|
||||
.gate-card p { margin: 0; color: var(--ink-80); }
|
||||
|
||||
/* Dialog and toasts. */
|
||||
dialog {
|
||||
border: 0;
|
||||
padding: 24px;
|
||||
width: min(440px, calc(100vw - 32px));
|
||||
color: var(--ink);
|
||||
background: var(--plate-float);
|
||||
border-radius: 28px;
|
||||
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
dialog[open] { animation: arrive var(--pop) both; }
|
||||
dialog::backdrop { background: rgba(5, 4, 14, 0.55); -webkit-backdrop-filter: blur(4px); backdrop-filter: blur(4px); }
|
||||
dialog h2 { margin: 0 0 6px; font: 600 20px/1.25 var(--heading); }
|
||||
dialog p { margin: 0 0 16px; color: var(--ink-80); }
|
||||
#dialog-fields { display: flex; flex-direction: column; gap: 10px; margin-bottom: 16px; }
|
||||
#dialog-fields:empty { display: none; }
|
||||
#dialog-fields label { display: flex; flex-direction: column; gap: 6px; font-size: 12px; color: var(--ink-60); }
|
||||
.dialog-actions { display: flex; justify-content: flex-end; gap: 8px; }
|
||||
.toasts { position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%); display: flex; flex-direction: column; gap: 8px; align-items: center; z-index: 10; pointer-events: none; width: max-content; max-width: calc(100vw - 32px); }
|
||||
.toast { padding: 10px 16px; border-radius: var(--r-row); background: var(--plate-float); -webkit-backdrop-filter: blur(var(--blur)); backdrop-filter: blur(var(--blur)); box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35); white-space: pre-line; animation: arrive var(--pop) both; }
|
||||
.toast.error { color: var(--danger); }
|
||||
|
||||
/* Phones and narrow windows: the pages as a bar at the top. */
|
||||
@media (max-width: 860px) {
|
||||
.shell { grid-template-columns: minmax(0, 1fr); }
|
||||
.side { position: sticky; top: 0; z-index: 5; height: auto; padding: 12px 16px 8px; gap: 10px; background: color-mix(in srgb, var(--page) 70%, transparent); -webkit-backdrop-filter: blur(var(--blur)); backdrop-filter: blur(var(--blur)); }
|
||||
.brand { display: none; }
|
||||
.server-card { padding: 0; background: none; backdrop-filter: none; -webkit-backdrop-filter: none; display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||
.server-card .server-address { display: none; }
|
||||
.server-state { margin: 0; }
|
||||
.nav { flex-direction: row; overflow-x: auto; scrollbar-width: none; margin: 0 -16px; padding: 0 16px; }
|
||||
.nav a { flex: none; height: 34px; }
|
||||
.nav .count { margin-left: 0; }
|
||||
.side-foot { display: none; }
|
||||
.content { padding: 16px 16px 40px; }
|
||||
.top h1 { font-size: 24px; }
|
||||
.power { width: 100%; }
|
||||
.power button { flex: 1; }
|
||||
.split { grid-template-columns: minmax(0, 1fr); }
|
||||
.rows li { flex-wrap: wrap; }
|
||||
.row-actions { width: 100%; justify-content: flex-end; margin-top: -4px; }
|
||||
.stats { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
|
||||
.stat { padding: 12px 14px; }
|
||||
.stat header { flex-direction: column; align-items: flex-start; gap: 2px; }
|
||||
.stat b { font-size: 21px; }
|
||||
.stat canvas { height: 40px; }
|
||||
.stat footer { display: none; }
|
||||
.console { min-height: calc(100vh - 230px); }
|
||||
}
|
||||
|
||||
.notice {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin: 16px max(24px, calc((100% - 1152px) / 2)) 0;
|
||||
padding: 10px 14px;
|
||||
border-radius: var(--radius);
|
||||
background: var(--plate-soft);
|
||||
border: 1px solid var(--rule);
|
||||
}
|
||||
.notice.failed {
|
||||
color: var(--danger);
|
||||
}
|
||||
button.destructive-fill { background: color-mix(in srgb, var(--danger) 80%, black); color: #fff; }
|
||||
button.destructive-fill:hover { background: color-mix(in srgb, var(--danger) 90%, black); }
|
||||
.mono { font-family: var(--mono); }
|
||||
.facts dt.mono { font-size: 12px; }
|
||||
@@ -1,14 +1,11 @@
|
||||
// The Horizon server panel (docs/server.md, section 6). It polls the control API once a second through
|
||||
// the panel's session; every change is a POST with the X-Horizon header. Text from the server is only
|
||||
// ever set with textContent.
|
||||
// The Horizon server panel (docs/server.md, section 6). It polls the control API once a second through the
|
||||
// panel's session; every change carries the X-Horizon header. Text from the server is only ever set with
|
||||
// textContent, never as HTML.
|
||||
"use strict";
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
let logNext = 0;
|
||||
let metricsSince = 0;
|
||||
const samples = [];
|
||||
let lastStatus = null;
|
||||
let backupsShown = "";
|
||||
|
||||
// --- Talking to the server -------------------------------------------------------------------------------
|
||||
|
||||
class SignedOut extends Error {}
|
||||
|
||||
@@ -30,24 +27,60 @@ async function api(method, path, body) {
|
||||
return value;
|
||||
}
|
||||
|
||||
function show(view) {
|
||||
for (const id of ["app", "signed-out", "gone"]) $(id).hidden = id !== view;
|
||||
for (const id of ["restart", "stop"]) $(id).disabled = view !== "app";
|
||||
/// Runs a change and says how it went; returns the reply, or null when it failed.
|
||||
async function act(method, path, body, done) {
|
||||
try {
|
||||
const reply = await api(method, path, body);
|
||||
const said = reply && Array.isArray(reply.output) ? reply.output.join("\n") : "";
|
||||
toast(said || done || "Done.");
|
||||
return reply;
|
||||
} catch (e) {
|
||||
if (e instanceof SignedOut) show("signed-out");
|
||||
toast(e.message, true);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
let toastTimer;
|
||||
function toast(text) {
|
||||
$("toast").textContent = text;
|
||||
$("toast").hidden = false;
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => ($("toast").hidden = true), 5000);
|
||||
// --- Small helpers -------------------------------------------------------------------------------------
|
||||
|
||||
/// An element: h("li", { class: "x", onclick }, child, "text", ...).
|
||||
function h(tag, props, ...children) {
|
||||
const el = document.createElement(tag);
|
||||
for (const [key, value] of Object.entries(props || {})) {
|
||||
if (value == null || value === false) continue;
|
||||
if (key.startsWith("on")) el.addEventListener(key.slice(2), value);
|
||||
else if (key === "class") el.className = value;
|
||||
else el.setAttribute(key, value === true ? "" : value);
|
||||
}
|
||||
for (const child of children.flat()) {
|
||||
if (child == null || child === false) continue;
|
||||
el.append(child instanceof Node ? child : document.createTextNode(String(child)));
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
function icon(name) {
|
||||
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||
const use = document.createElementNS("http://www.w3.org/2000/svg", "use");
|
||||
use.setAttribute("href", `#i-${name}`);
|
||||
svg.append(use);
|
||||
return svg;
|
||||
}
|
||||
|
||||
function button(label, action, { kind, iconName, small = true, title } = {}) {
|
||||
return h(
|
||||
"button",
|
||||
{ type: "button", class: [kind, small && "small"].filter(Boolean).join(" "), title, "aria-label": title, onclick: action },
|
||||
iconName && icon(iconName),
|
||||
label && h("span", { class: "label" }, label),
|
||||
);
|
||||
}
|
||||
|
||||
function duration(seconds) {
|
||||
if (seconds == null) return "";
|
||||
const d = Math.floor(seconds / 86400), h = Math.floor(seconds / 3600) % 24, m = Math.floor(seconds / 60) % 60;
|
||||
if (d) return `${d} d ${h} h`;
|
||||
if (h) return `${h} h ${m} min`;
|
||||
const d = Math.floor(seconds / 86400), hr = Math.floor(seconds / 3600) % 24, m = Math.floor(seconds / 60) % 60;
|
||||
if (d) return `${d} d ${hr} h`;
|
||||
if (hr) return `${hr} h ${m} min`;
|
||||
return m ? `${m} min` : `${seconds} s`;
|
||||
}
|
||||
|
||||
@@ -55,305 +88,610 @@ function bytes(n) {
|
||||
if (n == null) return "–";
|
||||
if (n >= 1e9) return `${(n / 1e9).toFixed(2)} GB`;
|
||||
if (n >= 1e6) return `${(n / 1e6).toFixed(1)} MB`;
|
||||
return `${Math.ceil(n / 1e3)} kB`;
|
||||
return `${Math.max(1, Math.ceil(n / 1e3))} kB`;
|
||||
}
|
||||
|
||||
function cell(text) {
|
||||
const td = document.createElement("td");
|
||||
td.textContent = text;
|
||||
return td;
|
||||
/// "2026-11-04_18-00-00-name.zip" (backup::file_name) → a date and the name given.
|
||||
function backupParts(name) {
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})_(\d{2})-(\d{2})-(\d{2})-?(.*?)(\.zip)?$/.exec(name);
|
||||
if (!m) return { when: null, label: name.replace(/\.zip$/, "") };
|
||||
const when = new Date(+m[1], +m[2] - 1, +m[3], +m[4], +m[5], +m[6]);
|
||||
return { when, label: m[7] };
|
||||
}
|
||||
|
||||
function button(label, action, danger) {
|
||||
const b = document.createElement("button");
|
||||
b.type = "button";
|
||||
b.textContent = label;
|
||||
if (danger) b.className = "danger";
|
||||
b.addEventListener("click", action);
|
||||
return b;
|
||||
const dateFormat = new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" });
|
||||
function ago(date) {
|
||||
const s = Math.round((Date.now() - date.getTime()) / 1000);
|
||||
if (s < 90) return "just now";
|
||||
if (s < 3600) return `${Math.round(s / 60)} min ago`;
|
||||
if (s < 86400) return `${Math.round(s / 3600)} h ago`;
|
||||
return `${Math.round(s / 86400)} d ago`;
|
||||
}
|
||||
|
||||
async function run(line) {
|
||||
const reply = await api("POST", "/api/v1/command", { line });
|
||||
return (reply.output || []).join("\n");
|
||||
/// A steady colour per player name, for the letter avatar (the panel loads no skins from elsewhere).
|
||||
function avatar(name) {
|
||||
let hash = 0;
|
||||
for (const c of name) hash = (hash * 31 + c.codePointAt(0)) >>> 0;
|
||||
const el = h("span", { class: "avatar", "aria-hidden": "true" }, name.slice(0, 1));
|
||||
el.style.background = `hsl(${hash % 360} 55% 42%)`;
|
||||
return el;
|
||||
}
|
||||
|
||||
function ping(ms) {
|
||||
const quality = ms == null ? "" : ms < 80 ? "good" : ms < 200 ? "fair" : "poor";
|
||||
return h("span", { class: "row-side" }, h("span", { class: `ping ${quality}`, "aria-hidden": "true" }, h("i"), h("i"), h("i")), ms == null ? "–" : `${ms} ms`);
|
||||
}
|
||||
|
||||
function fill(listId, emptyId, items) {
|
||||
$(listId).replaceChildren(...items);
|
||||
if (emptyId) $(emptyId).hidden = items.length > 0;
|
||||
}
|
||||
|
||||
// --- Toasts and the dialog ------------------------------------------------------------------------------
|
||||
|
||||
function toast(text, error) {
|
||||
const el = h("div", { class: `toast${error ? " error" : ""}` }, text);
|
||||
$("toasts").append(el);
|
||||
setTimeout(() => el.remove(), error ? 7000 : 4000);
|
||||
while ($("toasts").children.length > 3) $("toasts").firstChild.remove();
|
||||
}
|
||||
|
||||
/// Asks before a change: resolves to the fields' values (an object), or null when cancelled.
|
||||
function ask({ title, text, ok = "OK", danger = false, fields = [] }) {
|
||||
const dialog = $("dialog");
|
||||
$("dialog-title").textContent = title;
|
||||
$("dialog-text").textContent = text || "";
|
||||
$("dialog-text").hidden = !text;
|
||||
$("dialog-ok").textContent = ok;
|
||||
$("dialog-ok").className = danger ? "primary destructive-fill" : "primary";
|
||||
const inputs = fields.map((f) => {
|
||||
const input = f.options
|
||||
? h("select", { name: f.name }, f.options.map(([value, label]) => h("option", { value }, label)))
|
||||
: h("input", { name: f.name, placeholder: f.placeholder || "", maxlength: f.max || 200, autocomplete: "off" });
|
||||
return [f.name, input, h("label", {}, f.label, input)];
|
||||
});
|
||||
$("dialog-fields").replaceChildren(...inputs.map((i) => i[2]));
|
||||
dialog.returnValue = "";
|
||||
dialog.showModal();
|
||||
(inputs[0] ? inputs[0][1] : $("dialog-ok")).focus();
|
||||
return new Promise((resolve) => {
|
||||
dialog.addEventListener(
|
||||
"close",
|
||||
() => resolve(dialog.returnValue === "ok" ? Object.fromEntries(inputs.map(([n, i]) => [n, i.value.trim()])) : null),
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// --- Pages ----------------------------------------------------------------------------------------------
|
||||
|
||||
const PAGES = {
|
||||
overview: ["Overview", "How the server is doing right now."],
|
||||
players: ["Players", "Who is online, who may join and who is banned."],
|
||||
console: ["Console", "The server's log as it happens. Commands run as if typed in the server's console."],
|
||||
backups: ["Backups", "Zips of the world and the settings in backups/. Restoring keeps the current world as a backup."],
|
||||
reports: ["Crash reports", "The game writes one each time it stops unexpectedly."],
|
||||
};
|
||||
let page = "overview";
|
||||
|
||||
function route() {
|
||||
const wanted = location.hash.slice(1);
|
||||
page = PAGES[wanted] ? wanted : "overview";
|
||||
for (const section of document.querySelectorAll(".page")) section.hidden = section.dataset.page !== page;
|
||||
for (const link of document.querySelectorAll(".nav a")) {
|
||||
if (link.dataset.page === page) link.setAttribute("aria-current", "page");
|
||||
else link.removeAttribute("aria-current");
|
||||
}
|
||||
$("page-title").textContent = PAGES[page][0];
|
||||
$("page-lead").textContent = PAGES[page][1];
|
||||
document.title = `${PAGES[page][0]} · ${state.config ? state.config.server.motd : "Horizon server"}`;
|
||||
if (page === "console") scrollLog(true);
|
||||
if (state.status) renderUpdate(state.status.update);
|
||||
renderCharts();
|
||||
}
|
||||
|
||||
function show(view) {
|
||||
$("app").hidden = view !== "app";
|
||||
$("signed-out").hidden = view !== "signed-out";
|
||||
$("gone").hidden = view !== "gone";
|
||||
}
|
||||
|
||||
// --- State ----------------------------------------------------------------------------------------------
|
||||
|
||||
const state = {
|
||||
status: null,
|
||||
samples: [],
|
||||
metricsSince: 0,
|
||||
logNext: 0,
|
||||
lines: [],
|
||||
players: null,
|
||||
backups: [],
|
||||
crashes: [],
|
||||
config: null,
|
||||
};
|
||||
|
||||
// --- Status, power and update ---------------------------------------------------------------------------
|
||||
|
||||
function renderStatus(status) {
|
||||
lastStatus = status;
|
||||
state.status = status;
|
||||
const phase = $("phase");
|
||||
phase.textContent = status.phase;
|
||||
phase.className = `pill ${status.phase}`;
|
||||
const game = status.sample && status.sample.game;
|
||||
const parts = [`up ${duration(status.engine_uptime_seconds)}`, `horizon-server ${status.version}`];
|
||||
if (status.crashes) parts.push(`${status.crashes} crash${status.crashes > 1 ? "es" : ""}`);
|
||||
$("summary").textContent = parts.join(" · ");
|
||||
phase.className = `state ${status.phase}`;
|
||||
$("uptime").textContent = status.engine_uptime_seconds != null ? `· up ${duration(status.engine_uptime_seconds)}` : "";
|
||||
$("version").textContent = `horizon-server ${status.version}`;
|
||||
const stopped = status.phase === "stopped";
|
||||
$("start").hidden = !stopped;
|
||||
$("restart").hidden = stopped;
|
||||
const crashes = state.crashes.length;
|
||||
$("nav-reports").textContent = crashes ? String(crashes) : "";
|
||||
renderUpdate(status.update);
|
||||
|
||||
const players = (game && game.players) || [];
|
||||
$("player-count").textContent = game ? `${players.length} of ${game.max_players}` : "";
|
||||
$("no-players").hidden = players.length > 0;
|
||||
const body = $("players");
|
||||
body.replaceChildren(
|
||||
...players.map((p) => {
|
||||
const tr = document.createElement("tr");
|
||||
const actions = cell("");
|
||||
actions.append(
|
||||
button("Op", async () => toast(await run(`op ${p.name}`).catch((e) => e.message))),
|
||||
button("Kick", async () => {
|
||||
if (confirm(`Kick ${p.name}?`)) toast(await run(`kick ${p.name}`).catch((e) => e.message));
|
||||
}, true),
|
||||
button("Ban", async () => {
|
||||
const reason = prompt(`Ban ${p.name}? Reason (optional):`);
|
||||
if (reason === null) return;
|
||||
await api("POST", "/api/v1/players/ban", { name: p.name, reason: reason.trim() || undefined }).then(
|
||||
(r) => { toast(r.output.join("\n") || "Banned."); refreshLists(); },
|
||||
(e) => toast(e.message),
|
||||
);
|
||||
}, true),
|
||||
);
|
||||
tr.append(cell(p.name), cell(p.ping_ms == null ? "–" : `${p.ping_ms} ms`), actions);
|
||||
return tr;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// The banned players (GET /api/v1/players, "bans") with a Pardon each.
|
||||
function renderBans(bans) {
|
||||
$("no-bans").hidden = bans.length > 0;
|
||||
$("bans").replaceChildren(
|
||||
...bans.map((b) => {
|
||||
const tr = document.createElement("tr");
|
||||
const actions = cell("");
|
||||
actions.append(
|
||||
button("Pardon", async () => {
|
||||
await api("POST", "/api/v1/players/pardon", { name: b.name }).then(
|
||||
(r) => { toast(r.output.join("\n") || "Pardoned."); refreshLists(); },
|
||||
(e) => toast(e.message),
|
||||
);
|
||||
}),
|
||||
);
|
||||
tr.append(cell(b.name), cell(b.reason || "–"), actions);
|
||||
return tr;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// The crash reports (GET /api/v1/crash-reports) and one report's text.
|
||||
function renderCrashes(reports) {
|
||||
$("no-crashes").hidden = reports.length > 0;
|
||||
$("crashes").replaceChildren(
|
||||
...reports.map((r) => {
|
||||
const tr = document.createElement("tr");
|
||||
const actions = cell("");
|
||||
actions.append(
|
||||
button("Read", async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/v1/crash-reports/read?name=${encodeURIComponent(r.name)}`, {
|
||||
credentials: "same-origin",
|
||||
headers: { "X-Horizon": "1" },
|
||||
});
|
||||
if (!response.ok) throw new Error((await response.json().catch(() => ({}))).error || `error ${response.status}`);
|
||||
$("crash-text").textContent = await response.text();
|
||||
$("crash-text").hidden = false;
|
||||
$("crash-text").scrollIntoView({ block: "nearest" });
|
||||
} catch (e) {
|
||||
toast(e.message);
|
||||
}
|
||||
}),
|
||||
);
|
||||
tr.append(cell(r.name), cell(bytes(r.bytes)), actions);
|
||||
return tr;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function refreshLists() {
|
||||
const [players, crashes] = await Promise.all([
|
||||
api("GET", "/api/v1/players"),
|
||||
api("GET", "/api/v1/crash-reports"),
|
||||
]);
|
||||
renderBans(players.bans || []);
|
||||
renderCrashes(crashes.reports || []);
|
||||
renderOnline();
|
||||
renderFacts();
|
||||
}
|
||||
|
||||
// Updates (horizon-server update): a newer release, one under way, or how the last one ended.
|
||||
function renderUpdate(update) {
|
||||
const box = $("update");
|
||||
const offer = update && !update.busy && update.available;
|
||||
let text = "";
|
||||
if (update && update.busy) text = `Updating: ${update.busy}…`;
|
||||
else if (offer) text = `Horizon ${update.available} is out (this server runs ${update.installed}).`;
|
||||
else if (offer) text = `Horizon ${update.available} is out. This server runs ${update.installed}.`;
|
||||
else if (update && update.last && !update.ok) text = update.last;
|
||||
box.hidden = !text;
|
||||
box.className = update && update.last && !update.ok && !update.busy && !offer ? "notice failed" : "notice";
|
||||
$("update").hidden = !text || page !== "overview";
|
||||
$("update").classList.toggle("failed", Boolean(update && update.last && !update.ok && !update.busy && !offer));
|
||||
$("update-text").textContent = text;
|
||||
$("update-button").hidden = !offer;
|
||||
}
|
||||
|
||||
function renderBackups(list) {
|
||||
const key = JSON.stringify(list);
|
||||
if (key === backupsShown) return;
|
||||
backupsShown = key;
|
||||
$("no-backups").hidden = list.length > 0;
|
||||
$("backups").replaceChildren(
|
||||
...list.map((b) => {
|
||||
const tr = document.createElement("tr");
|
||||
const actions = cell("");
|
||||
actions.append(
|
||||
button("Restore", async () => {
|
||||
if (!confirm(`Restore ${b.name}? The server restarts with that world; the current one is kept in backups/.`)) return;
|
||||
try {
|
||||
await api("POST", "/api/v1/restore", { name: b.name });
|
||||
toast(`Restoring ${b.name}: the server is restarting.`);
|
||||
} catch (e) {
|
||||
toast(e.message);
|
||||
}
|
||||
}, true),
|
||||
);
|
||||
tr.append(cell(b.name), cell(bytes(b.bytes)), actions);
|
||||
return tr;
|
||||
}),
|
||||
);
|
||||
function onlinePlayers() {
|
||||
const game = state.status && state.status.sample && state.status.sample.game;
|
||||
return (game && game.players) || [];
|
||||
}
|
||||
|
||||
function appendLog(lines) {
|
||||
if (!lines.length) return;
|
||||
const log = $("log");
|
||||
const atBottom = log.scrollTop + log.clientHeight >= log.scrollHeight - 8;
|
||||
log.append(document.createTextNode(lines.join("\n") + "\n"));
|
||||
// Keep the page light: the last 2000 lines, as the server does.
|
||||
const text = log.textContent;
|
||||
const all = text.split("\n");
|
||||
if (all.length > 2100) log.textContent = all.slice(-2000).join("\n");
|
||||
if (atBottom) log.scrollTop = log.scrollHeight;
|
||||
function maxPlayers() {
|
||||
const game = state.status && state.status.sample && state.status.sample.game;
|
||||
return (game && game.max_players) || (state.config && state.config.server.max_players) || null;
|
||||
}
|
||||
|
||||
function chart(id, values, { max, unit, line, decimals = 1 }) {
|
||||
// --- Charts ---------------------------------------------------------------------------------------------
|
||||
|
||||
function sparkline(id, values, { max, line } = {}) {
|
||||
const canvas = $(id);
|
||||
if (!canvas || !canvas.offsetParent) return;
|
||||
const ratio = window.devicePixelRatio || 1;
|
||||
const width = canvas.clientWidth, height = canvas.clientHeight;
|
||||
if (canvas.width !== width * ratio) {
|
||||
canvas.width = width * ratio;
|
||||
canvas.height = height * ratio;
|
||||
if (canvas.width !== Math.round(width * ratio) || canvas.height !== Math.round(height * ratio)) {
|
||||
canvas.width = Math.round(width * ratio);
|
||||
canvas.height = Math.round(height * ratio);
|
||||
}
|
||||
const g = canvas.getContext("2d");
|
||||
g.setTransform(ratio, 0, 0, ratio, 0, 0);
|
||||
g.clearRect(0, 0, width, height);
|
||||
const style = getComputedStyle(document.documentElement);
|
||||
const css = getComputedStyle(document.documentElement);
|
||||
const accent = css.getPropertyValue("--accent-soft").trim();
|
||||
const known = values.filter((v) => v != null);
|
||||
const top = Math.max(max || 0, ...known, 1e-9) * 1.1;
|
||||
const top = Math.max(max || 0, ...known, 1e-9) * 1.08;
|
||||
const y = (v) => height - 2 - (v / top) * (height - 4);
|
||||
if (line != null) {
|
||||
g.strokeStyle = style.getPropertyValue("--rule");
|
||||
g.setLineDash([4, 4]);
|
||||
g.strokeStyle = css.getPropertyValue("--rule").trim();
|
||||
g.lineWidth = 1;
|
||||
g.setLineDash([3, 4]);
|
||||
g.beginPath();
|
||||
const y = height - (line / top) * height;
|
||||
g.moveTo(0, y);
|
||||
g.lineTo(width, y);
|
||||
g.moveTo(0, y(line));
|
||||
g.lineTo(width, y(line));
|
||||
g.stroke();
|
||||
g.setLineDash([]);
|
||||
}
|
||||
g.strokeStyle = style.getPropertyValue("--accent");
|
||||
g.lineWidth = 1.5;
|
||||
g.beginPath();
|
||||
const span = 600;
|
||||
let drawing = false;
|
||||
const x = (i) => width - ((values.length - 1 - i) / (span - 1)) * width;
|
||||
// Runs of known values, each drawn as a line over a soft fill.
|
||||
const runs = [];
|
||||
values.forEach((v, i) => {
|
||||
if (v == null) {
|
||||
drawing = false;
|
||||
return;
|
||||
}
|
||||
const x = width - ((values.length - 1 - i) / (span - 1)) * width;
|
||||
const y = height - (v / top) * height;
|
||||
if (drawing) g.lineTo(x, y);
|
||||
else g.moveTo(x, y);
|
||||
drawing = true;
|
||||
if (v == null) return;
|
||||
if (!runs.length || values[i - 1] == null) runs.push([]);
|
||||
runs[runs.length - 1].push([x(i), y(v)]);
|
||||
});
|
||||
g.stroke();
|
||||
const last = values[values.length - 1];
|
||||
$(`${id}-now`).textContent = last == null ? "–" : `${last.toFixed(decimals)}${unit}`;
|
||||
const gradient = g.createLinearGradient(0, 0, 0, height);
|
||||
gradient.addColorStop(0, `color-mix(in srgb, ${accent} 30%, transparent)`);
|
||||
gradient.addColorStop(1, "transparent");
|
||||
for (const run of runs) {
|
||||
g.beginPath();
|
||||
run.forEach(([px, py], i) => (i ? g.lineTo(px, py) : g.moveTo(px, py)));
|
||||
g.strokeStyle = accent;
|
||||
g.lineWidth = 1.6;
|
||||
g.lineJoin = "round";
|
||||
g.stroke();
|
||||
g.lineTo(run[run.length - 1][0], height);
|
||||
g.lineTo(run[0][0], height);
|
||||
g.closePath();
|
||||
g.fillStyle = gradient;
|
||||
g.fill();
|
||||
}
|
||||
}
|
||||
|
||||
function renderCharts() {
|
||||
chart("tps", samples.map((s) => s.tps), { max: 20, unit: "", line: 20 });
|
||||
chart("mspt", samples.map((s) => s.mspt), { max: 50, unit: " ms", line: 50 });
|
||||
chart("ram", samples.map((s) => (s.rss_bytes == null ? null : s.rss_bytes / 1e9)), { unit: " GB", decimals: 2 });
|
||||
chart("cpu", samples.map((s) => s.cpu_percent), { max: 100, unit: " %", decimals: 0 });
|
||||
$("ram-now").textContent = bytes(samples.length ? samples[samples.length - 1].rss_bytes : null);
|
||||
if (page !== "overview") return;
|
||||
const s = state.samples;
|
||||
const last = s[s.length - 1] || {};
|
||||
const tps = last.tps, mspt = last.mspt;
|
||||
$("tps-now").textContent = tps == null ? "–" : tps.toFixed(1);
|
||||
$("tps-now").className = tps == null ? "none" : tps < 15 ? "bad" : tps < 19.5 ? "meh" : "";
|
||||
$("mspt-now").textContent = mspt == null ? "–" : `${mspt.toFixed(1)} ms`;
|
||||
$("mspt-now").className = mspt == null ? "none" : mspt > 50 ? "bad" : mspt > 40 ? "meh" : "";
|
||||
$("mspt-note").textContent = last.mspt_p95 != null ? `95 % of ticks under ${last.mspt_p95.toFixed(1)} ms · budget 50 ms` : "budget 50 ms";
|
||||
const online = onlinePlayers().length, cap = maxPlayers();
|
||||
$("players-now").textContent = state.status && state.status.sample && state.status.sample.game ? `${online}${cap ? ` / ${cap}` : ""}` : "–";
|
||||
const peak = Math.max(0, ...s.map((x) => x.players || 0));
|
||||
$("players-note").textContent = s.length ? `peak ${peak} in the last ${Math.max(1, Math.round(s.length / 60))} min` : "";
|
||||
$("ram-now").textContent = bytes(last.rss_bytes);
|
||||
$("cpu-note").textContent = last.cpu_percent == null ? "" : `CPU ${Math.round(last.cpu_percent)} % of a core`;
|
||||
sparkline("tps", s.map((x) => x.tps), { max: 20, line: 20 });
|
||||
sparkline("mspt", s.map((x) => x.mspt), { max: 50, line: 50 });
|
||||
sparkline("players-chart", s.map((x) => x.players), { max: cap || 1 });
|
||||
sparkline("ram", s.map((x) => (x.rss_bytes == null ? null : x.rss_bytes / 1e9)));
|
||||
}
|
||||
|
||||
// --- Overview -------------------------------------------------------------------------------------------
|
||||
|
||||
function renderOnline() {
|
||||
const players = onlinePlayers();
|
||||
$("nav-players").textContent = state.status && state.status.sample && state.status.sample.game ? String(players.length) : "";
|
||||
fill("overview-players", "overview-no-players", players.slice(0, 6).map((p) => h("li", {}, avatar(p.name), h("span", { class: "row-main" }, h("b", {}, p.name), isOp(p.name) && h("small", {}, "Operator")), ping(p.ping_ms))));
|
||||
if (page === "players") renderPlayers();
|
||||
}
|
||||
|
||||
function renderFacts() {
|
||||
const c = state.config, s = state.status;
|
||||
if (!c || !s) return;
|
||||
$("server-name").textContent = c.server.motd || "Horizon server";
|
||||
$("server-address").textContent = c.server.bind ? `${c.server.bind}:${c.server.port}` : `port ${c.server.port}`;
|
||||
const last = state.backups.map((b) => backupParts(b.name).when).filter(Boolean).sort((a, b) => b - a)[0];
|
||||
const facts = [
|
||||
["Game", `${cap(c.world.game_mode)}, ${c.world.difficulty}${c.world.hardcore ? ", hardcore" : ""}`],
|
||||
["World", c.world.name],
|
||||
["Who can join", c.server.whitelist ? "Whitelisted players" : "Everyone"],
|
||||
["Accounts", c.server.online_mode ? "Checked with Minecraft" : "Not checked (offline mode)"],
|
||||
["Last backup", last ? `${ago(last)} · every ${c.backups.every}` : `none yet · every ${c.backups.every}`],
|
||||
["Supervisor up", duration(s.uptime_seconds)],
|
||||
];
|
||||
if (s.crashes) facts.push(["Crashes", `${s.crashes} since the supervisor started`]);
|
||||
$("facts").replaceChildren(...facts.flatMap(([k, v]) => [h("dt", {}, k), h("dd", {}, v)]));
|
||||
}
|
||||
|
||||
const cap = (text) => (text ? text[0].toUpperCase() + text.slice(1) : "");
|
||||
|
||||
// --- Players --------------------------------------------------------------------------------------------
|
||||
|
||||
function isOp(name) {
|
||||
return Boolean(state.players && state.players.ops.some((o) => o.name.toLowerCase() === name.toLowerCase()));
|
||||
}
|
||||
|
||||
async function kick(name) {
|
||||
const answer = await ask({ title: `Kick ${name}?`, text: "They can join again straight away.", ok: "Kick", danger: true, fields: [{ name: "reason", label: "Reason (optional)", placeholder: "Shown to the player" }] });
|
||||
if (answer) await act("POST", "/api/v1/players/kick", { name, reason: answer.reason || undefined }, `Kicked ${name}.`);
|
||||
}
|
||||
|
||||
async function ban(name) {
|
||||
const answer = await ask({ title: `Ban ${name}?`, text: "They are kicked and can't join until you pardon them.", ok: "Ban", danger: true, fields: [{ name: "reason", label: "Reason (optional)", placeholder: "Shown to the player" }] });
|
||||
if (answer && (await act("POST", "/api/v1/players/ban", { name, reason: answer.reason || undefined }, `Banned ${name}.`))) refreshLists();
|
||||
}
|
||||
|
||||
async function toggleOp(name) {
|
||||
const op = isOp(name);
|
||||
if (await act("POST", op ? "/api/v1/players/deop" : "/api/v1/players/op", { name }, op ? `${name} is no longer an operator.` : `${name} is now an operator.`)) refreshLists();
|
||||
}
|
||||
|
||||
function renderPlayers() {
|
||||
const lists = state.players;
|
||||
const online = onlinePlayers();
|
||||
fill("players-online", "players-none", online.map((p) => h(
|
||||
"li", {},
|
||||
avatar(p.name),
|
||||
h("span", { class: "row-main" }, h("b", {}, p.name), h("small", {}, isOp(p.name) ? "Operator" : "Player")),
|
||||
ping(p.ping_ms),
|
||||
h("span", { class: "row-actions" },
|
||||
button(isOp(p.name) ? "Deop" : "Op", () => toggleOp(p.name), { kind: "ghost" }),
|
||||
button("Kick", () => kick(p.name), { kind: "ghost" }),
|
||||
button("Ban", () => ban(p.name), { kind: "destructive" })),
|
||||
)));
|
||||
if (!lists) return;
|
||||
const banRow = (title, detail, tag, pardon) => h(
|
||||
"li", {}, h("span", { class: "file-icon" }, icon("user")),
|
||||
h("span", { class: "row-main" }, h("b", {}, title), h("small", {}, detail)),
|
||||
h("span", { class: "tag danger" }, tag),
|
||||
h("span", { class: "row-actions" }, button("Pardon", pardon, { kind: "ghost" })),
|
||||
);
|
||||
const since = (b) => [b.reason || "No reason given", b.created ? `since ${b.created.slice(0, 10)}` : null, b.expires ? `until ${b.expires.slice(0, 10)}` : null].filter(Boolean).join(" · ");
|
||||
fill("bans", "bans-none", [
|
||||
...lists.bans.map((b) => banRow(b.name, since(b), "Player", async () => {
|
||||
if (await act("POST", "/api/v1/players/pardon", { name: b.name }, `Pardoned ${b.name}.`)) refreshLists();
|
||||
})),
|
||||
]);
|
||||
}
|
||||
|
||||
// --- Console --------------------------------------------------------------------------------------------
|
||||
|
||||
const MAX_LINES = 2000;
|
||||
|
||||
function kind(line) {
|
||||
if (line.startsWith("> ")) return "you";
|
||||
if (/\/(ERROR|FATAL)\]/.test(line)) return "error";
|
||||
if (/\/WARN\]/.test(line)) return "warn";
|
||||
if (/\]: (<[^>]+>|\[[^\]]+\] )/.test(line) && !/\[Supervisor\//.test(line)) return "chat";
|
||||
if (/\[Supervisor\//.test(line)) return "sup";
|
||||
return "";
|
||||
}
|
||||
|
||||
function lineElement(line) {
|
||||
const k = kind(line);
|
||||
const m = /^(\[\d\d:\d\d:\d\d\])(.*)$/.exec(line);
|
||||
return h("span", { class: `line ${k}` }, m ? [h("span", { class: "t" }, m[1]), m[2]] : line);
|
||||
}
|
||||
|
||||
function atBottom(el) {
|
||||
return el.scrollTop + el.clientHeight >= el.scrollHeight - 12;
|
||||
}
|
||||
|
||||
function scrollLog(force) {
|
||||
const log = $("log");
|
||||
if (force) log.scrollTop = log.scrollHeight;
|
||||
$("jump").hidden = atBottom(log);
|
||||
}
|
||||
|
||||
function appendLog(lines) {
|
||||
if (!lines.length) return;
|
||||
state.lines.push(...lines);
|
||||
const trimmed = state.lines.length > MAX_LINES + 100;
|
||||
if (trimmed) state.lines.splice(0, state.lines.length - MAX_LINES);
|
||||
const log = $("log");
|
||||
const stick = atBottom(log);
|
||||
if (trimmed) renderLog();
|
||||
else log.append(...lines.map(lineElement));
|
||||
if (stick) log.scrollTop = log.scrollHeight;
|
||||
else $("jump").hidden = false;
|
||||
$("log-tail").replaceChildren(...state.lines.slice(-10).map(lineElement));
|
||||
$("log-tail").scrollTop = $("log-tail").scrollHeight;
|
||||
}
|
||||
|
||||
function renderLog() {
|
||||
$("log").replaceChildren(...state.lines.map(lineElement));
|
||||
scrollLog(true);
|
||||
}
|
||||
|
||||
const history = [];
|
||||
let historyAt = 0;
|
||||
|
||||
// --- Backups --------------------------------------------------------------------------------------------
|
||||
|
||||
function renderBackups() {
|
||||
const list = [...state.backups].sort((a, b) => (backupParts(b.name).when || 0) - (backupParts(a.name).when || 0));
|
||||
const c = state.config;
|
||||
$("backup-schedule").textContent = c ? (c.backups.every === "0" ? "Automatic backups are off." : `Automatic backup every ${c.backups.every}, the last ${c.backups.keep} kept.`) : "";
|
||||
fill("backups", "no-backups", list.map((b) => {
|
||||
const { when, label } = backupParts(b.name);
|
||||
const title = label || (when ? dateFormat.format(when) : b.name);
|
||||
const detail = [when && label ? dateFormat.format(when) : null, when ? ago(when) : null].filter(Boolean).join(" · ");
|
||||
return h(
|
||||
"li", {}, h("span", { class: "file-icon" }, icon("folder-01")),
|
||||
h("span", { class: "row-main" }, h("b", {}, title), h("small", {}, detail || b.name)),
|
||||
h("span", { class: "row-side" }, bytes(b.bytes)),
|
||||
h("span", { class: "row-actions" },
|
||||
button("Restore", () => restore(b.name), { kind: "ghost" })),
|
||||
);
|
||||
}));
|
||||
}
|
||||
|
||||
async function restore(name) {
|
||||
const answer = await ask({
|
||||
title: "Restore this backup?",
|
||||
text: "The server restarts with it; players are disconnected for a moment. The current world is kept in backups/.",
|
||||
ok: "Restore",
|
||||
danger: true,
|
||||
});
|
||||
if (answer) await act("POST", "/api/v1/restore", { name }, `Restoring ${name}: the server is restarting.`);
|
||||
}
|
||||
|
||||
// --- Crash reports ------- -----------------------------------------------------------------------------
|
||||
|
||||
let crashShown = null;
|
||||
|
||||
/// "crash-2026-11-03_21.14.07-server.txt" → "Nov 3, 2026, 9:14 PM".
|
||||
function crashTitle(name) {
|
||||
const m = /^crash-(\d{4})-(\d{2})-(\d{2})_(\d{2})\.(\d{2})\.(\d{2})/.exec(name);
|
||||
return m ? dateFormat.format(new Date(+m[1], +m[2] - 1, +m[3], +m[4], +m[5], +m[6])) : name;
|
||||
}
|
||||
|
||||
function renderCrashes() {
|
||||
$("nav-reports").textContent = state.crashes.length ? String(state.crashes.length) : "";
|
||||
fill("crashes", "no-crashes", state.crashes.map((r) => {
|
||||
const li = h(
|
||||
"li", { class: r.name === crashShown ? "selected" : "" }, h("span", { class: "file-icon" }, icon("information-circle")),
|
||||
h("span", { class: "row-main" }, h("b", {}, crashTitle(r.name)), h("small", {}, `${r.name} · ${bytes(r.bytes)}`)),
|
||||
h("span", { class: "row-actions" }, button("Read", () => readCrash(r.name), { kind: "ghost" })),
|
||||
);
|
||||
return li;
|
||||
}));
|
||||
}
|
||||
|
||||
async function readCrash(name) {
|
||||
try {
|
||||
const response = await fetch(`/api/v1/crash-reports/read?name=${encodeURIComponent(name)}`, { credentials: "same-origin", headers: { "X-Horizon": "1" } });
|
||||
if (!response.ok) throw new Error((await response.json().catch(() => ({}))).error || `error ${response.status}`);
|
||||
crashShown = name;
|
||||
$("crash-name").textContent = name;
|
||||
$("crash-text").textContent = await response.text();
|
||||
$("crash-viewer").hidden = false;
|
||||
renderCrashes();
|
||||
} catch (e) {
|
||||
toast(e.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Polling --------------------------------------------------------------------------------------------
|
||||
|
||||
async function refreshLists() {
|
||||
try {
|
||||
const [players, backups, crashes] = await Promise.all([
|
||||
api("GET", "/api/v1/players"),
|
||||
api("GET", "/api/v1/backups"),
|
||||
api("GET", "/api/v1/crash-reports"),
|
||||
]);
|
||||
state.players = players;
|
||||
state.crashes = crashes.reports || [];
|
||||
const key = JSON.stringify(backups.backups);
|
||||
if (key !== JSON.stringify(state.backups)) {
|
||||
state.backups = backups.backups;
|
||||
renderBackups();
|
||||
}
|
||||
renderPlayers();
|
||||
renderOnline();
|
||||
renderCrashes();
|
||||
renderFacts();
|
||||
} catch (e) {
|
||||
if (e instanceof SignedOut) show("signed-out");
|
||||
}
|
||||
}
|
||||
|
||||
async function loadConfig() {
|
||||
try {
|
||||
state.config = (await api("GET", "/api/v1/config")).config;
|
||||
renderFacts();
|
||||
renderBackups();
|
||||
route();
|
||||
} catch (e) {
|
||||
if (e instanceof SignedOut) show("signed-out");
|
||||
}
|
||||
}
|
||||
|
||||
let ticks = 0;
|
||||
let wasDown = false;
|
||||
async function poll() {
|
||||
try {
|
||||
const [status, metrics, logs] = await Promise.all([
|
||||
api("GET", "/api/v1/status"),
|
||||
api("GET", `/api/v1/metrics?since=${metricsSince}`),
|
||||
api("GET", `/api/v1/logs?since=${logNext}`),
|
||||
api("GET", `/api/v1/metrics?since=${state.metricsSince}`),
|
||||
api("GET", `/api/v1/logs?since=${state.logNext}`),
|
||||
]);
|
||||
show("app");
|
||||
renderStatus(status);
|
||||
if (wasDown || !state.config) {
|
||||
wasDown = false;
|
||||
await loadConfig();
|
||||
}
|
||||
for (const s of metrics.samples) {
|
||||
samples.push(s);
|
||||
metricsSince = s.t;
|
||||
state.samples.push(s);
|
||||
state.metricsSince = s.t;
|
||||
}
|
||||
samples.splice(0, Math.max(0, samples.length - 600));
|
||||
state.samples.splice(0, Math.max(0, state.samples.length - 600));
|
||||
renderStatus(status);
|
||||
renderCharts();
|
||||
logNext = logs.next;
|
||||
state.logNext = logs.next;
|
||||
appendLog(logs.lines);
|
||||
if (ticks++ % 10 === 0) {
|
||||
renderBackups((await api("GET", "/api/v1/backups")).backups);
|
||||
await refreshLists();
|
||||
}
|
||||
if (ticks++ % 5 === 0) await refreshLists();
|
||||
if (ticks % 30 === 0) loadConfig();
|
||||
} catch (e) {
|
||||
if (e instanceof SignedOut) show("signed-out");
|
||||
else if (lastStatus) {
|
||||
show("gone");
|
||||
$("phase").textContent = "down";
|
||||
$("phase").className = "pill down";
|
||||
} else show("gone");
|
||||
else {
|
||||
wasDown = true;
|
||||
show(state.status ? "app" : "gone");
|
||||
if (state.status) {
|
||||
$("phase").textContent = "not answering";
|
||||
$("phase").className = "state down";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Wiring ---------------------------------------------------------------------------------------------
|
||||
|
||||
function setUp() {
|
||||
$("command-form").addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const line = $("command").value.trim();
|
||||
if (!line) return;
|
||||
$("command").value = "";
|
||||
try {
|
||||
const output = await run(line);
|
||||
appendLog([`> ${line}`]);
|
||||
if (!output) toast("Done.");
|
||||
} catch (e) {
|
||||
toast(e.message);
|
||||
window.addEventListener("hashchange", route);
|
||||
window.addEventListener("resize", renderCharts);
|
||||
|
||||
$("start").addEventListener("click", () => act("POST", "/api/v1/start", undefined, "Starting…"));
|
||||
$("restart").addEventListener("click", async () => {
|
||||
if (await ask({ title: "Restart the game?", text: "Players are disconnected for a moment and can join again once it's up.", ok: "Restart" })) {
|
||||
await act("POST", "/api/v1/restart", undefined, "Restarting…");
|
||||
}
|
||||
});
|
||||
$("stop").addEventListener("click", async () => {
|
||||
if (await ask({ title: "Stop the server?", text: "The game saves and stops, and this panel goes with it. Start it again on the server with horizon-server start.", ok: "Stop", danger: true })) {
|
||||
await act("POST", "/api/v1/stop", undefined, "Stopping…");
|
||||
}
|
||||
});
|
||||
$("update-button").addEventListener("click", async () => {
|
||||
const update = state.status && state.status.update;
|
||||
if (!update || !update.available) return;
|
||||
if (await ask({ title: `Update to ${update.available}?`, text: `The world is backed up, then the game restarts on the new release. If it doesn't come up, the server goes back to ${update.installed} by itself.`, ok: "Update" })) {
|
||||
await act("POST", "/api/v1/update", {}, "Updating…");
|
||||
}
|
||||
});
|
||||
$("sign-out").addEventListener("click", async () => {
|
||||
await fetch("/logout", { method: "POST", credentials: "same-origin", headers: { "X-Horizon": "1" } }).catch(() => {});
|
||||
show("signed-out");
|
||||
});
|
||||
|
||||
// Players.
|
||||
$("ban-form").addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const target = event.target.target.value.trim();
|
||||
const reason = event.target.reason.value.trim() || undefined;
|
||||
const reply = await act("POST", "/api/v1/players/ban", { name: target, reason }, `Banned ${target}.`);
|
||||
if (reply) {
|
||||
event.target.reset();
|
||||
refreshLists();
|
||||
}
|
||||
});
|
||||
|
||||
// Console.
|
||||
$("log").addEventListener("scroll", () => scrollLog(false));
|
||||
$("jump").addEventListener("click", () => scrollLog(true));
|
||||
$("command-form").addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const line = $("command").value.trim().replace(/^\//, "");
|
||||
if (!line) return;
|
||||
$("command").value = "";
|
||||
history.push(line);
|
||||
historyAt = history.length;
|
||||
appendLog([`> ${line}`]);
|
||||
scrollLog(true);
|
||||
try {
|
||||
const reply = await api("POST", "/api/v1/command", { line });
|
||||
const output = (reply.output || []).join("\n");
|
||||
if (output) appendLog(output.split("\n").map((l) => ` ${l}`));
|
||||
} catch (e) {
|
||||
toast(e.message, true);
|
||||
}
|
||||
});
|
||||
$("command").addEventListener("keydown", (event) => {
|
||||
if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return;
|
||||
event.preventDefault();
|
||||
historyAt = Math.max(0, Math.min(history.length, historyAt + (event.key === "ArrowUp" ? -1 : 1)));
|
||||
$("command").value = history[historyAt] || "";
|
||||
});
|
||||
|
||||
// Backups.
|
||||
$("backup-form").addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const name = $("backup-name").value.trim();
|
||||
toast("Backing up…");
|
||||
try {
|
||||
const made = await api("POST", "/api/v1/backups", name ? { name } : {});
|
||||
const made = await act("POST", "/api/v1/backups", name ? { name } : {}, "Backed up.");
|
||||
if (made) {
|
||||
toast(`Backed up to backups/${made.name}`);
|
||||
$("backup-name").value = "";
|
||||
renderBackups((await api("GET", "/api/v1/backups")).backups);
|
||||
} catch (e) {
|
||||
toast(e.message);
|
||||
refreshLists();
|
||||
}
|
||||
});
|
||||
$("update-button").addEventListener("click", async () => {
|
||||
const update = lastStatus && lastStatus.update;
|
||||
if (!update || !update.available) return;
|
||||
if (!confirm(`Update to ${update.available}? The world is backed up, then the game restarts on it (players are disconnected for a moment). If it doesn't come up, the server goes back to ${update.installed} by itself.`)) return;
|
||||
await api("POST", "/api/v1/update", {}).then(() => toast("Updating…"), (e) => toast(e.message));
|
||||
});
|
||||
$("restart").addEventListener("click", async () => {
|
||||
if (!confirm("Restart the game? Players are disconnected for a moment.")) return;
|
||||
await api("POST", "/api/v1/restart").then(() => toast("Restarting…"), (e) => toast(e.message));
|
||||
});
|
||||
$("stop").addEventListener("click", async () => {
|
||||
if (!confirm("Stop the server? The panel stops with it; start it again on the server.")) return;
|
||||
await api("POST", "/api/v1/stop").then(() => toast("Stopping…"), (e) => toast(e.message));
|
||||
|
||||
// Crash reports.
|
||||
$("crash-copy").addEventListener("click", async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText($("crash-text").textContent);
|
||||
toast("Copied the report.");
|
||||
} catch (e) {
|
||||
toast("Couldn't copy: select the text instead.", true);
|
||||
}
|
||||
});
|
||||
|
||||
route();
|
||||
poll();
|
||||
setInterval(poll, 1000);
|
||||
}
|
||||
|
||||
@@ -32,6 +32,51 @@ const PAGE: &str = include_str!("../panel/index.html");
|
||||
const SCRIPT: &str = include_str!("../panel/panel.js");
|
||||
const STYLE: &str = include_str!("../panel/panel.css");
|
||||
|
||||
/// The fonts and the mark the page uses, from horizon-theme (Geist and Bricolage Grotesque, SIL OFL).
|
||||
const ASSETS: [(&str, &str, &[u8]); 6] = [
|
||||
(
|
||||
"/mark.png",
|
||||
"image/png",
|
||||
include_bytes!("../../horizon-theme/icons/horizon-64.png"),
|
||||
),
|
||||
(
|
||||
"/fonts/Geist-Regular.ttf",
|
||||
"font/ttf",
|
||||
include_bytes!("../../horizon-theme/fonts/Geist-Regular.ttf"),
|
||||
),
|
||||
(
|
||||
"/fonts/Geist-Medium.ttf",
|
||||
"font/ttf",
|
||||
include_bytes!("../../horizon-theme/fonts/Geist-Medium.ttf"),
|
||||
),
|
||||
(
|
||||
"/fonts/Geist-SemiBold.ttf",
|
||||
"font/ttf",
|
||||
include_bytes!("../../horizon-theme/fonts/Geist-SemiBold.ttf"),
|
||||
),
|
||||
(
|
||||
"/fonts/GeistMono-Regular.ttf",
|
||||
"font/ttf",
|
||||
include_bytes!("../../horizon-theme/fonts/GeistMono-Regular.ttf"),
|
||||
),
|
||||
(
|
||||
"/fonts/BricolageGrotesqueHeading-SemiBold.ttf",
|
||||
"font/ttf",
|
||||
include_bytes!("../../horizon-theme/fonts/BricolageGrotesqueHeading-SemiBold.ttf"),
|
||||
),
|
||||
];
|
||||
|
||||
/// A file built into the binary, written after the head.
|
||||
fn asset(content_type: &'static str, body: &'static [u8]) -> Response {
|
||||
let mut response = Response::text(200, content_type, "")
|
||||
.with("Content-Length", body.len().to_string())
|
||||
.with("Cache-Control", "public, max-age=86400");
|
||||
response.stream = Some(Box::new(move |mut stream: &std::net::TcpStream| {
|
||||
let _ = std::io::Write::write_all(&mut stream, body);
|
||||
}));
|
||||
response
|
||||
}
|
||||
|
||||
/// The panel's address, its pending login codes and its sessions.
|
||||
#[derive(Default)]
|
||||
pub struct Auth {
|
||||
@@ -137,7 +182,7 @@ fn secure(response: Response) -> Response {
|
||||
response
|
||||
.with(
|
||||
"Content-Security-Policy",
|
||||
"default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data:; \
|
||||
"default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; \
|
||||
connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'",
|
||||
)
|
||||
.with("X-Content-Type-Options", "nosniff")
|
||||
@@ -160,6 +205,9 @@ fn handle(shared: &Arc<Shared>, address: SocketAddr, request: &Request) -> Respo
|
||||
return Response::text(200, "text/javascript; charset=utf-8", SCRIPT);
|
||||
}
|
||||
("GET", "/panel.css") => return Response::text(200, "text/css; charset=utf-8", STYLE),
|
||||
("GET", _) if let Some((_, kind, body)) = ASSETS.iter().find(|(p, _, _)| *p == path) => {
|
||||
return asset(kind, body);
|
||||
}
|
||||
("GET", "/login") => {
|
||||
let code = request.param("code").unwrap_or_default();
|
||||
return match shared.panel.lock().unwrap().redeem(code) {
|
||||
|
||||
Reference in new issue
Block a user