{# The panel shell. Three things are present on every page: the machine strip, so you always know which box you are about to change; the navigation, with live counts; and the log drawer, docked at the bottom rather than hidden behind a click per resource. #} {% macro nav_item(href, label, key, count) -%} {{ label }} {%- if count is not none %}{{ count }}{% endif %} {%- endmacro %} {% block title %}{{ machine.hostname }}{% endblock %} - WASM {# Everything is served from this host. The panel works on a machine with no route to the internet, which is rather the point of self-hosting. #} {# uPlot's stylesheet is not here: only pages with charts need it, and panel.js injects it beside the lazily loaded script. #} {# The id is the client's hook: panel.js swaps this icon for a coloured status dot that follows the machine's worst active state. #} {# Three scripts, and no Alpine. The panel serves itself under script-src 'self' with no unsafe-eval, because it runs systemd as root and an injected script here is a root shell. Alpine turns every x-data, @click and x-text into a function built from a string, which that policy forbids outright: the browser refused all of them, the log drawer never initialised, and the mobile navigation could not be opened. It was not a subtle failure - it was the whole framework never running - and no test could see it, because the markup was correct and the server was correct. The two islands it was there for are a hundred lines of panel.js now. Loosening the policy for a dependency that costs 47 KB and does not work is not a trade this product makes. #} {# The SSE extension must run after htmx and before the document is processed; three deferred classic scripts execute in exactly this order. It is a vendored file served from this host, so script-src 'self' holds. #} {# Every mutation htmx sends carries the session's CSRF token. The header name comes from the server rather than being typed here: the shell used to send "X-CSRF-Token" while wasm.web.auth reads CSRF_HEADER_NAME, which is "X-WASM-CSRF", so every restart, every delete and the sign-out button came back 403 from a browser. Two spellings of one name in two files cannot be kept in agreement by care alone. #} {# The navigation's open state lives on the body, not on .shell, because the control that opens it has to sit outside the sidebar: a toggle inside the panel it reveals goes off-canvas with it, which is why the panel shipped with a sidebar that could be styled open and reached by nothing. #} {# The live feed is attached to the body: one EventSource per tab, opened by the htmx SSE extension and shared by everything on the page. The body is the one element htmx never swaps - hx-boost replaces its contents, not the element - so the connection survives navigation, and any sse-swap element that arrives in a swap re-binds to it as its closest connected ancestor. panel.js listens on the same source for the events that are not swaps. #} {# Outside the machine strip on purpose: that fragment replaces itself every five seconds, and a control that is destroyed under the operator's finger takes their keyboard focus with it. #} {# The signature element: one dense instrument line for this machine. The fragment carries its own element so htmx can swap it whole. #} {% include "fragments/machine.html" %}
{# tabindex makes the skip link's destination focusable; without it the link moves the viewport and leaves the keyboard where it was. #}
{% block content %}{% endblock %}
{# The docked log drawer. It costs vertical space, and that is the bet: the moment logs matter most is mid-deploy, and hiding them behind a click per resource is what makes a web panel feel worse than an SSH session. #}
{# The bar is a plain element that happens to be clickable, and the toggle is a real button. It used to be the other way round: the bar carried role="button" and wrapped two more buttons, which is invalid nesting, and its keyboard handling was hand-rolled. A native button is focusable, operable with Enter and Space, and announced correctly without any of that. #}
Log {# The text is server-rendered and then kept in step by panel.js, so the bar reads correctly even in the instant before the script runs and on a machine where it never does. #} nothing attached {# The find field sits inside the clickable bar, so panel.js returns before the toggle branch when the click lands here: typing a search must not slam the drawer shut around it. Enter walks the matches, Shift+Enter walks them backwards. #}
{# Hidden while the terminal is following the tail; scrolling up pauses the follow (xterm's own behaviour) and this is the way back down. #}
{# The command palette: Ctrl+K (Cmd+K on a Mac) anywhere in the shell. The catalogue is server-rendered into the JSON block below - the fixed screens plus every deployed application, from the shared context - so the overlay works the instant the shortcut lands, with no fetch and nothing the client has to be trusted to assemble. panel.js only filters it. tojson escapes <, & and quotes, so the block cannot be broken out of by a domain name. A native dialog, because showModal gives focus trapping and Escape for free instead of hand-rolling both. #}

Enter opens - Esc closes