8b7b1479a1
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>
348 lines
21 KiB
HTML
348 lines
21 KiB
HTML
<!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 host’s display name. Most operators use the box’s 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 server’s 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 & 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 doesn’t 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 won’t 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 — they’re cheap.</li>
|
||
</ul>
|
||
</aside>
|
||
|
||
</div>
|
||
|
||
</div>
|
||
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<style>
|
||
@keyframes pulse-wait {
|
||
0%, 100% { opacity: 0.45; }
|
||
50% { opacity: 1; }
|
||
}
|
||
</style>
|
||
|
||
</body>
|
||
</html>
|