feat: redesign site with interactive failover playground and command palette

This commit is contained in:
Matt
2026-08-22 16:54:02 +00:00
parent 0d45db1fda
commit efbdf2356f
22 changed files with 1471 additions and 1119 deletions
+132 -346
View File
@@ -1,5 +1,4 @@
---
// Static data for the mockup — compiled away at build time, zero runtime JS.
const navItems = [
{ label: 'Dashboard', icon: 'grid', active: true },
{ label: 'Nodes', icon: 'server', active: false },
@@ -9,13 +8,11 @@ const navItems = [
{ label: 'Tasks', icon: 'todo', active: false },
{ label: 'Backups', icon: 'shield', active: false },
]
const clusterNodes = [
{ name: 'pve1', dot: 'bg-success shadow-[0_0_6px] shadow-success/60', chip: 'primary' },
{ name: 'pve2', dot: 'bg-warning', chip: null },
{ name: 'pve3', dot: 'bg-success', chip: null },
]
const nodeCards = [
{
name: 'pve1',
@@ -24,9 +21,9 @@ const nodeCards = [
statusLabel: null,
fill: 'bg-primary',
rows: [
{ label: 'CPU', value: '42% · 3.4/8 c', width: '42%' },
{ label: 'RAM', value: '10.2/32 GiB', width: '32%' },
{ label: 'Disk', value: '187/500 GB', width: '37%' },
{ label: 'CPU', value: '42% · 3.4/8 c', width: '42', liveKey: 'cpu' },
{ label: 'RAM', value: '10.2/32 GiB', width: '32', liveKey: 'ram' },
{ label: 'Disk', value: '187/500 GB', width: '37', liveKey: null },
],
footer: ['up 42d 11h', '4 VMs'],
},
@@ -38,9 +35,9 @@ const nodeCards = [
statusCls: 'text-warning',
fill: 'bg-warning',
rows: [
{ label: 'CPU', value: '31% · 2.5/8 c', width: '31%' },
{ label: 'RAM', value: '7.1/32 GiB', width: '22%' },
{ label: 'Disk', value: '163/500 GB', width: '33%' },
{ label: 'CPU', value: '31% · 2.5/8 c', width: '31', liveKey: 'cpu' },
{ label: 'RAM', value: '7.1/32 GiB', width: '22', liveKey: 'ram' },
{ label: 'Disk', value: '163/500 GB', width: '33', liveKey: null },
],
footer: ['up 9d 4h', '2 VMs'],
},
@@ -52,46 +49,23 @@ const nodeCards = [
statusCls: 'text-success',
fill: 'bg-primary',
rows: [
{ label: 'CPU', value: '18% · 1.4/8 c', width: '18%' },
{ label: 'RAM', value: '5.8/16 GiB', width: '36%' },
{ label: 'Disk', value: '121/240 GB', width: '50%' },
{ label: 'CPU', value: '18% · 1.4/8 c', width: '18', liveKey: 'cpu' },
{ label: 'RAM', value: '5.8/16 GiB', width: '36', liveKey: 'ram' },
{ label: 'Disk', value: '121/240 GB', width: '50', liveKey: null },
],
footer: ['up 117d 2h', '3 VMs'],
},
]
const tasks = [
{
chip: { label: 'RUNNING', cls: 'border-warning/25 bg-warning/10 text-warning' },
text: 'start VM 100',
node: 'pve1',
time: '12s ago',
},
{
chip: { label: 'OK', cls: 'border-success/25 bg-success/10 text-success' },
text: 'backup VM 101 → local-lvm',
node: 'pve2',
time: '3m ago',
},
{
chip: { label: 'OK', cls: 'border-success/25 bg-success/10 text-success' },
text: 'migrate VM 102',
node: 'pve1 → pve3',
time: '18m ago',
},
{
chip: { label: 'DONE', cls: 'border-border bg-muted text-muted-foreground' },
text: 'update apt',
node: 'pve3',
time: '1h ago',
},
{ chip: { label: 'RUNNING', cls: 'border-warning/30 bg-warning/10 text-warning' }, text: 'start VM 100', node: 'pve1', time: '12s ago' },
{ chip: { label: 'OK', cls: 'border-success/25 bg-success/10 text-success' }, text: 'backup VM 101 → local-lvm', node: 'pve2', time: '3m ago' },
{ chip: { label: 'OK', cls: 'border-success/25 bg-success/10 text-success' }, text: 'migrate VM 102', node: 'pve1 → pve3', time: '18m ago' },
{ chip: { label: 'DONE', cls: 'border-white/[0.06] bg-muted text-muted-foreground' }, text: 'update apt', node: 'pve3', time: '1h ago' },
]
const gauges = [
{ label: 'CPU', icon: 'cpu', percent: '30%', width: '30%', detail: '7.3 / 24 cores' },
{ label: 'RAM', icon: 'mem', percent: '24%', width: '24%', detail: '23.1 / 96 GiB' },
{ label: 'CPU', icon: 'cpu', percent: '30', width: '30', detail: '7.3 / 24 cores' },
{ label: 'RAM', icon: 'mem', percent: '24', width: '24', detail: '23.1 / 96 GiB' },
]
const quickActions = [
{ label: 'Start', icon: 'play' },
{ label: 'Stop', icon: 'stop' },
@@ -100,187 +74,71 @@ const quickActions = [
]
---
<div
role="img"
aria-label="Clustri desktop app showing the cluster dashboard"
class="w-full select-none"
>
<!-- Inline icon library (rendered once, referenced via <use>) -->
<div role="img" aria-label="Clustri desktop app showing the cluster dashboard" class="w-full select-none" data-live-dashboard>
<svg class="absolute h-0 w-0 overflow-hidden" aria-hidden="true">
<defs>
<symbol id="i-hex" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
</symbol>
<symbol id="i-grid" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="7" height="9" x="3" y="3" rx="1" />
<rect width="7" height="5" x="14" y="3" rx="1" />
<rect width="7" height="9" x="14" y="12" rx="1" />
<rect width="7" height="5" x="3" y="16" rx="1" />
</symbol>
<symbol id="i-server" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="20" height="8" x="2" y="2" rx="2" />
<rect width="20" height="8" x="2" y="14" rx="2" />
<line x1="6" x2="6.01" y1="6" y2="6" />
<line x1="6" x2="6.01" y1="18" y2="18" />
</symbol>
<symbol id="i-box" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z" />
<path d="m3.3 7 8.7 5 8.7-5" />
<path d="M12 22V12" />
</symbol>
<symbol id="i-hdd" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="22" x2="2" y1="12" y2="12" />
<path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" />
<line x1="6" x2="6.01" y1="16" y2="16" />
<line x1="10" x2="10.01" y1="16" y2="16" />
</symbol>
<symbol id="i-todo" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="6" height="6" x="3" y="5" rx="1" />
<path d="m3 17 2 2 4-4" />
<path d="M13 6h8" />
<path d="M13 12h8" />
<path d="M13 18h8" />
</symbol>
<symbol id="i-shield" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1 1 0 0 1 1.52 0C14.5 3.8 17 5 19 5a1 1 0 0 1 1 1z" />
</symbol>
<symbol id="i-gear" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" />
<circle cx="12" cy="12" r="3" />
</symbol>
<symbol id="i-plus" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M5 12h14" />
<path d="M12 5v14" />
</symbol>
<symbol id="i-alert" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z" />
<path d="M12 9v4" />
<path d="M12 17h.01" />
</symbol>
<symbol id="i-cpu" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="16" height="16" x="4" y="4" rx="2" />
<rect width="6" height="6" x="9" y="9" rx="1" />
<path d="M15 2v2" />
<path d="M15 20v2" />
<path d="M2 15h2" />
<path d="M2 9h2" />
<path d="M20 15h2" />
<path d="M20 9h2" />
<path d="M9 2v2" />
<path d="M9 20v2" />
</symbol>
<symbol id="i-mem" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M6 19v-3" />
<path d="M10 19v-3" />
<path d="M14 19v-3" />
<path d="M18 19v-3" />
<path d="M8 11V9" />
<path d="M16 11V9" />
<path d="M12 11V9" />
<path d="M2 15h20" />
<path d="M2 7a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v1.1a2 2 0 0 0 0 3.837V17a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2v-5.1a2 2 0 0 0 0-3.837Z" />
</symbol>
<symbol id="i-activity" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 12h-4l-3 9L9 3l-3 9H2" />
</symbol>
<symbol id="i-play" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polygon points="6 3 20 12 6 21 6 3" />
</symbol>
<symbol id="i-stop" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="10" height="10" x="7" y="7" rx="1" />
</symbol>
<symbol id="i-migrate" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m16 3 4 4-4 4" />
<path d="M20 7H4" />
<path d="m8 21-4-4 4-4" />
<path d="M4 17h16" />
</symbol>
<symbol id="i-term" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="4 17 10 11 4 5" />
<line x1="12" x2="20" y1="19" y2="19" />
</symbol>
<symbol id="i-hex" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" /></symbol>
<symbol id="i-grid" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="7" height="9" x="3" y="3" rx="1" /><rect width="7" height="5" x="14" y="3" rx="1" /><rect width="7" height="9" x="14" y="12" rx="1" /><rect width="7" height="5" x="3" y="16" rx="1" /></symbol>
<symbol id="i-server" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="20" height="8" x="2" y="2" rx="2" /><rect width="20" height="8" x="2" y="14" rx="2" /><line x1="6" x2="6.01" y1="6" y2="6" /><line x1="6" x2="6.01" y1="18" y2="18" /></symbol>
<symbol id="i-box" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z" /><path d="m3.3 7 8.7 5 8.7-5" /><path d="M12 22V12" /></symbol>
<symbol id="i-hdd" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" x2="2" y1="12" y2="12" /><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" /><line x1="6" x2="6.01" y1="16" y2="16" /><line x1="10" x2="10.01" y1="16" y2="16" /></symbol>
<symbol id="i-todo" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="6" height="6" x="3" y="5" rx="1" /><path d="m3 17 2 2 4-4" /><path d="M13 6h8" /><path d="M13 12h8" /><path d="M13 18h8" /></symbol>
<symbol id="i-shield" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1 1 0 0 1 1.52 0C14.5 3.8 17 5 19 5a1 1 0 0 1 1 1z" /></symbol>
<symbol id="i-gear" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" /><circle cx="12" cy="12" r="3" /></symbol>
<symbol id="i-plus" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14" /><path d="M12 5v14" /></symbol>
<symbol id="i-alert" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z" /><path d="M12 9v4" /><path d="M12 17h.01" /></symbol>
<symbol id="i-cpu" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="16" height="16" x="4" y="4" rx="2" /><rect width="6" height="6" x="9" y="9" rx="1" /><path d="M15 2v2" /><path d="M15 20v2" /><path d="M2 15h2" /><path d="M2 9h2" /><path d="M20 15h2" /><path d="M20 9h2" /><path d="M9 2v2" /><path d="M9 20v2" /></symbol>
<symbol id="i-mem" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 19v-3" /><path d="M10 19v-3" /><path d="M14 19v-3" /><path d="M18 19v-3" /><path d="M8 11V9" /><path d="M16 11V9" /><path d="M12 11V9" /><path d="M2 15h20" /><path d="M2 7a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v1.1a2 2 0 0 0 0 3.837V17a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2v-5.1a2 2 0 0 0 0-3.837Z" /></symbol>
<symbol id="i-activity" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 12h-4l-3 9L9 3l-3 9H2" /></symbol>
<symbol id="i-play" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="6 3 20 12 6 21 6 3" /></symbol>
<symbol id="i-stop" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="10" height="10" x="7" y="7" rx="1" /></symbol>
<symbol id="i-migrate" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m16 3 4 4-4 4" /><path d="M20 7H4" /><path d="m8 21-4-4 4-4" /><path d="M4 17h16" /></symbol>
<symbol id="i-term" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 17 10 11 4 5" /><line x1="12" x2="20" y1="19" y2="19" /></symbol>
</defs>
</svg>
<!-- Window frame -->
<div class="overflow-hidden rounded-lg border border-border bg-card shadow-card">
<!-- Title bar -->
<div class="relative flex h-9 items-center border-b border-border bg-background">
<div class="overflow-hidden rounded-xl border border-white/[0.08] bg-card shadow-card">
<div class="relative flex h-9 items-center border-b border-white/[0.06] bg-background">
<div class="absolute left-4 flex items-center gap-1.5" aria-hidden="true">
<span class="h-2.5 w-2.5 rounded-full border border-border bg-muted-foreground/40"></span>
<span class="h-2.5 w-2.5 rounded-full border border-border bg-muted-foreground/40"></span>
<span class="h-2.5 w-2.5 rounded-full border border-border bg-muted-foreground/40"></span>
<span class="h-2.5 w-2.5 rounded-full border border-white/[0.06] bg-muted-foreground/40"></span>
<span class="h-2.5 w-2.5 rounded-full border border-white/[0.06] bg-muted-foreground/40"></span>
<span class="h-2.5 w-2.5 rounded-full border border-white/[0.06] bg-muted-foreground/40"></span>
</div>
<p class="w-full text-center font-mono text-[10px] tabular-nums text-muted-foreground">
Clustri — Ops Console
</p>
<p class="w-full text-center font-mono text-[10px] tabular-nums text-muted-foreground">Clustri — Ops Console</p>
</div>
<!-- Failover banner -->
<div class="flex items-center gap-2 border-b border-warning/25 bg-warning/10 px-4 py-1.5">
<span class="h-1.5 w-1.5 shrink-0 rounded-full bg-warning" aria-hidden="true"></span>
<p class="shrink-0 font-mono text-[10px] text-warning">Operating on a fallback node</p>
<p class="ml-auto min-w-0 truncate font-mono text-[10px] tabular-nums text-warning/80">
https://192.168.1.12:8006
</p>
<p class="ml-auto min-w-0 truncate font-mono text-[10px] tabular-nums text-warning/80">https://192.168.1.12:8006</p>
</div>
<!-- Body -->
<div class="flex">
<!-- Sidebar -->
<aside class="hidden w-52 shrink-0 flex-col border-r border-border bg-card sm:flex">
<!-- Brand header -->
<div class="flex h-14 shrink-0 items-center gap-2.5 border-b border-border px-4">
<aside class="hidden w-52 shrink-0 flex-col border-r border-white/[0.06] bg-card sm:flex">
<div class="flex h-14 shrink-0 items-center gap-2.5 border-b border-white/[0.06] px-4">
<div class="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-primary/15 text-primary">
<svg class="h-4 w-4" aria-hidden="true"><use href="#i-hex" /></svg>
</div>
<div class="min-w-0 leading-tight">
<p class="truncate text-sm font-semibold tracking-tight text-foreground">Clustri</p>
<p class="font-mono text-[9px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
Ops Console
</p>
<p class="font-mono text-[9px] font-medium uppercase tracking-[0.14em] text-muted-foreground">Ops Console</p>
</div>
</div>
<!-- Navigation -->
<div class="min-h-0 flex-1 p-2">
<div class="space-y-1">
<!-- Active connection -->
<div class="flex items-center gap-2.5 rounded-md bg-accent px-3 py-2">
<span
class="h-1.5 w-1.5 shrink-0 rounded-full bg-success shadow-[0_0_6px] shadow-success/60"
aria-hidden="true"
></span>
<span class="h-1.5 w-1.5 shrink-0 rounded-full bg-success shadow-[0_0_6px] shadow-success/60" aria-hidden="true"></span>
<span class="truncate font-mono text-[13px] font-medium text-accent-foreground">home-lab</span>
</div>
<!-- Sub-nav -->
<div class="ml-4 mt-1 space-y-0.5">
{
navItems.map((item) => (
<button
type="button"
class:list={[
'relative flex w-full items-center gap-2.5 rounded-md px-3 py-1.5 text-[12px] font-medium transition-colors',
item.active
? 'bg-primary/10 text-primary'
: 'text-muted-foreground hover:bg-accent/60 hover:text-foreground',
]}
>
{item.active && (
<span
class="absolute left-0 top-1/2 h-4 w-0.5 -translate-y-1/2 rounded-full bg-primary"
aria-hidden="true"
></span>
)}
<svg class="h-4 w-4 shrink-0" aria-hidden="true"><use href={`#i-${item.icon}`} /></svg>
<span>{item.label}</span>
</button>
))
}
{navItems.map((item) => (
<button type="button" class:list={['relative flex w-full items-center gap-2.5 rounded-md px-3 py-1.5 text-[12px] font-medium transition-colors', item.active ? 'bg-primary/10 text-primary' : 'text-muted-foreground hover:bg-accent/60 hover:text-foreground']}>
{item.active && <span class="absolute left-0 top-1/2 h-4 w-0.5 -translate-y-1/2 rounded-full bg-primary" aria-hidden="true"></span>}
<svg class="h-4 w-4 shrink-0" aria-hidden="true"><use href={`#i-${item.icon}`} /></svg>
<span>{item.label}</span>
</button>
))}
</div>
<!-- Second connection (offline) -->
<div class="mt-2">
<div class="flex items-center gap-2.5 rounded-md px-3 py-2">
<span class="h-1.5 w-1.5 shrink-0 rounded-full bg-muted-foreground/40" aria-hidden="true"></span>
@@ -288,195 +146,123 @@ const quickActions = [
</div>
<p class="ml-7 font-mono text-[10px] text-muted-foreground">Offline</p>
</div>
<!-- Cluster nodes -->
<p class="px-3 pb-1 pt-3 font-mono text-[10px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
Cluster nodes
</p>
<p class="px-3 pb-1 pt-3 font-mono text-[10px] font-medium uppercase tracking-[0.14em] text-muted-foreground">Cluster nodes</p>
<div class="space-y-0.5">
{
clusterNodes.map((n) => (
<div class="flex w-full items-center gap-2.5 rounded-md px-3 py-1.5">
<span class={`h-1.5 w-1.5 shrink-0 rounded-full ${n.dot}`} aria-hidden="true"></span>
<span class="min-w-0 flex-1 truncate font-mono text-[13px] font-medium text-muted-foreground">
{n.name}
</span>
{n.chip && (
<span class="shrink-0 rounded border border-primary/30 bg-primary/10 px-1 py-px font-mono text-[9px] uppercase text-primary">
{n.chip}
</span>
)}
</div>
))
}
{clusterNodes.map((n) => (
<div class="flex w-full items-center gap-2.5 rounded-md px-3 py-1.5">
<span class={`h-1.5 w-1.5 shrink-0 rounded-full ${n.dot}`} aria-hidden="true"></span>
<span class="min-w-0 flex-1 truncate font-mono text-[13px] font-medium text-muted-foreground">{n.name}</span>
{n.chip && <span class="shrink-0 rounded border border-primary/30 bg-primary/10 px-1 py-px font-mono text-[9px] uppercase text-primary">{n.chip}</span>}
</div>
))}
</div>
</div>
</div>
<!-- Sidebar footer -->
<div class="space-y-1 border-t border-border p-2">
<button
type="button"
class="flex w-full items-center gap-2.5 rounded-md px-3 py-1.5 text-[12px] font-medium text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
>
<svg class="h-4 w-4 shrink-0" aria-hidden="true"><use href="#i-gear" /></svg>
<span>Settings</span>
<div class="space-y-1 border-t border-white/[0.06] p-2">
<button type="button" class="flex w-full items-center gap-2.5 rounded-md px-3 py-1.5 text-[12px] font-medium text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground">
<svg class="h-4 w-4 shrink-0" aria-hidden="true"><use href="#i-gear" /></svg><span>Settings</span>
</button>
<button
type="button"
class="flex w-full items-center gap-2 rounded-md border border-border bg-background px-3 py-1.5 text-[12px] font-medium text-muted-foreground shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground"
>
<svg class="h-3.5 w-3.5 shrink-0" aria-hidden="true"><use href="#i-plus" /></svg>
<span>Add Connection</span>
<button type="button" class="flex w-full items-center gap-2 rounded-md border border-white/[0.06] bg-background px-3 py-1.5 text-[12px] font-medium text-muted-foreground shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground">
<svg class="h-3.5 w-3.5 shrink-0" aria-hidden="true"><use href="#i-plus" /></svg><span>Add Connection</span>
</button>
</div>
</aside>
<!-- Main area -->
<main class="min-w-0 flex-1 bg-background p-4">
<div class="space-y-4">
<!-- Header row -->
<div class="flex flex-wrap items-center justify-between gap-2">
<h2 class="text-base font-semibold tracking-tight">Overview</h2>
<div class="flex flex-wrap items-center gap-2">
<span class="font-mono text-[10px] tabular-nums text-muted-foreground">
home-lab · 192.168.1.10:8006
</span>
<span class="font-mono text-[10px] tabular-nums text-muted-foreground">
3 nodes · 2 online
</span>
<span class="rounded border border-success/25 bg-success/10 px-1.5 py-0.5 font-mono text-[10px] text-success">
Connected
</span>
<span class="font-mono text-[10px] tabular-nums text-muted-foreground">home-lab · 192.168.1.10:8006</span>
<span class="font-mono text-[10px] tabular-nums text-muted-foreground">3 nodes · 2 online</span>
<span class="rounded border border-success/25 bg-success/10 px-1.5 py-0.5 font-mono text-[10px] text-success">Connected</span>
</div>
</div>
<!-- Node health grid -->
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
{
nodeCards.map((node) => (
<article class="rounded-md border border-border bg-card p-3">
<div class="flex items-center justify-between gap-2">
<div class="flex min-w-0 items-center gap-2">
<span class={`h-1.5 w-1.5 shrink-0 rounded-full ${node.dot}`} aria-hidden="true"></span>
<h3 class="truncate font-mono text-[13px] font-semibold tracking-tight text-foreground">
{node.name}
</h3>
{nodeCards.map((node) => (
<article class="rounded-md border border-white/[0.06] bg-card p-3">
<div class="flex items-center justify-between gap-2">
<div class="flex min-w-0 items-center gap-2">
<span class={`h-1.5 w-1.5 shrink-0 rounded-full ${node.dot}`} aria-hidden="true"></span>
<h3 class="truncate font-mono text-[13px] font-semibold tracking-tight text-foreground">{node.name}</h3>
</div>
{node.chip && <span class={`shrink-0 rounded border px-1 py-px font-mono text-[9px] uppercase ${node.chip.cls}`}>{node.chip.label}</span>}
{node.statusLabel && <span class={`shrink-0 font-mono text-[9px] font-medium uppercase ${node.statusCls}`}>{node.statusLabel}</span>}
</div>
<div class="mt-3 space-y-2.5">
{node.rows.map((row) => (
<div>
<div class="flex items-center justify-between gap-2">
<span class="font-mono text-[9px] uppercase tracking-[0.1em] text-muted-foreground">{row.label}</span>
<span class="font-mono text-[10px] tabular-nums text-muted-foreground">{row.value}</span>
</div>
<div class="mt-1 h-1 overflow-hidden rounded-full bg-muted">
<div
class={`h-full rounded-full transition-all duration-700 ease-out ${node.fill}`}
style={`width: ${row.width}%`}
data-live-bar={row.liveKey ? '' : undefined}
data-base={row.width}
></div>
</div>
</div>
{node.chip && (
<span class={`shrink-0 rounded border px-1 py-px font-mono text-[9px] uppercase ${node.chip.cls}`}>
{node.chip.label}
</span>
)}
{node.statusLabel && (
<span class={`shrink-0 font-mono text-[9px] font-medium uppercase ${node.statusCls}`}>
{node.statusLabel}
</span>
)}
</div>
<div class="mt-3 space-y-2.5">
{
node.rows.map((row) => (
<div>
<div class="flex items-center justify-between gap-2">
<span class="font-mono text-[9px] uppercase tracking-[0.1em] text-muted-foreground">
{row.label}
</span>
<span class="font-mono text-[10px] tabular-nums text-muted-foreground">
{row.value}
</span>
</div>
<div class="mt-1 h-1 overflow-hidden rounded-full bg-muted">
<div class={`h-full rounded-full ${node.fill}`} style={`width: ${row.width}`}></div>
</div>
</div>
))
}
</div>
<div class="mt-3 flex items-center justify-between border-t pt-2 font-mono text-[9px] tabular-nums text-muted-foreground">
<span>{node.footer[0]}</span>
<span>{node.footer[1]}</span>
</div>
</article>
))
}
))}
</div>
<div class="mt-3 flex items-center justify-between border-t pt-2 font-mono text-[9px] tabular-nums text-muted-foreground">
<span>{node.footer[0]}</span><span>{node.footer[1]}</span>
</div>
</article>
))}
</div>
<!-- Lower row: activity feed + resource gauges -->
<div class="grid grid-cols-1 gap-3 sm:grid-cols-5">
<section class="rounded-md border border-border bg-card sm:col-span-3">
<header class="flex items-center gap-2 border-b border-border px-3 py-2">
<section class="rounded-md border border-white/[0.06] bg-card sm:col-span-3">
<header class="flex items-center gap-2 border-b border-white/[0.06] px-3 py-2">
<svg class="h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden="true"><use href="#i-activity" /></svg>
<h3 class="font-mono text-[10px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
Task activity
</h3>
<h3 class="font-mono text-[10px] font-medium uppercase tracking-[0.14em] text-muted-foreground">Task activity</h3>
<span class="ml-auto h-1.5 w-1.5 animate-pulse rounded-full bg-success" aria-hidden="true"></span>
</header>
<div class="divide-y divide-border">
{
tasks.map((task) => (
<div class="flex items-center justify-between gap-3 px-3 py-2">
<div class="flex min-w-0 items-center gap-2">
<span class={`shrink-0 rounded border px-1.5 py-0.5 font-mono text-[9px] font-medium ${task.chip.cls}`}>
{task.chip.label}
</span>
<p class="truncate font-mono text-[11px] tabular-nums text-foreground">
{task.text}
<span class="text-muted-foreground"> · {task.node}</span>
</p>
</div>
<span class="shrink-0 font-mono text-[10px] tabular-nums text-muted-foreground">
{task.time}
</span>
<div class="divide-y divide-border" data-live-tasks>
{tasks.map((task) => (
<div class="flex items-center justify-between gap-3 px-3 py-2">
<div class="flex min-w-0 items-center gap-2">
<span class={`shrink-0 rounded border px-1.5 py-0.5 font-mono text-[9px] font-medium ${task.chip.cls}`}>{task.chip.label}</span>
<p class="truncate font-mono text-[11px] tabular-nums text-foreground">{task.text}<span class="text-muted-foreground"> · {task.node}</span></p>
</div>
))
}
<span class="shrink-0 font-mono text-[10px] tabular-nums text-muted-foreground">{task.time}</span>
</div>
))}
</div>
</section>
<div class="space-y-3 sm:col-span-2">
{
gauges.map((gauge) => (
<section class="rounded-md border border-border bg-card p-3">
<div class="flex items-center gap-1.5">
<svg class="h-3.5 w-3.5 text-muted-foreground" aria-hidden="true"><use href={`#i-${gauge.icon}`} /></svg>
<span class="font-mono text-[9px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
{gauge.label}
</span>
</div>
<div class="mt-2 flex items-end justify-between gap-2">
<span class="font-mono text-2xl font-semibold leading-none tracking-tight tabular-nums">
{gauge.percent}
</span>
<span class="font-mono text-[10px] tabular-nums text-muted-foreground">
{gauge.detail}
</span>
</div>
<div class="mt-2 h-1 overflow-hidden rounded-full bg-muted">
<div class="h-full rounded-full bg-primary" style={`width: ${gauge.width}`}></div>
</div>
</section>
))
}
{gauges.map((gauge) => (
<section class="rounded-md border border-white/[0.06] bg-card p-3">
<div class="flex items-center gap-1.5">
<svg class="h-3.5 w-3.5 text-muted-foreground" aria-hidden="true"><use href={`#i-${gauge.icon}`} /></svg>
<span class="font-mono text-[9px] font-medium uppercase tracking-[0.14em] text-muted-foreground">{gauge.label}</span>
</div>
<div class="mt-2 flex items-end justify-between gap-2">
<span class="font-mono text-2xl font-semibold leading-none tracking-tight tabular-nums" data-live-cpu={gauge.label === 'CPU' ? '' : undefined} data-live-ram={gauge.label === 'RAM' ? '' : undefined} data-base={gauge.percent}>{gauge.percent}%</span>
<span class="font-mono text-[10px] tabular-nums text-muted-foreground">{gauge.detail}</span>
</div>
<div class="mt-2 h-1 overflow-hidden rounded-full bg-muted">
<div class="h-full rounded-full bg-primary transition-all duration-700 ease-out" style={`width: ${gauge.width}%`} data-live-bar data-base={gauge.width}></div>
</div>
</section>
))}
</div>
</div>
<!-- Quick actions -->
<div class="flex flex-wrap items-center gap-2">
{
quickActions.map((action) => (
<button
type="button"
class="flex items-center gap-1.5 rounded border border-border px-3 py-1.5 text-[11px] font-medium text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
>
<svg class="h-3 w-3" aria-hidden="true"><use href={`#i-${action.icon}`} /></svg>
<span>{action.label}</span>
</button>
))
}
{quickActions.map((action) => (
<button type="button" class="flex items-center gap-1.5 rounded border border-white/[0.06] px-3 py-1.5 text-[11px] font-medium text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground">
<svg class="h-3 w-3" aria-hidden="true"><use href={`#i-${action.icon}`} /></svg><span>{action.label}</span>
</button>
))}
</div>
</div>
</main>
</div>
</div>
</div>
+39 -139
View File
@@ -1,152 +1,52 @@
<!--
ArchitectureStrip — the request path from React to the Proxmox API.
<section id="architecture" aria-labelledby="arch-title" class="scroll-mt-24 border-t border-white/[0.06] py-20 sm:py-28">
<div class="mx-auto max-w-7xl px-6 lg:px-8">
<div class="max-w-2xl" data-reveal>
<p class="font-mono text-[11px] uppercase tracking-[0.18em] text-primary">Architecture</p>
<h2 id="arch-title" class="mt-4 text-3xl font-semibold tracking-[-0.03em] text-foreground sm:text-4xl">One proxy between you and the API.</h2>
<p class="mt-4 text-[15px] leading-relaxed text-muted-foreground">The frontend calls Rust commands over Tauri IPC. The backend proxies them to the Proxmox API over HTTPS.</p>
</div>
Three sequential stages: React frontend → Rust backend → Proxmox API.
The middle stage carries the brand accent (border-primary/30,
bg-primary/10) so the pipeline reads left-to-right, not as three equal
cards. Below lg the stages stack with ↓ connectors between them.
Design constraints (see src/styles/global.css):
- Dark-only surfaces: bg-background / bg-card; muted-orange text-primary is
the single brand accent.
- Green / amber / red are reserved for status semantics (success /
warning / destructive) only.
- No gradients, no glows, no glassmorphism, no emoji, no fake metrics.
-->
<div class="mx-auto max-w-7xl px-6 lg:px-8">
<section
id="architecture"
aria-labelledby="architecture-title"
class="scroll-mt-24 py-16 sm:py-20"
>
<p class="font-mono text-[11px] uppercase tracking-[0.2em] text-muted-foreground">
ARCHITECTURE
</p>
<h2
id="architecture-title"
class="mt-4 text-2xl font-semibold tracking-tighter text-foreground sm:text-3xl"
>
One proxy between you and the API.
</h2>
<p class="mt-3 max-w-[58ch] text-[15px] leading-relaxed text-muted-foreground">
The frontend calls Rust commands over Tauri IPC. The backend proxies them
to the Proxmox API over HTTPS.
</p>
<!--
3-stage pipeline: React frontend → Rust backend → Proxmox API.
On lg the grid is 1fr / auto / 1fr / auto / 1fr with centered arrows;
below lg the stages stack with ↓ connectors between them.
-->
<div class="mt-10 grid gap-3 lg:grid-cols-[1fr_auto_1fr_auto_1fr] lg:items-center">
<!-- Stage 01 · React frontend -->
<div class="rounded-lg border border-border bg-card p-5">
<div class="flex items-start justify-between gap-4">
<p class="text-sm font-semibold text-foreground">React frontend</p>
<span class="font-mono text-[9px] text-muted-foreground">01</span>
</div>
<div class="mt-1.5 space-y-1 font-mono text-[11px] text-muted-foreground">
<p>TanStack Query · Zustand</p>
<p>Geist · JetBrains Mono</p>
<p>React 19</p>
</div>
<div class="mt-10 grid gap-3 lg:grid-cols-[1fr_auto_1fr_auto_1fr] lg:items-stretch" data-stagger>
<div class="rounded-xl border border-white/[0.06] bg-card p-5" data-stagger-child>
<div class="flex items-start justify-between gap-4"><p class="text-sm font-semibold text-foreground">React frontend</p><span class="font-mono text-[10px] text-muted-foreground">01</span></div>
<div class="mt-2 space-y-1 font-mono text-[11px] text-muted-foreground"><p>TanStack Query · Zustand</p><p>Geist · JetBrains Mono</p><p>React 19</p></div>
</div>
<div class="hidden flex-col items-center justify-center gap-1 lg:flex flow-arrow" aria-hidden="true"><span class="text-sm text-primary">→</span><span class="font-mono text-[9px] text-muted-foreground">tauri invoke</span></div>
<div class="flex items-center justify-center gap-1.5 py-1 lg:hidden flow-arrow" aria-hidden="true"><span class="text-sm text-primary">↓</span><span class="font-mono text-[9px] text-muted-foreground">tauri invoke</span></div>
<!-- Arrow · tauri invoke -->
<div class="hidden flex-col items-center justify-center gap-1 lg:flex" aria-hidden="true">
<span class="text-xl text-primary">→</span>
<span class="font-mono text-[9px] text-muted-foreground">tauri invoke</span>
</div>
<div class="flex items-center justify-center gap-1.5 py-2 lg:hidden" aria-hidden="true">
<span class="text-xl text-primary">↓</span>
<span class="font-mono text-[9px] text-muted-foreground">tauri invoke</span>
<div class="rounded-xl border border-primary/25 bg-primary/[0.07] p-5" data-stagger-child>
<div class="flex items-start justify-between gap-4"><p class="text-sm font-semibold text-primary">Rust backend</p><span class="font-mono text-[10px] text-muted-foreground">02</span></div>
<div class="mt-2 space-y-1 font-mono text-[11px] text-primary/75"><p>ConnectionManager</p><p>endpoint rotation · retry</p><p>TOFU cert pinning</p><p>OS keyring</p></div>
</div>
<div class="hidden flex-col items-center justify-center gap-1 lg:flex flow-arrow" aria-hidden="true"><span class="text-sm text-primary">→</span><span class="font-mono text-[9px] text-muted-foreground">HTTPS</span></div>
<div class="flex items-center justify-center gap-1.5 py-1 lg:hidden flow-arrow" aria-hidden="true"><span class="text-sm text-primary">↓</span><span class="font-mono text-[9px] text-muted-foreground">HTTPS</span></div>
<!-- Stage 02 · Rust backend (emphasized core) -->
<div class="rounded-lg border border-primary/30 bg-primary/10 p-5">
<div class="flex items-start justify-between gap-4">
<p class="text-sm font-semibold text-primary">Rust backend</p>
<span class="font-mono text-[9px] text-muted-foreground">02</span>
</div>
<div class="mt-1.5 space-y-1 font-mono text-[11px] text-primary/80">
<p>ConnectionManager</p>
<p>endpoint rotation · retry</p>
<p>TOFU cert pinning</p>
<p>credentials in OS keyring</p>
</div>
</div>
<!-- Arrow · HTTPS -->
<div class="hidden flex-col items-center justify-center gap-1 lg:flex" aria-hidden="true">
<span class="text-xl text-primary">→</span>
<span class="font-mono text-[9px] text-muted-foreground">HTTPS</span>
</div>
<div class="flex items-center justify-center gap-1.5 py-2 lg:hidden" aria-hidden="true">
<span class="text-xl text-primary">↓</span>
<span class="font-mono text-[9px] text-muted-foreground">HTTPS</span>
</div>
<!-- Stage 03 · Proxmox API -->
<div class="rounded-lg border border-border bg-card p-5">
<div class="flex items-start justify-between gap-4">
<p class="text-sm font-semibold text-foreground">Proxmox API</p>
<span class="font-mono text-[9px] text-muted-foreground">03</span>
</div>
<div class="mt-1.5 space-y-1 font-mono text-[11px] text-muted-foreground">
<p>/api2/json</p>
<p>nodes · cluster · vms</p>
<p>self-signed TLS</p>
</div>
<div class="rounded-xl border border-white/[0.06] bg-card p-5" data-stagger-child>
<div class="flex items-start justify-between gap-4"><p class="text-sm font-semibold text-foreground">Proxmox API</p><span class="font-mono text-[10px] text-muted-foreground">03</span></div>
<div class="mt-2 space-y-1 font-mono text-[11px] text-muted-foreground"><p>/api2/json</p><p>nodes · cluster · vms</p><p>self-signed TLS</p></div>
</div>
</div>
<!-- Request example + auth note, and resilience note -->
<div class="mt-8 grid gap-6 lg:grid-cols-2 lg:items-stretch">
<!-- Request example -->
<div class="overflow-x-auto rounded-lg border border-border bg-card p-5">
<p class="font-mono text-[10px] text-muted-foreground">
POST /nodes/pve/qemu/100/status/start
</p>
<p class="mt-3 whitespace-nowrap font-mono text-[13px] tabular-nums text-foreground">
https://192.168.1.10:8006/api2/json/nodes/pve/qemu/100/status/start
</p>
<div class="mt-3 flex flex-wrap items-center gap-2">
<span class="rounded border border-border bg-muted px-2 py-0.5 font-mono text-[10px] text-muted-foreground">
PVEAPIToken
</span>
<span class="rounded border border-border bg-muted px-2 py-0.5 font-mono text-[10px] text-muted-foreground">
PVEAuthCookie + CSRFPreventionToken
</span>
<span class="font-mono text-[10px] text-muted-foreground">
Headers injected by the backend
</span>
<div class="mt-8 grid gap-6 lg:grid-cols-2">
<div class="overflow-hidden rounded-xl border border-white/[0.06] bg-card" data-reveal>
<div class="border-b border-white/[0.06] bg-background px-4 py-2.5 font-mono text-[10px] text-muted-foreground">POST /nodes/pve/qemu/100/status/start</div>
<div class="px-4 py-4">
<p class="truncate font-mono text-[13px] tabular-nums text-foreground">https://192.168.1.10:8006/api2/json/nodes/pve/qemu/100/status/start</p>
<div class="mt-3 flex flex-wrap gap-2">
<span class="rounded border border-white/[0.08] bg-muted px-2 py-1 font-mono text-[10px] text-muted-foreground">PVEAPIToken</span>
<span class="rounded border border-white/[0.08] bg-muted px-2 py-1 font-mono text-[10px] text-muted-foreground">PVEAuthCookie + CSRFPreventionToken</span>
</div>
</div>
</div>
<!-- Resilience note -->
<div class="lg:pt-5">
<p class="font-mono text-[10px] text-muted-foreground">failover model</p>
<p class="mt-3 text-sm leading-relaxed text-muted-foreground">
Requests ride an ordered, deduplicated endpoint list: primary,
fallbacks, then discovered cluster nodes. A connect, timeout, DNS, or
transport failure retries on the next endpoint.
</p>
<div class="mt-4 flex flex-wrap gap-4 font-mono text-[10px]">
<span class="flex items-center gap-1.5">
<span class="h-1.5 w-1.5 rounded-full bg-success" aria-hidden="true"></span>
<span class="text-muted-foreground">connected</span>
</span>
<span class="flex items-center gap-1.5">
<span class="h-1.5 w-1.5 rounded-full bg-warning" aria-hidden="true"></span>
<span class="text-muted-foreground">failover</span>
</span>
<span class="flex items-center gap-1.5">
<span class="h-1.5 w-1.5 rounded-full bg-destructive" aria-hidden="true"></span>
<span class="text-muted-foreground">failed</span>
</span>
<div class="py-2" data-reveal data-reveal-delay="100">
<p class="font-mono text-[10px] uppercase tracking-[0.08em] text-muted-foreground">Failover model</p>
<p class="mt-3 text-sm leading-relaxed text-muted-foreground">Ordered, deduped endpoints: primary → fallbacks → discovered nodes. Connect / timeout / DNS / transport failure → retry next endpoint.</p>
<div class="mt-4 flex gap-4 font-mono text-[10px]">
<span class="inline-flex items-center gap-1.5"><span class="h-1.5 w-1.5 rounded-full bg-success"></span> connected</span>
<span class="inline-flex items-center gap-1.5"><span class="h-1.5 w-1.5 rounded-full bg-warning"></span> failover</span>
<span class="inline-flex items-center gap-1.5"><span class="h-1.5 w-1.5 rounded-full bg-destructive"></span> failed</span>
</div>
</div>
</div>
</section>
</div>
</div>
</section>
+11
View File
@@ -0,0 +1,11 @@
<div id="cmdk-overlay" class="cmdk-overlay fixed inset-0 z-[100] hidden items-start justify-center px-4 pt-[20vh]" role="dialog" aria-modal="true" aria-label="Command palette">
<div id="cmdk-panel" class="cmdk-panel w-full max-w-[520px] overflow-hidden rounded-xl border border-white/[0.08] bg-card">
<div class="flex items-center gap-3 border-b border-white/[0.06] px-4">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></svg>
<input id="cmdk-input" type="text" placeholder="Type a command…" autocomplete="off" spellcheck="false" class="h-12 w-full bg-transparent font-mono text-sm text-foreground placeholder:text-muted-foreground/60 focus:outline-none" />
<kbd class="hidden rounded border border-white/[0.08] bg-white/[0.04] px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground sm:inline">ESC</kbd>
</div>
<div id="cmdk-list" class="max-h-[320px] overflow-auto p-2"></div>
<div class="border-t border-white/[0.06] px-3 py-2 font-mono text-[10px] text-muted-foreground/60">↑↓ to navigate · ↵ to select · esc to close</div>
</div>
</div>
+21 -40
View File
@@ -1,46 +1,27 @@
<!--
DownloadCta — closing section, one centered column (the page's closing
statement). Id anchors the Download link in the nav and footer.
-->
<section id="download" class="scroll-mt-24 py-24 sm:py-32">
<section id="download" class="scroll-mt-24 border-t border-white/[0.06] py-20 sm:py-28">
<div class="mx-auto max-w-3xl px-6 text-center">
<p class="font-mono text-[11px] uppercase tracking-[0.2em] text-muted-foreground">
RELEASES
</p>
<h2 class="mt-4 text-3xl font-semibold tracking-tighter text-foreground sm:text-4xl">
Releases coming soon.
</h2>
<p class="mx-auto mt-4 max-w-[46ch] text-[15px] leading-relaxed text-muted-foreground">
<p class="font-mono text-[11px] uppercase tracking-[0.18em] text-primary" data-reveal>Releases</p>
<h2 class="mt-4 text-3xl font-semibold tracking-[-0.03em] text-foreground sm:text-4xl" data-reveal data-reveal-delay="80">Coming soon.</h2>
<p class="mx-auto mt-4 max-w-[46ch] text-[15px] leading-relaxed text-muted-foreground" data-reveal data-reveal-delay="140">
Clustri is in active development. Packages for Linux, macOS, and Windows are on the way.
</p>
<div class="mt-8 flex flex-wrap items-center justify-center gap-3">
<a
href="#"
class="rounded-md bg-primary px-5 py-2.5 text-sm font-semibold text-primary-foreground transition hover:brightness-110 active:scale-[0.98]"
>
Get Clustri
</a>
<a
href="#"
class="rounded-md border border-border bg-background px-5 py-2.5 text-sm font-semibold text-foreground transition hover:bg-accent/60"
>
Build from source
</a>
</div>
<div class="mt-6 flex items-center justify-center gap-2">
<span class="rounded border border-border bg-muted px-2.5 py-1 font-mono text-[11px] text-muted-foreground">
Linux
</span>
<span class="rounded border border-border bg-muted px-2.5 py-1 font-mono text-[11px] text-muted-foreground">
macOS
</span>
<span class="rounded border border-border bg-muted px-2.5 py-1 font-mono text-[11px] text-muted-foreground">
Windows
</span>
<div class="relative mt-10 overflow-hidden rounded-xl border border-white/[0.07] bg-card p-6 sm:p-8 shadow-card" data-reveal data-reveal-delay="200">
<div class="pointer-events-none absolute inset-0 grid-bg opacity-[0.12]" aria-hidden="true"></div>
<div class="pointer-events-none absolute inset-x-0 top-0 h-px bg-primary/30" aria-hidden="true"></div>
<div class="relative">
<div class="flex flex-wrap items-center justify-center gap-3">
<a href="#" class="rounded-[10px] bg-primary px-6 py-3 text-sm font-semibold text-primary-foreground transition hover:bg-primary/90 active:scale-[0.98]">Get Clustri</a>
<a href="#" class="rounded-[10px] border border-white/[0.08] bg-white/[0.03] px-6 py-3 text-sm font-semibold text-muted-foreground transition hover:border-white/15 hover:text-foreground active:scale-[0.98]">Build from source</a>
</div>
<div class="mt-6 flex flex-wrap items-center justify-center gap-2">
<span class="rounded-md border border-white/[0.06] bg-background px-2.5 py-1 font-mono text-[11px] text-muted-foreground">Linux</span>
<span class="rounded-md border border-white/[0.06] bg-background px-2.5 py-1 font-mono text-[11px] text-muted-foreground">macOS</span>
<span class="rounded-md border border-white/[0.06] bg-background px-2.5 py-1 font-mono text-[11px] text-muted-foreground">Windows</span>
</div>
<p class="mt-4 font-mono text-[10px] text-muted-foreground/60">cargo test · 64 tests · MIT · v0.1.0</p>
</div>
</div>
</div>
<!-- Close the section, consistent with the hero's closing hairline -->
<div class="mt-16 border-b border-border" aria-hidden="true"></div>
</section>
@@ -0,0 +1,281 @@
<section id="failover-playground" aria-labelledby="fp-title" class="relative overflow-hidden border-y border-white/[0.06] bg-card/40">
<div class="pointer-events-none absolute inset-0 grid-bg opacity-[0.18]" aria-hidden="true"></div>
<div class="relative mx-auto max-w-7xl px-6 py-20 lg:px-8 lg:py-28">
<div class="grid gap-10 lg:grid-cols-12 lg:items-start">
<!-- Copy + controls -->
<div class="lg:col-span-5">
<p class="font-mono text-[11px] uppercase tracking-[0.18em] text-primary" data-reveal>Failover — try it</p>
<h2 id="fp-title" class="mt-4 text-3xl font-semibold tracking-[-0.03em] text-foreground sm:text-4xl" data-reveal data-reveal-delay="80">
One node dies.<br /><span class="text-muted-foreground">You don't notice.</span>
</h2>
<p class="mt-4 max-w-[42ch] text-[15px] leading-relaxed text-muted-foreground" data-reveal data-reveal-delay="140">
Requests ride an ordered endpoint list: primary, fallbacks, then discovered cluster nodes. Kill a node on the right — watch the packets reroute and the status flip.
</p>
<div class="mt-8 flex flex-wrap gap-2" data-reveal data-reveal-delay="180">
<button id="fp-kill-pve1" type="button" class="rounded-md border border-white/[0.08] bg-white/[0.04] px-3 py-2 font-mono text-[11px] font-medium text-foreground transition hover:border-white/15 hover:bg-white/[0.07]">Kill pve1</button>
<button id="fp-kill-pve2" type="button" class="rounded-md border border-white/[0.08] bg-white/[0.04] px-3 py-2 font-mono text-[11px] font-medium text-foreground transition hover:border-white/15 hover:bg-white/[0.07]">Kill pve2</button>
<button id="fp-kill-pve3" type="button" class="rounded-md border border-white/[0.08] bg-white/[0.04] px-3 py-2 font-mono text-[11px] font-medium text-foreground transition hover:border-white/15 hover:bg-white/[0.07]">Kill pve3</button>
<button id="fp-heal" type="button" class="rounded-md bg-primary px-3 py-2 font-mono text-[11px] font-semibold text-primary-foreground transition hover:bg-primary/90">Heal all</button>
</div>
<div class="mt-6 flex flex-wrap gap-3 font-mono text-[10px]" data-reveal data-reveal-delay="220">
<span class="inline-flex items-center gap-1.5"><span class="h-1.5 w-1.5 rounded-full bg-success"></span> connected</span>
<span class="inline-flex items-center gap-1.5"><span class="h-1.5 w-1.5 rounded-full bg-warning"></span> failover</span>
<span class="inline-flex items-center gap-1.5"><span class="h-1.5 w-1.5 rounded-full bg-destructive"></span> failed</span>
<span class="ml-1 text-muted-foreground">— tap a node or use the buttons</span>
</div>
<div class="mt-6 rounded-md border border-white/[0.06] bg-background px-3 py-2.5 font-mono text-[11px] leading-relaxed text-muted-foreground" data-reveal data-reveal-delay="260">
<span class="text-foreground">How it works:</span> deduped order <span class="text-foreground">primary → fallbacks → discovered</span>. On connect / timeout / DNS / transport failure, retry the next endpoint. Status reflects which endpoint actually answered.
</div>
</div>
<!-- Diagram -->
<div class="lg:col-span-7" data-reveal data-reveal-delay="120">
<div class="relative overflow-hidden rounded-xl border border-white/[0.07] bg-card shadow-card">
<!-- top rule -->
<div id="fp-top-rule" class="h-px w-full bg-primary/30 transition-colors" aria-hidden="true"></div>
<!-- header -->
<div class="flex items-center justify-between gap-3 border-b border-white/[0.06] px-4 py-3">
<p class="font-mono text-[10px] tracking-[0.06em] text-muted-foreground">request → endpoints[0] → retry on failure</p>
<span id="fp-status-chip" class="rounded border border-success/25 bg-success/10 px-1.5 py-0.5 font-mono text-[10px] uppercase text-success">Connected</span>
</div>
<!-- body: rail + nodes -->
<div class="relative flex gap-0">
<!-- packet rail -->
<div class="relative w-[36px] shrink-0 border-r border-white/[0.04] bg-background/40">
<div class="absolute left-1/2 top-3 bottom-3 w-px -translate-x-1/2 bg-white/[0.08]" aria-hidden="true"></div>
<!-- dots injected by JS -->
<div id="fp-rail" class="absolute inset-0 overflow-hidden" aria-hidden="true"></div>
<div class="absolute left-1/2 top-2 h-2 w-2 -translate-x-1/2 rounded-full bg-primary shadow-[0_0_8px] shadow-primary/60" aria-hidden="true"></div>
</div>
<!-- node list -->
<div class="min-w-0 flex-1">
<button id="fp-node-0" type="button" class="flex w-full items-center justify-between gap-3 border-b border-white/[0.06] px-4 py-3.5 text-left transition hover:bg-white/[0.02]">
<span class="min-w-0">
<span class="flex items-center gap-2">
<span id="fp-dot-0" class="h-1.5 w-1.5 shrink-0 rounded-full bg-success shadow-[0_0_6px] shadow-success/50"></span>
<span class="truncate font-mono text-[13px] font-medium tabular-nums text-foreground">192.168.1.10:8006</span>
</span>
<span class="mt-1 block font-mono text-[10px] text-muted-foreground">pve1 · primary</span>
</span>
<span class="shrink-0 rounded border border-primary/30 bg-primary/10 px-1.5 py-0.5 font-mono text-[10px] uppercase text-primary">Primary</span>
</button>
<button id="fp-node-1" type="button" class="flex w-full items-center justify-between gap-3 border-b border-white/[0.06] px-4 py-3.5 text-left transition hover:bg-white/[0.02]">
<span class="min-w-0">
<span class="flex items-center gap-2">
<span id="fp-dot-1" class="h-1.5 w-1.5 shrink-0 rounded-full bg-success"></span>
<span class="truncate font-mono text-[13px] font-medium tabular-nums text-foreground">192.168.1.11:8006</span>
</span>
<span class="mt-1 block font-mono text-[10px] text-muted-foreground">pve2 · fallback</span>
</span>
<span class="shrink-0 rounded bg-muted px-1.5 py-0.5 font-mono text-[10px] uppercase text-muted-foreground">Fallback</span>
</button>
<button id="fp-node-2" type="button" class="flex w-full items-center justify-between gap-3 px-4 py-3.5 text-left transition hover:bg-white/[0.02]">
<span class="min-w-0">
<span class="flex items-center gap-2">
<span id="fp-dot-2" class="h-1.5 w-1.5 shrink-0 rounded-full bg-success"></span>
<span class="truncate font-mono text-[13px] font-medium tabular-nums text-foreground">pve3.discovery</span>
</span>
<span class="mt-1 block font-mono text-[10px] text-muted-foreground">pve3 · cluster</span>
</span>
<span class="shrink-0 rounded bg-muted px-1.5 py-0.5 font-mono text-[10px] uppercase text-muted-foreground">Cluster</span>
</button>
</div>
</div>
<!-- status bar -->
<div class="border-t border-white/[0.06] bg-background px-4 py-3">
<div class="flex items-center justify-between gap-3">
<span id="fp-status-text" class="font-mono text-[11px] tabular-nums text-muted-foreground">status: connected — pve1 answered</span>
<span id="fp-latency" class="font-mono text-[10px] tabular-nums text-muted-foreground">~18 ms</span>
</div>
<div class="mt-2 h-1 overflow-hidden rounded-full bg-muted">
<div id="fp-bar" class="h-full rounded-full bg-primary transition-all duration-500" style="width: 18%"></div>
</div>
</div>
</div>
<p class="mt-3 text-center font-mono text-[10px] text-muted-foreground/60">Click a node row to kill / heal it. Packets reroute automatically.</p>
</div>
</div>
</div>
</section>
<script>
const prefersReduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches
const alive = [true, true, true]
const nodes = [
document.getElementById('fp-node-0'),
document.getElementById('fp-node-1'),
document.getElementById('fp-node-2'),
]
const dots = [
document.getElementById('fp-dot-0'),
document.getElementById('fp-dot-1'),
document.getElementById('fp-dot-2'),
]
const chip = document.getElementById('fp-status-chip')
const statusText = document.getElementById('fp-status-text')
const topRule = document.getElementById('fp-top-rule')
const bar = document.getElementById('fp-bar')
const latency = document.getElementById('fp-latency')
const rail = document.getElementById('fp-rail')
const names = ['pve1', 'pve2', 'pve3']
const addrs = ['192.168.1.10:8006', '192.168.1.11:8006', 'pve3.discovery']
let dotEls = []
let raf = 0
let t = 0
function activeIndex() {
for (let i = 0; i < alive.length; i++) if (alive[i]) return i
return -1
}
function render() {
const ai = activeIndex()
const failed = ai === -1
const failover = ai > 0
nodes.forEach((node, i) => {
if (!node) return
const dead = !alive[i]
node.classList.toggle('opacity-40', dead)
node.classList.toggle('bg-destructive/5', dead)
const isActive = i === ai && !failed
node.setAttribute('aria-pressed', dead ? 'true' : 'false')
})
dots.forEach((d, i) => {
if (!d) return
if (!alive[i]) {
d.className = 'h-1.5 w-1.5 shrink-0 rounded-full bg-destructive shadow-[0_0_6px] shadow-destructive/40'
} else if (i === ai) {
d.className = 'h-1.5 w-1.5 shrink-0 rounded-full bg-success shadow-[0_0_6px] shadow-success/50'
} else {
d.className = 'h-1.5 w-1.5 shrink-0 rounded-full bg-white/20'
}
})
if (!chip || !statusText || !topRule || !bar || !latency) return
if (failed) {
chip.textContent = 'Failed'
chip.className = 'rounded border border-destructive/30 bg-destructive/10 px-1.5 py-0.5 font-mono text-[10px] uppercase text-destructive'
statusText.textContent = 'status: failed — no endpoint reachable'
statusText.className = 'font-mono text-[11px] tabular-nums text-destructive'
topRule.className = 'h-px w-full bg-destructive/40'
bar.style.width = '100%'
bar.className = 'h-full rounded-full bg-destructive transition-all duration-500'
latency.textContent = 'timeout'
} else if (failover) {
chip.textContent = 'Failover'
chip.className = 'rounded border border-warning/30 bg-warning/10 px-1.5 py-0.5 font-mono text-[10px] uppercase text-warning'
statusText.textContent = `status: failover — ${names[ai]} answered (${addrs[ai]})`
statusText.className = 'font-mono text-[11px] tabular-nums text-warning'
topRule.className = 'h-px w-full bg-warning/40'
bar.style.width = ai === 1 ? '56%' : '88%'
bar.className = 'h-full rounded-full bg-warning transition-all duration-500'
latency.textContent = ai === 1 ? '~42 ms' : '~78 ms'
} else {
chip.textContent = 'Connected'
chip.className = 'rounded border border-success/25 bg-success/10 px-1.5 py-0.5 font-mono text-[10px] uppercase text-success'
statusText.textContent = `status: connected — ${names[0]} answered`
statusText.className = 'font-mono text-[11px] tabular-nums text-success'
topRule.className = 'h-px w-full bg-primary/30'
bar.style.width = '18%'
bar.className = 'h-full rounded-full bg-primary transition-all duration-500'
latency.textContent = '~18 ms'
}
}
function kill(i) {
alive[i] = !alive[i]
render()
restartDots()
}
function healAll() {
alive[0] = true; alive[1] = true; alive[2] = true
render()
restartDots()
}
document.getElementById('fp-kill-pve1')?.addEventListener('click', () => kill(0))
document.getElementById('fp-kill-pve2')?.addEventListener('click', () => kill(1))
document.getElementById('fp-kill-pve3')?.addEventListener('click', () => kill(2))
document.getElementById('fp-heal')?.addEventListener('click', healAll)
nodes.forEach((n, i) => n?.addEventListener('click', () => kill(i)))
function createDots() {
if (!rail) return
rail.innerHTML = ''
dotEls = []
for (let k = 0; k < 2; k++) {
const d = document.createElement('div')
d.className = 'absolute left-1/2 h-2 w-2 -translate-x-1/2 rounded-full bg-primary shadow-[0_0_8px] shadow-primary/60'
d.style.top = '8px'
// @ts-ignore
d._offset = k * 0.5
rail.appendChild(d)
dotEls.push(d)
}
}
function restartDots() {
if (prefersReduced) return
cancelAnimationFrame(raf)
t = 0
createDots()
animate()
}
function targetYs() {
const ai = activeIndex()
if (ai === -1) return [0.88]
// map active index to rail progress (0..1): pve1 ~0.26, pve2 ~0.55, pve3 ~0.84
const map = [0.28, 0.56, 0.86]
return [map[ai]]
}
function animate() {
if (prefersReduced || !rail) return
t += 0.018
const h = rail.clientHeight || 160
const targets = targetYs()
dotEls.forEach((el) => {
const phase = (t + el._offset) % 1
// ease: move from 0 to target, then fade and reset
const ty = phase * (targets[0] * h)
const visible = phase < 0.92
el.style.transform = `translate(-50%, ${ty}px)`
el.style.opacity = visible ? '1' : '0'
// if failed, make dot red and slower
const ai = activeIndex()
if (ai === -1) {
el.style.background = 'var(--color-destructive)'
// @ts-ignore
el.style.boxShadow = '0 0 8px oklch(0.62 0.18 25 / 0.5)'
} else if (ai > 0) {
el.style.background = 'var(--color-warning)'
// @ts-ignore
el.style.boxShadow = '0 0 8px oklch(0.76 0.13 80 / 0.5)'
} else {
el.style.background = 'var(--color-primary)'
// @ts-ignore
el.style.boxShadow = '0 0 8px oklch(0.72 0.16 52 / 0.6)'
}
})
raf = requestAnimationFrame(animate)
}
render()
if (!prefersReduced) { createDots(); animate() }
</script>
+134 -326
View File
@@ -1,336 +1,144 @@
<!--
FeatureBlocks — the five feature sections of the Clustri marketing site.
<!-- Security -->
<section id="security" aria-labelledby="sec-title" class="scroll-mt-24 py-20 sm:py-28">
<div class="mx-auto max-w-7xl px-6 lg:px-8">
<div class="grid gap-10 lg:grid-cols-12 lg:items-start">
<div class="lg:col-span-6" data-reveal>
<p class="font-mono text-[11px] uppercase tracking-[0.18em] text-primary">Security</p>
<h2 id="sec-title" class="mt-4 text-3xl font-semibold tracking-[-0.03em] text-foreground sm:text-4xl">
Your keys stay<br /><span class="text-muted-foreground">in your pocket.</span>
</h2>
<p class="mt-4 max-w-[44ch] text-[15px] leading-relaxed text-muted-foreground">
API tokens live in the OS keyring — never on disk. Self-signed certs are pinned by SHA-256 on first connect and verified every time after.
</p>
<div class="mt-6 space-y-3 font-mono text-[11px] leading-relaxed">
<div class="flex gap-2"><span class="text-primary">—</span><span class="text-muted-foreground">Token mode: <span class="text-foreground">PVEAPIToken / PBSAPIToken</span> header</span></div>
<div class="flex gap-2"><span class="text-primary">—</span><span class="text-muted-foreground">Password mode: ticket auth + <span class="text-foreground">CSRFPreventionToken</span></span></div>
<div class="flex gap-2"><span class="text-primary">—</span><span class="text-muted-foreground">Fallbacks skip TOFU — intentional for home-lab failover; primary is always pinned.</span></div>
</div>
</div>
Design constraints (see src/styles/global.css):
- Dark-only surfaces: bg-background / bg-card; muted-orange text-primary is
the single brand accent.
- Green / amber are reserved for status semantics (success / warning) only.
- No gradients, no glows, no glassmorphism, no emoji, no fake metrics.
- No uppercase eyebrows: section identity is the mono index (0105) beside
each heading.
All visuals are hand-built schematic markup, never screenshots.
-->
<div class="lg:col-span-6" data-reveal data-reveal-delay="120">
<div class="rounded-xl border border-white/[0.07] bg-card p-5 shadow-card">
<div class="flex items-center gap-2">
<span class="h-2 w-2 rounded-full bg-success shadow-[0_0_6px] shadow-success/60" aria-hidden="true"></span>
<span class="font-mono text-[10px] uppercase tracking-[0.08em] text-muted-foreground">SHA-256 fingerprint — pinned</span>
<span class="ml-auto rounded border border-success/20 bg-success/10 px-1.5 py-0.5 font-mono text-[10px] uppercase text-success">Pinned</span>
</div>
<p class="mt-4 overflow-x-auto whitespace-nowrap font-mono text-[13px] tracking-[0.12em] text-foreground">A3:0F:71:C9:52:B8:4D:E0:11:2A:7F:9C:63:D4:18:0B</p>
<div class="mt-4 flex flex-wrap gap-2 border-t border-white/[0.06] pt-4 font-mono text-[10px]">
<span class="rounded bg-white/[0.04] px-2 py-1 text-muted-foreground">OS keyring</span>
<span class="rounded bg-white/[0.04] px-2 py-1 text-muted-foreground">rustls · sha2 · x509-cert</span>
<span class="rounded bg-white/[0.04] px-2 py-1 text-muted-foreground">no telemetry</span>
</div>
<p class="mt-3 font-mono text-[10px] text-muted-foreground">Captured on first connect. Verified on every connect after. “Accept anyway” per-connection when you rotate certs.</p>
</div>
<div id="features" class="mx-auto max-w-7xl px-6 lg:px-8">
<!--
01 · FAILOVER — split text/diagram.
Below lg the grid collapses to a single column (copy first, stack after).
-->
<section
id="failover"
aria-labelledby="features-failover-title"
class="scroll-mt-24 py-16 sm:py-20"
>
<div class="grid gap-10 lg:grid-cols-2 lg:items-center">
<div class="mt-4 grid grid-cols-3 gap-3">
<div class="rounded-lg border border-white/[0.06] bg-background p-3 text-center">
<p class="font-mono text-[10px] uppercase tracking-[0.08em] text-muted-foreground">Tokens</p>
<p class="mt-1 font-mono text-[11px] font-medium text-foreground">Keyring only</p>
</div>
<div class="rounded-lg border border-white/[0.06] bg-background p-3 text-center">
<p class="font-mono text-[10px] uppercase tracking-[0.08em] text-muted-foreground">Certs</p>
<p class="mt-1 font-mono text-[11px] font-medium text-foreground">TOFU SHA-256</p>
</div>
<div class="rounded-lg border border-white/[0.06] bg-background p-3 text-center">
<p class="font-mono text-[10px] uppercase tracking-[0.08em] text-muted-foreground">Transport</p>
<p class="mt-1 font-mono text-[11px] font-medium text-foreground">rustls</p>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- Bold statement — full bleed -->
<section class="relative overflow-hidden border-y border-white/[0.06] bg-[#08080a]">
<div class="pointer-events-none absolute inset-0 grid-bg opacity-[0.22]" aria-hidden="true"></div>
<div class="relative mx-auto max-w-7xl px-6 py-20 sm:py-28 lg:px-8">
<div class="max-w-4xl" data-reveal>
<p class="font-mono text-[11px] uppercase tracking-[0.18em] text-primary">The idea</p>
<p class="mt-6 text-3xl font-semibold leading-[0.95] tracking-[-0.04em] text-foreground sm:text-5xl lg:text-[3.4rem]">
Proxmox is powerful.<br />
<span class="text-muted-foreground">The web UI is not the point of failure.</span>
</p>
<p class="mt-6 max-w-[52ch] text-[15px] leading-relaxed text-muted-foreground sm:text-[16px]">
Clustri sits between you and the API. When a node drops, the app retries the next one. You keep working. The cluster keeps running.
</p>
<div class="mt-8 h-px w-12 bg-primary/60" aria-hidden="true"></div>
</div>
</div>
</section>
<!-- Console -->
<section id="console" aria-labelledby="console-title" class="scroll-mt-24 py-20 sm:py-28">
<div class="mx-auto max-w-7xl px-6 lg:px-8">
<div class="grid gap-10 lg:grid-cols-12">
<div class="lg:col-span-5" data-reveal>
<p class="font-mono text-[11px] uppercase tracking-[0.18em] text-primary">Console</p>
<h2 id="console-title" class="mt-4 text-3xl font-semibold tracking-[-0.03em] text-foreground sm:text-4xl">Type like you're there.</h2>
<p class="mt-4 max-w-[44ch] text-[15px] leading-relaxed text-muted-foreground">
noVNC for VMs, xterm.js for containers. Fullscreen, keyboard passthrough, and Ctrl+Alt+Del where you expect it.
</p>
<div class="mt-6 inline-flex gap-2">
<span class="rounded border border-white/[0.06] bg-card px-2.5 py-1 font-mono text-[11px] text-muted-foreground">Fullscreen</span>
<span class="rounded border border-white/[0.06] bg-card px-2.5 py-1 font-mono text-[11px] text-muted-foreground">Ctrl+Alt+Del</span>
</div>
</div>
<div class="lg:col-span-7" data-reveal data-reveal-delay="120">
<div class="overflow-hidden rounded-xl border border-white/[0.07] bg-card shadow-card">
<div class="flex items-center justify-between border-b border-white/[0.06] bg-background px-4 py-2.5">
<div class="flex items-center gap-1.5" aria-hidden="true">
<span class="h-2.5 w-2.5 rounded-full border border-white/[0.06] bg-white/10"></span>
<span class="h-2.5 w-2.5 rounded-full border border-white/[0.06] bg-white/10"></span>
<span class="h-2.5 w-2.5 rounded-full border border-white/[0.06] bg-white/10"></span>
</div>
<p class="font-mono text-[10px] text-muted-foreground">root@pve1 · xterm</p>
<kbd class="rounded border border-white/[0.08] bg-muted px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">Ctrl+Alt+Del</kbd>
</div>
<div class="bg-[#0a0a0a] px-4 py-4 font-mono text-[12px] leading-relaxed" data-terminal>
<div data-line><span class="text-primary">root@pve1:~#</span> <span class="text-foreground">pct enter 101</span></div>
<div data-line><span class="text-primary">root@db-01:~#</span> <span class="text-foreground">uptime</span></div>
<div data-line><span data-output class="whitespace-pre text-muted-foreground"> 23:41:05 up 9 days, 4:21, 1 user, load average: 0.42, 0.38, 0.35</span></div>
<div data-line><span class="text-primary">root@db-01:~#</span> <span class="animate-pulse text-foreground">█</span></div>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- Power user -->
<section id="power-user" aria-labelledby="power-title" class="scroll-mt-24 border-t border-white/[0.06] py-20 sm:py-28">
<div class="mx-auto max-w-7xl px-6 lg:px-8">
<div class="flex flex-wrap items-end justify-between gap-6" data-reveal>
<div>
<div class="flex items-baseline gap-3">
<span class="shrink-0 font-mono text-sm text-primary">01</span>
<h2
id="features-failover-title"
class="text-2xl font-semibold tracking-tighter text-foreground sm:text-3xl"
>
Failover that happens on its own
</h2>
</div>
<p class="mt-3 max-w-[58ch] text-[15px] leading-relaxed text-muted-foreground">
Requests ride an ordered endpoint list: primary, fallbacks, then
discovered cluster nodes. When one dies, the next takes over, and the
status flips to failover so you always know which node answered.
</p>
<p class="font-mono text-[11px] uppercase tracking-[0.18em] text-primary">Power user</p>
<h2 id="power-title" class="mt-4 text-3xl font-semibold tracking-[-0.03em] text-foreground sm:text-4xl">Built for the people who keep the lights on.</h2>
</div>
<div
role="img"
aria-label="Ordered endpoint stack: primary, fallback, then discovered cluster node, with a failover status bar"
class="select-none rounded-lg border border-border bg-card p-5"
>
<p class="font-mono text-[10px] text-muted-foreground">
request → endpoints[0] → retry on failure
</p>
<div class="mt-4">
<div class="flex items-center justify-between gap-4 border-b border-border py-3">
<span class="min-w-0 truncate font-mono text-[13px] tabular-nums text-foreground">
192.168.1.10:8006
</span>
<span
class="shrink-0 rounded border border-primary/30 bg-primary/10 px-1.5 py-0.5 font-mono text-[10px] uppercase text-primary"
>
Primary
</span>
</div>
<div class="flex items-center justify-between gap-4 border-b border-border py-3">
<span class="min-w-0 truncate font-mono text-[13px] tabular-nums text-foreground">
192.168.1.11:8006
</span>
<span
class="shrink-0 rounded bg-muted px-1.5 py-0.5 font-mono text-[10px] uppercase text-muted-foreground"
>
Fallback
</span>
</div>
<div class="flex items-center justify-between gap-4 border-b border-border py-3 last:border-0">
<span class="min-w-0 truncate font-mono text-[13px] tabular-nums text-foreground">
pve3.discovery
</span>
<span
class="shrink-0 rounded bg-muted px-1.5 py-0.5 font-mono text-[10px] uppercase text-muted-foreground"
>
Cluster
</span>
</div>
</div>
<div class="mt-4">
<span class="font-mono text-[10px] text-muted-foreground">
status: failover
</span>
<div class="mt-1.5 h-1 overflow-hidden rounded-full bg-muted">
<div class="h-full w-1/2 rounded-full bg-primary"></div>
</div>
</div>
</div>
</div>
</section>
<!--
02 · SECURITY — full-width editorial band.
Single column on every breakpoint; the fingerprint block is the visual
and spans full width up to max-w-2xl.
-->
<section
id="security"
aria-labelledby="features-security-title"
class="scroll-mt-24 py-16 sm:py-20"
>
<div class="flex items-baseline gap-3">
<span class="shrink-0 font-mono text-sm text-primary">02</span>
<h2
id="features-security-title"
class="text-2xl font-semibold tracking-tighter text-foreground sm:text-3xl"
>
Credentials never touch disk
</h2>
</div>
<p class="mt-3 max-w-[58ch] text-[15px] leading-relaxed text-muted-foreground">
API tokens live in the OS keyring. Self-signed certificates are pinned by
SHA-256 fingerprint on first connect and verified on every connect after.
</p>
<div class="mt-8 max-w-2xl rounded-lg border border-border bg-card p-5">
<div class="flex flex-wrap items-center gap-x-3 gap-y-2">
<span class="font-mono text-[10px] text-muted-foreground">
SHA-256 fingerprint
</span>
<span
class="rounded border border-success/25 bg-success/10 px-1.5 py-0.5 font-mono text-[10px] uppercase text-success"
>
Pinned
</span>
</div>
<div class="mt-4 overflow-x-auto">
<p class="whitespace-nowrap font-mono text-sm tracking-[0.18em] text-foreground">
A3:0F:71:C9:52:B8:4D:E0:11:2A:7F:9C:63:D4:18:0B
</p>
</div>
<p class="mt-3 font-mono text-[10px] text-muted-foreground">
Example fingerprint · captured on first connect, verified on every connect after.
<p class="max-w-[44ch] text-[15px] leading-relaxed text-muted-foreground">
Tasks stream over WebSocket with polling fallback. <button data-cmdk-trigger class="underline decoration-white/15 underline-offset-4 hover:text-foreground">⌘K</button> opens the palette. The tray keeps one-click actions one click away.
</p>
</div>
</section>
<!--
03 · VMS — asymmetric split (5/7).
Below lg the grid collapses to a single column (copy first, list after).
-->
<section
id="vms"
aria-labelledby="features-vms-title"
class="scroll-mt-24 py-16 sm:py-20"
>
<div class="grid gap-10 lg:grid-cols-12 lg:items-start">
<div class="lg:col-span-5">
<div class="flex items-baseline gap-3">
<span class="shrink-0 font-mono text-sm text-primary">03</span>
<h2
id="features-vms-title"
class="text-2xl font-semibold tracking-tighter text-foreground sm:text-3xl"
>
The full VM lifecycle in one place
</h2>
</div>
<p class="mt-3 max-w-[58ch] text-[15px] leading-relaxed text-muted-foreground">
Start, stop, shutdown, reboot, suspend, resume, migrate. Plus disks,
NICs, snapshots, backup jobs, and restore.
</p>
<div class="mt-10 grid gap-0 border-y border-white/[0.06] sm:grid-cols-2" data-stagger>
<div class="flex items-center justify-between gap-4 border-b border-white/[0.06] px-1 py-4 sm:border-r sm:px-4" data-stagger-child>
<kbd class="rounded border border-white/[0.08] bg-muted px-2 py-1 font-mono text-[10px] text-muted-foreground">Cmd / Ctrl + K</kbd>
<span class="text-sm text-muted-foreground">Command palette</span>
</div>
<div class="lg:col-span-7">
<p class="mb-3 font-mono text-[10px] text-muted-foreground">
migrate 102 <span class="text-muted-foreground/70">→</span> pve3
</p>
<div
role="img"
aria-label="VM list: VM 100 web-01 running, VM 101 db-01 running, VM 102 worker-01 stopped"
class="select-none rounded-lg border border-border bg-card"
>
<div class="flex items-center justify-between border-b border-border px-4 py-2.5">
<span class="font-mono text-[10px] uppercase text-muted-foreground">
VM ID
</span>
<span class="font-mono text-[10px] uppercase text-muted-foreground">
Status
</span>
</div>
<div class="flex items-center justify-between gap-4 border-b border-border px-4 py-2.5">
<span class="min-w-0 font-mono text-[13px] tabular-nums text-foreground">
100<span class="text-muted-foreground"> · web-01</span>
</span>
<span
class="shrink-0 rounded border border-success/25 bg-success/10 px-1.5 py-0.5 font-mono text-[10px] uppercase text-success"
>
Running
</span>
</div>
<div class="flex items-center justify-between gap-4 border-b border-border px-4 py-2.5">
<span class="min-w-0 font-mono text-[13px] tabular-nums text-foreground">
101<span class="text-muted-foreground"> · db-01</span>
</span>
<span
class="shrink-0 rounded border border-success/25 bg-success/10 px-1.5 py-0.5 font-mono text-[10px] uppercase text-success"
>
Running
</span>
</div>
<div class="flex items-center justify-between gap-4 border-b border-border px-4 py-2.5 last:border-0">
<span class="min-w-0 font-mono text-[13px] tabular-nums text-foreground">
102<span class="text-muted-foreground"> · worker-01</span>
</span>
<span
class="shrink-0 rounded bg-muted px-1.5 py-0.5 font-mono text-[10px] uppercase text-muted-foreground"
>
Stopped
</span>
</div>
</div>
<div class="flex items-center justify-between gap-4 border-b border-white/[0.06] px-1 py-4 sm:px-4" data-stagger-child>
<span class="font-mono text-[11px] font-medium text-primary">WebSocket</span>
<span class="text-sm text-muted-foreground">Live task &amp; event stream</span>
</div>
<div class="flex items-center justify-between gap-4 border-b border-white/[0.06] px-1 py-4 sm:border-r sm:px-4 sm:border-b-0" data-stagger-child>
<span class="font-mono text-[11px] font-medium text-primary">Tray menu</span>
<span class="text-sm text-muted-foreground">Show / hide · connections · quit</span>
</div>
<div class="flex items-center justify-between gap-4 px-1 py-4 sm:px-4" data-stagger-child>
<span class="font-mono text-[11px] font-medium text-primary">10s polling</span>
<span class="text-sm text-muted-foreground">Connection status fallback</span>
</div>
</div>
</section>
<!--
04 · CONSOLE — full-width terminal.
Single column on every breakpoint; the terminal window spans full width
up to max-w-3xl.
-->
<section
id="console"
aria-labelledby="features-console-title"
class="scroll-mt-24 py-16 sm:py-20"
>
<div class="flex items-baseline gap-3">
<span class="shrink-0 font-mono text-sm text-primary">04</span>
<h2
id="features-console-title"
class="text-2xl font-semibold tracking-tighter text-foreground sm:text-3xl"
>
Consoles that behave like the real thing
</h2>
</div>
<p class="mt-3 max-w-[58ch] text-[15px] leading-relaxed text-muted-foreground">
noVNC for VMs, xterm.js for containers. Fullscreen, keyboard passthrough,
and Ctrl+Alt+Del where you expect it.
</p>
<div
role="img"
aria-label="Terminal session inside a container: pct enter 101, uptime, and its output"
class="mt-8 max-w-3xl select-none overflow-hidden rounded-lg border border-border bg-card shadow-card"
>
<div class="relative flex items-center border-b border-border bg-background px-4 py-2">
<div class="absolute left-4 flex items-center gap-1.5" aria-hidden="true">
<span class="h-2.5 w-2.5 rounded-full border border-border bg-muted-foreground/40"></span>
<span class="h-2.5 w-2.5 rounded-full border border-border bg-muted-foreground/40"></span>
<span class="h-2.5 w-2.5 rounded-full border border-border bg-muted-foreground/40"></span>
</div>
<p class="w-full text-center font-mono text-[10px] text-muted-foreground">
root@pve1: ~ (xterm)
</p>
</div>
<div class="overflow-x-auto bg-background px-4 py-4 font-mono text-[12px] leading-relaxed">
<p>
<span class="text-primary">root@pve1:~#</span>
<span class="text-foreground">pct enter 101</span>
</p>
<p>
<span class="text-primary">root@db-01:~#</span>
<span class="text-foreground">uptime</span>
</p>
<!-- &nbsp; indentation survives Astro's whitespace trimming -->
<p class="whitespace-pre text-muted-foreground">&nbsp;&nbsp;23:41:05 up 9 days, 4:21, 1 user, load average: 0.42, 0.38, 0.35</p>
<p>
<span class="text-primary">root@db-01:~#</span>
<span class="animate-pulse text-foreground">█</span>
</p>
</div>
</div>
<div class="mt-3 flex justify-end">
<kbd
class="rounded border border-border bg-muted px-2 py-0.5 font-mono text-[10px] text-muted-foreground"
>
Ctrl+Alt+Del
</kbd>
</div>
</section>
<!--
05 · POWER USER — shortcut sheet.
Two columns on sm and up, single column below sm.
-->
<section
id="power-user"
aria-labelledby="features-power-user-title"
class="scroll-mt-24 py-16 sm:py-20"
>
<div class="flex items-baseline gap-3">
<span class="shrink-0 font-mono text-sm text-primary">05</span>
<h2
id="features-power-user-title"
class="text-2xl font-semibold tracking-tighter text-foreground sm:text-3xl"
>
Built for the daily grind
</h2>
</div>
<p class="mt-3 max-w-[58ch] text-[15px] leading-relaxed text-muted-foreground">
Tasks and events stream over WebSocket, with polling as the fallback.
Cmd/Ctrl+K opens the command palette. The tray menu keeps one-click
actions one click away.
</p>
<div class="mt-8 grid gap-x-12 gap-y-0 border-t border-border sm:grid-cols-2">
<div class="flex items-center justify-between gap-4 border-b border-border py-4">
<kbd
class="shrink-0 rounded border border-border bg-muted px-2 py-0.5 font-mono text-[10px] text-muted-foreground"
>
Cmd/Ctrl + K
</kbd>
<p class="text-right text-sm text-muted-foreground">Command palette</p>
</div>
<div class="flex items-center justify-between gap-4 border-b border-border py-4">
<span class="shrink-0 font-mono text-[11px] text-primary">WebSocket</span>
<p class="text-right text-sm text-muted-foreground">
Live task &amp; event stream
</p>
</div>
<div class="flex items-center justify-between gap-4 border-b border-border py-4">
<span class="shrink-0 font-mono text-[11px] text-primary">Tray menu</span>
<p class="text-right text-sm text-muted-foreground">
Show / hide · connections · quit
</p>
</div>
<div class="flex items-center justify-between gap-4 border-b border-border py-4">
<span class="shrink-0 font-mono text-[11px] text-primary">10s</span>
<p class="text-right text-sm text-muted-foreground">
Connection status polling
</p>
</div>
</div>
</section>
</div>
</div>
</section>
+20 -56
View File
@@ -2,77 +2,41 @@
const year = new Date().getFullYear()
---
<!--
Footer — minimal, one row on desktop, stacked on mobile.
-->
<footer class="border-t border-border">
<footer class="border-t border-white/[0.06] bg-[#08080a]">
<div class="mx-auto max-w-7xl px-6 py-10 lg:px-8">
<div class="flex flex-col gap-8 md:flex-row md:items-start md:justify-between">
<div>
<div class="flex items-center gap-2.5">
<span class="flex h-8 w-8 items-center justify-center rounded-md bg-primary/15 text-primary">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="h-4 w-4"
aria-hidden="true"
>
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
</svg>
<span class="flex h-7 w-7 items-center justify-center rounded-[8px] bg-primary text-primary-foreground">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-3.5 w-3.5" aria-hidden="true"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" /></svg>
</span>
<span class="text-sm font-semibold tracking-tight text-foreground">Clustri</span>
<span class="hidden font-mono text-[9px] uppercase tracking-[0.14em] text-muted-foreground sm:inline">
Ops Console
</span>
<span class="hidden font-mono text-[9px] uppercase tracking-[0.14em] text-muted-foreground sm:inline">Ops Console</span>
</div>
<p class="mt-3 font-mono text-[11px] text-muted-foreground">v0.1.0</p>
<p class="mt-3 font-mono text-[11px] text-muted-foreground">v0.1.0 · MIT · Not affiliated with Proxmox.</p>
</div>
<div class="flex flex-col gap-8 sm:flex-row sm:gap-16">
<nav aria-label="Navigate" class="space-y-2.5">
<p class="text-[11px] font-mono uppercase tracking-[0.14em] text-muted-foreground">
Navigate
</p>
<a href="#failover" class="block text-[13px] text-muted-foreground transition-colors hover:text-foreground">
Features
</a>
<a href="#security" class="block text-[13px] text-muted-foreground transition-colors hover:text-foreground">
Security
</a>
<a href="#console" class="block text-[13px] text-muted-foreground transition-colors hover:text-foreground">
Console
</a>
<a href="#architecture" class="block text-[13px] text-muted-foreground transition-colors hover:text-foreground">
Architecture
</a>
<a href="#download" class="block text-[13px] text-muted-foreground transition-colors hover:text-foreground">
Download
</a>
<nav aria-label="Navigate" class="space-y-2">
<p class="font-mono text-[10px] uppercase tracking-[0.14em] text-muted-foreground">Navigate</p>
<a href="#failover-playground" class="block font-mono text-[12px] text-muted-foreground transition-colors hover:text-foreground">Failover demo</a>
<a href="#security" class="block font-mono text-[12px] text-muted-foreground transition-colors hover:text-foreground">Security</a>
<a href="#tour" class="block font-mono text-[12px] text-muted-foreground transition-colors hover:text-foreground">Tour</a>
<a href="#console" class="block font-mono text-[12px] text-muted-foreground transition-colors hover:text-foreground">Console</a>
<a href="#architecture" class="block font-mono text-[12px] text-muted-foreground transition-colors hover:text-foreground">Architecture</a>
<a href="#download" class="block font-mono text-[12px] text-muted-foreground transition-colors hover:text-foreground">Download</a>
</nav>
<nav aria-label="Source" class="space-y-2.5">
<p class="text-[11px] font-mono uppercase tracking-[0.14em] text-muted-foreground">
Source
</p>
<a href="#" class="block text-[13px] text-muted-foreground transition-colors hover:text-foreground">
GitHub
</a>
<a href="#" class="block text-[13px] text-muted-foreground transition-colors hover:text-foreground">
Build from source
</a>
<nav aria-label="Source" class="space-y-2">
<p class="font-mono text-[10px] uppercase tracking-[0.14em] text-muted-foreground">Source</p>
<a href="#" class="block font-mono text-[12px] text-muted-foreground transition-colors hover:text-foreground">GitHub</a>
<a href="#" class="block font-mono text-[12px] text-muted-foreground transition-colors hover:text-foreground">Build from source</a>
</nav>
</div>
</div>
<div class="mt-10 flex flex-col gap-2 border-t border-border pt-6 md:flex-row md:items-center md:justify-between">
<p class="text-[12px] text-muted-foreground">© {year} Clustri. MIT license.</p>
<p class="text-[12px] text-muted-foreground">Not affiliated with Proxmox.</p>
<div class="mt-10 flex flex-col gap-2 border-t border-white/[0.06] pt-6 font-mono text-[11px] text-muted-foreground/60 md:flex-row md:items-center md:justify-between">
<p>© {year} Clustri. MIT license.</p>
<p class="flex items-center gap-2">Press <kbd class="rounded border border-white/[0.08] bg-white/[0.04] px-1.5 py-0.5 text-[10px]">⌘K</kbd> anywhere</p>
</div>
</div>
</footer>
+61 -64
View File
@@ -2,76 +2,73 @@
import AppWindowMockup from './AppWindowMockup.astro'
---
<section class="relative overflow-hidden">
<!-- Copy block -->
<div class="hero-copy mx-auto max-w-7xl px-6 pt-20 lg:px-8 lg:pt-24">
<p class="font-mono text-[11px] uppercase tracking-[0.2em] text-muted-foreground">
OPS CONSOLE
</p>
<h1 class="mt-4 max-w-3xl text-4xl font-semibold leading-[1.05] tracking-tighter text-foreground sm:text-5xl lg:text-6xl">
One <span class="text-primary">console</span> for your Proxmox cluster.
</h1>
<p class="mt-5 max-w-2xl text-base leading-relaxed text-muted-foreground sm:text-lg">
Manage Proxmox VE nodes, VMs, storage, and backups from a desktop app that keeps working when a node goes down.
</p>
<div class="mt-8 flex flex-wrap items-center gap-3">
<a
href="#"
class="rounded-md bg-primary px-5 py-2.5 text-sm font-semibold text-primary-foreground transition hover:brightness-110 active:scale-[0.98]"
>
Get Clustri
</a>
<a
href="#"
class="rounded-md border border-border bg-background px-5 py-2.5 text-sm font-semibold text-foreground transition hover:bg-accent/60 active:scale-[0.98]"
>
Read the docs
</a>
</div>
<p class="mt-8 font-mono text-[11px] tabular-nums text-muted-foreground">
<span>v0.1.0</span><span aria-hidden="true"> · </span><span>Linux</span><span aria-hidden="true"> · </span><span>macOS</span><span aria-hidden="true"> · </span><span>Windows</span>
</p>
</div>
<section class="relative overflow-hidden border-b border-white/[0.06]">
<!-- Subtle grid -->
<div class="pointer-events-none absolute inset-0 grid-bg opacity-[0.35] [mask-image:radial-gradient(ellipse_80%_60%_at_50%_0%,black_40%,transparent_85%)]" aria-hidden="true"></div>
<!-- Product mockup, full-bleed below the copy -->
<div class="hero-mockup mt-16 sm:mt-20">
<div class="mx-auto max-w-6xl px-6 lg:px-8">
<div class="pointer-events-none select-none [mask-image:linear-gradient(to_bottom,black_0%,black_78%,transparent_100%)]">
<AppWindowMockup />
<!-- Faint topology lines top-right -->
<svg class="pointer-events-none absolute right-0 top-0 hidden h-[520px] w-[700px] opacity-[0.07] lg:block" viewBox="0 0 700 520" fill="none" aria-hidden="true">
<circle cx="520" cy="120" r="3" fill="white" /><circle cx="620" cy="200" r="3" fill="white" /><circle cx="480" cy="260" r="3" fill="white" />
<path d="M520 120 L620 200 M520 120 L480 260 M620 200 L480 260" stroke="white" stroke-width="1" stroke-dasharray="6 6" />
<circle cx="520" cy="120" r="18" stroke="white" stroke-width="0.7" opacity="0.5" />
<circle cx="620" cy="200" r="18" stroke="white" stroke-width="0.7" opacity="0.5" />
<circle cx="480" cy="260" r="18" stroke="white" stroke-width="0.7" opacity="0.5" />
</svg>
<div class="relative mx-auto max-w-7xl px-6 lg:px-8">
<!-- Copy -->
<div class="pt-28 sm:pt-36 lg:pt-40">
<div class="flex flex-wrap items-center gap-3 font-mono text-[11px]">
<span class="inline-flex items-center gap-1.5 rounded-full border border-success/20 bg-success/10 px-2.5 py-1 text-success">
<span class="h-1.5 w-1.5 rounded-full bg-success shadow-[0_0_6px] shadow-success/60"></span>
Live
</span>
<span class="text-muted-foreground">v0.1.0</span>
<span class="text-white/20">·</span>
<span class="text-muted-foreground">Linux · macOS · Windows</span>
<span class="hidden items-center gap-1.5 text-muted-foreground/60 sm:inline-flex">
<span class="text-white/10">—</span>
<button data-cmdk-trigger class="rounded border border-white/10 px-1.5 py-0.5 text-[10px] hover:border-white/15 hover:text-foreground">⌘K</button>
<span>to jump</span>
</span>
</div>
<h1 class="mt-6 max-w-[14ch] text-[2.6rem] font-semibold leading-[0.92] tracking-[-0.04em] text-foreground sm:text-[3.5rem] lg:text-[4.6rem]">
Your cluster,<br />
<span class="text-muted-foreground">not your problem.</span>
</h1>
<p class="mt-5 max-w-[52ch] text-[15px] leading-relaxed text-muted-foreground sm:text-[16px]">
Clustri is a cross-platform desktop console for Proxmox VE. Manage nodes, VMs, storage and backups — and keep working when a node goes down.
</p>
<div class="mt-8 flex flex-wrap items-center gap-3">
<a href="#download" class="rounded-[10px] bg-primary px-6 py-3 text-sm font-semibold text-primary-foreground transition hover:bg-primary/90 active:scale-[0.98]">Get Clustri</a>
<a href="#failover-playground" class="rounded-[10px] border border-white/[0.08] bg-white/[0.04] px-6 py-3 text-sm font-semibold text-foreground transition hover:border-white/15 hover:bg-white/[0.06] active:scale-[0.98]">Try the failover demo ↓</a>
</div>
<div class="mt-6 flex flex-wrap gap-x-4 gap-y-1 font-mono text-[10px] uppercase tracking-[0.08em] text-muted-foreground/70">
<span>Rust · Tauri 2</span><span class="text-white/10">/</span><span>React 19 · Tailwind v4</span><span class="text-white/10">/</span><span>MIT licensed</span>
</div>
</div>
</div>
<!-- Close the hero -->
<div class="mt-16 border-b border-border" aria-hidden="true"></div>
<!-- Mockup -->
<div class="mt-12 sm:mt-16">
<div class="relative overflow-hidden rounded-xl border border-white/[0.08] bg-card shadow-card" data-parallax="0.06">
<!-- thin orange top rule -->
<div class="h-px w-full bg-primary/40" aria-hidden="true"></div>
<div class="p-2 sm:p-3">
<AppWindowMockup />
</div>
</div>
<p class="mt-3 text-center font-mono text-[10px] text-muted-foreground/60">Live demo — values tick, tasks stream. The real app looks like this.</p>
</div>
</div>
</section>
<style>
@keyframes hero-rise {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes hero-fade {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.hero-copy {
animation: hero-rise 0.7s cubic-bezier(0.16, 1, 0.3, 1) both;
}
.hero-mockup {
animation: hero-fade 1s ease 0.15s both;
@keyframes hero-in {
from { opacity: 0; transform: translateY(14px); }
to { opacity: 1; transform: translateY(0); }
}
</style>
+26 -41
View File
@@ -1,52 +1,37 @@
<!--
Nav — sticky site header. Renders on a single line at lg, height capped at
64px (h-16). Links point at in-page feature anchors.
-->
<header class="sticky top-0 z-50 border-b border-border bg-background/85 backdrop-blur-sm">
<nav class="mx-auto flex h-16 max-w-7xl items-center justify-between gap-6 px-6 lg:px-8" aria-label="Primary">
<header class="nav-header fixed top-0 left-0 right-0 z-50 px-4 pt-4">
<nav
class="nav-pill mx-auto flex h-[46px] max-w-6xl items-center justify-between gap-6 rounded-[12px] border border-white/[0.07] bg-[#0f0f14]/70 px-4 backdrop-blur-xl transition-all duration-400"
aria-label="Primary"
>
<a href="#" class="flex items-center gap-2.5">
<span class="flex h-8 w-8 items-center justify-center rounded-md bg-primary/15 text-primary">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="h-4 w-4"
aria-hidden="true"
>
<span class="flex h-7 w-7 items-center justify-center rounded-[8px] bg-primary text-primary-foreground">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-3.5 w-3.5" aria-hidden="true">
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
</svg>
</span>
<span class="text-sm font-semibold tracking-tight text-foreground">Clustri</span>
<span class="hidden font-mono text-[9px] uppercase tracking-[0.14em] text-muted-foreground sm:inline">
Ops Console
</span>
<span class="hidden font-mono text-[9px] uppercase tracking-[0.14em] text-muted-foreground sm:inline">Ops Console</span>
</a>
<div class="hidden items-center gap-6 md:flex">
<a href="#failover" class="text-[13px] font-medium text-muted-foreground transition-colors hover:text-foreground">
Features
</a>
<a href="#security" class="text-[13px] font-medium text-muted-foreground transition-colors hover:text-foreground">
Security
</a>
<a href="#console" class="text-[13px] font-medium text-muted-foreground transition-colors hover:text-foreground">
Console
</a>
<a href="#architecture" class="text-[13px] font-medium text-muted-foreground transition-colors hover:text-foreground">
Architecture
</a>
<div class="hidden items-center gap-5 md:flex">
<a href="#failover-playground" class="font-mono text-[11px] uppercase tracking-[0.08em] text-muted-foreground transition-colors hover:text-foreground">Failover</a>
<a href="#security" class="font-mono text-[11px] uppercase tracking-[0.08em] text-muted-foreground transition-colors hover:text-foreground">Security</a>
<a href="#tour" class="font-mono text-[11px] uppercase tracking-[0.08em] text-muted-foreground transition-colors hover:text-foreground">Tour</a>
<a href="#architecture" class="font-mono text-[11px] uppercase tracking-[0.08em] text-muted-foreground transition-colors hover:text-foreground">Architecture</a>
</div>
<a
href="#"
class="rounded-md bg-primary px-4 py-2 text-[13px] font-semibold text-primary-foreground transition hover:brightness-110 active:scale-[0.98]"
>
Get Clustri
</a>
<div class="flex items-center gap-2">
<button data-cmdk-trigger class="hidden sm:flex items-center gap-1.5 rounded-md border border-white/[0.06] bg-white/[0.04] px-2.5 py-1.5 font-mono text-[11px] text-muted-foreground transition-colors hover:border-white/10 hover:text-foreground" aria-label="Open command palette">
<span>⌘K</span>
</button>
<a href="#download" class="rounded-[10px] bg-primary px-4 py-2 text-[13px] font-semibold text-primary-foreground transition-all hover:bg-primary/90 active:scale-[0.98]">Get Clustri</a>
</div>
</nav>
</header>
<style>
.nav-scrolled .nav-pill {
border-color: rgba(255, 255, 255, 0.08);
background-color: oklch(0.11 0.008 275 / 0.85);
}
</style>
+85
View File
@@ -0,0 +1,85 @@
<section id="tour" aria-labelledby="tour-title" class="scroll-mt-24 py-20 sm:py-28">
<div class="mx-auto max-w-7xl px-6 lg:px-8">
<div class="flex flex-wrap items-end justify-between gap-6">
<div data-reveal>
<p class="font-mono text-[11px] uppercase tracking-[0.18em] text-primary">Tour</p>
<h2 id="tour-title" class="mt-4 max-w-[14ch] text-3xl font-semibold tracking-[-0.03em] text-foreground sm:text-4xl">
Feels like the Proxmox UI,<br /><span class="text-muted-foreground">without the browser.</span>
</h2>
<p class="mt-4 max-w-[48ch] text-[15px] leading-relaxed text-muted-foreground">
Native window, keyboard-first, no electron bloat. Tabs switch instantly — the data underneath stays live via WebSocket or 10s polling.
</p>
</div>
<!-- Tabs -->
<div class="flex gap-1 rounded-lg border border-white/[0.06] bg-card p-1" role="tablist" aria-label="Screenshot tour" data-reveal data-reveal-delay="100">
<button role="tab" aria-selected="true" data-tour-tab="vms" class="rounded-md bg-primary px-3.5 py-1.5 font-mono text-[11px] font-semibold text-primary-foreground">VMs</button>
<button role="tab" aria-selected="false" data-tour-tab="console" class="rounded-md px-3.5 py-1.5 font-mono text-[11px] font-medium text-muted-foreground hover:text-foreground">Console</button>
<button role="tab" aria-selected="false" data-tour-tab="backups" class="rounded-md px-3.5 py-1.5 font-mono text-[11px] font-medium text-muted-foreground hover:text-foreground">Backups</button>
</div>
</div>
<!-- Frame -->
<div class="mt-10 overflow-hidden rounded-xl border border-white/[0.07] bg-card shadow-card" data-reveal data-reveal-delay="160">
<div class="flex h-9 items-center justify-between border-b border-white/[0.06] bg-background px-4">
<div class="flex items-center gap-1.5" aria-hidden="true">
<span class="h-2.5 w-2.5 rounded-full border border-white/[0.06] bg-white/10"></span>
<span class="h-2.5 w-2.5 rounded-full border border-white/[0.06] bg-white/10"></span>
<span class="h-2.5 w-2.5 rounded-full border border-white/[0.06] bg-white/10"></span>
</div>
<p id="tour-frame-label" class="font-mono text-[10px] tabular-nums text-muted-foreground">Virtual Machines — 4 VMs across the cluster</p>
<span class="hidden font-mono text-[10px] text-success sm:inline">● live</span>
</div>
<div class="relative bg-[#0e0e12]">
<img id="tour-img" src="/screenshots/vms.png" alt="Clustri VM list showing running and stopped VMs" width="1600" height="1000" class="h-auto w-full object-cover object-top transition-opacity duration-300" loading="lazy" decoding="async" />
</div>
</div>
<div class="mt-4 grid gap-3 sm:grid-cols-3">
<div class="rounded-lg border border-white/[0.06] bg-card p-4" data-reveal>
<p class="font-mono text-[11px] font-medium uppercase tracking-[0.08em] text-foreground">VMs &amp; Containers</p>
<p class="mt-1.5 text-[13px] leading-relaxed text-muted-foreground">Filter by node, type, status. Start / stop / migrate / console in one click. QEMU and LXC.</p>
</div>
<div class="rounded-lg border border-white/[0.06] bg-card p-4" data-reveal data-reveal-delay="80">
<p class="font-mono text-[11px] font-medium uppercase tracking-[0.08em] text-foreground">Console</p>
<p class="mt-1.5 text-[13px] leading-relaxed text-muted-foreground">noVNC for VMs, xterm.js for CTs. Fullscreen, resize, Ctrl+Alt+Del passthrough.</p>
</div>
<div class="rounded-lg border border-white/[0.06] bg-card p-4" data-reveal data-reveal-delay="160">
<p class="font-mono text-[11px] font-medium uppercase tracking-[0.08em] text-foreground">Backups</p>
<p class="mt-1.5 text-[13px] leading-relaxed text-muted-foreground">Jobs, groups, snapshots. Verify, prune (dry-run), GC, archive download via OS dialog.</p>
</div>
</div>
</div>
</section>
<script>
const img = document.getElementById('tour-img') as HTMLImageElement | null
const label = document.getElementById('tour-frame-label')
const tabs = document.querySelectorAll<HTMLButtonElement>('[data-tour-tab]')
const sources: Record<string, { src: string; label: string; alt: string }> = {
vms: { src: '/screenshots/vms.png', label: 'Virtual Machines — 4 VMs across the cluster', alt: 'Clustri VM list showing running and stopped VMs' },
console: { src: '/screenshots/console.png', label: 'VNC Console — web-01 (QEMU) on pve1', alt: 'Clustri console view with VNC viewer' },
backups: { src: '/screenshots/backups.png', label: 'Backups — datastores, groups and snapshots', alt: 'Clustri backups view with datastores and prune controls' },
}
tabs.forEach((btn) => {
btn.addEventListener('click', () => {
const key = btn.dataset.tourTab!
const s = sources[key]
if (!s || !img || !label) return
tabs.forEach((b) => {
const active = b === btn
b.setAttribute('aria-selected', String(active))
b.className = active
? 'rounded-md bg-primary px-3.5 py-1.5 font-mono text-[11px] font-semibold text-primary-foreground'
: 'rounded-md px-3.5 py-1.5 font-mono text-[11px] font-medium text-muted-foreground hover:text-foreground'
})
img.style.opacity = '0'
setTimeout(() => {
img.src = s.src
img.alt = s.alt
label.textContent = s.label
img.style.opacity = '1'
}, 180)
})
})
</script>
+33
View File
@@ -0,0 +1,33 @@
<section aria-label="Facts" class="border-y border-white/[0.06] bg-[#08080a]">
<div class="mx-auto max-w-7xl px-6 lg:px-8">
<div class="grid grid-cols-2 gap-0 py-8 sm:grid-cols-4" data-stagger>
<div class="flex flex-col items-center gap-1 border-r border-white/[0.06] px-2 py-6" data-stagger-child>
<span class="font-mono text-3xl font-semibold tracking-[-0.03em] tabular-nums text-foreground sm:text-4xl" data-countup="0">0</span>
<span class="font-mono text-[10px] uppercase tracking-[0.14em] text-muted-foreground">Telemetry</span>
<span class="font-mono text-[10px] text-muted-foreground/60">ships with the app</span>
</div>
<div class="flex flex-col items-center gap-1 border-r border-white/[0.06] px-2 py-6 sm:border-r" data-stagger-child>
<span class="font-mono text-3xl font-semibold tracking-[-0.03em] tabular-nums text-foreground sm:text-4xl" data-countup="0">0</span>
<span class="font-mono text-[10px] uppercase tracking-[0.14em] text-muted-foreground">Cloud dependency</span>
<span class="font-mono text-[10px] text-muted-foreground/60">talks to your nodes</span>
</div>
<div class="flex flex-col items-center gap-1 border-r border-white/[0.06] px-2 py-6 max-sm:border-r-0" data-stagger-child>
<span class="font-mono text-3xl font-semibold tracking-[-0.03em] tabular-nums text-primary sm:text-4xl" data-countup="64">0</span>
<span class="font-mono text-[10px] uppercase tracking-[0.14em] text-muted-foreground">Rust tests</span>
<span class="font-mono text-[10px] text-muted-foreground/60">httpmock · rcgen · tempfile</span>
</div>
<div class="flex flex-col items-center gap-1 px-2 py-6" data-stagger-child>
<span class="font-mono text-3xl font-semibold tracking-[-0.03em] tabular-nums text-foreground sm:text-4xl" data-countup="3">0</span>
<span class="font-mono text-[10px] uppercase tracking-[0.14em] text-muted-foreground">Platforms</span>
<span class="font-mono text-[10px] text-muted-foreground/60">Linux · macOS · Windows</span>
</div>
</div>
<!-- mono ticker -->
<div class="overflow-hidden border-t border-white/[0.06] py-3" aria-hidden="true">
<div class="flex gap-6 whitespace-nowrap font-mono text-[10px] uppercase tracking-[0.12em] text-muted-foreground/40">
<span>start</span><span class="text-white/10">·</span><span>stop</span><span class="text-white/10">·</span><span>shutdown</span><span class="text-white/10">·</span><span>reboot</span><span class="text-white/10">·</span><span>suspend</span><span class="text-white/10">·</span><span>resume</span><span class="text-white/10">·</span><span>migrate</span><span class="text-white/10">·</span><span>snapshot</span><span class="text-white/10">·</span><span>backup</span><span class="text-white/10">·</span><span>restore</span><span class="text-white/10">·</span><span>prune</span><span class="text-white/10">·</span><span>GC</span><span class="text-white/10">·</span><span>noVNC</span><span class="text-white/10">·</span><span>xterm.js</span>
</div>
</div>
</div>
</section>