web/src/routes/+layout.svelte
Ref: Size: 7.1 KiB History
<script lang="ts">
import favicon from '$lib/assets/favicon.svg';
import { onMount } from 'svelte';
import { fleet, fetchMe, connect, refresh, dismissError, startClock } from '$lib/fleet.svelte';
let { children } = $props();
onMount(async () => {
startClock();
try {
// A 401 here redirects to /auth/login (handled in req); a throw means
// we're on our way out, so render nothing further.
await fetchMe();
} catch {
return;
}
refresh();
connect();
});
async function signOut() {
await fetch('/auth/logout', { method: 'POST' });
window.location.href = '/';
}
// Dismissing the banner removes the focused button from the DOM, which
// drops keyboard focus to <body>. Land it somewhere useful instead: the
// fleet filter when present (overview), else the first main link/control.
function dismissAndRefocus() {
dismissError();
// An open modal owns focus: dismissing the banner above it must not
// send keyboard focus behind the overlay.
const target =
document.querySelector<HTMLElement>('.modal input, .modal select, .modal button') ??
document.getElementById('fleet-filter') ??
document.querySelector<HTMLElement>('main a, main button, main input');
target?.focus();
}
</script>
<svelte:head>
<link rel="icon" href={favicon} />
<title>eitri fleet</title>
</svelte:head>
<header>
<a href="/" class="brand">eitri</a>
<span class="fleet-label">fleet</span>
<span class="spacer"></span>
{#if fleet.me}
<span class="dot {fleet.connected ? 'on' : 'off'}"></span>
<span class="conn">{fleet.connected ? 'live' : 'disconnected'}</span>
<span class="email">{fleet.me.email}</span>
<a href="/settings">settings</a>
<button type="button" class="ghost" onclick={signOut}>Sign out</button>
{/if}
</header>
{#if fleet.error}
<div class="error" role="alert">
<span>{fleet.error}</span>
<button type="button" class="dismiss" onclick={dismissAndRefocus} aria-label="dismiss error">✕</button>
</div>
{/if}
<main>
{#if fleet.me}
{@render children()}
{:else}
<p class="hint">Signing in…</p>
{/if}
</main>
<style>
/* The console is derived from the site (site/style.css) and softened for an
app: the same monospace and the same square unfilled boxes, but rules
instead of a full cell grid. The vocabulary is named once here — ink,
paper, hairline, faint, wash — so the rest of the console inherits it
instead of carrying literals. */
:global(:root) {
color-scheme: light dark;
--ink: CanvasText;
--paper: Canvas;
--hairline: color-mix(in srgb, CanvasText 25%, transparent);
--faint: color-mix(in srgb, CanvasText 55%, transparent);
--wash: color-mix(in srgb, CanvasText 6%, transparent);
--ok: #3a9e4f;
--bad: #c2503a;
}
:global(*) {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:global(body) {
font-family: monospace;
line-height: 1.5;
color: var(--ink);
background: var(--paper);
}
:global(a) {
color: inherit;
}
:global(h1) {
margin-bottom: 0.5em;
}
:global(h2) {
margin: 1.5em 0 0.5em;
}
:global(h3) {
margin: 1em 0 0.5em;
}
:global(p, ul, ol) {
margin-bottom: 1em;
}
:global(ul, ol) {
padding-left: 2em;
}
:global(li) {
margin-bottom: 0.25em;
}
:global(code) {
word-break: break-all;
}
/* One strong rule under the head, a hairline between rows, and a wash under
the pointer: enough structure to track a row across nine columns without
boxing every cell. */
:global(table) {
border-collapse: collapse;
margin: 0.5em 0 1.5em;
}
:global(th) {
border-bottom: 1px solid var(--ink);
padding: 0.35em 0.9em 0.35em 0;
text-align: left;
font-weight: bold;
white-space: nowrap;
}
:global(td) {
border-bottom: 1px solid var(--hairline);
padding: 0.45em 0.9em 0.45em 0;
white-space: nowrap;
}
:global(tr:hover td) {
background: var(--wash);
}
/* A key/value table has no head, so its keys are labels rather than
headings: faint, unemphasised, and ruled like any other row. */
:global(.kv th) {
border-bottom: 1px solid var(--hairline);
color: var(--faint);
font-weight: normal;
vertical-align: top;
width: 20ch;
}
:global(td.num, th.num) {
text-align: right;
font-variant-numeric: tabular-nums;
}
/* A status light is a glyph, not a swatch: filled and green for on, hollow
and faint for off. Only the glyph takes the colour — the label beside it
stays ink, so the state reads without it. */
:global(.dot) {
display: inline-block;
margin-right: 0.25em;
}
:global(.dot.on) {
color: var(--ok);
}
:global(.dot.off) {
color: var(--faint);
}
:global(.dot.on::before) {
content: '●';
}
:global(.dot.off::before) {
content: '○';
}
:global(.hint) {
color: var(--faint);
}
/* The two-column split the VM, host and settings pages share: facts down the
wider left, what-you-can-do down the right, stated once here so the three
pages cannot drift apart. Below the breakpoint there is no grid at all and
the blocks stack in source order. A column is not a page—each table inside
it keeps its natural width and scrolls sideways in its own .scroll box, so
a fingerprint or a CPU model is scrolled to rather than squeezed and the
page itself never scrolls. The overflow-x stays inside the query on
purpose: an always-on one makes each box a formatting context, which stops
margins collapsing and changes the stacked spacing narrower than this. */
@media (min-width: 1100px) {
:global(.columns) {
display: grid;
grid-template-columns: minmax(0, 3fr) minmax(0, 2fr);
column-gap: 3em;
align-items: start;
}
:global(.scroll) {
overflow-x: auto;
}
:global(.scroll table) {
min-width: 100%;
}
}
header {
display: flex;
align-items: center;
gap: 0.75em;
padding: 1em;
border-bottom: 1px solid var(--hairline);
}
.brand {
font-weight: bold;
text-decoration: none;
}
.fleet-label,
.conn,
.email {
color: var(--faint);
}
.spacer {
flex: 1;
}
/* Controls are real but quiet: a hairline box that firms up to ink when you
reach for it, and to red when what it does cannot be undone. */
:global(input, select, textarea) {
font: inherit;
color: inherit;
background: none;
border: 1px solid var(--hairline);
border-radius: 0;
padding: 0.05em 0.6em;
box-sizing: border-box;
}
:global(input:focus, select:focus, textarea:focus) {
border-color: var(--ink);
}
:global(button) {
font: inherit;
color: inherit;
background: none;
border: 1px solid var(--hairline);
border-radius: 0;
padding: 0.05em 0.6em;
cursor: pointer;
}
:global(button:hover) {
border-color: var(--ink);
}
:global(button.danger:hover) {
border-color: var(--bad);
color: var(--bad);
}
/* A tick box is the one control the browser draws better than we can. */
:global(input[type='checkbox'], input[type='radio']) {
border: 0;
padding: 0;
}
/* Wide enough for a full fleet table; the site's 72ch measure is for prose,
and it is the language that carries over, not the measure. */
main {
padding: 1em;
max-width: 170ch;
margin: 0 auto;
}
.error {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75em;
padding: 0.5em 1em;
border-bottom: 1px solid var(--hairline);
color: var(--bad);
}
</style>