File
Blob: ui/client/App.svelte
| 1 | <script lang="ts"> |
| 2 | import { onMount, tick } from "svelte"; |
| 3 | import Button from "../shared/Button.svelte"; |
| 4 | import Shell from "../shared/Shell.svelte"; |
| 5 | import ScrollRegion from "../shared/ScrollRegion.svelte"; |
| 6 | import DomainTokens from "./DomainTokens.svelte"; |
| 7 | import CustomDomains from "./CustomDomains.svelte"; |
| 8 | import { |
| 9 | APIError, |
| 10 | createAPI, |
| 11 | errorMessage, |
| 12 | type ClientStatus, |
| 13 | type RegisteredTunnel, |
| 14 | type TunnelSyncResult, |
| 15 | } from "./api"; |
| 16 | |
| 17 | interface TunnelRow extends Partial<TunnelSyncResult> { |
| 18 | hostname: string; |
| 19 | target: string; |
| 20 | headerHost?: string; |
| 21 | registered: boolean; |
| 22 | } |
| 23 | |
| 24 | type RemovalAction = "unpublish" | "release"; |
| 25 | type Confirmation = { action: RemovalAction; tunnel: TunnelRow; trigger: HTMLButtonElement }; |
| 26 | type Notice = { |
| 27 | tone: "success" | "warning" | "error"; |
| 28 | title: string; |
| 29 | detail?: string; |
| 30 | saveFailed?: boolean; |
| 31 | restore?: string | null; |
| 32 | }; |
| 33 | |
| 34 | let status = $state<ClientStatus | null>(null); |
| 35 | let registered = $state<RegisteredTunnel[] | null>(null); |
| 36 | let statusError = $state(""); |
| 37 | let listError = $state(""); |
| 38 | let statusCheckedAt = $state<Date | null>(null); |
| 39 | let listCheckedAt = $state<Date | null>(null); |
| 40 | let refreshing = $state(false); |
| 41 | let checkingStatus = $state(false); |
| 42 | let checkingHostnames = $state(false); |
| 43 | let pendingAction = $state<RemovalAction | "synchronize" | "">(""); |
| 44 | let confirmation = $state<Confirmation | null>(null); |
| 45 | let confirmButton = $state<HTMLButtonElement>(); |
| 46 | let tunnelsHeading = $state<HTMLHeadingElement>(); |
| 47 | let tunnelNotice = $state<Notice | null>(null); |
| 48 | let statusRequestID = 0; |
| 49 | let hostnameRequestID = 0; |
| 50 | const api = createAPI(); |
| 51 | |
| 52 | const synchronization = $derived(status?.synchronization); |
| 53 | const connectedNodes = $derived(status?.connectedNodes ?? []); |
| 54 | const actionBusy = $derived(Boolean(pendingAction)); |
| 55 | const unsavedChanges = $derived(Boolean(synchronization?.attemptedAt && !synchronization.saved)); |
| 56 | const tunnelRows = $derived(mergeTunnels(registered, synchronization?.tunnels)); |
| 57 | const configuredCount = $derived(synchronization?.tunnels?.length ?? 0); |
| 58 | const syncIssue = $derived<Notice | null>( |
| 59 | synchronization?.error || status?.pending || unsavedChanges |
| 60 | ? { |
| 61 | tone: "warning", |
| 62 | title: unsavedChanges ? "Changes not saved." : "Synchronization incomplete.", |
| 63 | detail: synchronization?.error, |
| 64 | } |
| 65 | : null, |
| 66 | ); |
| 67 | const syncNotice = $derived( |
| 68 | syncIssue && tunnelNotice?.tone === "success" ? syncIssue : tunnelNotice || syncIssue, |
| 69 | ); |
| 70 | |
| 71 | function mergeTunnels(hostnames: RegisteredTunnel[] | null, outcomes?: TunnelSyncResult[]): TunnelRow[] { |
| 72 | const rows = new Map<string, TunnelRow>(); |
| 73 | for (const [index, tunnel] of (outcomes ?? []).entries()) { |
| 74 | rows.set(tunnel.hostname || `unassigned:${index}`, { ...tunnel, registered: false }); |
| 75 | } |
| 76 | for (const tunnel of hostnames ?? []) { |
| 77 | const outcome = rows.get(tunnel.hostname); |
| 78 | rows.set(tunnel.hostname, { |
| 79 | ...tunnel, |
| 80 | target: outcomes ? "" : tunnel.target === "(unused)" ? "" : tunnel.target, |
| 81 | headerHost: outcome && outcome.target !== tunnel.target ? "" : tunnel.headerHost, |
| 82 | ...outcome, |
| 83 | registered: true, |
| 84 | }); |
| 85 | } |
| 86 | return [...rows.values()]; |
| 87 | } |
| 88 | |
| 89 | function timeLabel(value: string | Date | null | undefined): string { |
| 90 | return value |
| 91 | ? new Date(value).toLocaleTimeString([], { |
| 92 | hour: "2-digit", |
| 93 | minute: "2-digit", |
| 94 | second: "2-digit", |
| 95 | }) |
| 96 | : "Not checked"; |
| 97 | } |
| 98 | |
| 99 | function publicationLabel(tunnel: TunnelRow): string { |
| 100 | if (!tunnel.target) return tunnel.registered ? "Reserved" : "No target"; |
| 101 | if (tunnel.published && tunnel.error) return "Partially published"; |
| 102 | if (tunnel.published) return "Published"; |
| 103 | if (tunnel.error) return "Not published"; |
| 104 | return "Not checked"; |
| 105 | } |
| 106 | |
| 107 | function restorationSnippet(tunnel: TunnelRow): string { |
| 108 | return ` - hostname: ${JSON.stringify(tunnel.hostname)}\n target: ${JSON.stringify(tunnel.target || "http://127.0.0.1:8080")}${tunnel.headerHost ? `\n headerHost: ${JSON.stringify(tunnel.headerHost)}` : ""}`; |
| 109 | } |
| 110 | |
| 111 | async function loadStatus(force = false) { |
| 112 | if (checkingStatus && !force) return; |
| 113 | const requestID = ++statusRequestID; |
| 114 | checkingStatus = true; |
| 115 | try { |
| 116 | const nextStatus = await api.request<ClientStatus>("/api/status"); |
| 117 | if (requestID !== statusRequestID) return; |
| 118 | status = nextStatus; |
| 119 | statusCheckedAt = new Date(); |
| 120 | statusError = ""; |
| 121 | } catch (error) { |
| 122 | if (requestID === statusRequestID) statusError = errorMessage(error); |
| 123 | } finally { |
| 124 | if (requestID === statusRequestID) checkingStatus = false; |
| 125 | } |
| 126 | } |
| 127 | |
| 128 | async function loadHostnames(force = false) { |
| 129 | if (checkingHostnames && !force) return; |
| 130 | const requestID = ++hostnameRequestID; |
| 131 | checkingHostnames = true; |
| 132 | try { |
| 133 | const next = await api.request<RegisteredTunnel[]>("/api/ls"); |
| 134 | if (requestID !== hostnameRequestID) return; |
| 135 | registered = next; |
| 136 | listCheckedAt = new Date(); |
| 137 | listError = ""; |
| 138 | } catch (error) { |
| 139 | if (requestID === hostnameRequestID) listError = errorMessage(error); |
| 140 | } finally { |
| 141 | if (requestID === hostnameRequestID) checkingHostnames = false; |
| 142 | } |
| 143 | } |
| 144 | |
| 145 | async function refresh() { |
| 146 | if (refreshing || pendingAction) return; |
| 147 | refreshing = true; |
| 148 | await Promise.allSettled([loadStatus(true), loadHostnames(true)]); |
| 149 | refreshing = false; |
| 150 | } |
| 151 | |
| 152 | async function synchronize() { |
| 153 | if (pendingAction || refreshing) return; |
| 154 | pendingAction = "synchronize"; |
| 155 | tunnelNotice = null; |
| 156 | try { |
| 157 | await api.request("/api/reload", { method: "POST" }); |
| 158 | tunnelNotice = { tone: "success", title: "Synchronized and saved." }; |
| 159 | } catch (error) { |
| 160 | const outcome = error instanceof APIError ? error.outcome : undefined; |
| 161 | if (outcome?.applied && outcome.tunnels && status) { |
| 162 | status = { |
| 163 | ...status, |
| 164 | synchronization: { |
| 165 | applied: true, |
| 166 | saved: outcome.saved ?? false, |
| 167 | error: outcome.error, |
| 168 | attemptedAt: outcome.attemptedAt, |
| 169 | tunnels: outcome.tunnels, |
| 170 | }, |
| 171 | }; |
| 172 | } |
| 173 | tunnelNotice = { |
| 174 | tone: outcome?.applied ? "warning" : "error", |
| 175 | title: outcome?.applied |
| 176 | ? outcome.saved |
| 177 | ? "Saved; publication incomplete." |
| 178 | : "Applied; save failed." |
| 179 | : outcome |
| 180 | ? "Configuration not applied." |
| 181 | : "Synchronization result unknown.", |
| 182 | detail: errorMessage(error), |
| 183 | }; |
| 184 | } finally { |
| 185 | pendingAction = ""; |
| 186 | await refresh(); |
| 187 | } |
| 188 | } |
| 189 | |
| 190 | async function askRemoval(action: RemovalAction, tunnel: TunnelRow, trigger: HTMLButtonElement) { |
| 191 | if (pendingAction || refreshing) return; |
| 192 | confirmation = { action, tunnel, trigger }; |
| 193 | await tick(); |
| 194 | confirmButton?.focus(); |
| 195 | } |
| 196 | |
| 197 | function cancelRemoval() { |
| 198 | if (pendingAction) return; |
| 199 | const trigger = confirmation?.trigger; |
| 200 | confirmation = null; |
| 201 | trigger?.focus(); |
| 202 | } |
| 203 | |
| 204 | async function removeTunnel() { |
| 205 | if (!confirmation || pendingAction || refreshing) return; |
| 206 | const { action, tunnel } = confirmation; |
| 207 | pendingAction = action; |
| 208 | tunnelNotice = null; |
| 209 | try { |
| 210 | await api.request(`/api/${action}/${encodeURIComponent(tunnel.hostname)}`, { method: "POST" }); |
| 211 | tunnelNotice = { |
| 212 | tone: "success", |
| 213 | title: `${tunnel.hostname} ${action === "unpublish" ? "unpublished" : "released"}.`, |
| 214 | restore: action === "unpublish" ? restorationSnippet(tunnel) : null, |
| 215 | }; |
| 216 | confirmation = null; |
| 217 | } catch (error) { |
| 218 | const outcome = error instanceof APIError ? error.outcome : undefined; |
| 219 | const applied = outcome?.applied; |
| 220 | tunnelNotice = { |
| 221 | tone: applied ? "warning" : "error", |
| 222 | title: applied |
| 223 | ? `${tunnel.hostname} ${action === "unpublish" ? "unpublished" : "released"}; saving failed.` |
| 224 | : outcome |
| 225 | ? `Could not ${action} ${tunnel.hostname}.` |
| 226 | : `Could not confirm ${action} for ${tunnel.hostname}.`, |
| 227 | detail: errorMessage(error), |
| 228 | saveFailed: applied, |
| 229 | restore: applied && action === "unpublish" ? restorationSnippet(tunnel) : null, |
| 230 | }; |
| 231 | if (applied) confirmation = null; |
| 232 | } finally { |
| 233 | pendingAction = ""; |
| 234 | await refresh(); |
| 235 | if (!confirmation) tunnelsHeading?.focus(); |
| 236 | } |
| 237 | } |
| 238 | |
| 239 | onMount(() => { |
| 240 | void refresh(); |
| 241 | const timer = setInterval(() => { |
| 242 | if (!refreshing && !pendingAction && !document.hidden) { |
| 243 | void loadStatus(); |
| 244 | void loadHostnames(); |
| 245 | } |
| 246 | }, 15_000); |
| 247 | return () => { |
| 248 | clearInterval(timer); |
| 249 | api.abort(); |
| 250 | }; |
| 251 | }); |
| 252 | </script> |
| 253 | |
| 254 | <svelte:head> |
| 255 | <title>Specter · Client manager</title> |
| 256 | <meta name="description" content="Manage Specter tunnels and custom domains." /> |
| 257 | </svelte:head> |
| 258 | |
| 259 | <svelte:window onkeydown={(event) => { if (event.key === "Escape") cancelRemoval(); }} /> |
| 260 | |
| 261 | <Shell label="Client manager" home="#main-content"> |
| 262 | <section id="tunnels" class="min-w-0 scroll-mt-6" aria-labelledby="tunnels-heading"> |
| 263 | <div class="mb-3.5 flex flex-col items-start justify-between gap-3.5 min-[600px]:flex-row min-[600px]:items-center min-[600px]:gap-4"> |
| 264 | <h1 id="tunnels-heading" class="text-2xl leading-tight font-bold tracking-tight" bind:this={tunnelsHeading} tabindex="-1"> |
| 265 | Tunnels <span class="ms-1.5 text-sm font-medium text-muted">{tunnelRows.length}</span> |
| 266 | </h1> |
| 267 | <div class="flex flex-wrap items-center gap-2"> |
| 268 | <Button onclick={refresh} disabled={refreshing || actionBusy}>{refreshing ? "Refreshing…" : "Refresh"}</Button> |
| 269 | <Button variant="primary" onclick={synchronize} disabled={actionBusy || refreshing}>{pendingAction === "synchronize" ? "Synchronizing…" : "Synchronize"}</Button> |
| 270 | </div> |
| 271 | </div> |
| 272 | |
| 273 | <div class="mb-4 flex flex-col gap-2.5 border-y border-line py-2.5 min-[600px]:flex-row min-[600px]:flex-wrap min-[600px]:gap-x-8 min-[600px]:gap-y-4 [&>div]:flex [&>div]:min-w-0 [&>div]:flex-wrap [&>div]:items-center [&>div]:gap-2.5" aria-busy={checkingStatus}> |
| 274 | <div><span class="min-w-[7.625rem] text-[0.8125rem] text-muted min-[600px]:min-w-0">Server</span><span class="font-mono text-[0.8125rem] wrap-anywhere">{status?.apex || (statusError ? "Unavailable" : "Loading…")}</span></div> |
| 275 | <div> |
| 276 | <span class="min-w-[7.625rem] text-[0.8125rem] text-muted min-[600px]:min-w-0">Connected</span> |
| 277 | <span class={status && !connectedNodes.length ? 'text-warning' : ''}> |
| 278 | {#if status}<span class={['me-1.5 inline-block size-1.5 rounded-full align-[1px]', connectedNodes.length ? 'bg-success' : 'bg-warning']} aria-hidden="true"></span>{connectedNodes.length} {connectedNodes.length === 1 ? "node" : "nodes"}{:else}Not checked{/if} |
| 279 | </span> |
| 280 | </div> |
| 281 | <div><span class="min-w-[7.625rem] text-[0.8125rem] text-muted min-[600px]:min-w-0">Last sync</span><span>{synchronization?.attemptedAt ? timeLabel(synchronization.attemptedAt) : "Not attempted"}</span></div> |
| 282 | </div> |
| 283 | |
| 284 | {#if syncNotice} |
| 285 | <div class={[ |
| 286 | 'my-2.5 rounded-md border bg-chrome px-3 py-2 wrap-anywhere', |
| 287 | syncNotice.tone === 'success' && 'border-success/40 text-success', |
| 288 | syncNotice.tone === 'warning' && 'border-warning/40 text-warning', |
| 289 | syncNotice.tone === 'error' && 'border-danger/40 text-danger', |
| 290 | ]} role="status"> |
| 291 | <strong class="font-semibold">{syncNotice.title}</strong> |
| 292 | {#if status?.pending}<span> Retry {status.retryAt ? timeLabel(status.retryAt) : "pending"}.</span>{/if} |
| 293 | {#if unsavedChanges || (syncNotice.saveFailed && statusError)}<p class="mt-1">Review the YAML before reloading or restarting.</p>{/if} |
| 294 | {#if syncNotice.detail}<details class="mt-1"><summary class="cursor-pointer text-[0.8125rem] text-zinc-200">Error details</summary><p class="mt-2">{syncNotice.detail}</p></details>{/if} |
| 295 | {#if syncNotice.restore} |
| 296 | <details class="my-2"><summary class="cursor-pointer py-1 font-medium text-zinc-200">Restore tunnel</summary><p class="mt-2">Add under <code>tunnels:</code>, restore other options, then synchronize.</p><pre class="my-3 overflow-auto rounded-md border border-line bg-chrome px-3.5 py-3 font-mono text-[0.8125rem] leading-relaxed whitespace-pre-wrap">{syncNotice.restore}</pre></details> |
| 297 | {/if} |
| 298 | </div> |
| 299 | {/if} |
| 300 | {#if statusError || listError} |
| 301 | <div class="my-2.5 rounded-md border border-danger/40 bg-chrome px-3 py-2 text-danger wrap-anywhere" role="status"> |
| 302 | {#if statusError}<div><strong class="font-semibold">Status unavailable.</strong>{#if status} Snapshot {timeLabel(statusCheckedAt)}.{/if}<details class="mt-1"><summary class="cursor-pointer text-[0.8125rem] text-zinc-200">Status error</summary><p class="mt-2">{statusError}</p></details></div>{/if} |
| 303 | {#if listError}<div><strong class="font-semibold">Hostnames unavailable.</strong>{#if registered} Snapshot {timeLabel(listCheckedAt)}.{/if}<details class="mt-1"><summary class="cursor-pointer text-[0.8125rem] text-zinc-200">Hostname error</summary><p class="mt-2">{listError}</p></details></div>{/if} |
| 304 | </div> |
| 305 | {/if} |
| 306 | |
| 307 | <div aria-busy={refreshing}> |
| 308 | {#if registered === null && refreshing && !tunnelRows.length} |
| 309 | <div class="rounded-md border border-line bg-chrome px-4 py-4.5" role="status">Loading tunnels…</div> |
| 310 | {:else if tunnelRows.length === 0} |
| 311 | <div class="rounded-md border border-line bg-chrome px-4 py-4.5">{#if statusError || listError}No data. Refresh to retry.{:else}No tunnels. <a href="#configuration" class="text-accent-300 underline underline-offset-4 hover:text-accent-200">Add a target</a>, then synchronize.{/if}</div> |
| 312 | {:else} |
| 313 | <ScrollRegion label="Tunnels" class="bg-chrome"> |
| 314 | <table class="w-full min-w-[760px] table-fixed border-collapse text-start [&_td]:px-3.5 [&_td]:py-2.5 [&_td]:align-top [&_td]:wrap-anywhere [&_th]:px-3.5 [&_th]:py-2.5 [&_th]:text-start [&_th]:align-top [&_th]:wrap-anywhere"> |
| 315 | <thead class="text-xs text-muted [&_th]:font-medium"><tr><th class="w-1/4" scope="col">Hostname</th><th class="w-[27%]" scope="col">Local target</th><th class="w-1/4" scope="col">Publication</th><th class="w-[23%]" scope="col">Actions</th></tr></thead> |
| 316 | <tbody> |
| 317 | {#each tunnelRows as tunnel, index (tunnel.hostname || `unassigned:${index}`)} |
| 318 | <tr class="border-t border-line hover:bg-surface/50"> |
| 319 | <th scope="row" class="font-medium"><span class="font-mono text-[0.8125rem]">{tunnel.hostname || "Awaiting hostname"}</span></th> |
| 320 | <td><span class={['font-mono text-[0.8125rem]', !tunnel.target && 'text-muted']}>{tunnel.target || "None"}</span>{#if tunnel.headerHost}<span class="mt-1 block text-xs text-muted">Host: <span class="font-mono">{tunnel.headerHost}</span></span>{/if}</td> |
| 321 | <td> |
| 322 | <span class={['text-[0.8125rem]', tunnel.published && !tunnel.error && 'text-success', tunnel.error && 'text-warning']}>{publicationLabel(tunnel)}</span> |
| 323 | {#if tunnel.published}<span class="mt-1 block text-xs text-muted">{tunnel.publishedEndpoints} {tunnel.publishedEndpoints === 1 ? "endpoint" : "endpoints"}</span>{/if} |
| 324 | {#if tunnel.error}<details class="mt-1"><summary class="cursor-pointer text-[0.8125rem] text-zinc-200">Error</summary><p class="mt-2 text-xs text-danger">{tunnel.error}</p></details>{/if} |
| 325 | </td> |
| 326 | <td> |
| 327 | <div class="flex flex-wrap items-center justify-end gap-1.5"> |
| 328 | <Button size="sm" disabled={actionBusy || refreshing || !tunnel.hostname || !tunnel.target} aria-label={`Unpublish ${tunnel.hostname || "unassigned tunnel"}`} onclick={(event) => askRemoval("unpublish", tunnel, event.currentTarget)}>Unpublish</Button> |
| 329 | <Button size="sm" variant="danger" disabled={actionBusy || refreshing || !tunnel.hostname} aria-label={`Release ${tunnel.hostname || "unassigned tunnel"}`} onclick={(event) => askRemoval("release", tunnel, event.currentTarget)}>Release</Button> |
| 330 | </div> |
| 331 | </td> |
| 332 | </tr> |
| 333 | {#if confirmation?.tunnel.hostname === tunnel.hostname} |
| 334 | <tr class="border-t border-line bg-surface"><td colspan="4"> |
| 335 | <div class="flex flex-wrap items-center justify-between gap-4" role="group" aria-label={`${confirmation.action === "unpublish" ? "Unpublish" : "Release"} ${tunnel.hostname}`}> |
| 336 | <p class="max-w-[68ch] text-zinc-300">{confirmation.action === "unpublish" ? "Remove target from YAML; keep hostname." : "Remove target from YAML; release hostname for others to claim."}</p> |
| 337 | <div class="flex flex-wrap items-center gap-2"><Button onclick={cancelRemoval} disabled={Boolean(pendingAction)}>Cancel</Button><Button bind:element={confirmButton} variant="danger" onclick={removeTunnel} disabled={actionBusy || refreshing}>{pendingAction ? "Applying…" : confirmation.action === "unpublish" ? "Confirm unpublish" : "Confirm release"}</Button></div> |
| 338 | </div> |
| 339 | </td></tr> |
| 340 | {/if} |
| 341 | {/each} |
| 342 | </tbody> |
| 343 | </table> |
| 344 | </ScrollRegion> |
| 345 | {/if} |
| 346 | </div> |
| 347 | <p class="mt-2.5 text-[0.8125rem] text-muted min-[600px]:text-xs">Publication: last acknowledgement; target health unchecked.</p> |
| 348 | </section> |
| 349 | |
| 350 | <div class="mt-6 grid grid-cols-1 gap-7 border-t border-line pt-5 min-[600px]:grid-cols-2 min-[800px]:gap-8"> |
| 351 | <CustomDomains /> |
| 352 | <section id="configuration" class="min-w-0 scroll-mt-6 border-t border-line pt-5 min-[600px]:border-0 min-[600px]:pt-0" aria-labelledby="configuration-heading"> |
| 353 | <h2 id="configuration-heading" class="mb-3.5 text-lg leading-snug font-semibold tracking-tight">Configuration</h2> |
| 354 | <dl class="mb-2.5 [&>div]:grid [&>div]:grid-cols-[9.375rem_minmax(0,1fr)] [&>div]:gap-4 [&>div]:py-0.5 [&_dd]:wrap-anywhere [&_dt]:text-muted"> |
| 355 | <div><dt>Configured tunnels</dt><dd>{status ? configuredCount : "Unavailable"}</dd></div> |
| 356 | <div><dt>Disk state</dt><dd class={synchronization?.attemptedAt && !synchronization.saved ? 'text-warning' : ''}>{!synchronization?.attemptedAt ? "Not checked" : synchronization.saved ? "Saved" : "Unsaved"}</dd></div> |
| 357 | </dl> |
| 358 | <details class="my-2"> |
| 359 | <summary class="cursor-pointer py-1 font-medium text-zinc-200">Edit tunnels</summary> |
| 360 | <pre class="my-3 overflow-auto rounded-md border border-line bg-chrome px-3.5 py-3 font-mono text-[0.8125rem] leading-relaxed whitespace-pre-wrap" aria-label="Example tunnel configuration">{"tunnels:\n - target: http://127.0.0.1:8080\n # hostname: app.example.com"}</pre> |
| 361 | <p class="mt-1.5 mb-2.5 max-w-[72ch] text-[0.8125rem] leading-relaxed text-muted">Edit your existing YAML, then synchronize. Keep credentials intact. Omit hostname for automatic assignment; otherwise use a reserved or verified hostname.</p> |
| 362 | </details> |
| 363 | </section> |
| 364 | </div> |
| 365 | |
| 366 | <DomainTokens |
| 367 | hostnames={registered?.map((t) => t.hostname).filter(Boolean) ?? null} |
| 368 | loadingHostnames={checkingHostnames} |
| 369 | hostnameError={listError} |
| 370 | onRefreshHostnames={() => loadHostnames(true)} |
| 371 | /> |
| 372 | |
| 373 | <footer class="mt-6 flex justify-end border-t border-line py-3 text-xs text-muted">{statusCheckedAt ? `Checked ${timeLabel(statusCheckedAt)}` : statusError ? "Status unavailable" : "Connecting…"}</footer> |
| 374 | </Shell> |