Infra: operator-console redesign (per the Infrastructure design package)
Refactor the Infrastructure tier into the dark operator console from the design package, wired to the real fleet backend (no new plumbing): - InfraNav: page-scoped left nav grouped RUN HERE FIRST (Local & Tailscale, with online count + sub-items) / NEXT (Cloud providers, SOON) + coral "Connect a host" button; coral spine on the active view. - FleetConsole: center reads top-to-bottom — kicker → "Your fleet" → 5 stat tiles (HOSTS/ONLINE/MEMORY/STORAGE/CONTAINERS) → Tailscale device card → LOCAL HOSTS host cards + add-host dashed tile. Cloud view = how-it-works + SOON. - Host cards (design system): status dot (blink when online) + hostname, IP/version line, CPU/RAM meter bars (cyan→amber→coral by load), disk/load/ctrs mini-row, and a footer that's a copyable `ssh <node>` target (online) OR a "waiting for daemon" spinner (pairing) — PLUS a Terminal button that opens the in-app shell. - FleetPill (top bar, N/M hosts online) + FleetStatusBar (ambient: daemon · tailnet · WSS · sandbox). Per the chosen reconciliation: kept the cloud-apps computer pull-out as the right column, the connect-host wizard as a modal, and the in-app terminal. Dashboard infra branch rewired (InfraNav + FleetConsole + status bar; dropped the old InfraStage/InfraConsole split). cm-blink/spin/cm-fade keyframes already existed. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
8e71c8403b
commit
b840de7c3b
@@ -0,0 +1,708 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<script src="./support.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<x-dc>
|
||||||
|
<helmet>
|
||||||
|
<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=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body { margin: 0; padding: 0; background: #08080a; }
|
||||||
|
body { font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; -webkit-font-smoothing: antialiased; }
|
||||||
|
@keyframes cm-blink { 0%,100% { opacity: 1; } 50% { opacity: .3; } }
|
||||||
|
@keyframes cm-spin { to { transform: rotate(360deg); } }
|
||||||
|
@keyframes cm-fade { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }
|
||||||
|
@keyframes cm-pip { 0%,100% { opacity: .35; } 50% { opacity: 1; } }
|
||||||
|
.cm-scroll::-webkit-scrollbar { width: 8px; }
|
||||||
|
.cm-scroll::-webkit-scrollbar-thumb { background: rgba(255,255,255,.1); border-radius: 4px; }
|
||||||
|
</style>
|
||||||
|
</helmet>
|
||||||
|
|
||||||
|
<div style="width:100%; height:100vh; min-height:660px; background:#08080a; color:#f3f3f5; display:flex; flex-direction:column; overflow:hidden;">
|
||||||
|
|
||||||
|
<!-- TOP BAR -->
|
||||||
|
<div style="height:50px; flex:none; display:flex; align-items:center; gap:13px; padding:0 16px; border-bottom:1px solid rgba(255,255,255,.06); background:linear-gradient(180deg,#0d0d10,#0a0a0c);">
|
||||||
|
<div style="display:flex; align-items:center; gap:9px;">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 22 22" fill="none"><path d="M11 3 L18.5 17 L3.5 17 Z" stroke="#ff6f61" stroke-width="1.3" stroke-linejoin="round" opacity="0.55"></path><circle cx="11" cy="3.5" r="2.2" fill="#ff6f61"></circle><circle cx="18" cy="17" r="2.2" fill="#ff6f61"></circle><circle cx="4" cy="17" r="2.2" fill="#ff6f61"></circle></svg>
|
||||||
|
<span style="font-size:14px; font-weight:700;">Clawmates</span>
|
||||||
|
</div>
|
||||||
|
<span style="font-family:'JetBrains Mono',monospace; font-size:12px; color:#6a6a72;">Large World</span>
|
||||||
|
<span style="color:#3a3a40;">/</span>
|
||||||
|
<span style="font-family:'JetBrains Mono',monospace; font-size:12px; color:#6a6a72;">Agents</span>
|
||||||
|
<span style="color:#3a3a40;">/</span>
|
||||||
|
<span style="font-family:'JetBrains Mono',monospace; font-size:12px; color:#f3f3f5; background:rgba(255,111,97,.12); border:1px solid rgba(255,111,97,.28); padding:3px 9px; border-radius:6px;">Infrastructure</span>
|
||||||
|
<div style="flex:1;"></div>
|
||||||
|
<div style="display:flex; align-items:center; gap:7px; font-family:'JetBrains Mono',monospace; font-size:11px; color:#5fd08a; padding:5px 10px; border:1px solid rgba(95,208,138,.25); border-radius:7px; background:rgba(95,208,138,.06);">
|
||||||
|
<span style="width:6px; height:6px; border-radius:50%; background:#5fd08a; animation:cm-blink 1.6s infinite;"></span>{{ fleetOnline }}/{{ fleetTotal }} hosts online
|
||||||
|
</div>
|
||||||
|
<div style="width:30px; height:30px; border-radius:8px; background:linear-gradient(135deg,#ff8a7a,#ff5f57); display:flex; align-items:center; justify-content:center; font-size:12px; font-weight:700; color:#2a0d0a;">O</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- BODY -->
|
||||||
|
<div style="flex:1; display:flex; min-height:0;">
|
||||||
|
|
||||||
|
<!-- ICON RAIL -->
|
||||||
|
<div style="width:54px; flex:none; border-right:1px solid rgba(255,255,255,.06); background:#0a0a0c; display:flex; flex-direction:column; align-items:center; padding:12px 0; gap:6px;">
|
||||||
|
<div style="width:40px; height:44px; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:4px; border-radius:9px; color:#5a5a62;">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 20 20"><circle cx="10" cy="10" r="7.5" stroke="currentColor" stroke-width="1.4" fill="none"></circle><circle cx="10" cy="10" r="2.4" fill="currentColor"></circle></svg>
|
||||||
|
<span style="font-family:'JetBrains Mono',monospace; font-size:7px; letter-spacing:.05em;">WORLD</span>
|
||||||
|
</div>
|
||||||
|
<div style="width:40px; height:44px; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:4px; border-radius:9px; color:#5a5a62;">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 20 20"><rect x="4" y="4" width="12" height="12" rx="3.5" stroke="currentColor" stroke-width="1.4" fill="none"></rect><circle cx="10" cy="10" r="2.2" fill="currentColor"></circle></svg>
|
||||||
|
<span style="font-family:'JetBrains Mono',monospace; font-size:7px; letter-spacing:.05em;">AGENT</span>
|
||||||
|
</div>
|
||||||
|
<div style="position:relative; width:40px; height:44px; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:4px; border-radius:9px; color:#ff6f61; background:rgba(255,111,97,.1);">
|
||||||
|
<span style="position:absolute; left:0; top:7px; bottom:7px; width:3px; border-radius:0 3px 3px 0; background:#ff6f61;"></span>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 20 20"><rect x="3" y="4" width="14" height="5" rx="1.6" stroke="currentColor" stroke-width="1.4" fill="none"></rect><rect x="3" y="11" width="14" height="5" rx="1.6" stroke="currentColor" stroke-width="1.4" fill="none"></rect><circle cx="6" cy="6.5" r="1" fill="currentColor"></circle><circle cx="6" cy="13.5" r="1" fill="currentColor"></circle></svg>
|
||||||
|
<span style="font-family:'JetBrains Mono',monospace; font-size:7px; letter-spacing:.05em;">INFRA</span>
|
||||||
|
</div>
|
||||||
|
<div style="flex:1;"></div>
|
||||||
|
<div style="width:28px; height:28px; border-radius:8px; border:1px dashed rgba(255,255,255,.16); display:flex; align-items:center; justify-content:center; color:#ff6f61; font-size:16px; font-weight:300;">+</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- LEFT NAV -->
|
||||||
|
<div style="width:216px; flex:none; border-right:1px solid rgba(255,255,255,.06); background:#0b0b0e; display:flex; flex-direction:column; min-height:0;">
|
||||||
|
<div style="padding:14px 14px 12px;">
|
||||||
|
<div style="font-family:'JetBrains Mono',monospace; font-size:10px; letter-spacing:.12em; color:#5a5a62;">INFRASTRUCTURE</div>
|
||||||
|
<div style="font-size:17px; font-weight:700; margin-top:3px;">Your fleet</div>
|
||||||
|
</div>
|
||||||
|
<div style="flex:1; overflow-y:auto; padding:4px 8px;" class="cm-scroll">
|
||||||
|
<div style="font-family:'JetBrains Mono',monospace; font-size:9px; letter-spacing:.14em; color:#5a5a62; padding:8px 8px 6px;">RUN HERE FIRST</div>
|
||||||
|
<div style="display:flex; align-items:center; gap:10px; padding:9px 10px; border-radius:9px; cursor:pointer; position:relative; {{ navLocalStyle }}" onClick="{{ goLocal }}">
|
||||||
|
<sc-if value="{{ isLocal }}" hint-placeholder-val="{{ true }}"><span style="position:absolute; left:0; top:8px; bottom:8px; width:3px; border-radius:0 3px 3px 0; background:#ff6f61;"></span></sc-if>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 20 20" style="color:{{ navLocalIcon }};"><rect x="3" y="4" width="14" height="5" rx="1.6" stroke="currentColor" stroke-width="1.4" fill="none"></rect><rect x="3" y="11" width="14" height="5" rx="1.6" stroke="currentColor" stroke-width="1.4" fill="none"></rect><circle cx="6" cy="6.5" r="1" fill="currentColor"></circle><circle cx="6" cy="13.5" r="1" fill="currentColor"></circle></svg>
|
||||||
|
<div style="flex:1;"><div style="font-size:13px; font-weight:600; color:{{ navLocalText }};">Local & Tailscale</div></div>
|
||||||
|
<span style="font-family:'JetBrains Mono',monospace; font-size:9px; color:#5fd08a;">{{ fleetOnline }}</span>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; flex-direction:column; gap:1px; margin:2px 0 10px 14px; padding-left:10px; border-left:1px solid rgba(255,255,255,.07);">
|
||||||
|
<div style="display:flex; align-items:center; gap:8px; padding:6px 9px; border-radius:7px; cursor:pointer;"><span style="width:5px; height:5px; border-radius:50%; background:#5fd08a;"></span><span style="font-size:12px; color:#b5b5bd;">Local hosts</span><span style="flex:1;"></span><span style="font-family:'JetBrains Mono',monospace; font-size:9px; color:#5a5a62;">{{ fleetTotal }}</span></div>
|
||||||
|
<div style="display:flex; align-items:center; gap:8px; padding:6px 9px; border-radius:7px; cursor:pointer;"><span style="width:5px; height:5px; border-radius:50%; background:#5ec8d8;"></span><span style="font-size:12px; color:#b5b5bd;">Tailscale network</span><span style="flex:1;"></span><span style="font-family:'JetBrains Mono',monospace; font-size:9px; color:#5a5a62;">{{ tsDeviceCount }}</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="font-family:'JetBrains Mono',monospace; font-size:9px; letter-spacing:.14em; color:#5a5a62; padding:8px 8px 6px;">NEXT</div>
|
||||||
|
<div style="display:flex; align-items:center; gap:10px; padding:9px 10px; border-radius:9px; cursor:pointer; position:relative; {{ navCloudStyle }}" onClick="{{ goCloud }}">
|
||||||
|
<sc-if value="{{ isCloud }}" hint-placeholder-val="{{ false }}"><span style="position:absolute; left:0; top:8px; bottom:8px; width:3px; border-radius:0 3px 3px 0; background:#ff6f61;"></span></sc-if>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 20 20" style="color:{{ navCloudIcon }};"><path d="M6 14h9a3 3 0 000-6 4.5 4.5 0 00-8.7-1.2A3.4 3.4 0 006 14z" stroke="currentColor" stroke-width="1.4" fill="none"></path></svg>
|
||||||
|
<div style="flex:1;"><div style="font-size:13px; font-weight:600; color:{{ navCloudText }};">Cloud providers</div></div>
|
||||||
|
<span style="font-family:'JetBrains Mono',monospace; font-size:8px; color:#e8b465; padding:2px 6px; border-radius:5px; background:rgba(232,196,106,.1);">SOON</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="flex:none; padding:10px 12px; border-top:1px solid rgba(255,255,255,.06);">
|
||||||
|
<div style="display:flex; align-items:center; justify-content:center; gap:7px; height:36px; border-radius:9px; background:linear-gradient(135deg,#ff8a7a,#ff5f57); color:#2a0d0a; font-size:13px; font-weight:700; cursor:pointer;" onClick="{{ openHostWizard }}"><span style="font-size:15px; line-height:1;">+</span> Connect a host</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- CENTER -->
|
||||||
|
<div style="flex:1; min-width:0; background:radial-gradient(130% 100% at 50% 0%, #0d0d12 0%, #08080a 60%); display:flex; flex-direction:column; min-height:0;">
|
||||||
|
<sc-if value="{{ isLocal }}" hint-placeholder-val="{{ true }}">
|
||||||
|
<div style="flex:1; min-height:0; overflow-y:auto; padding:22px 26px 30px;" class="cm-scroll">
|
||||||
|
|
||||||
|
<div style="margin-bottom:6px; font-family:'JetBrains Mono',monospace; font-size:10px; letter-spacing:.14em; color:#5a5a62;">LOCAL & TAILSCALE</div>
|
||||||
|
<div style="display:flex; align-items:flex-end; gap:14px; margin-bottom:20px;">
|
||||||
|
<div><div style="font-size:26px; font-weight:700; letter-spacing:-.02em;">Your fleet</div><div style="font-size:13px; color:#8a8a92; margin-top:3px;">Run agents on hardware you own. Put a node on your tailnet, then pair it — no inbound port, no keys.</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- fleet stats -->
|
||||||
|
<div style="display:grid; grid-template-columns:repeat(5,1fr); gap:10px; margin-bottom:22px;">
|
||||||
|
<div style="border-radius:12px; border:1px solid rgba(255,255,255,.07); background:#0d0d10; padding:13px 15px;"><div style="font-family:'JetBrains Mono',monospace; font-size:9px; letter-spacing:.1em; color:#6a6a72;">HOSTS</div><div style="font-size:22px; font-weight:700; margin-top:4px;">{{ fleetTotal }}</div></div>
|
||||||
|
<div style="border-radius:12px; border:1px solid rgba(95,208,138,.18); background:#0d0d10; padding:13px 15px;"><div style="font-family:'JetBrains Mono',monospace; font-size:9px; letter-spacing:.1em; color:#6a6a72;">ONLINE</div><div style="font-size:22px; font-weight:700; margin-top:4px; color:#5fd08a;">{{ fleetOnline }}</div></div>
|
||||||
|
<div style="border-radius:12px; border:1px solid rgba(255,255,255,.07); background:#0d0d10; padding:13px 15px;"><div style="font-family:'JetBrains Mono',monospace; font-size:9px; letter-spacing:.1em; color:#6a6a72;">vCPU</div><div style="font-size:22px; font-weight:700; margin-top:4px;">{{ vcpu }}</div></div>
|
||||||
|
<div style="border-radius:12px; border:1px solid rgba(255,255,255,.07); background:#0d0d10; padding:13px 15px;"><div style="font-family:'JetBrains Mono',monospace; font-size:9px; letter-spacing:.1em; color:#6a6a72;">MEMORY</div><div style="font-size:22px; font-weight:700; margin-top:4px;">{{ ram }}</div></div>
|
||||||
|
<div style="border-radius:12px; border:1px solid rgba(94,200,216,.18); background:#0d0d10; padding:13px 15px;"><div style="font-family:'JetBrains Mono',monospace; font-size:9px; letter-spacing:.1em; color:#6a6a72;">CONTAINERS</div><div style="font-size:22px; font-weight:700; margin-top:4px; color:#5ec8d8;">{{ containersRunning }}</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tailscale network -->
|
||||||
|
<div style="border-radius:14px; border:1px solid rgba(255,255,255,.08); background:#0c0c0f; margin-bottom:22px; overflow:hidden;">
|
||||||
|
<div style="display:flex; align-items:center; gap:11px; padding:13px 16px; border-bottom:1px solid rgba(255,255,255,.05);">
|
||||||
|
<div style="width:30px; height:30px; border-radius:8px; background:#fff; display:flex; align-items:center; justify-content:center;">
|
||||||
|
<svg width="17" height="17" viewBox="0 0 24 24"><g fill="#141414"><circle cx="5" cy="5" r="2.1" opacity=".3"></circle><circle cx="12" cy="5" r="2.1" opacity=".3"></circle><circle cx="19" cy="5" r="2.1" opacity=".3"></circle><circle cx="5" cy="12" r="2.1"></circle><circle cx="12" cy="12" r="2.1"></circle><circle cx="19" cy="12" r="2.1"></circle><circle cx="5" cy="19" r="2.1" opacity=".3"></circle><circle cx="12" cy="19" r="2.1"></circle><circle cx="19" cy="19" r="2.1" opacity=".3"></circle></g></svg>
|
||||||
|
</div>
|
||||||
|
<div style="flex:1;"><div style="font-size:14px; font-weight:700;">Tailscale network</div><div style="font-family:'JetBrains Mono',monospace; font-size:10px; color:#6a6a72;">{{ tailnet }} · live device status</div></div>
|
||||||
|
<span style="display:flex; align-items:center; gap:6px; font-family:'JetBrains Mono',monospace; font-size:10px; color:#5fd08a; padding:4px 10px; border-radius:7px; background:rgba(95,208,138,.08); border:1px solid rgba(95,208,138,.25);"><span style="width:6px; height:6px; border-radius:50%; background:#5fd08a; animation:cm-blink 1.6s infinite;"></span>connected</span>
|
||||||
|
<span style="font-family:'JetBrains Mono',monospace; font-size:10px; color:#5ec8d8; cursor:pointer;" onClick="{{ openTailscale }}">manage →</span>
|
||||||
|
</div>
|
||||||
|
<div style="display:grid; grid-template-columns:1fr 1fr; gap:1px; background:rgba(255,255,255,.04);">
|
||||||
|
<sc-for list="{{ tsDevices }}" as="d" hint-placeholder-count="4">
|
||||||
|
<div style="display:flex; align-items:center; gap:10px; padding:11px 16px; background:#0c0c0f;">
|
||||||
|
<span style="width:7px; height:7px; border-radius:50%; background:{{ d.dot }};"></span>
|
||||||
|
<span style="font-size:13px; font-weight:600; color:#eaeaee;">{{ d.name }}</span>
|
||||||
|
<span style="flex:1;"></span>
|
||||||
|
<span style="font-family:'JetBrains Mono',monospace; font-size:10px; color:#8a8a92;">{{ d.os }}</span>
|
||||||
|
<span style="font-family:'JetBrains Mono',monospace; font-size:10px; color:#5ec8d8; width:96px; text-align:right;">{{ d.ip }}</span>
|
||||||
|
<span style="font-family:'JetBrains Mono',monospace; font-size:10px; color:#6a6a72; width:40px; text-align:right;">{{ d.seen }}</span>
|
||||||
|
</div>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- host health cards -->
|
||||||
|
<div style="display:flex; align-items:center; gap:10px; margin-bottom:12px;">
|
||||||
|
<span style="font-family:'JetBrains Mono',monospace; font-size:10px; letter-spacing:.12em; color:#5a5a62;">LOCAL HOSTS</span>
|
||||||
|
<span style="flex:1; height:1px; background:rgba(255,255,255,.06);"></span>
|
||||||
|
<span style="font-family:'JetBrains Mono',monospace; font-size:10px; color:#5ec8d8; cursor:pointer;" onClick="{{ openHostWizard }}">+ connect a host</span>
|
||||||
|
</div>
|
||||||
|
<div style="display:grid; grid-template-columns:repeat(auto-fill, minmax(290px, 1fr)); gap:14px;">
|
||||||
|
<sc-for list="{{ hostCards }}" as="h" hint-placeholder-count="3">
|
||||||
|
<div style="border-radius:13px; border:1px solid {{ h.accentBorder }}; background:#0d0d10; padding:15px; display:flex; flex-direction:column;">
|
||||||
|
<div style="display:flex; align-items:center; gap:9px; margin-bottom:13px;">
|
||||||
|
<span style="position:relative; width:9px; height:9px;"><span style="position:absolute; inset:0; border-radius:50%; background:{{ h.statusColor }};"></span></span>
|
||||||
|
<span style="font-size:14px; font-weight:700;">{{ h.name }}</span>
|
||||||
|
<span style="flex:1;"></span>
|
||||||
|
<span style="font-family:'JetBrains Mono',monospace; font-size:9px; color:{{ h.statusColor }};">{{ h.statusLabel }}</span>
|
||||||
|
</div>
|
||||||
|
<div style="font-family:'JetBrains Mono',monospace; font-size:10px; color:#6a6a72; margin-bottom:14px;">{{ h.os }}</div>
|
||||||
|
|
||||||
|
<div style="display:flex; flex-direction:column; gap:9px; margin-bottom:13px;">
|
||||||
|
<div>
|
||||||
|
<div style="display:flex; justify-content:space-between; font-family:'JetBrains Mono',monospace; font-size:9px; color:#8a8a92; margin-bottom:4px;"><span>CPU</span><span style="color:{{ h.cpuCol }};">{{ h.cpu }}%</span></div>
|
||||||
|
<div style="height:4px; border-radius:2px; background:rgba(255,255,255,.07); overflow:hidden;"><div style="width:{{ h.cpuW }}; height:100%; background:{{ h.cpuCol }};"></div></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style="display:flex; justify-content:space-between; font-family:'JetBrains Mono',monospace; font-size:9px; color:#8a8a92; margin-bottom:4px;"><span>RAM</span><span style="color:{{ h.ramCol }};">{{ h.ram }}%</span></div>
|
||||||
|
<div style="height:4px; border-radius:2px; background:rgba(255,255,255,.07); overflow:hidden;"><div style="width:{{ h.ramW }}; height:100%; background:{{ h.ramCol }};"></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex; gap:6px; margin-bottom:13px;">
|
||||||
|
<div style="flex:1; text-align:center; padding:7px 0; border-radius:8px; background:rgba(255,255,255,.03);"><div style="font-family:'JetBrains Mono',monospace; font-size:9px; color:#6a6a72;">disk</div><div style="font-size:12px; font-weight:600; margin-top:2px;">{{ h.disk }}%</div></div>
|
||||||
|
<div style="flex:1; text-align:center; padding:7px 0; border-radius:8px; background:rgba(255,255,255,.03);"><div style="font-family:'JetBrains Mono',monospace; font-size:9px; color:#6a6a72;">load</div><div style="font-size:12px; font-weight:600; margin-top:2px;">{{ h.load }}</div></div>
|
||||||
|
<div style="flex:1; text-align:center; padding:7px 0; border-radius:8px; background:rgba(255,255,255,.03);"><div style="font-family:'JetBrains Mono',monospace; font-size:9px; color:#6a6a72;">ctrs</div><div style="font-size:12px; font-weight:600; margin-top:2px;">{{ h.containers }}</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<sc-if value="{{ h.hasSsh }}" hint-placeholder-val="{{ true }}">
|
||||||
|
<div style="display:flex; align-items:center; gap:8px; padding:8px 11px; border-radius:8px; background:#070708; border:1px solid rgba(255,255,255,.07);">
|
||||||
|
<span style="width:14px; height:14px; flex:none;"><svg width="14" height="14" viewBox="0 0 24 24"><g fill="#5ec8d8"><circle cx="6" cy="6" r="1.8"></circle><circle cx="12" cy="6" r="1.8" opacity=".4"></circle><circle cx="6" cy="12" r="1.8"></circle><circle cx="12" cy="12" r="1.8"></circle><circle cx="18" cy="12" r="1.8" opacity=".4"></circle><circle cx="12" cy="18" r="1.8" opacity=".4"></circle></g></svg></span>
|
||||||
|
<span style="font-family:'JetBrains Mono',monospace; font-size:11px; color:#cfcfd5; flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">{{ h.sshTarget }}</span>
|
||||||
|
<span style="font-family:'JetBrains Mono',monospace; font-size:9px; color:#5ec8d8; cursor:pointer;">copy</span>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
<sc-if value="{{ h.pairing }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div style="display:flex; align-items:center; gap:8px; padding:8px 11px; border-radius:8px; background:rgba(232,196,106,.06); border:1px solid rgba(232,196,106,.2);">
|
||||||
|
<span style="width:11px; height:11px; border:2px solid #e8b465; border-top-color:transparent; border-radius:50%; animation:cm-spin .8s linear infinite;"></span>
|
||||||
|
<span style="font-family:'JetBrains Mono',monospace; font-size:10px; color:#e8b465;">waiting for daemon to dial home…</span>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
</div>
|
||||||
|
</sc-for>
|
||||||
|
|
||||||
|
<!-- add host tile -->
|
||||||
|
<div style="border-radius:13px; border:1.5px dashed rgba(255,255,255,.14); background:transparent; padding:15px; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:8px; min-height:200px; cursor:pointer;" onClick="{{ openHostWizard }}">
|
||||||
|
<div style="width:38px; height:38px; border-radius:11px; background:rgba(255,111,97,.12); display:flex; align-items:center; justify-content:center; color:#ff6f61; font-size:22px; font-weight:300;">+</div>
|
||||||
|
<div style="font-size:13px; font-weight:600; color:#cfcfd5;">Connect a host</div>
|
||||||
|
<div style="font-family:'JetBrains Mono',monospace; font-size:9px; color:#6a6a72; text-align:center;">Mac · Linux · edge device</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
<sc-if value="{{ isCloud }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div style="flex:1; min-height:0; overflow-y:auto; padding:22px 26px 30px;" class="cm-scroll">
|
||||||
|
<div style="margin-bottom:6px; font-family:'JetBrains Mono',monospace; font-size:10px; letter-spacing:.14em; color:#5a5a62;">CLOUD PROVIDERS</div>
|
||||||
|
<div style="font-size:26px; font-weight:700; letter-spacing:-.02em; margin-bottom:4px;">Power the platform from the cloud</div>
|
||||||
|
<div style="font-size:13px; color:#8a8a92; max-width:600px; margin-bottom:22px; line-height:1.5;">Connect a provider and Clawmates provisions agent runners on demand. Each booted VM runs the same daemon, dials home over outbound WSS, and joins your fleet — credentials stay in the secret broker.</div>
|
||||||
|
|
||||||
|
<!-- how cloud access works -->
|
||||||
|
<div style="display:grid; grid-template-columns:repeat(3,1fr); gap:12px; margin-bottom:26px;">
|
||||||
|
<div style="border-radius:12px; border:1px solid rgba(255,255,255,.07); background:#0c0c0f; padding:15px;">
|
||||||
|
<div style="display:flex; align-items:center; gap:8px; margin-bottom:9px;"><span style="width:22px; height:22px; border-radius:6px; background:rgba(255,111,97,.14); display:flex; align-items:center; justify-content:center; font-family:'JetBrains Mono',monospace; font-size:11px; font-weight:700; color:#ff8a7a;">1</span><span style="font-size:13px; font-weight:700;">Connect credentials</span></div>
|
||||||
|
<div style="font-size:11.5px; color:#8a8a92; line-height:1.5;">Scoped IAM role or API token — held by the <span style="color:#cfcfd5;">secret broker</span>, never reaches agent code.</div>
|
||||||
|
</div>
|
||||||
|
<div style="border-radius:12px; border:1px solid rgba(255,255,255,.07); background:#0c0c0f; padding:15px;">
|
||||||
|
<div style="display:flex; align-items:center; gap:8px; margin-bottom:9px;"><span style="width:22px; height:22px; border-radius:6px; background:rgba(94,200,216,.14); display:flex; align-items:center; justify-content:center; font-family:'JetBrains Mono',monospace; font-size:11px; font-weight:700; color:#5ec8d8;">2</span><span style="font-size:13px; font-weight:700;">Provision a runner</span></div>
|
||||||
|
<div style="font-size:11.5px; color:#8a8a92; line-height:1.5;">We bake <span style="font-family:'JetBrains Mono',monospace; color:#cfcfd5;">clawmates-node</span> into the image; it boots and dials home — no inbound port.</div>
|
||||||
|
</div>
|
||||||
|
<div style="border-radius:12px; border:1px solid rgba(255,255,255,.07); background:#0c0c0f; padding:15px;">
|
||||||
|
<div style="display:flex; align-items:center; gap:8px; margin-bottom:9px;"><span style="width:22px; height:22px; border-radius:6px; background:rgba(95,208,138,.14); display:flex; align-items:center; justify-content:center; font-family:'JetBrains Mono',monospace; font-size:11px; font-weight:700; color:#5fd08a;">3</span><span style="font-size:13px; font-weight:700;">Agents run sandboxed</span></div>
|
||||||
|
<div style="font-size:11.5px; color:#8a8a92; line-height:1.5;">§15 network-isolated sandboxes on the cloud host; every egress is a gated door.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- provider grid -->
|
||||||
|
<div style="display:flex; align-items:center; gap:10px; margin-bottom:12px;">
|
||||||
|
<span style="font-family:'JetBrains Mono',monospace; font-size:10px; letter-spacing:.12em; color:#5a5a62;">PROVIDERS</span>
|
||||||
|
<span style="flex:1; height:1px; background:rgba(255,255,255,.06);"></span>
|
||||||
|
<span style="font-family:'JetBrains Mono',monospace; font-size:10px; color:#6a6a72;">1 connected</span>
|
||||||
|
</div>
|
||||||
|
<div style="display:grid; grid-template-columns:repeat(auto-fill, minmax(232px, 1fr)); gap:14px; margin-bottom:28px;">
|
||||||
|
<sc-for list="{{ cloudProviders }}" as="p" hint-placeholder-count="5">
|
||||||
|
<div style="border-radius:14px; border:1px solid {{ p.cardBorder }}; background:#0d0d10; padding:18px; display:flex; flex-direction:column;">
|
||||||
|
<div style="display:flex; align-items:flex-start; gap:11px; margin-bottom:14px;">
|
||||||
|
<div style="width:42px; height:42px; border-radius:11px; background:{{ p.tile }}; display:flex; align-items:center; justify-content:center; font-weight:700; color:{{ p.ink }}; font-size:13px;">
|
||||||
|
<sc-if value="{{ p.isText }}" hint-placeholder-val="{{ true }}">{{ p.short }}</sc-if>
|
||||||
|
<sc-if value="{{ p.isApple }}" hint-placeholder-val="{{ false }}"><svg width="20" height="20" viewBox="0 0 24 24" fill="none"><rect x="6" y="6" width="12" height="12" rx="2.5" stroke="#1a1a1d" stroke-width="1.6"></rect><rect x="9.5" y="9.5" width="5" height="5" rx="1" fill="#1a1a1d"></rect><path d="M9 6V3M15 6V3M9 21v-3M15 21v-3M6 9H3M6 15H3M21 9h-3M21 15h-3" stroke="#1a1a1d" stroke-width="1.5" stroke-linecap="round"></path></svg></sc-if>
|
||||||
|
</div>
|
||||||
|
<div style="flex:1; min-width:0;">
|
||||||
|
<div style="font-size:14px; font-weight:700;">{{ p.name }}</div>
|
||||||
|
<div style="font-family:'JetBrains Mono',monospace; font-size:9px; color:#6a6a72; margin-top:2px;">{{ p.regions }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="font-family:'JetBrains Mono',monospace; font-size:10px; color:#8a8a92; margin-bottom:16px; line-height:1.5;">{{ p.offers }}</div>
|
||||||
|
<div style="margin-top:auto; display:flex; align-items:center; justify-content:center; gap:7px; height:34px; border-radius:8px; cursor:pointer; font-family:'JetBrains Mono',monospace; font-size:11px; font-weight:600; {{ p.statusStyle }}" onClick="{{ p.onConnect }}">{{ p.statusLabel }}</div>
|
||||||
|
</div>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- live cloud runners (connected provider) -->
|
||||||
|
<div style="display:flex; align-items:center; gap:10px; margin-bottom:12px;">
|
||||||
|
<div style="width:24px; height:24px; border-radius:7px; background:linear-gradient(135deg,#ff9a3c,#ff7314); display:flex; align-items:center; justify-content:center; font-family:'JetBrains Mono',monospace; font-size:9px; font-weight:700; color:#241200;">aws</div>
|
||||||
|
<span style="font-size:14px; font-weight:700;">Cloud runners</span>
|
||||||
|
<span style="font-family:'JetBrains Mono',monospace; font-size:10px; color:#5fd08a;">● 2 online · 5 agents</span>
|
||||||
|
<span style="flex:1;"></span>
|
||||||
|
<span style="font-family:'JetBrains Mono',monospace; font-size:10px; color:#e8b465;">~$0.44/hr</span>
|
||||||
|
</div>
|
||||||
|
<div style="display:grid; grid-template-columns:repeat(auto-fill, minmax(300px, 1fr)); gap:14px;">
|
||||||
|
<sc-for list="{{ cloudRunners }}" as="r" hint-placeholder-count="2">
|
||||||
|
<div style="border-radius:13px; border:1px solid rgba(95,208,138,.18); background:#0d0d10; padding:15px;">
|
||||||
|
<div style="display:flex; align-items:center; gap:9px; margin-bottom:12px;">
|
||||||
|
<span style="width:8px; height:8px; border-radius:50%; background:#5fd08a;"></span>
|
||||||
|
<span style="font-size:13px; font-weight:700;">{{ r.name }}</span>
|
||||||
|
<span style="flex:1;"></span>
|
||||||
|
<span style="font-family:'JetBrains Mono',monospace; font-size:9px; color:#5ec8d8;">{{ r.region }}</span>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; gap:14px; margin-bottom:12px;">
|
||||||
|
<div style="flex:1;">
|
||||||
|
<div style="display:flex; justify-content:space-between; font-family:'JetBrains Mono',monospace; font-size:9px; color:#8a8a92; margin-bottom:4px;"><span>CPU</span><span style="color:#5ec8d8;">{{ r.cpu }}%</span></div>
|
||||||
|
<div style="height:4px; border-radius:2px; background:rgba(255,255,255,.07); overflow:hidden;"><div style="width:{{ r.cpuW }}; height:100%; background:#5ec8d8;"></div></div>
|
||||||
|
</div>
|
||||||
|
<div style="flex:1;">
|
||||||
|
<div style="display:flex; justify-content:space-between; font-family:'JetBrains Mono',monospace; font-size:9px; color:#8a8a92; margin-bottom:4px;"><span>RAM</span><span style="color:#5fd08a;">{{ r.ram }}%</span></div>
|
||||||
|
<div style="height:4px; border-radius:2px; background:rgba(255,255,255,.07); overflow:hidden;"><div style="width:{{ r.ramW }}; height:100%; background:#5fd08a;"></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; align-items:center; gap:8px; font-family:'JetBrains Mono',monospace; font-size:10px;">
|
||||||
|
<span style="color:#cfcfd5; padding:3px 8px; border-radius:6px; background:rgba(255,255,255,.04);">{{ r.agents }} agents</span>
|
||||||
|
<span style="flex:1;"></span>
|
||||||
|
<span style="color:#e8b465;">{{ r.cost }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- RIGHT PANEL -->
|
||||||
|
<div style="width:344px; flex:none; border-left:1px solid rgba(255,255,255,.06); background:#0b0b0e; display:flex; flex-direction:column; min-height:0;">
|
||||||
|
<div style="flex:none; display:flex; align-items:center; gap:8px; padding:13px 16px; border-bottom:1px solid rgba(255,255,255,.06);">
|
||||||
|
<sc-if value="{{ showMenu }}" hint-placeholder-val="{{ true }}"><span style="font-family:'JetBrains Mono',monospace; font-size:10px; letter-spacing:.14em; color:#5a5a62;">ADD TO FLEET</span></sc-if>
|
||||||
|
<sc-if value="{{ showWizard }}" hint-placeholder-val="{{ false }}"><span style="font-family:'JetBrains Mono',monospace; font-size:10px; color:#5ec8d8; cursor:pointer;" onClick="{{ backToMenu }}">← add to fleet</span></sc-if>
|
||||||
|
<sc-if value="{{ showTailscale }}" hint-placeholder-val="{{ false }}"><span style="font-family:'JetBrains Mono',monospace; font-size:10px; color:#5ec8d8; cursor:pointer;" onClick="{{ backToMenu }}">← add to fleet</span></sc-if>
|
||||||
|
<sc-if value="{{ showCloudMenu }}" hint-placeholder-val="{{ false }}"><span style="font-family:'JetBrains Mono',monospace; font-size:10px; letter-spacing:.14em; color:#5a5a62;">CONNECT A PROVIDER</span></sc-if>
|
||||||
|
<sc-if value="{{ showCloudConnect }}" hint-placeholder-val="{{ false }}"><span style="font-family:'JetBrains Mono',monospace; font-size:10px; color:#5ec8d8; cursor:pointer;" onClick="{{ backToMenu }}">← providers</span></sc-if>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="flex:1; min-height:0; overflow-y:auto; padding:16px;" class="cm-scroll">
|
||||||
|
|
||||||
|
<!-- MENU -->
|
||||||
|
<sc-if value="{{ showMenu }}" hint-placeholder-val="{{ true }}">
|
||||||
|
<div style="display:flex; flex-direction:column; gap:11px;">
|
||||||
|
<div style="border-radius:13px; border:1px solid rgba(255,111,97,.3); background:linear-gradient(180deg,#140f12,#0d0d10); padding:16px; cursor:pointer;" onClick="{{ openHostWizard }}">
|
||||||
|
<div style="display:flex; align-items:center; gap:11px; margin-bottom:9px;">
|
||||||
|
<div style="width:34px; height:34px; border-radius:9px; background:rgba(255,111,97,.16); display:flex; align-items:center; justify-content:center; color:#ff6f61;"><svg width="18" height="18" viewBox="0 0 20 20"><rect x="3" y="4" width="14" height="5" rx="1.6" stroke="currentColor" stroke-width="1.4" fill="none"></rect><rect x="3" y="11" width="14" height="5" rx="1.6" stroke="currentColor" stroke-width="1.4" fill="none"></rect></svg></div>
|
||||||
|
<div style="flex:1;"><div style="font-size:14px; font-weight:700;">Connect a host</div><div style="font-family:'JetBrains Mono',monospace; font-size:9px; color:#ff8a7a;">recommended first step</div></div>
|
||||||
|
<span style="color:#ff8a7a;">→</span>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:12px; color:#9a9aa2; line-height:1.5;">Pair a Mac, Linux box, or edge device. The daemon dials home over outbound WSS — no inbound port, no keys.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="border-radius:13px; border:1px solid rgba(255,255,255,.1); background:#0d0d10; padding:16px; cursor:pointer;" onClick="{{ openTailscale }}">
|
||||||
|
<div style="display:flex; align-items:center; gap:11px; margin-bottom:9px;">
|
||||||
|
<div style="width:34px; height:34px; border-radius:9px; background:#fff; display:flex; align-items:center; justify-content:center;"><svg width="17" height="17" viewBox="0 0 24 24"><g fill="#141414"><circle cx="5" cy="5" r="2.1" opacity=".3"></circle><circle cx="12" cy="5" r="2.1" opacity=".3"></circle><circle cx="19" cy="5" r="2.1" opacity=".3"></circle><circle cx="5" cy="12" r="2.1"></circle><circle cx="12" cy="12" r="2.1"></circle><circle cx="19" cy="12" r="2.1"></circle><circle cx="5" cy="19" r="2.1" opacity=".3"></circle><circle cx="12" cy="19" r="2.1"></circle><circle cx="19" cy="19" r="2.1" opacity=".3"></circle></g></svg></div>
|
||||||
|
<div style="flex:1;"><div style="font-size:14px; font-weight:700;">Tailscale network</div><div style="font-family:'JetBrains Mono',monospace; font-size:9px; color:#5fd08a;">connected · {{ tsDeviceCount }} devices</div></div>
|
||||||
|
<span style="color:#8a8a92;">→</span>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:12px; color:#9a9aa2; line-height:1.5;">Paste your tailnet + API key for live device status across the whole network.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="border-radius:13px; border:1px solid rgba(255,255,255,.06); background:#0a0a0c; padding:16px; opacity:.7;">
|
||||||
|
<div style="display:flex; align-items:center; gap:11px; margin-bottom:9px;">
|
||||||
|
<div style="width:34px; height:34px; border-radius:9px; background:rgba(255,255,255,.05); display:flex; align-items:center; justify-content:center; color:#6a6a72;"><svg width="18" height="18" viewBox="0 0 20 20"><path d="M6 14h9a3 3 0 000-6 4.5 4.5 0 00-8.7-1.2A3.4 3.4 0 006 14z" stroke="currentColor" stroke-width="1.4" fill="none"></path></svg></div>
|
||||||
|
<div style="flex:1;"><div style="font-size:14px; font-weight:700; color:#9a9aa2;">Cloud providers</div><div style="font-family:'JetBrains Mono',monospace; font-size:9px; color:#6a6a72;">AWS · GCP · Azure</div></div>
|
||||||
|
<span style="font-family:'JetBrains Mono',monospace; font-size:8px; color:#e8b465; padding:2px 6px; border-radius:5px; background:rgba(232,196,106,.1);">NEXT</span>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:12px; color:#6a6a72; line-height:1.5;">Local & Tailscale first. Cloud provisioning arrives next.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
|
||||||
|
<!-- WIZARD -->
|
||||||
|
<sc-if value="{{ showWizard }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div style="animation:cm-fade .2s ease;">
|
||||||
|
<div style="font-size:16px; font-weight:700; margin-bottom:3px;">Connect a host</div>
|
||||||
|
<div style="font-family:'JetBrains Mono',monospace; font-size:10px; color:#6a6a72; margin-bottom:16px;">3 steps · ~2 min</div>
|
||||||
|
|
||||||
|
<!-- stepper -->
|
||||||
|
<div style="display:flex; align-items:center; gap:6px; margin-bottom:18px;">
|
||||||
|
<span style="width:24px; height:24px; border-radius:50%; display:flex; align-items:center; justify-content:center; font-family:'JetBrains Mono',monospace; font-size:11px; font-weight:700; {{ stepIs1Style }}">1</span>
|
||||||
|
<span style="flex:1; height:1px; background:rgba(255,255,255,.1);"></span>
|
||||||
|
<span style="width:24px; height:24px; border-radius:50%; display:flex; align-items:center; justify-content:center; font-family:'JetBrains Mono',monospace; font-size:11px; font-weight:700; {{ stepIs2Style }}">2</span>
|
||||||
|
<span style="flex:1; height:1px; background:rgba(255,255,255,.1);"></span>
|
||||||
|
<span style="width:24px; height:24px; border-radius:50%; display:flex; align-items:center; justify-content:center; font-family:'JetBrains Mono',monospace; font-size:11px; font-weight:700; {{ stepIs3Style }}">3</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- STEP 1 INSTALL -->
|
||||||
|
<sc-if value="{{ step1 }}" hint-placeholder-val="{{ true }}">
|
||||||
|
<div style="animation:cm-fade .2s ease;">
|
||||||
|
<div style="font-size:14px; font-weight:700; margin-bottom:4px;">Install the daemon</div>
|
||||||
|
<div style="font-size:12px; color:#9a9aa2; line-height:1.55; margin-bottom:13px;">We minted a one-time token. Run this on the node you're adding:</div>
|
||||||
|
<div style="border-radius:10px; background:#070708; border:1px solid rgba(255,255,255,.08); overflow:hidden; margin-bottom:6px;">
|
||||||
|
<div style="display:flex; align-items:center; gap:7px; padding:7px 11px; border-bottom:1px solid rgba(255,255,255,.05);"><span style="width:6px; height:6px; border-radius:50%; background:#ff5f57;"></span><span style="width:6px; height:6px; border-radius:50%; background:#febc2e;"></span><span style="width:6px; height:6px; border-radius:50%; background:#28c840;"></span><span style="flex:1;"></span><span style="font-family:'JetBrains Mono',monospace; font-size:9px; color:#5ec8d8; cursor:pointer;" onClick="{{ copyToken }}">{{ copyLabel }}</span></div>
|
||||||
|
<div style="padding:12px; font-family:'JetBrains Mono',monospace; font-size:11px; line-height:1.7; color:#cfcfd5;"><span style="color:#5fd08a;">clawmates-node</span> \<br> --server <span style="color:#5ec8d8;">https://clawmates.work</span> \<br> --token <span style="color:#e8b465;">cmn_8f3a…d21</span></div>
|
||||||
|
</div>
|
||||||
|
<div style="font-family:'JetBrains Mono',monospace; font-size:9px; color:#5a5a62; margin-bottom:14px;">one-time token · expires in 15 min</div>
|
||||||
|
|
||||||
|
<div style="display:flex; flex-direction:column; gap:9px; margin-bottom:16px;">
|
||||||
|
<div style="display:flex; gap:9px; padding:10px 12px; border-radius:9px; background:rgba(94,200,216,.05); border:1px solid rgba(94,200,216,.18);">
|
||||||
|
<span style="color:#5ec8d8; flex:none;">ⓘ</span>
|
||||||
|
<div style="font-size:11px; color:#a8a8b0; line-height:1.5;"><span style="color:#cfcfd5; font-weight:600;">Docker</span> should be installed on the node so it can run agents later.</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; gap:9px; padding:10px 12px; border-radius:9px; background:rgba(255,255,255,.03); border:1px solid rgba(255,255,255,.07);">
|
||||||
|
<span style="width:14px; flex:none;"><svg width="14" height="14" viewBox="0 0 24 24"><g fill="#9a9aa2"><circle cx="6" cy="6" r="1.8"></circle><circle cx="12" cy="6" r="1.8" opacity=".4"></circle><circle cx="6" cy="12" r="1.8"></circle><circle cx="12" cy="12" r="1.8"></circle><circle cx="18" cy="12" r="1.8" opacity=".4"></circle></g></svg></span>
|
||||||
|
<div style="font-size:11px; color:#a8a8b0; line-height:1.5;">Tip: run <span style="font-family:'JetBrains Mono',monospace; color:#cfcfd5;">sudo tailscale up --ssh</span> first for keyless, ACL-gated SSH to this node.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex; align-items:center; justify-content:center; gap:8px; height:42px; border-radius:10px; background:linear-gradient(135deg,#ff8a7a,#ff5f57); color:#2a0d0a; font-size:13px; font-weight:700; cursor:pointer;" onClick="{{ toConnect }}">I've run it — waiting for connection →</div>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
|
||||||
|
<!-- STEP 2 CONNECT -->
|
||||||
|
<sc-if value="{{ step2 }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div style="animation:cm-fade .2s ease;">
|
||||||
|
<sc-if value="{{ connecting }}" hint-placeholder-val="{{ true }}">
|
||||||
|
<div style="display:flex; flex-direction:column; align-items:center; text-align:center; padding:24px 0;">
|
||||||
|
<div style="width:42px; height:42px; border:3px solid rgba(94,200,216,.25); border-top-color:#5ec8d8; border-radius:50%; animation:cm-spin .8s linear infinite; margin-bottom:16px;"></div>
|
||||||
|
<div style="font-size:14px; font-weight:700; margin-bottom:5px;">Waiting for the daemon to dial home…</div>
|
||||||
|
<div style="font-size:12px; color:#8a8a92; line-height:1.5; max-width:240px;">Outbound WSS connection · NAT-friendly · no inbound port opened.</div>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
<sc-if value="{{ connected }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div style="animation:cm-fade .2s ease;">
|
||||||
|
<div style="display:flex; align-items:center; gap:9px; margin-bottom:14px;"><span style="width:26px; height:26px; border-radius:50%; background:rgba(95,208,138,.16); display:flex; align-items:center; justify-content:center; color:#5fd08a; font-size:14px;">✓</span><div><div style="font-size:14px; font-weight:700;">Connected</div><div style="font-family:'JetBrains Mono',monospace; font-size:9px; color:#5fd08a;">daemon online · forge-02</div></div></div>
|
||||||
|
<div style="border-radius:11px; border:1px solid rgba(255,255,255,.08); background:#0d0d10; padding:13px 15px; margin-bottom:16px;">
|
||||||
|
<div style="font-family:'JetBrains Mono',monospace; font-size:9px; letter-spacing:.1em; color:#5a5a62; margin-bottom:10px;">HOST SPECS</div>
|
||||||
|
<div style="display:flex; flex-direction:column; gap:8px; font-family:'JetBrains Mono',monospace; font-size:11px;">
|
||||||
|
<div style="display:flex;"><span style="color:#6a6a72; width:78px;">hostname</span><span style="color:#cfcfd5;">forge-02</span></div>
|
||||||
|
<div style="display:flex;"><span style="color:#6a6a72; width:78px;">os</span><span style="color:#cfcfd5;">Ubuntu 24.04 · x86_64</span></div>
|
||||||
|
<div style="display:flex;"><span style="color:#6a6a72; width:78px;">cpu</span><span style="color:#cfcfd5;">16 vCPU · AMD EPYC</span></div>
|
||||||
|
<div style="display:flex;"><span style="color:#6a6a72; width:78px;">memory</span><span style="color:#cfcfd5;">64 GB</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; align-items:center; justify-content:center; gap:8px; height:42px; border-radius:10px; background:linear-gradient(135deg,#ff8a7a,#ff5f57); color:#2a0d0a; font-size:13px; font-weight:700; cursor:pointer;" onClick="{{ toVerify }}">Verify the host →</div>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
|
||||||
|
<!-- STEP 3 VERIFY -->
|
||||||
|
<sc-if value="{{ step3 }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div style="animation:cm-fade .2s ease;">
|
||||||
|
<div style="font-size:14px; font-weight:700; margin-bottom:4px;">Verify we can operate</div>
|
||||||
|
<div style="font-size:12px; color:#9a9aa2; line-height:1.55; margin-bottom:13px;">Built-in check confirms the daemon can run agents on this host.</div>
|
||||||
|
<div style="border-radius:10px; background:#070708; border:1px solid rgba(255,255,255,.08); padding:13px; font-family:'JetBrains Mono',monospace; font-size:11px; line-height:1.8; margin-bottom:16px;">
|
||||||
|
<div style="color:#5fd08a;">✓ uname -a</div>
|
||||||
|
<div style="color:#6a6a72; padding-left:14px;">Linux forge-02 6.8.0 x86_64 GNU/Linux</div>
|
||||||
|
<div style="color:#5fd08a; margin-top:4px;">✓ docker version</div>
|
||||||
|
<div style="color:#6a6a72; padding-left:14px;">Client/Server 27.1.1 · runtime ok</div>
|
||||||
|
<div style="color:#5fd08a; margin-top:4px;">✓ §15 sandbox profile applied</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; align-items:center; justify-content:center; gap:8px; height:42px; border-radius:10px; background:linear-gradient(135deg,#7fe0a0,#5fd08a); color:#06281a; font-size:13px; font-weight:700; cursor:pointer;" onClick="{{ finishWizard }}">✓ Add forge-02 to fleet</div>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
|
||||||
|
<!-- TAILSCALE CONNECT -->
|
||||||
|
<sc-if value="{{ showTailscale }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div style="animation:cm-fade .2s ease;">
|
||||||
|
<div style="display:flex; align-items:center; gap:10px; margin-bottom:14px;">
|
||||||
|
<div style="width:32px; height:32px; border-radius:8px; background:#fff; display:flex; align-items:center; justify-content:center;"><svg width="17" height="17" viewBox="0 0 24 24"><g fill="#141414"><circle cx="5" cy="5" r="2.1" opacity=".3"></circle><circle cx="12" cy="5" r="2.1" opacity=".3"></circle><circle cx="19" cy="5" r="2.1" opacity=".3"></circle><circle cx="5" cy="12" r="2.1"></circle><circle cx="12" cy="12" r="2.1"></circle><circle cx="19" cy="12" r="2.1"></circle><circle cx="5" cy="19" r="2.1" opacity=".3"></circle><circle cx="12" cy="19" r="2.1"></circle><circle cx="19" cy="19" r="2.1" opacity=".3"></circle></g></svg></div>
|
||||||
|
<div><div style="font-size:15px; font-weight:700;">Tailscale network</div><div style="font-family:'JetBrains Mono',monospace; font-size:9px; color:#5fd08a;">connected</div></div>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:12px; color:#9a9aa2; line-height:1.55; margin-bottom:14px;">Connect your tailnet for live device status across the whole network — online, last-seen, IP, OS.</div>
|
||||||
|
|
||||||
|
<div style="margin-bottom:12px;">
|
||||||
|
<div style="font-family:'JetBrains Mono',monospace; font-size:9px; letter-spacing:.1em; color:#6a6a72; margin-bottom:6px;">TAILNET</div>
|
||||||
|
<div style="height:40px; border-radius:9px; border:1px solid rgba(255,255,255,.1); background:#0d0d10; display:flex; align-items:center; padding:0 12px; font-family:'JetBrains Mono',monospace; font-size:12px; color:#cfcfd5;">{{ tailnet }}</div>
|
||||||
|
</div>
|
||||||
|
<div style="margin-bottom:16px;">
|
||||||
|
<div style="font-family:'JetBrains Mono',monospace; font-size:9px; letter-spacing:.1em; color:#6a6a72; margin-bottom:6px;">API KEY</div>
|
||||||
|
<div style="height:40px; border-radius:9px; border:1px solid rgba(255,255,255,.1); background:#0d0d10; display:flex; align-items:center; padding:0 12px; font-family:'JetBrains Mono',monospace; font-size:12px; color:#6a6a72;">tskey-api-••••••••••••3f9</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="border-radius:11px; border:1px solid rgba(255,255,255,.07); background:#0d0d10; overflow:hidden;">
|
||||||
|
<div style="font-family:'JetBrains Mono',monospace; font-size:9px; letter-spacing:.1em; color:#5a5a62; padding:10px 13px; border-bottom:1px solid rgba(255,255,255,.05);">{{ tsDeviceCount }} DEVICES</div>
|
||||||
|
<sc-for list="{{ tsDevices }}" as="d" hint-placeholder-count="4">
|
||||||
|
<div style="display:flex; align-items:center; gap:9px; padding:9px 13px; border-bottom:1px solid rgba(255,255,255,.03);">
|
||||||
|
<span style="width:6px; height:6px; border-radius:50%; background:{{ d.dot }};"></span>
|
||||||
|
<span style="font-size:12px; font-weight:600; color:#eaeaee;">{{ d.name }}</span>
|
||||||
|
<span style="flex:1;"></span>
|
||||||
|
<span style="font-family:'JetBrains Mono',monospace; font-size:10px; color:#5ec8d8;">{{ d.ip }}</span>
|
||||||
|
</div>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
|
||||||
|
<!-- CLOUD MENU (provider quick-connect) -->
|
||||||
|
<sc-if value="{{ showCloudMenu }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div style="animation:cm-fade .2s ease;">
|
||||||
|
<div style="font-size:12px; color:#9a9aa2; line-height:1.5; margin-bottom:14px;">Pick a provider to connect. Credentials are held by the secret broker and never reach agent code.</div>
|
||||||
|
<div style="display:flex; flex-direction:column; gap:8px;">
|
||||||
|
<sc-for list="{{ cloudProviders }}" as="p" hint-placeholder-count="5">
|
||||||
|
<div style="display:flex; align-items:center; gap:11px; padding:11px 12px; border-radius:11px; border:1px solid {{ p.cardBorder }}; background:#0d0d10; cursor:pointer;" onClick="{{ p.onConnect }}">
|
||||||
|
<div style="width:32px; height:32px; flex:none; border-radius:8px; background:{{ p.tile }}; display:flex; align-items:center; justify-content:center; font-weight:700; color:{{ p.ink }}; font-size:11px;">
|
||||||
|
<sc-if value="{{ p.isText }}" hint-placeholder-val="{{ true }}">{{ p.short }}</sc-if>
|
||||||
|
<sc-if value="{{ p.isApple }}" hint-placeholder-val="{{ false }}"><svg width="16" height="16" viewBox="0 0 24 24" fill="none"><rect x="6" y="6" width="12" height="12" rx="2.5" stroke="#1a1a1d" stroke-width="1.8"></rect><rect x="9.5" y="9.5" width="5" height="5" rx="1" fill="#1a1a1d"></rect></svg></sc-if>
|
||||||
|
</div>
|
||||||
|
<div style="flex:1; min-width:0;">
|
||||||
|
<div style="font-size:13px; font-weight:600; color:#eaeaee;">{{ p.name }}</div>
|
||||||
|
<div style="font-family:'JetBrains Mono',monospace; font-size:9px; color:#6a6a72;">{{ p.offers }}</div>
|
||||||
|
</div>
|
||||||
|
<span style="font-family:'JetBrains Mono',monospace; font-size:9px; padding:3px 8px; border-radius:6px; {{ p.statusStyle }}">{{ p.statusLabel }}</span>
|
||||||
|
</div>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:16px; display:flex; gap:9px; padding:11px 12px; border-radius:10px; background:rgba(94,200,216,.05); border:1px solid rgba(94,200,216,.18);">
|
||||||
|
<span style="color:#5ec8d8; flex:none;">ⓘ</span>
|
||||||
|
<div style="font-size:11px; color:#a8a8b0; line-height:1.5;">Prefer your own hardware? <span style="color:#5ec8d8; cursor:pointer;" onClick="{{ goLocal }}">Local & Tailscale</span> has zero inbound exposure.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
|
||||||
|
<!-- CLOUD CONNECT WIZARD -->
|
||||||
|
<sc-if value="{{ showCloudConnect }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div style="animation:cm-fade .2s ease;">
|
||||||
|
<div style="display:flex; align-items:center; gap:10px; margin-bottom:16px;">
|
||||||
|
<div style="width:36px; height:36px; border-radius:9px; background:{{ selProvTile }}; display:flex; align-items:center; justify-content:center; font-weight:700; color:{{ selProvInk }}; font-size:12px;">
|
||||||
|
<sc-if value="{{ selProvIsText }}" hint-placeholder-val="{{ true }}">{{ selProvShort }}</sc-if>
|
||||||
|
<sc-if value="{{ selProvIsApple }}" hint-placeholder-val="{{ false }}"><svg width="18" height="18" viewBox="0 0 24 24" fill="none"><rect x="6" y="6" width="12" height="12" rx="2.5" stroke="#1a1a1d" stroke-width="1.8"></rect><rect x="9.5" y="9.5" width="5" height="5" rx="1" fill="#1a1a1d"></rect></svg></sc-if>
|
||||||
|
</div>
|
||||||
|
<div><div style="font-size:15px; font-weight:700;">{{ selProvName }}</div><div style="font-family:'JetBrains Mono',monospace; font-size:9px; color:#6a6a72;">connect provider</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- creds -->
|
||||||
|
<sc-if value="{{ phaseCreds }}" hint-placeholder-val="{{ true }}">
|
||||||
|
<div style="animation:cm-fade .2s ease;">
|
||||||
|
<div style="display:flex; gap:9px; padding:10px 12px; border-radius:9px; background:rgba(95,208,138,.05); border:1px solid rgba(95,208,138,.2); margin-bottom:16px;">
|
||||||
|
<span style="width:14px; flex:none;"><svg width="14" height="14" viewBox="0 0 20 20"><path d="M10 2l5 2v4c0 4-2.5 6-5 7-2.5-1-5-3-5-7V4z" fill="none" stroke="#5fd08a" stroke-width="1.4" stroke-linejoin="round"></path></svg></span>
|
||||||
|
<div style="font-size:11px; color:#a8a8b0; line-height:1.5;">Stored in the <span style="color:#cfcfd5; font-weight:600;">secret broker</span> over a private socket. Never enters agent code or any sandbox.</div>
|
||||||
|
</div>
|
||||||
|
<div style="margin-bottom:12px;">
|
||||||
|
<div style="font-family:'JetBrains Mono',monospace; font-size:9px; letter-spacing:.1em; color:#6a6a72; margin-bottom:6px;">ACCESS KEY ID</div>
|
||||||
|
<div style="height:40px; border-radius:9px; border:1px solid rgba(255,255,255,.1); background:#0d0d10; display:flex; align-items:center; padding:0 12px; font-family:'JetBrains Mono',monospace; font-size:12px; color:#cfcfd5;">AKIA••••••••••7Q</div>
|
||||||
|
</div>
|
||||||
|
<div style="margin-bottom:12px;">
|
||||||
|
<div style="font-family:'JetBrains Mono',monospace; font-size:9px; letter-spacing:.1em; color:#6a6a72; margin-bottom:6px;">SECRET ACCESS KEY</div>
|
||||||
|
<div style="height:40px; border-radius:9px; border:1px solid rgba(255,255,255,.1); background:#0d0d10; display:flex; align-items:center; padding:0 12px; font-family:'JetBrains Mono',monospace; font-size:12px; color:#6a6a72;">••••••••••••••••••••</div>
|
||||||
|
</div>
|
||||||
|
<div style="margin-bottom:18px;">
|
||||||
|
<div style="font-family:'JetBrains Mono',monospace; font-size:9px; letter-spacing:.1em; color:#6a6a72; margin-bottom:6px;">SCOPE</div>
|
||||||
|
<div style="display:flex; flex-wrap:wrap; gap:6px;">
|
||||||
|
<span style="font-family:'JetBrains Mono',monospace; font-size:10px; color:#cfcfd5; padding:4px 9px; border-radius:6px; background:rgba(255,255,255,.04);">compute:provision</span>
|
||||||
|
<span style="font-family:'JetBrains Mono',monospace; font-size:10px; color:#cfcfd5; padding:4px 9px; border-radius:6px; background:rgba(255,255,255,.04);">compute:terminate</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; align-items:center; justify-content:center; gap:8px; height:42px; border-radius:10px; background:linear-gradient(135deg,#ff8a7a,#ff5f57); color:#2a0d0a; font-size:13px; font-weight:700; cursor:pointer;" onClick="{{ toConfig }}">Store & continue →</div>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
|
||||||
|
<!-- config -->
|
||||||
|
<sc-if value="{{ phaseConfig }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div style="animation:cm-fade .2s ease;">
|
||||||
|
<div style="font-size:13px; font-weight:700; margin-bottom:11px;">Region</div>
|
||||||
|
<div style="display:flex; flex-wrap:wrap; gap:7px; margin-bottom:18px;">
|
||||||
|
<sc-for list="{{ regions }}" as="rg" hint-placeholder-count="3">
|
||||||
|
<span style="font-family:'JetBrains Mono',monospace; font-size:11px; padding:7px 12px; border-radius:8px; cursor:pointer; {{ rg.style }}" onClick="{{ rg.onPick }}">{{ rg.label }}</span>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:13px; font-weight:700; margin-bottom:11px;">Instance size</div>
|
||||||
|
<div style="display:flex; flex-wrap:wrap; gap:7px; margin-bottom:18px;">
|
||||||
|
<sc-for list="{{ sizes }}" as="sz" hint-placeholder-count="3">
|
||||||
|
<span style="font-family:'JetBrains Mono',monospace; font-size:11px; padding:7px 12px; border-radius:8px; cursor:pointer; {{ sz.style }}" onClick="{{ sz.onPick }}">{{ sz.label }}</span>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
<div style="border-radius:10px; border:1px solid rgba(255,255,255,.07); background:#0d0d10; padding:12px 14px; margin-bottom:16px; font-family:'JetBrains Mono',monospace; font-size:10px; color:#8a8a92; line-height:1.7;">
|
||||||
|
image: <span style="color:#5fd08a;">clawmates-node baked</span><br>boot: <span style="color:#5ec8d8;">dials home · outbound WSS</span><br>sandbox: <span style="color:#6fd0c0;">§15 applied on boot</span>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; gap:8px;">
|
||||||
|
<div style="flex:none; width:44px; display:flex; align-items:center; justify-content:center; height:42px; border-radius:10px; border:1px solid rgba(255,255,255,.12); color:#9a9aa2; cursor:pointer;" onClick="{{ backToCreds }}">←</div>
|
||||||
|
<div style="flex:1; display:flex; align-items:center; justify-content:center; gap:8px; height:42px; border-radius:10px; background:linear-gradient(135deg,#ff8a7a,#ff5f57); color:#2a0d0a; font-size:13px; font-weight:700; cursor:pointer;" onClick="{{ provisionRunner }}">Provision runner →</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
|
||||||
|
<!-- provisioning -->
|
||||||
|
<sc-if value="{{ provisioning }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div style="display:flex; flex-direction:column; align-items:center; text-align:center; padding:24px 0; animation:cm-fade .2s ease;">
|
||||||
|
<div style="width:42px; height:42px; border:3px solid rgba(232,196,106,.25); border-top-color:#e8b465; border-radius:50%; animation:cm-spin .8s linear infinite; margin-bottom:16px;"></div>
|
||||||
|
<div style="font-size:14px; font-weight:700; margin-bottom:5px;">Provisioning runner…</div>
|
||||||
|
<div style="font-size:12px; color:#8a8a92; line-height:1.6; max-width:250px;">launching instance · baking daemon · dialing home over WSS</div>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
|
||||||
|
<!-- provisioned -->
|
||||||
|
<sc-if value="{{ provisioned }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div style="animation:cm-fade .2s ease;">
|
||||||
|
<div style="display:flex; align-items:center; gap:9px; margin-bottom:14px;"><span style="width:26px; height:26px; border-radius:50%; background:rgba(95,208,138,.16); display:flex; align-items:center; justify-content:center; color:#5fd08a; font-size:14px;">✓</span><div><div style="font-size:14px; font-weight:700;">Runner online</div><div style="font-family:'JetBrains Mono',monospace; font-size:9px; color:#5fd08a;">joined fleet · dialing home ok</div></div></div>
|
||||||
|
<div style="border-radius:11px; border:1px solid rgba(255,255,255,.08); background:#0d0d10; padding:13px 15px; margin-bottom:16px; font-family:'JetBrains Mono',monospace; font-size:11px; line-height:1.8;">
|
||||||
|
<div style="display:flex;"><span style="color:#6a6a72; width:70px;">instance</span><span style="color:#cfcfd5;">{{ size }}</span></div>
|
||||||
|
<div style="display:flex;"><span style="color:#6a6a72; width:70px;">region</span><span style="color:#cfcfd5;">{{ region }}</span></div>
|
||||||
|
<div style="display:flex;"><span style="color:#6a6a72; width:70px;">daemon</span><span style="color:#5fd08a;">online · §15</span></div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; align-items:center; justify-content:center; gap:8px; height:42px; border-radius:10px; background:linear-gradient(135deg,#7fe0a0,#5fd08a); color:#06281a; font-size:13px; font-weight:700; cursor:pointer;" onClick="{{ finishCloud }}">✓ Done</div>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- STATUS BAR -->
|
||||||
|
<div style="height:26px; flex:none; display:flex; align-items:center; gap:16px; padding:0 16px; border-top:1px solid rgba(255,255,255,.06); background:#0a0a0c; font-family:'JetBrains Mono',monospace; font-size:10px; color:#5a5a62;">
|
||||||
|
<span style="color:#5fd08a;">● daemon control plane ok</span>
|
||||||
|
<span>tailnet {{ tailnet }}</span>
|
||||||
|
<span style="flex:1;"></span>
|
||||||
|
<span>outbound WSS · no inbound port</span>
|
||||||
|
<span style="color:#5ec8d8;">§15 sandbox-ready</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</x-dc>
|
||||||
|
<script type="text/x-dc" data-dc-script>
|
||||||
|
class Component extends DCLogic {
|
||||||
|
state = { nav: 'local', addMode: 'menu', step: 1, connecting: false, connected: false, copied: false, tsConnected: true,
|
||||||
|
cloudProvider: 'gcp', cloudPhase: 'creds', provisioning: false, provisioned: false, region: 'us-east-1', size: 'c7g.2xlarge' };
|
||||||
|
|
||||||
|
componentWillUnmount() { if (this._t) clearTimeout(this._t); if (this._t2) clearTimeout(this._t2); }
|
||||||
|
|
||||||
|
_enterConnecting() {
|
||||||
|
this.setState({ step: 2, connecting: true, connected: false });
|
||||||
|
if (this._t) clearTimeout(this._t);
|
||||||
|
this._t = setTimeout(() => this.setState({ connecting: false, connected: true }), 1800);
|
||||||
|
}
|
||||||
|
_enterProvisioning() {
|
||||||
|
this.setState({ cloudPhase: 'provisioning', provisioning: true, provisioned: false });
|
||||||
|
if (this._t2) clearTimeout(this._t2);
|
||||||
|
this._t2 = setTimeout(() => this.setState({ provisioning: false, provisioned: true }), 2200);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderVals() {
|
||||||
|
const s = this.state;
|
||||||
|
const hosts = [
|
||||||
|
{ id:'forge', name:'forge-01', os:'Ubuntu 24.04 · x86_64', kind:'Linux', status:'online', cpu:34, ram:61, mem:'normal', memColor:'#5fd08a', disk:48, load:'1.24', containers:6, tsip:'100.92.14.3', spark:'0,13 8,9 16,11 24,5 32,8 40,4 48,7' },
|
||||||
|
{ id:'studio', name:'mac-studio', os:'macOS 15.3 · arm64', kind:'macOS', status:'online', cpu:12, ram:38, mem:'normal', memColor:'#5fd08a', disk:22, load:'0.86', containers:2, tsip:'100.92.14.7', spark:'0,12 8,13 16,10 24,11 32,7 40,9 48,8' },
|
||||||
|
{ id:'edge', name:'edge-rpi5', os:'Debian 12 · arm64', kind:'Linux', status:'pairing', cpu:0, ram:0, mem:'—', memColor:'#5a5a62', disk:0, load:'—', containers:0, tsip:'pending', spark:'0,8 8,8 16,8 24,8 32,8 40,8 48,8' },
|
||||||
|
];
|
||||||
|
const meter = (v, col) => ({ w: Math.max(3, v) + '%', col });
|
||||||
|
const cpuCol = v => v > 75 ? '#ff6f61' : v > 45 ? '#e8b465' : '#5ec8d8';
|
||||||
|
const ramCol = v => v > 80 ? '#ff6f61' : v > 55 ? '#e8b465' : '#5fd08a';
|
||||||
|
const hostCards = hosts.map(h => ({
|
||||||
|
...h,
|
||||||
|
online: h.status === 'online',
|
||||||
|
pairing: h.status === 'pairing',
|
||||||
|
statusColor: h.status === 'online' ? '#5fd08a' : h.status === 'pairing' ? '#e8b465' : '#5a5a62',
|
||||||
|
statusLabel: h.status === 'online' ? 'online' : h.status === 'pairing' ? 'pairing…' : 'offline',
|
||||||
|
accentBorder: h.status === 'online' ? 'rgba(95,208,138,.18)' : h.status === 'pairing' ? 'rgba(232,196,106,.3)' : 'rgba(255,255,255,.07)',
|
||||||
|
cpuW: Math.max(3, h.cpu) + '%', cpuCol: cpuCol(h.cpu),
|
||||||
|
ramW: Math.max(3, h.ram) + '%', ramCol: ramCol(h.ram),
|
||||||
|
diskW: Math.max(3, h.disk) + '%',
|
||||||
|
sshTarget: 'ssh ' + h.tsip,
|
||||||
|
hasSsh: h.status === 'online',
|
||||||
|
}));
|
||||||
|
|
||||||
|
const tsDevices = [
|
||||||
|
{ name:'forge-01', ip:'100.92.14.3', os:'linux', status:'online', seen:'now', dot:'#5fd08a' },
|
||||||
|
{ name:'mac-studio', ip:'100.92.14.7', os:'macOS', status:'online', seen:'now', dot:'#5fd08a' },
|
||||||
|
{ name:'edge-rpi5', ip:'100.92.14.9', os:'linux', status:'online', seen:'2m', dot:'#5fd08a' },
|
||||||
|
{ name:'ops-laptop', ip:'100.92.14.21', os:'macOS', status:'offline', seen:'3h', dot:'#5a5a62' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---- cloud ----
|
||||||
|
const inCloud = s.nav === 'cloud';
|
||||||
|
const provData = [
|
||||||
|
{ id:'aws', name:'Amazon Web Services', short:'aws', tile:'linear-gradient(135deg,#ff9a3c,#ff7314)', ink:'#241200', offers:'EC2 · Fargate · Graviton', regions:'31 regions', connected:true, runners:2, icon:'text' },
|
||||||
|
{ id:'gcp', name:'Google Cloud', short:'GCP', tile:'linear-gradient(135deg,#5b9bf0,#3b6fd4)', ink:'#031027', offers:'Compute Engine · Cloud Run', regions:'40 regions', connected:false, runners:0, icon:'text' },
|
||||||
|
{ id:'apple', name:'Apple silicon', short:'', tile:'linear-gradient(135deg,#ececed,#b6b6be)', ink:'#1a1a1d', offers:'Mac instances · M-series', regions:'macOS / iOS builds', connected:false, runners:0, icon:'chip' },
|
||||||
|
{ id:'hetzner', name:'Hetzner', short:'HZ', tile:'linear-gradient(135deg,#e44a4a,#c5102d)', ink:'#2a0207', offers:'Cloud VMs · dedicated', regions:'EU · US · low cost', connected:false, runners:0, icon:'text' },
|
||||||
|
{ id:'do', name:'DigitalOcean', short:'DO', tile:'linear-gradient(135deg,#3aa0ff,#0069ff)', ink:'#021428', offers:'Droplets · App Platform · K8s', regions:'15 datacenters', connected:false, runners:0, icon:'text' },
|
||||||
|
];
|
||||||
|
const cloudProviders = provData.map(p => ({
|
||||||
|
...p,
|
||||||
|
isApple: p.icon === 'chip',
|
||||||
|
isText: p.icon === 'text',
|
||||||
|
statusLabel: p.connected ? '● connected' : 'Connect →',
|
||||||
|
statusStyle: p.connected
|
||||||
|
? 'color:#5fd08a; background:rgba(95,208,138,.08); border:1px solid rgba(95,208,138,.25);'
|
||||||
|
: 'color:#ff8a7a; background:rgba(255,111,97,.08); border:1px solid rgba(255,111,97,.28);',
|
||||||
|
cardBorder: p.connected ? 'rgba(95,208,138,.2)' : 'rgba(255,255,255,.08)',
|
||||||
|
onConnect: () => this.setState({ nav:'cloud', addMode:'cloud-connect', cloudProvider:p.id, cloudPhase:'creds', provisioning:false, provisioned:false }),
|
||||||
|
}));
|
||||||
|
const selProv = cloudProviders.find(p => p.id === s.cloudProvider) || cloudProviders[0];
|
||||||
|
const cloudRunners = [
|
||||||
|
{ name:'aws-ec2 · c7g.2xlarge', region:'us-east-1', status:'online', cpu:52, ram:44, agents:3, cost:'$0.29/hr', cpuW:'52%', ramW:'44%' },
|
||||||
|
{ name:'aws-ec2 · c7g.xlarge', region:'eu-west-1', status:'online', cpu:28, ram:36, agents:2, cost:'$0.15/hr', cpuW:'28%', ramW:'36%' },
|
||||||
|
];
|
||||||
|
const selChip = 'background:rgba(255,111,97,.14); border:1px solid rgba(255,111,97,.4); color:#ff8a7a;';
|
||||||
|
const idleChip = 'background:#0d0d10; border:1px solid rgba(255,255,255,.1); color:#9a9aa2;';
|
||||||
|
const regions = ['us-east-1','eu-west-1','ap-south-1'].map(r => ({ label:r, style: s.region===r?selChip:idleChip, onPick:()=>this.setState({region:r}) }));
|
||||||
|
const sizes = ['c7g.xlarge','c7g.2xlarge','c7g.4xlarge'].map(z => ({ label:z, style: s.size===z?selChip:idleChip, onPick:()=>this.setState({size:z}) }));
|
||||||
|
|
||||||
|
const navActive = 'background:rgba(255,111,97,.1);';
|
||||||
|
const navIdle = '';
|
||||||
|
return {
|
||||||
|
// cloud
|
||||||
|
cloudProviders, cloudRunners, regions, sizes,
|
||||||
|
selProvName: selProv?selProv.name:'', selProvTile: selProv?selProv.tile:'', selProvShort: selProv?selProv.short:'', selProvInk: selProv?selProv.ink:'', selProvIsApple: selProv?selProv.isApple:false, selProvIsText: selProv?selProv.isText:true,
|
||||||
|
phaseCreds: s.cloudPhase==='creds', phaseConfig: s.cloudPhase==='config', provisioning: s.provisioning, provisioned: s.provisioned,
|
||||||
|
toConfig: () => this.setState({ cloudPhase:'config' }),
|
||||||
|
backToCreds: () => this.setState({ cloudPhase:'creds' }),
|
||||||
|
provisionRunner: () => this._enterProvisioning(),
|
||||||
|
finishCloud: () => this.setState({ addMode:'menu', provisioning:false, provisioned:false }),
|
||||||
|
region: s.region, size: s.size,
|
||||||
|
showCloudMenu: inCloud && s.addMode!=='cloud-connect',
|
||||||
|
showCloudConnect: inCloud && s.addMode==='cloud-connect',
|
||||||
|
// nav
|
||||||
|
isLocal: s.nav==='local', isCloud: s.nav==='cloud',
|
||||||
|
navLocalStyle: s.nav==='local'?navActive:navIdle, navLocalIcon: s.nav==='local'?'#ff8a7a':'#8a8a92', navLocalText: s.nav==='local'?'#fff':'#cfcfd5',
|
||||||
|
navCloudStyle: s.nav==='cloud'?navActive:navIdle, navCloudIcon: s.nav==='cloud'?'#ff8a7a':'#6a6a72', navCloudText: s.nav==='cloud'?'#fff':'#9a9aa2',
|
||||||
|
goLocal: () => this.setState({ nav:'local', addMode:'menu' }),
|
||||||
|
goCloud: () => this.setState({ nav:'cloud', addMode:'menu' }),
|
||||||
|
// fleet
|
||||||
|
fleetTotal: 3, fleetOnline: 2, vcpu: 28, ram: '96 GB', containersRunning: 8,
|
||||||
|
hostCards, tsDevices, tsDeviceCount: tsDevices.length, tailnet: 'acme-org.ts.net',
|
||||||
|
tsConnected: s.tsConnected,
|
||||||
|
// right panel modes
|
||||||
|
showMenu: !inCloud && s.addMode==='menu', showWizard: !inCloud && s.addMode==='host-wizard', showTailscale: !inCloud && s.addMode==='tailscale',
|
||||||
|
openHostWizard: () => this.setState({ addMode:'host-wizard', step:1, connecting:false, connected:false }),
|
||||||
|
openTailscale: () => this.setState({ addMode:'tailscale' }),
|
||||||
|
backToMenu: () => this.setState({ addMode:'menu' }),
|
||||||
|
// wizard
|
||||||
|
step1: s.step===1, step2: s.step===2, step3: s.step===3,
|
||||||
|
stepIs1Style: this._stepDot(s.step,1), stepIs2Style: this._stepDot(s.step,2), stepIs3Style: this._stepDot(s.step,3),
|
||||||
|
connecting: s.connecting, connected: s.connected,
|
||||||
|
copied: s.copied,
|
||||||
|
copyToken: () => { this.setState({ copied:true }); setTimeout(()=>this.setState({copied:false}), 1400); },
|
||||||
|
copyLabel: s.copied ? 'copied ✓' : 'copy',
|
||||||
|
toInstall: () => this.setState({ step:1 }),
|
||||||
|
toConnect: () => this._enterConnecting(),
|
||||||
|
toVerify: () => this.setState({ step:3 }),
|
||||||
|
finishWizard: () => this.setState({ addMode:'menu', step:1 }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
_stepDot(cur, n) {
|
||||||
|
if (cur === n) return 'background:#ff6f61; color:#2a0d0a;';
|
||||||
|
if (cur > n) return 'background:rgba(95,208,138,.2); color:#5fd08a; border:1px solid rgba(95,208,138,.4);';
|
||||||
|
return 'background:#141417; color:#6a6a72; border:1px solid rgba(255,255,255,.1);';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
# Infrastructure Page — Design Guide
|
||||||
|
|
||||||
|
How the Clawmates **Infrastructure** page is put together, and the rules to follow if you want to build (or extend) a page in this style. Use this as the recipe; the `.dc.html` file in this folder is the working reference.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. What this page is
|
||||||
|
|
||||||
|
A single full-bleed control surface for managing the agent **fleet** — the machines that run agents. It answers three questions at a glance:
|
||||||
|
|
||||||
|
1. **What hardware do I have, and is it healthy?** (stats + host cards)
|
||||||
|
2. **How do I add more?** (right-hand "Add to fleet" panel + wizards)
|
||||||
|
3. **Is the connective tissue up?** (Tailscale status, daemon control plane, sandbox readiness)
|
||||||
|
|
||||||
|
The whole thing is a **dark operator console** — closer to a terminal/observability tool than a marketing page. Every design decision below serves that feeling: dense, calm, trustworthy, no decoration that isn't carrying information.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. The layout system
|
||||||
|
|
||||||
|
The page is a **fixed full-viewport shell** (`100vw × 100vh`, `overflow:hidden`) divided into five regions. Lock this skeleton first — everything else hangs off it.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ TOP BAR (50px) logo · breadcrumb · fleet pill · avatar │
|
||||||
|
├──┬──────────┬───────────────────────────────┬────────────┤
|
||||||
|
│ │ │ │ │
|
||||||
|
│IC│ LEFT NAV │ CENTER (fluid) │ RIGHT PANEL│
|
||||||
|
│ON│ 216px │ stats → network → cards │ 344px │
|
||||||
|
│54│ │ │ contextual│
|
||||||
|
│ │ │ │ │
|
||||||
|
├──┴──────────┴───────────────────────────────┴────────────┤
|
||||||
|
│ STATUS BAR (26px) daemon ok · tailnet · WSS · sandbox │
|
||||||
|
└─────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Icon rail (54px):** product-level switcher (World / Agent / Infra). Pure glyphs + 7px mono labels.
|
||||||
|
- **Left nav (216px):** scoped to *this* page — sources of compute, grouped under `RUN HERE FIRST` and `NEXT`. The active item gets a 3px coral spine on its left edge.
|
||||||
|
- **Center (fluid, scrolls):** the content. Reads top-to-bottom as a narrative: heading → fleet stats → network → host cards.
|
||||||
|
- **Right panel (344px):** the **action surface**. It is *contextual* — it swaps between a menu, the connect-a-host wizard, the Tailscale view, and the cloud wizard, but the column never moves. This separation (read on the left/center, act on the right) is the core interaction idea.
|
||||||
|
- **Status bar (26px):** ambient system truth. Always-on, mono, low-contrast.
|
||||||
|
|
||||||
|
Only the center and right-panel inner columns scroll; the rails, top bar and status bar are pinned (`flex:none`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Color
|
||||||
|
|
||||||
|
A near-black warm-neutral base with **functional accents only**. Don't introduce a color unless it *means* something.
|
||||||
|
|
||||||
|
| Token | Value | Used for |
|
||||||
|
|---|---|---|
|
||||||
|
| Canvas | `#08080a` | page background |
|
||||||
|
| Surface | `#0d0d10` / `#0c0c0f` | cards, panels |
|
||||||
|
| Rail / nav | `#0a0a0c` / `#0b0b0e` | chrome columns |
|
||||||
|
| Hairline | `rgba(255,255,255,.06–.08)` | every border & divider |
|
||||||
|
| Text high | `#f3f3f5` | headings, key values |
|
||||||
|
| Text mid | `#8a8a92 / #9a9aa2` | body copy |
|
||||||
|
| Text low | `#5a5a62 / #6a6a72` | mono labels, meta |
|
||||||
|
|
||||||
|
**Accents (each owns a meaning — keep it that way):**
|
||||||
|
|
||||||
|
- **Coral `#ff6f61`** → brand + the *primary* action / active nav. Used sparingly so it always pulls the eye to "the next thing to do."
|
||||||
|
- **Green `#5fd08a`** → healthy / online / connected.
|
||||||
|
- **Cyan `#5ec8d8`** → network, links, secondary actions, IPs.
|
||||||
|
- **Amber `#e8b465`** → pending / pairing / cost / "SOON".
|
||||||
|
- Coral→red gradient `linear-gradient(135deg,#ff8a7a,#ff5f57)` is the one filled button treatment; green gradient marks a *completed* terminal action.
|
||||||
|
|
||||||
|
Rule of thumb: a screen should be ~90% neutral. If it looks colorful, you've over-accented.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Type
|
||||||
|
|
||||||
|
Two families, sharp roles:
|
||||||
|
|
||||||
|
- **Helvetica Neue** (system sans) — all UI prose, headings, labels-with-sentences.
|
||||||
|
- **JetBrains Mono** — anything machine: IPs, hostnames, commands, stat labels (`HOSTS`, `vCPU`), counts, status text, the status bar. This mono/sans split is what makes the page read as an *operator tool* rather than a website. Use it deliberately.
|
||||||
|
|
||||||
|
Scale (don't go below these):
|
||||||
|
- Section heading: `26px / 700 / -.02em`
|
||||||
|
- Card title: `14px / 700`
|
||||||
|
- Body: `12–13px`
|
||||||
|
- Mono labels: `9–10px`, letter-spacing `.1–.14em`, low-contrast color. These uppercase mono kickers (`LOCAL & TAILSCALE`, `PROVIDERS`) sit above almost every block and are the page's connective rhythm.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Components & their patterns
|
||||||
|
|
||||||
|
**Stat tiles** — a 5-col grid of `border + #0d0d10` cards: tiny mono label on top, big `22px/700` number below. Healthy/notable ones tint their border + number (green for ONLINE, cyan for CONTAINERS).
|
||||||
|
|
||||||
|
**Host health card** — the workhorse. Each one carries: status dot + name + status label; mono OS line; CPU & RAM as thin 4px meter bars (color shifts cyan→amber→coral as load climbs); a 3-up disk/load/ctrs mini-row; and a footer that is *either* a copyable `ssh` target (online) *or* a spinning "waiting for daemon" chip (pairing). Same card, state-driven contents.
|
||||||
|
|
||||||
|
**Add-host tile** — a dashed-border ghost card that ends the grid; clicking it opens the wizard. Always leave the "add another" affordance visible.
|
||||||
|
|
||||||
|
**Section header row** — mono kicker · 1px rule that flexes to fill · a cyan inline action (`+ connect a host`). Used to break the center column into labeled bands.
|
||||||
|
|
||||||
|
**Right-panel wizards** — stepped flows (install → connect → verify). A numbered stepper at top; one step visible at a time with a small `cm-fade` entrance; terminal "success" state uses the green gradient button. Faux terminals (mac traffic-light dots + mono body) show commands and check output.
|
||||||
|
|
||||||
|
**Meters, dots, pills** — health is shown the same way everywhere: a colored dot for state, a thin bar for utilization, a bordered pill for a labeled status. Reuse these three primitives instead of inventing new indicators.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Motion
|
||||||
|
|
||||||
|
Restrained and diegetic. Four keyframes only:
|
||||||
|
- `cm-blink` — slow pulse on live "online" dots.
|
||||||
|
- `cm-spin` — spinners while waiting on the daemon / provisioning.
|
||||||
|
- `cm-fade` — 0.2s rise on step/panel changes.
|
||||||
|
- `cm-pip` — subtle status flicker.
|
||||||
|
|
||||||
|
No page-load chrome animation, no parallax, no decorative motion. Movement only ever means "something is live" or "the view just changed."
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Voice
|
||||||
|
|
||||||
|
Technical, plain, reassuring about security. The copy keeps repeating the trust story: *"outbound WSS — no inbound port, no keys"*, *"credentials stay in the secret broker"*, *"§15 network-isolated sandboxes"*. Lead with the user's own hardware (`RUN HERE FIRST` → Local & Tailscale), mark cloud as `NEXT`/`SOON`. Buttons say what will happen (`I've run it — waiting for connection →`, `Add forge-02 to fleet`), never just "Submit."
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. How it's built (structure)
|
||||||
|
|
||||||
|
This is a **Design Component** (`.dc.html`) — one self-contained file that opens in a browser.
|
||||||
|
|
||||||
|
- **Template** = the markup, styled entirely with **inline styles** (no CSS classes except the four `@keyframes` and scrollbar rules in `<helmet>`). This is intentional: inline styles paint immediately as the file streams.
|
||||||
|
- **Logic** = a `class Component extends DCLogic` at the bottom. All state lives in `this.state` (`nav`, `addMode`, wizard `step`, `connecting`, etc.); `renderVals()` returns every value and handler the template binds with `{{ … }}`.
|
||||||
|
- **Data is mocked** in `renderVals()` — `hosts`, `tsDevices`, `cloudProviders`, `cloudRunners`. To wire this to real data, replace those arrays with your fetched fleet state; the rest of the template is shape-stable.
|
||||||
|
- **Control flow:** `<sc-if>` swaps the center view (local vs cloud) and the right-panel mode; `<sc-for>` repeats host cards, devices, providers. State-driven contents, not separate pages.
|
||||||
|
|
||||||
|
### To build a new page in this style
|
||||||
|
1. Drop the **5-region shell** first (top bar / icon rail / nav / center / status bar) and pin everything but center.
|
||||||
|
2. Establish the **mono-kicker → content** rhythm in the center column.
|
||||||
|
3. Put all *actions* in the **right panel**; make it swap by `addMode`, never navigate away.
|
||||||
|
4. Use only the **five accents**, each for its one meaning; keep ~90% neutral.
|
||||||
|
5. Show state with the **dot / meter / pill** trio and the four keyframes — nothing more.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Files in this package
|
||||||
|
|
||||||
|
- `Clawmates Infrastructure.dc.html` — the page (open directly in a browser).
|
||||||
|
- `support.js` — the runtime the `.dc.html` needs; keep it beside the page.
|
||||||
|
- `DESIGN-GUIDE.md` — this document.
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -25,7 +25,8 @@ import { ObservePanel } from "../observe/ObservePanel";
|
|||||||
import { StructureTree, orgNode, clawNode, type TreeItem } from "./StructureTree";
|
import { StructureTree, orgNode, clawNode, type TreeItem } from "./StructureTree";
|
||||||
import { UserMenu } from "./UserMenu";
|
import { UserMenu } from "./UserMenu";
|
||||||
import { ToolPanel, type ToolKey } from "./ToolPanel";
|
import { ToolPanel, type ToolKey } from "./ToolPanel";
|
||||||
import { InfraSidebar, InfraStage, InfraConsole, INFRA_CATS } from "./InfraStage";
|
import { InfraNav, FleetConsole, FleetStatusBar, FleetPill } from "./fleet/FleetConsole";
|
||||||
|
import { ConnectHostWizard } from "./ConnectHostWizard";
|
||||||
import { INFRA_CATALOG } from "@/components/computer/catalogs/infra";
|
import { INFRA_CATALOG } from "@/components/computer/catalogs/infra";
|
||||||
import { ClawChatSection } from "./ClawChatSection";
|
import { ClawChatSection } from "./ClawChatSection";
|
||||||
import { ResizableSplit } from "./ResizableSplit";
|
import { ResizableSplit } from "./ResizableSplit";
|
||||||
@@ -191,8 +192,9 @@ export function Dashboard({ user, orgs, claws }: { user?: { display_name?: strin
|
|||||||
// Large World right slide-out (sized like the agent computer: phone/tablet/full).
|
// Large World right slide-out (sized like the agent computer: phone/tablet/full).
|
||||||
const [worldPanelOpen, setWorldPanelOpen] = useState(false);
|
const [worldPanelOpen, setWorldPanelOpen] = useState(false);
|
||||||
const [worldSize, setWorldSize] = useState<DeviceSize>("phone");
|
const [worldSize, setWorldSize] = useState<DeviceSize>("phone");
|
||||||
// Infrastructure tier: which category is selected in the left list.
|
// Infrastructure tier: which nav view (local / cloud) + the connect-host wizard.
|
||||||
const [infraSel, setInfraSel] = useState<string | null>("fleet");
|
const [infraSel, setInfraSel] = useState<string | null>("local");
|
||||||
|
const [infraConnectOpen, setInfraConnectOpen] = useState(false);
|
||||||
|
|
||||||
const org: DemoOrg = findOrg(orgId) ?? fallbackOrg;
|
const org: DemoOrg = findOrg(orgId) ?? fallbackOrg;
|
||||||
const company: DemoCompany = findCompany(companyId) ?? org.companies[0] ?? EMPTY_ORG.companies[0];
|
const company: DemoCompany = findCompany(companyId) ?? org.companies[0] ?? EMPTY_ORG.companies[0];
|
||||||
@@ -500,6 +502,7 @@ export function Dashboard({ user, orgs, claws }: { user?: { display_name?: strin
|
|||||||
<span style={crumbStyle(isInfra)} onClick={() => setTier("infra")}>Infrastructure</span>
|
<span style={crumbStyle(isInfra)} onClick={() => setTier("infra")}>Infrastructure</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ flex: 1 }} />
|
<div style={{ flex: 1 }} />
|
||||||
|
{isInfra ? <FleetPill /> : null}
|
||||||
<UserMenu onOpen={setToolOpen} />
|
<UserMenu onOpen={setToolOpen} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -540,12 +543,7 @@ export function Dashboard({ user, orgs, claws }: { user?: { display_name?: strin
|
|||||||
</div>
|
</div>
|
||||||
<button type="button" onClick={() => { setSelectMode((v) => { if (v) setSelectedAgents(new Set()); return !v; }); }} title="Select to manage" aria-label="Select to manage" style={{ flex: "none", width: 34, height: 34, borderRadius: 9, border: `1px solid ${selectMode ? "rgba(255,111,97,.5)" : "rgba(255,255,255,.12)"}`, background: selectMode ? "rgba(255,111,97,.12)" : "transparent", color: selectMode ? "#ff6f61" : "#9a9aa2", cursor: "pointer", display: "inline-flex", alignItems: "center", justifyContent: "center" }}><Wrench aria-hidden size={16} /></button>
|
<button type="button" onClick={() => { setSelectMode((v) => { if (v) setSelectedAgents(new Set()); return !v; }); }} title="Select to manage" aria-label="Select to manage" style={{ flex: "none", width: 34, height: 34, borderRadius: 9, border: `1px solid ${selectMode ? "rgba(255,111,97,.5)" : "rgba(255,255,255,.12)"}`, background: selectMode ? "rgba(255,111,97,.12)" : "transparent", color: selectMode ? "#ff6f61" : "#9a9aa2", cursor: "pointer", display: "inline-flex", alignItems: "center", justifyContent: "center" }}><Wrench aria-hidden size={16} /></button>
|
||||||
</div>
|
</div>
|
||||||
) : isInfra ? (
|
) : isInfra ? null : (
|
||||||
<div style={{ padding: "16px 16px 12px", borderBottom: "1px solid rgba(255,255,255,.06)" }}>
|
|
||||||
<div style={{ fontFamily: mono, fontSize: 10, letterSpacing: ".12em", color: "#5a5a62", marginBottom: 6 }}>{INFRA_CATS.length} CATEGORIES</div>
|
|
||||||
<div style={{ fontSize: 18, fontWeight: 700, color: "#f3f3f5", letterSpacing: "-.01em" }}>Infrastructure</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div style={{ padding: "16px 16px 12px", borderBottom: "1px solid rgba(255,255,255,.06)", display: "flex", alignItems: "flex-start", gap: 8 }}>
|
<div style={{ padding: "16px 16px 12px", borderBottom: "1px solid rgba(255,255,255,.06)", display: "flex", alignItems: "flex-start", gap: 8 }}>
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
<div style={{ fontFamily: mono, fontSize: 10, letterSpacing: ".12em", color: "#5a5a62", marginBottom: 6 }}>{allAgents.length} AGENT{allAgents.length === 1 ? "" : "S"}</div>
|
<div style={{ fontFamily: mono, fontSize: 10, letterSpacing: ".12em", color: "#5a5a62", marginBottom: 6 }}>{allAgents.length} AGENT{allAgents.length === 1 ? "" : "S"}</div>
|
||||||
@@ -560,7 +558,7 @@ export function Dashboard({ user, orgs, claws }: { user?: { display_name?: strin
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{isInfra ? (
|
{isInfra ? (
|
||||||
<InfraSidebar selected={infraSel} onSelect={setInfraSel} />
|
<InfraNav view={infraSel ?? "local"} onSelect={setInfraSel} onConnectHost={() => setInfraConnectOpen(true)} />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* The collapsible org → company → team → agent tree (World) or the flat agents list. */}
|
{/* The collapsible org → company → team → agent tree (World) or the flat agents list. */}
|
||||||
@@ -661,20 +659,11 @@ export function Dashboard({ user, orgs, claws }: { user?: { display_name?: strin
|
|||||||
</>
|
</>
|
||||||
) : isInfra ? (
|
) : isInfra ? (
|
||||||
<>
|
<>
|
||||||
{/* Center: infra overview + console, condensed by the computer's
|
{/* Center: the operator console (stats → Tailscale → host cards) +
|
||||||
width — the same template as an agent (anatomy + chat). */}
|
a thin status bar, condensed by the computer pull-out's width. */}
|
||||||
<div style={{ position: "absolute", top: 0, bottom: 0, left: 0, right: "var(--computer-width)", transition: "left var(--duration-normal) var(--ease-app), right var(--duration-normal) var(--ease-app)" }}>
|
<div style={{ position: "absolute", top: 0, bottom: 0, left: 0, right: "var(--computer-width)", display: "flex", flexDirection: "column", transition: "right var(--duration-normal) var(--ease-app)" }}>
|
||||||
{chatMin ? (
|
<FleetConsole view={infraSel ?? "local"} onConnectHost={() => setInfraConnectOpen(true)} />
|
||||||
<InfraStage selected={infraSel} />
|
<FleetStatusBar />
|
||||||
) : (
|
|
||||||
<ResizableSplit
|
|
||||||
defaultRatio={0.62}
|
|
||||||
minTop={120}
|
|
||||||
minBottom={120}
|
|
||||||
top={<InfraStage selected={infraSel} />}
|
|
||||||
bottom={<InfraConsole onMinimize={() => setChatMin(true)} />}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
{/* Right: the IDENTICAL computer chrome, with cloud-infra apps. */}
|
{/* Right: the IDENTICAL computer chrome, with cloud-infra apps. */}
|
||||||
<DevicePanel catalog={INFRA_CATALOG} />
|
<DevicePanel catalog={INFRA_CATALOG} />
|
||||||
@@ -710,6 +699,7 @@ export function Dashboard({ user, orgs, claws }: { user?: { display_name?: strin
|
|||||||
<button type="button" aria-label="Computer" title="Computer" onClick={() => setParams({ app: "home", device: "phone" })} style={{ display: "flex", alignItems: "center", justifyContent: "center", width: 38, height: 38, borderRadius: "50%", border: "1px solid rgba(255,111,97,.4)", background: "rgba(255,111,97,.08)", color: "#ff6f61", cursor: "pointer" }}><Monitor aria-hidden size={20} /></button>
|
<button type="button" aria-label="Computer" title="Computer" onClick={() => setParams({ app: "home", device: "phone" })} style={{ display: "flex", alignItems: "center", justifyContent: "center", width: 38, height: 38, borderRadius: "50%", border: "1px solid rgba(255,111,97,.4)", background: "rgba(255,111,97,.08)", color: "#ff6f61", cursor: "pointer" }}><Monitor aria-hidden size={20} /></button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{infraConnectOpen ? <ConnectHostWizard onClose={() => setInfraConnectOpen(false)} /> : null}
|
||||||
</>
|
</>
|
||||||
) : richAgent && clawAgent ? (
|
) : richAgent && clawAgent ? (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -0,0 +1,314 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// The Infrastructure tier's operator console (per the Infrastructure design
|
||||||
|
// package): a page-scoped grouped left nav + a center that reads top-to-bottom —
|
||||||
|
// fleet stats → Tailscale device card → host health cards. Wired to the real
|
||||||
|
// fleet backend. The right column stays the cloud-apps computer pull-out, and
|
||||||
|
// the connect-host wizard is a modal; both open via onConnectHost.
|
||||||
|
|
||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import { Cloud, Server, Terminal } from "lucide-react";
|
||||||
|
import { useQueryStates } from "nuqs";
|
||||||
|
|
||||||
|
import { useFetchJson } from "@/lib/api/use-fetch";
|
||||||
|
import { panelParsers } from "@/lib/url/panel-params";
|
||||||
|
|
||||||
|
import { TailscaleSection, useNodes, type FleetNode } from "./FleetPanels";
|
||||||
|
|
||||||
|
const mono = "'JetBrains Mono', ui-monospace, monospace";
|
||||||
|
|
||||||
|
function fmtBytes(b: number): string {
|
||||||
|
if (b >= 1e12) return `${(b / 1e12).toFixed(1)} TB`;
|
||||||
|
if (b >= 1e9) return `${(b / 1e9).toFixed(0)} GB`;
|
||||||
|
if (b >= 1e6) return `${(b / 1e6).toFixed(0)} MB`;
|
||||||
|
return `${b}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_COLOR: Record<FleetNode["status"], string> = {
|
||||||
|
online: "#5fd08a",
|
||||||
|
offline: "#6a6a72",
|
||||||
|
pending: "#e8b465",
|
||||||
|
draining: "#ff8a7a",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** cyan → amber → coral as utilization climbs. */
|
||||||
|
function loadColor(pct: number): string {
|
||||||
|
if (pct >= 85) return "#ff6f61";
|
||||||
|
if (pct >= 60) return "#e8b465";
|
||||||
|
return "#5ec8d8";
|
||||||
|
}
|
||||||
|
|
||||||
|
function tsOnline(lastSeen: string | null): boolean {
|
||||||
|
if (!lastSeen) return false;
|
||||||
|
const t = Date.parse(lastSeen);
|
||||||
|
return Number.isFinite(t) && Date.now() - t < 5 * 60 * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TsDevice {
|
||||||
|
name: string | null;
|
||||||
|
addr: string | null;
|
||||||
|
os: string | null;
|
||||||
|
lastSeen: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Left nav: RUN HERE FIRST / NEXT ──────────────────────────────────────────
|
||||||
|
export function InfraNav({
|
||||||
|
view,
|
||||||
|
onSelect,
|
||||||
|
onConnectHost,
|
||||||
|
}: {
|
||||||
|
view: string;
|
||||||
|
onSelect: (v: string) => void;
|
||||||
|
onConnectHost: () => void;
|
||||||
|
}) {
|
||||||
|
const { nodes } = useNodes();
|
||||||
|
const online = nodes.filter((n) => n.status === "online").length;
|
||||||
|
const { data: ts } = useFetchJson<{ devices: TsDevice[] }>("/api/fleet/tailscale/devices");
|
||||||
|
const tsCount = ts?.devices?.length ?? 0;
|
||||||
|
const isLocal = view === "local";
|
||||||
|
const isCloud = view === "cloud";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div style={{ padding: "14px 14px 12px" }}>
|
||||||
|
<div style={{ fontFamily: mono, fontSize: 10, letterSpacing: ".12em", color: "#5a5a62" }}>INFRASTRUCTURE</div>
|
||||||
|
<div style={{ fontSize: 17, fontWeight: 700, marginTop: 3 }}>Your fleet</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, overflowY: "auto", padding: "4px 8px" }}>
|
||||||
|
<div style={{ fontFamily: mono, fontSize: 9, letterSpacing: ".14em", color: "#5a5a62", padding: "8px 8px 6px" }}>RUN HERE FIRST</div>
|
||||||
|
<button type="button" onClick={() => onSelect("local")} style={{ width: "100%", display: "flex", alignItems: "center", gap: 10, padding: "9px 10px", borderRadius: 9, cursor: "pointer", position: "relative", border: 0, textAlign: "left", background: isLocal ? "rgba(255,111,97,.1)" : "transparent", color: isLocal ? "#f3f3f5" : "#cfcfd5" }}>
|
||||||
|
{isLocal ? <span style={{ position: "absolute", left: 0, top: 8, bottom: 8, width: 3, borderRadius: "0 3px 3px 0", background: "#ff6f61" }} /> : null}
|
||||||
|
<Server size={16} style={{ color: isLocal ? "#ff8a7a" : "#8a8a92" }} />
|
||||||
|
<span style={{ flex: 1, fontSize: 13, fontWeight: 600 }}>Local & Tailscale</span>
|
||||||
|
<span style={{ fontFamily: mono, fontSize: 9, color: "#5fd08a" }}>{online}</span>
|
||||||
|
</button>
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 1, margin: "2px 0 10px 14px", paddingLeft: 10, borderLeft: "1px solid rgba(255,255,255,.07)" }}>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 8, padding: "6px 9px" }}><span style={{ width: 5, height: 5, borderRadius: "50%", background: "#5fd08a" }} /><span style={{ fontSize: 12, color: "#b5b5bd" }}>Local hosts</span><span style={{ flex: 1 }} /><span style={{ fontFamily: mono, fontSize: 9, color: "#5a5a62" }}>{nodes.length}</span></div>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 8, padding: "6px 9px" }}><span style={{ width: 5, height: 5, borderRadius: "50%", background: "#5ec8d8" }} /><span style={{ fontSize: 12, color: "#b5b5bd" }}>Tailscale network</span><span style={{ flex: 1 }} /><span style={{ fontFamily: mono, fontSize: 9, color: "#5a5a62" }}>{tsCount}</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ fontFamily: mono, fontSize: 9, letterSpacing: ".14em", color: "#5a5a62", padding: "8px 8px 6px" }}>NEXT</div>
|
||||||
|
<button type="button" onClick={() => onSelect("cloud")} style={{ width: "100%", display: "flex", alignItems: "center", gap: 10, padding: "9px 10px", borderRadius: 9, cursor: "pointer", position: "relative", border: 0, textAlign: "left", background: isCloud ? "rgba(255,111,97,.1)" : "transparent", color: isCloud ? "#f3f3f5" : "#cfcfd5" }}>
|
||||||
|
{isCloud ? <span style={{ position: "absolute", left: 0, top: 8, bottom: 8, width: 3, borderRadius: "0 3px 3px 0", background: "#ff6f61" }} /> : null}
|
||||||
|
<Cloud size={16} style={{ color: isCloud ? "#ff8a7a" : "#8a8a92" }} />
|
||||||
|
<span style={{ flex: 1, fontSize: 13, fontWeight: 600 }}>Cloud providers</span>
|
||||||
|
<span style={{ fontFamily: mono, fontSize: 8, color: "#e8b465", padding: "2px 6px", borderRadius: 5, background: "rgba(232,196,106,.1)" }}>SOON</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: "none", padding: "10px 12px", borderTop: "1px solid rgba(255,255,255,.06)" }}>
|
||||||
|
<button type="button" onClick={onConnectHost} style={{ width: "100%", display: "flex", alignItems: "center", justifyContent: "center", gap: 7, height: 36, borderRadius: 9, border: 0, background: "linear-gradient(135deg,#ff8a7a,#ff5f57)", color: "#2a0d0a", fontSize: 13, fontWeight: 700, cursor: "pointer" }}><span style={{ fontSize: 15, lineHeight: 1 }}>+</span> Connect a host</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Host health card ─────────────────────────────────────────────────────────
|
||||||
|
function Meter({ label, pct }: { label: string; pct: number }) {
|
||||||
|
const col = loadColor(pct);
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between", fontFamily: mono, fontSize: 9, color: "#8a8a92", marginBottom: 4 }}><span>{label}</span><span style={{ color: col }}>{pct.toFixed(0)}%</span></div>
|
||||||
|
<div style={{ height: 4, borderRadius: 2, background: "rgba(255,255,255,.07)", overflow: "hidden" }}><div style={{ width: `${Math.max(0, Math.min(100, pct))}%`, height: "100%", background: col }} /></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Mini({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div style={{ flex: 1, textAlign: "center", padding: "7px 0", borderRadius: 8, background: "rgba(255,255,255,.03)" }}>
|
||||||
|
<div style={{ fontFamily: mono, fontSize: 9, color: "#6a6a72" }}>{label}</div>
|
||||||
|
<div style={{ fontSize: 12, fontWeight: 600, marginTop: 2 }}>{value}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HostCard({ node, onRemoved }: { node: FleetNode; onRemoved: () => void }) {
|
||||||
|
const [, setParams] = useQueryStates(panelParsers, { shallow: true });
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const h = node.health;
|
||||||
|
const memPct = h && h.memTotal > 0 ? (h.memUsed / h.memTotal) * 100 : 0;
|
||||||
|
const diskPct = h && h.diskTotal > 0 ? ((h.diskTotal - h.diskFree) / h.diskTotal) * 100 : 0;
|
||||||
|
const sshHost = node.tailscaleIp ?? node.localIp;
|
||||||
|
const remove = useCallback(() => {
|
||||||
|
if (!confirm(`Remove "${node.hostname ?? node.name}" from your fleet?`)) return;
|
||||||
|
fetch(`/api/nodes/${node.id}`, { method: "DELETE" }).then(onRemoved);
|
||||||
|
}, [node.id, node.hostname, node.name, onRemoved]);
|
||||||
|
const copySsh = useCallback(() => {
|
||||||
|
if (!sshHost) return;
|
||||||
|
navigator.clipboard?.writeText(`ssh ${sshHost}`).then(() => {
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 1500);
|
||||||
|
});
|
||||||
|
}, [sshHost]);
|
||||||
|
|
||||||
|
const accentBorder = node.status === "online" ? "rgba(95,208,138,.18)" : node.status === "pending" ? "rgba(232,196,106,.2)" : "rgba(255,255,255,.07)";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ borderRadius: 13, border: `1px solid ${accentBorder}`, background: "#0d0d10", padding: 15, display: "flex", flexDirection: "column" }}>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 9, marginBottom: 13 }}>
|
||||||
|
<span style={{ width: 9, height: 9, borderRadius: "50%", background: STATUS_COLOR[node.status], animation: node.status === "online" ? "cm-blink 1.6s infinite" : "none" }} />
|
||||||
|
<span style={{ fontSize: 14, fontWeight: 700, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{node.hostname ?? node.name}</span>
|
||||||
|
<span style={{ flex: 1 }} />
|
||||||
|
{node.status === "online" ? (
|
||||||
|
<button type="button" onClick={() => setParams({ app: "terminal", node: node.id, device: "phone" })} title="Open terminal" aria-label="Open terminal" style={{ width: 26, height: 26, borderRadius: 7, border: "1px solid rgba(94,200,216,.3)", background: "rgba(94,200,216,.08)", color: "#5ec8d8", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center" }}><Terminal size={13} /></button>
|
||||||
|
) : null}
|
||||||
|
<button type="button" onClick={remove} title="Remove" aria-label="Remove node" style={{ fontFamily: mono, fontSize: 9, color: STATUS_COLOR[node.status], background: "transparent", border: 0, cursor: "pointer", padding: "2px 4px" }}>{node.status.toUpperCase()}</button>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontFamily: mono, fontSize: 10, color: "#6a6a72", marginBottom: 14 }}>
|
||||||
|
{node.localIp ?? "—"}{node.agentVersion ? ` · v${node.agentVersion}` : ""}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{h ? (
|
||||||
|
<>
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 9, marginBottom: 13 }}>
|
||||||
|
<Meter label="CPU" pct={h.cpuPct} />
|
||||||
|
<Meter label="RAM" pct={memPct} />
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", gap: 6, marginBottom: 13 }}>
|
||||||
|
<Mini label="disk" value={`${diskPct.toFixed(0)}%`} />
|
||||||
|
<Mini label="load" value={h.load1.toFixed(2)} />
|
||||||
|
<Mini label="ctrs" value={String(h.containerCount)} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{node.status === "online" && sshHost ? (
|
||||||
|
<button type="button" onClick={copySsh} style={{ display: "flex", alignItems: "center", gap: 8, padding: "8px 11px", borderRadius: 8, background: "#070708", border: "1px solid rgba(255,255,255,.07)", cursor: "pointer", textAlign: "left" }}>
|
||||||
|
<Server size={13} style={{ color: "#5ec8d8", flex: "none" }} />
|
||||||
|
<span style={{ fontFamily: mono, fontSize: 11, color: "#cfcfd5", flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>ssh {sshHost}</span>
|
||||||
|
<span style={{ fontFamily: mono, fontSize: 9, color: copied ? "#5fd08a" : "#5ec8d8" }}>{copied ? "copied" : "copy"}</span>
|
||||||
|
</button>
|
||||||
|
) : node.status === "pending" ? (
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 8, padding: "8px 11px", borderRadius: 8, background: "rgba(232,196,106,.06)", border: "1px solid rgba(232,196,106,.2)" }}>
|
||||||
|
<span style={{ width: 11, height: 11, border: "2px solid #e8b465", borderTopColor: "transparent", borderRadius: "50%", animation: "spin .8s linear infinite" }} />
|
||||||
|
<span style={{ fontFamily: mono, fontSize: 10, color: "#e8b465" }}>waiting for daemon to dial home…</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Center console ───────────────────────────────────────────────────────────
|
||||||
|
function Stat({ label, value, tint }: { label: string; value: string; tint?: string }) {
|
||||||
|
return (
|
||||||
|
<div style={{ borderRadius: 12, border: `1px solid ${tint ? `${tint}30` : "rgba(255,255,255,.07)"}`, background: "#0d0d10", padding: "13px 15px" }}>
|
||||||
|
<div style={{ fontFamily: mono, fontSize: 9, letterSpacing: ".1em", color: "#6a6a72" }}>{label}</div>
|
||||||
|
<div style={{ fontSize: 22, fontWeight: 700, marginTop: 4, color: tint ?? "#f3f3f5" }}>{value}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FleetConsole({ view, onConnectHost }: { view: string; onConnectHost: () => void }) {
|
||||||
|
const { nodes, refresh } = useNodes();
|
||||||
|
const { data: ts } = useFetchJson<{ connected: boolean; tailnet: string | null; devices: TsDevice[] }>("/api/fleet/tailscale/devices");
|
||||||
|
const online = nodes.filter((n) => n.status === "online");
|
||||||
|
const totalMem = online.reduce((a, n) => a + (n.health?.memTotal ?? 0), 0);
|
||||||
|
const totalDisk = online.reduce((a, n) => a + (n.health?.diskTotal ?? 0), 0);
|
||||||
|
const containers = online.reduce((a, n) => a + (n.health?.containerCount ?? 0), 0);
|
||||||
|
const tsDevices = ts?.devices ?? [];
|
||||||
|
const tsOnlineN = tsDevices.filter((d) => tsOnline(d.lastSeen)).length;
|
||||||
|
|
||||||
|
if (view === "cloud") {
|
||||||
|
return (
|
||||||
|
<div style={{ flex: 1, minHeight: 0, overflowY: "auto", padding: "22px 26px 30px" }}>
|
||||||
|
<div style={{ fontFamily: mono, fontSize: 10, letterSpacing: ".14em", color: "#5a5a62", marginBottom: 6 }}>CLOUD PROVIDERS</div>
|
||||||
|
<div style={{ fontSize: 26, fontWeight: 700, letterSpacing: "-.02em", marginBottom: 4 }}>Power the platform from the cloud</div>
|
||||||
|
<p style={{ fontSize: 13, color: "#8a8a92", maxWidth: 600, marginBottom: 22, lineHeight: 1.5 }}>Connect a provider and Clawmates provisions agent runners on demand. Each booted VM runs the same daemon, dials home over outbound WSS, and joins your fleet — credentials stay in the secret broker.</p>
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(3,1fr)", gap: 12 }}>
|
||||||
|
{[["1", "#ff8a7a", "Connect credentials", "Scoped IAM role or API token, held by the secret broker — never reaches agent code."], ["2", "#5ec8d8", "Provision a runner", "We bake clawmates-node into the image; it boots and dials home — no inbound port."], ["3", "#5fd08a", "Agents run sandboxed", "§15 network-isolated sandboxes on the cloud host; every egress is a gated door."]].map(([n, c, t, d]) => (
|
||||||
|
<div key={n} style={{ borderRadius: 12, border: "1px solid rgba(255,255,255,.07)", background: "#0c0c0f", padding: 15 }}>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 9 }}><span style={{ width: 22, height: 22, borderRadius: 6, background: `${c}24`, display: "flex", alignItems: "center", justifyContent: "center", fontFamily: mono, fontSize: 11, fontWeight: 700, color: c }}>{n}</span><span style={{ fontSize: 13, fontWeight: 700 }}>{t}</span></div>
|
||||||
|
<div style={{ fontSize: 11.5, color: "#8a8a92", lineHeight: 1.5 }}>{d}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 26, fontFamily: mono, fontSize: 12, color: "#6a6a72" }}>Provider connect — coming next. Local & Tailscale has zero inbound exposure today.</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ flex: 1, minHeight: 0, overflowY: "auto", padding: "22px 26px 30px" }}>
|
||||||
|
<div style={{ fontFamily: mono, fontSize: 10, letterSpacing: ".14em", color: "#5a5a62", marginBottom: 6 }}>LOCAL & TAILSCALE</div>
|
||||||
|
<div style={{ marginBottom: 20 }}>
|
||||||
|
<div style={{ fontSize: 26, fontWeight: 700, letterSpacing: "-.02em" }}>Your fleet</div>
|
||||||
|
<div style={{ fontSize: 13, color: "#8a8a92", marginTop: 3 }}>Run agents on hardware you own. Put a node on your tailnet, then pair it — no inbound port, no keys.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(5,1fr)", gap: 10, marginBottom: 22 }}>
|
||||||
|
<Stat label="HOSTS" value={String(nodes.length)} />
|
||||||
|
<Stat label="ONLINE" value={String(online.length)} tint="#5fd08a" />
|
||||||
|
<Stat label="MEMORY" value={fmtBytes(totalMem)} />
|
||||||
|
<Stat label="STORAGE" value={fmtBytes(totalDisk)} />
|
||||||
|
<Stat label="CONTAINERS" value={String(containers)} tint="#5ec8d8" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ borderRadius: 14, border: "1px solid rgba(255,255,255,.08)", background: "#0c0c0f", marginBottom: 22, overflow: "hidden" }}>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 11, padding: "13px 16px", borderBottom: "1px solid rgba(255,255,255,.05)" }}>
|
||||||
|
<span style={{ width: 30, height: 30, borderRadius: 8, background: "rgba(94,200,216,.1)", border: "1px solid rgba(94,200,216,.25)", display: "flex", alignItems: "center", justifyContent: "center", color: "#5ec8d8" }}><Server size={16} /></span>
|
||||||
|
<div style={{ flex: 1 }}><div style={{ fontSize: 14, fontWeight: 700 }}>Tailscale network</div><div style={{ fontFamily: mono, fontSize: 10, color: "#6a6a72" }}>{ts?.connected ? `${ts.tailnet ?? "tailnet"} · ${tsOnlineN}/${tsDevices.length} online` : "not connected"}</div></div>
|
||||||
|
</div>
|
||||||
|
{tsDevices.length ? (
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 1, background: "rgba(255,255,255,.04)" }}>
|
||||||
|
{tsDevices.slice(0, 8).map((d, i) => (
|
||||||
|
<div key={d.addr ?? i} style={{ display: "flex", alignItems: "center", gap: 10, padding: "11px 16px", background: "#0c0c0f" }}>
|
||||||
|
<span style={{ width: 7, height: 7, borderRadius: "50%", background: tsOnline(d.lastSeen) ? "#5fd08a" : "#6a6a72" }} />
|
||||||
|
<span style={{ fontSize: 13, fontWeight: 600, color: "#eaeaee", minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{d.name ?? "device"}</span>
|
||||||
|
<span style={{ flex: 1 }} />
|
||||||
|
<span style={{ fontFamily: mono, fontSize: 10, color: "#5ec8d8" }}>{d.addr}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ padding: 16 }}><TailscaleSection /></div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 12 }}>
|
||||||
|
<span style={{ fontFamily: mono, fontSize: 10, letterSpacing: ".12em", color: "#5a5a62" }}>LOCAL HOSTS</span>
|
||||||
|
<span style={{ flex: 1, height: 1, background: "rgba(255,255,255,.06)" }} />
|
||||||
|
<button type="button" onClick={onConnectHost} style={{ fontFamily: mono, fontSize: 10, color: "#5ec8d8", background: "transparent", border: 0, cursor: "pointer" }}>+ connect a host</button>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(290px, 1fr))", gap: 14 }}>
|
||||||
|
{nodes.map((n) => (
|
||||||
|
<HostCard key={n.id} node={n} onRemoved={refresh} />
|
||||||
|
))}
|
||||||
|
<button type="button" onClick={onConnectHost} style={{ borderRadius: 13, border: "1.5px dashed rgba(255,255,255,.14)", background: "transparent", padding: 15, display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: 8, minHeight: 200, cursor: "pointer" }}>
|
||||||
|
<span style={{ width: 38, height: 38, borderRadius: 11, background: "rgba(255,111,97,.12)", display: "flex", alignItems: "center", justifyContent: "center", color: "#ff6f61", fontSize: 22, fontWeight: 300 }}>+</span>
|
||||||
|
<span style={{ fontSize: 13, fontWeight: 600, color: "#cfcfd5" }}>Connect a host</span>
|
||||||
|
<span style={{ fontFamily: mono, fontSize: 9, color: "#6a6a72" }}>Mac · Linux · edge device</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Top-bar pill: live online/total host count. */
|
||||||
|
export function FleetPill() {
|
||||||
|
const { nodes } = useNodes();
|
||||||
|
const online = nodes.filter((n) => n.status === "online").length;
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 7, fontFamily: mono, fontSize: 11, color: "#5fd08a", padding: "5px 10px", border: "1px solid rgba(95,208,138,.25)", borderRadius: 7, background: "rgba(95,208,138,.06)" }}>
|
||||||
|
<span style={{ width: 6, height: 6, borderRadius: "50%", background: "#5fd08a", animation: "cm-blink 1.6s infinite" }} />
|
||||||
|
{online}/{nodes.length} hosts online
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Thin always-on status bar (ambient system truth). */
|
||||||
|
export function FleetStatusBar() {
|
||||||
|
const { nodes } = useNodes();
|
||||||
|
const online = nodes.filter((n) => n.status === "online").length;
|
||||||
|
const { data: ts } = useFetchJson<{ connected: boolean }>("/api/fleet/tailscale");
|
||||||
|
const cell = (dot: string, text: string) => (
|
||||||
|
<span style={{ display: "inline-flex", alignItems: "center", gap: 6 }}><span style={{ width: 5, height: 5, borderRadius: "50%", background: dot }} />{text}</span>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div style={{ height: 26, flex: "none", display: "flex", alignItems: "center", gap: 18, padding: "0 16px", borderTop: "1px solid rgba(255,255,255,.06)", background: "#0a0a0c", fontFamily: mono, fontSize: 10, color: "#6a6a72" }}>
|
||||||
|
{cell(online > 0 ? "#5fd08a" : "#6a6a72", `daemon control plane · ${online} online`)}
|
||||||
|
{cell(ts?.connected ? "#5fd08a" : "#6a6a72", ts?.connected ? "tailnet connected" : "tailnet not linked")}
|
||||||
|
{cell("#5ec8d8", "outbound WSS · no inbound port")}
|
||||||
|
{cell("#5fd08a", "§15 sandboxes ready")}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -239,7 +239,7 @@ function tsOnline(lastSeen: string | null): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Connect a workspace's Tailscale (BYO) and show its tailnet device metrics. */
|
/** Connect a workspace's Tailscale (BYO) and show its tailnet device metrics. */
|
||||||
function TailscaleSection() {
|
export function TailscaleSection() {
|
||||||
const { data: status, refresh } = useFetchJson<{ connected: boolean; tailnet: string | null }>("/api/fleet/tailscale");
|
const { data: status, refresh } = useFetchJson<{ connected: boolean; tailnet: string | null }>("/api/fleet/tailscale");
|
||||||
const { data: dev } = useFetchJson<{ connected: boolean; devices: TsDevice[]; error?: string }>(status?.connected ? "/api/fleet/tailscale/devices" : null);
|
const { data: dev } = useFetchJson<{ connected: boolean; devices: TsDevice[]; error?: string }>(status?.connected ? "/api/fleet/tailscale/devices" : null);
|
||||||
const [apiKey, setApiKey] = useState("");
|
const [apiKey, setApiKey] = useState("");
|
||||||
|
|||||||
Reference in New Issue
Block a user