design: extend v1 to login / add-host / host-detail / job-log + lock components

Five hi-fi screens completing the Phase 1 surface, all in v1's dark
operator-console register.

  v1-login          Sparse centred card. Sign-in + first-error variant.
                    No marketing chrome; build version sits in footer
                    so a returning operator can spot agent drift.

  v1-add-host       Focused two-column page (form left, contextual
                    "what happens next" right) — not a modal. Two
                    states: form (state A) and minted-token result
                    with install command (state B). Backed by
                    POST /api/enrollment-tokens (P1-32).

  v1-host-detail    Persistent header (status dot, mono name, tags,
                    primary CTAs, vitals strip) over four sub-tabs
                    (Snapshots / Jobs / Repo / Settings). Snapshots
                    is the default — the thing 90% of operators
                    want when they click a host name. Right rail
                    holds Recent activity, run-now stack, and a
                    danger-zone panel.

  v1-job-log        WS-streamed log view. Three states: running (live
                    progress bar + auto-scroll cursor), succeeded
                    (summary stats + final lines), failed (error
                    panel + tail). Backed by WS /api/jobs/{id}/stream
                    (P1-21 remainder).

  v1-components     The load-bearing reference. 14 sections covering
                    tokens (colour + type scale), status, buttons,
                    form fields, tags, tabs, host row, log viewer,
                    progress bar, stat tile, modal, toast, install
                    snippet, empty-state pattern. Every CSS class is
                    real and copy-able into the Go template build.

This locks the visual register before P1-23 onwards. Each Phase 1
template gets a {{define}} matching a section in v1-components.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-01 19:05:39 +01:00
parent cca525a04d
commit 8b7b1479a1
5 changed files with 1995 additions and 0 deletions
+347
View File
@@ -0,0 +1,347 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>restic-manager · v1 Add host</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<style>
:root {
--bg: oklch(0.17 0.006 250); --panel: oklch(0.20 0.007 250); --panel-hi: oklch(0.23 0.008 250);
--line: oklch(0.27 0.010 250); --line-soft: oklch(0.23 0.008 250);
--ink: oklch(0.96 0.005 250); --ink-mid: oklch(0.78 0.005 250);
--ink-mute: oklch(0.58 0.006 250); --ink-fade: oklch(0.42 0.006 250);
--ok: oklch(0.78 0.14 155); --warn: oklch(0.82 0.13 80); --bad: oklch(0.70 0.20 25);
--accent: oklch(0.82 0.12 195);
}
html, body { background: var(--bg); color: var(--ink); }
body { font-family: 'Inter', system-ui, sans-serif; }
.mono { font-family: 'JetBrains Mono', monospace; font-variant-numeric: tabular-nums; }
.text-pretty { text-wrap: pretty; }
::selection { background: color-mix(in oklch, var(--accent), transparent 70%); }
.panel { background: var(--panel); border: 1px solid var(--line-soft); }
.hairline { box-shadow: inset 0 -1px 0 var(--line-soft); }
.field-label { font-size: 12px; color: var(--ink-mid); margin-bottom: 6px; display: block; }
.field-help { font-size: 12px; color: var(--ink-mute); margin-top: 6px; line-height: 1.55; text-wrap: pretty; }
.field {
width: 100%; padding: 9px 12px;
background: var(--bg); border: 1px solid var(--line-soft);
color: var(--ink); border-radius: 5px;
font-size: 13px; font-family: inherit; outline: none;
transition: border-color 120ms ease;
}
.field:focus { border-color: var(--accent); }
.field.mono { font-family: 'JetBrains Mono', monospace; font-size: 12px; }
.field.with-prefix { padding-left: 64px; }
.field-prefix {
position: absolute; left: 12px; top: 50%; transform: translateY(-50%);
font-family: 'JetBrains Mono', monospace; font-size: 12px;
color: var(--ink-mute); pointer-events: none;
}
.btn {
font-size: 13px; font-weight: 500; padding: 8px 14px; border-radius: 5px;
background: transparent; border: 1px solid var(--line); color: var(--ink-mid);
transition: all 120ms ease; cursor: pointer;
}
.btn:hover { background: var(--panel-hi); color: var(--ink); }
.btn-primary { color: oklch(0.18 0.01 195); background: var(--accent); border-color: var(--accent); }
.btn-primary:hover { filter: brightness(1.08); }
.btn-ghost { border-color: transparent; }
.btn-ghost:hover { background: var(--panel-hi); border-color: transparent; color: var(--ink); }
.nav-tab { font-size: 13px; padding: 18px 0; color: var(--ink-mute); border-bottom: 2px solid transparent; margin-right: 28px; cursor: pointer; }
.nav-tab.active { color: var(--ink); border-color: var(--accent); }
.nav-tab:hover { color: var(--ink); }
.tag {
display: inline-flex; align-items: center; gap: 6px;
font-size: 11px; line-height: 1; padding: 5px 7px 5px 9px;
border: 1px solid var(--line); color: var(--ink-mid);
border-radius: 3px; letter-spacing: 0.01em;
}
.tag .x { color: var(--ink-fade); cursor: pointer; }
.tag .x:hover { color: var(--ink); }
.doc { max-width: 1280px; margin: 0 auto; padding: 0 32px; }
.philosophy { padding: 56px 0 32px; border-bottom: 1px solid var(--line-soft); }
.philosophy h1 { font-size: 22px; font-weight: 600; letter-spacing: -0.01em; }
.philosophy p { color: var(--ink-mid); max-width: 680px; margin-top: 14px; line-height: 1.65; text-wrap: pretty; }
.philosophy .meta { color: var(--ink-fade); font-size: 12px; margin-top: 14px; }
.stage-frame { margin: 48px -32px; border-top: 1px solid var(--line-soft); border-bottom: 1px solid var(--line-soft); }
.stage-label { padding: 16px 32px 0; font-size: 11px; color: var(--ink-fade); letter-spacing: 0.18em; text-transform: uppercase; }
.crumbs { font-size: 12px; color: var(--ink-mute); }
.crumbs a { color: var(--ink-mute); text-decoration: underline; text-underline-offset: 3px; text-decoration-color: var(--line); }
.crumbs .sep { color: var(--ink-fade); margin: 0 8px; }
</style>
</head>
<body>
<div class="doc">
<header class="philosophy">
<div class="text-xs uppercase tracking-[0.18em] text-[color:var(--ink-fade)] mb-3">v1 · Add host</div>
<h1>Add a host.</h1>
<p>
A focused two-column page, not a modal: the form lives where the cursor
needs it, the contextual help and security footnote live where the eye
naturally drifts. After the form is submitted the same URL renders the
result state — token + install command — so the operator never loses
their place.
</p>
<p class="meta">
Backed by <span class="mono" style="color: var(--ink-mid);">POST /api/enrollment-tokens</span>
(P1-32). Repo creds become an AEAD blob bound to the token hash;
<span class="mono" style="color: var(--ink-mid);">ConsumeEnrollmentToken</span> rebinds them
under the new host_id and the WS push lands them on the agent.
</p>
</header>
<!-- Stage 1: form state -->
<div class="stage-label">State A · form</div>
<div class="stage-frame">
<div style="background: var(--bg);">
<!-- chrome (same as dashboard) -->
<div class="hairline" style="background: var(--bg);">
<div class="doc flex items-center justify-between" style="padding: 16px 0;">
<div class="flex items-center gap-3">
<div class="mono" style="font-size:13px; color: var(--ink); font-weight:500;">restic-manager</div>
<div class="mono" style="font-size:11px; color: var(--ink-fade);">v0.1.0-alpha</div>
</div>
<div class="flex items-center gap-5">
<div class="mono" style="font-size:12px; color: var(--ink-mute);">steve@dcglab</div>
<button class="btn btn-ghost">Sign out</button>
</div>
</div>
</div>
<div class="hairline" style="background: var(--bg);">
<div class="doc flex items-end" style="padding-top: 0;">
<nav class="flex items-end">
<div class="nav-tab active">Dashboard</div>
<div class="nav-tab">Repos</div>
<div class="nav-tab">Alerts <span class="mono ml-1.5" style="font-size:11px; color: var(--bad);">5</span></div>
<div class="nav-tab">Audit</div>
<div class="nav-tab">Settings</div>
</nav>
</div>
</div>
<!-- page header -->
<div class="doc" style="padding: 36px 32px 12px;">
<div class="crumbs"><a>Dashboard</a><span class="sep">/</span><span style="color: var(--ink-mid);">Add host</span></div>
<h1 style="font-size: 24px; font-weight: 500; letter-spacing: -0.012em; margin-top: 10px;">Add a host</h1>
<p style="color: var(--ink-mute); font-size: 13px; margin-top: 6px; max-width: 580px;" class="text-pretty">
Mints a one-time enrolment token (TTL 1 hour) and binds the repo credentials to it.
The token can only be used once — generate a fresh one if it expires or you typed
something wrong.
</p>
</div>
<!-- two-column body -->
<div class="doc grid grid-cols-12 gap-8" style="padding: 28px 32px 56px;">
<!-- form -->
<form class="col-span-7 panel" style="border-radius: 7px; padding: 28px 32px;">
<h3 style="font-size: 13px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.08em; color: var(--ink-mute); margin-bottom: 18px;">Host</h3>
<div style="margin-bottom: 22px;">
<label class="field-label" for="ah-name">Hostname</label>
<input id="ah-name" type="text" class="field mono" value="prod-redis-01">
<div class="field-help">Becomes the hosts display name. Most operators use the boxs actual hostname so logs line up.</div>
</div>
<div style="margin-bottom: 28px;">
<label class="field-label">Tags</label>
<div class="flex items-center gap-1.5 flex-wrap" style="padding: 8px; background: var(--bg); border: 1px solid var(--line-soft); border-radius: 5px;">
<span class="tag">prod <span class="x">×</span></span>
<span class="tag">cache <span class="x">×</span></span>
<input type="text" placeholder="add tag…" class="mono" style="flex: 1; min-width: 80px; background: transparent; border: none; color: var(--ink); font-size: 12px; padding: 4px 6px; outline: none;">
</div>
<div class="field-help">Free-form. Used for filtering and grouping on the dashboard.</div>
</div>
<h3 style="font-size: 13px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.08em; color: var(--ink-mute); margin-bottom: 18px; padding-top: 6px; border-top: 1px solid var(--line-soft); padding-top: 24px;">Restic repository</h3>
<div style="margin-bottom: 22px;">
<label class="field-label" for="ah-url">Repo URL</label>
<div style="position: relative;">
<input id="ah-url" type="text" class="field mono with-prefix" value="rest:https://restic.unraid.lab/prod-redis-01/">
<span class="field-prefix">repo:</span>
</div>
<div class="field-help">Whatever <span class="mono" style="color: var(--ink-mid);">restic -r</span> would accept. Most fleets terminate at a <span class="mono" style="color: var(--ink-mid);">restic/rest-server</span>; <span class="mono" style="color: var(--ink-mid);">s3:</span> and <span class="mono" style="color: var(--ink-mid);">b2:</span> URLs work equally well.</div>
</div>
<div style="margin-bottom: 22px;">
<label class="field-label" for="ah-user">Repo username <span style="color: var(--ink-fade); font-weight: 400;">· optional</span></label>
<input id="ah-user" type="text" class="field mono" value="prod-redis-01">
<div class="field-help">For <span class="mono" style="color: var(--ink-mid);">rest-server</span> with htpasswd, this is the per-host user.</div>
</div>
<div style="margin-bottom: 28px;">
<label class="field-label" for="ah-pass">Repo password</label>
<input id="ah-pass" type="password" class="field" value="••••••••••••••••">
<div class="field-help">Encrypted at rest using the servers AEAD key. Pushed to the agent only over the authenticated WebSocket.</div>
</div>
<div style="display: flex; gap: 8px; padding-top: 18px; border-top: 1px solid var(--line-soft);">
<button type="submit" class="btn btn-primary">Mint token &amp; show install command</button>
<button type="button" class="btn">Cancel</button>
</div>
</form>
<!-- contextual help -->
<aside class="col-span-5">
<div style="font-size: 11px; text-transform: uppercase; letter-spacing: 0.1em; color: var(--ink-fade); margin-bottom: 12px;">What happens next</div>
<ol style="counter-reset: step; padding: 0; margin: 0; list-style: none;">
<li class="step" style="position: relative; padding-left: 32px; padding-bottom: 18px;">
<span style="position: absolute; left: 0; top: 0; width: 22px; height: 22px; border: 1px solid var(--line); border-radius: 50%; font-size: 11px; line-height: 20px; text-align: center; color: var(--ink-mute); font-family: 'JetBrains Mono';">1</span>
<div style="font-size: 13px; color: var(--ink); font-weight: 500;">You get a one-time install command</div>
<div style="font-size: 12px; color: var(--ink-mute); margin-top: 4px; line-height: 1.6;" class="text-pretty">
A <span class="mono" style="color: var(--ink-mid);">curl … | sh</span> snippet with the server URL and a 1h token baked in.
</div>
</li>
<li style="position: relative; padding-left: 32px; padding-bottom: 18px;">
<span style="position: absolute; left: 0; top: 0; width: 22px; height: 22px; border: 1px solid var(--line); border-radius: 50%; font-size: 11px; line-height: 20px; text-align: center; color: var(--ink-mute); font-family: 'JetBrains Mono';">2</span>
<div style="font-size: 13px; color: var(--ink); font-weight: 500;">You run it on the box you want to back up</div>
<div style="font-size: 12px; color: var(--ink-mute); margin-top: 4px; line-height: 1.6;" class="text-pretty">
Installer creates a service user, drops the agent binary, registers a sandboxed systemd unit, and enrols.
</div>
</li>
<li style="position: relative; padding-left: 32px; padding-bottom: 18px;">
<span style="position: absolute; left: 0; top: 0; width: 22px; height: 22px; border: 1px solid var(--line); border-radius: 50%; font-size: 11px; line-height: 20px; text-align: center; color: var(--ink-mute); font-family: 'JetBrains Mono';">3</span>
<div style="font-size: 13px; color: var(--ink); font-weight: 500;">The host appears on the dashboard within seconds</div>
<div style="font-size: 12px; color: var(--ink-mute); margin-top: 4px; line-height: 1.6;" class="text-pretty">
Server pushes the encrypted repo creds over the WS on first <span class="mono" style="color: var(--ink-mid);">hello</span>; agent decrypts and persists to <span class="mono" style="color: var(--ink-mid);">secrets.enc</span>.
</div>
</li>
<li style="position: relative; padding-left: 32px;">
<span style="position: absolute; left: 0; top: 0; width: 22px; height: 22px; border: 1px solid var(--line); border-radius: 50%; font-size: 11px; line-height: 20px; text-align: center; color: var(--ink-mute); font-family: 'JetBrains Mono';">4</span>
<div style="font-size: 13px; color: var(--ink); font-weight: 500;">You hit “Run backup now”</div>
<div style="font-size: 12px; color: var(--ink-mute); margin-top: 4px; line-height: 1.6;" class="text-pretty">
First snapshot lands in the repo. Subsequent ones run on whatever schedule you set (Phase 2).
</div>
</li>
</ol>
<div style="margin-top: 32px; padding: 14px 16px; background: var(--panel); border: 1px solid var(--line-soft); border-radius: 6px;">
<div style="font-size: 11px; color: var(--warn); text-transform: uppercase; letter-spacing: 0.08em; font-weight: 600; margin-bottom: 6px;">Prerequisite</div>
<p style="font-size: 12px; color: var(--ink-mid); line-height: 1.6;" class="text-pretty">
<span class="mono" style="color: var(--ink);">restic</span> ≥ 0.16 must already be installed on the target host. The agent does not install it for you — different distros, different package managers, too much surface area to maintain.
</p>
</div>
</aside>
</div>
</div>
</div>
<!-- Stage 2: result state -->
<div class="stage-label">State B · token minted, install command shown</div>
<div class="stage-frame">
<div style="background: var(--bg);">
<!-- chrome -->
<div class="hairline" style="background: var(--bg);">
<div class="doc flex items-center justify-between" style="padding: 16px 0;">
<div class="flex items-center gap-3">
<div class="mono" style="font-size:13px; color: var(--ink); font-weight:500;">restic-manager</div>
<div class="mono" style="font-size:11px; color: var(--ink-fade);">v0.1.0-alpha</div>
</div>
<div class="flex items-center gap-5">
<div class="mono" style="font-size:12px; color: var(--ink-mute);">steve@dcglab</div>
<button class="btn btn-ghost">Sign out</button>
</div>
</div>
</div>
<div class="hairline" style="background: var(--bg);">
<div class="doc flex items-end">
<nav class="flex items-end">
<div class="nav-tab active">Dashboard</div>
<div class="nav-tab">Repos</div>
<div class="nav-tab">Alerts <span class="mono ml-1.5" style="font-size:11px; color: var(--bad);">5</span></div>
<div class="nav-tab">Audit</div>
<div class="nav-tab">Settings</div>
</nav>
</div>
</div>
<div class="doc" style="padding: 36px 32px 12px;">
<div class="crumbs"><a>Dashboard</a><span class="sep">/</span><span style="color: var(--ink-mid);">Add host</span><span class="sep">/</span><span style="color: var(--ink);">prod-redis-01</span></div>
<div class="flex items-center gap-3 mt-2.5">
<h1 style="font-size: 24px; font-weight: 500; letter-spacing: -0.012em;">Token minted</h1>
<span class="mono" style="font-size: 11px; padding: 4px 8px; background: color-mix(in oklch, var(--ok), transparent 88%); color: var(--ok); border: 1px solid color-mix(in oklch, var(--ok), transparent 70%); border-radius: 3px;">expires in 59m 54s</span>
</div>
<p style="color: var(--ink-mute); font-size: 13px; margin-top: 6px; max-width: 580px;" class="text-pretty">
Run the snippet below on the target box. The host will appear on the
dashboard within a few seconds of the agent connecting.
</p>
</div>
<div class="doc" style="padding: 28px 32px 56px;">
<!-- install command -->
<div class="panel" style="border-radius: 7px; overflow: hidden;">
<div class="hairline flex items-center justify-between" style="padding: 12px 16px;">
<div style="font-size: 11px; color: var(--ink-fade); text-transform: uppercase; letter-spacing: 0.1em;">Install command · paste-and-run</div>
<div class="flex items-center gap-2">
<button class="btn btn-ghost mono" style="font-size: 11px;">Download install-prod-redis-01.sh</button>
<button class="btn">Copy</button>
</div>
</div>
<pre class="mono" style="margin: 0; padding: 18px 18px; font-size: 12.5px; line-height: 1.75; color: var(--ink-mid); white-space: pre-wrap; word-break: break-all;">curl -fsSL <span style="color: var(--accent);">https://restic.lab.example/install.sh</span> | sudo \
RM_SERVER=<span style="color: var(--accent);">https://restic.lab.example</span> \
RM_TOKEN=<span style="color: var(--accent);">HdqFbQh8U-I1fb52iP1M8qxvoYS5t9VZ-T-yghr_CzA</span> sh</pre>
</div>
<!-- live status of the new host -->
<div class="grid grid-cols-12 gap-6" style="margin-top: 28px;">
<div class="col-span-7 panel" style="border-radius: 7px; padding: 22px 24px;">
<div style="font-size: 11px; color: var(--ink-fade); text-transform: uppercase; letter-spacing: 0.1em; margin-bottom: 14px;">Awaiting agent connection</div>
<div class="flex items-center gap-3">
<span style="width: 9px; height: 9px; border-radius: 50%; background: var(--ink-fade); animation: pulse-wait 1.6s ease-in-out infinite;"></span>
<span class="mono" style="font-size: 14px; color: var(--ink);">prod-redis-01</span>
<span style="font-size: 12px; color: var(--ink-mute);">— enrolment will mark this online</span>
</div>
<div style="margin-top: 14px; padding: 10px 12px; background: var(--bg); border: 1px solid var(--line-soft); border-radius: 5px; font-family: 'JetBrains Mono'; font-size: 11.5px; color: var(--ink-mute); line-height: 1.7;">
<div>11:42:18.221 <span style="color: var(--ink-mid);">server</span> token minted · 1h ttl</div>
<div style="color: var(--ink-fade);"> <span style="color: var(--ink-fade);">awaiting POST /api/agents/enroll …</span></div>
</div>
</div>
<aside class="col-span-5">
<div style="font-size: 11px; color: var(--ink-fade); text-transform: uppercase; letter-spacing: 0.1em; margin-bottom: 12px;">If the agent doesnt appear</div>
<ul style="list-style: none; padding: 0; margin: 0; font-size: 13px; color: var(--ink-mid); line-height: 1.65;" class="text-pretty">
<li style="padding: 8px 0; border-bottom: 1px solid var(--line-soft);">Check the box can reach <span class="mono" style="color: var(--ink);">https://restic.lab.example</span> over HTTPS.</li>
<li style="padding: 8px 0; border-bottom: 1px solid var(--line-soft);">Check <span class="mono" style="color: var(--ink);">restic --version</span> ≥ 0.16 — the installer wont bail on this, but backups will fail.</li>
<li style="padding: 8px 0; border-bottom: 1px solid var(--line-soft);">Check <span class="mono" style="color: var(--ink);">journalctl -u restic-manager-agent -n 50</span> on the target box.</li>
<li style="padding: 8px 0;">Token expired? Mint a new one — theyre cheap.</li>
</ul>
</aside>
</div>
</div>
</div>
</div>
</div>
<style>
@keyframes pulse-wait {
0%, 100% { opacity: 0.45; }
50% { opacity: 1; }
}
</style>
</body>
</html>