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
+8 -1
View File
@@ -9,7 +9,8 @@
"version": "0.1.0",
"dependencies": {
"@fontsource-variable/geist": "^5.3.0",
"@fontsource-variable/jetbrains-mono": "^5.3.0"
"@fontsource-variable/jetbrains-mono": "^5.3.0",
"gsap": "^3.15.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.3",
@@ -4187,6 +4188,12 @@
"dev": true,
"license": "ISC"
},
"node_modules/gsap": {
"version": "3.15.0",
"resolved": "https://registry.npmjs.org/gsap/-/gsap-3.15.0.tgz",
"integrity": "sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A==",
"license": "Standard 'no charge' license: https://gsap.com/standard-license."
},
"node_modules/h3": {
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/h3/-/h3-1.15.11.tgz",
+2 -1
View File
@@ -10,7 +10,8 @@
},
"dependencies": {
"@fontsource-variable/geist": "^5.3.0",
"@fontsource-variable/jetbrains-mono": "^5.3.0"
"@fontsource-variable/jetbrains-mono": "^5.3.0",
"gsap": "^3.15.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.3",
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

+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>
+3
View File
@@ -17,5 +17,8 @@ const { title = 'Clustri', description = 'A desktop client for managing Proxmox
</head>
<body>
<slot />
<script>
import '../scripts/reveal.ts'
</script>
</body>
</html>
+8
View File
@@ -2,10 +2,14 @@
import Base from '../layouts/Base.astro'
import Nav from '../components/Nav.astro'
import Hero from '../components/Hero.astro'
import FailoverPlayground from '../components/FailoverPlayground.astro'
import FeatureBlocks from '../components/FeatureBlocks.astro'
import ScreenshotTour from '../components/ScreenshotTour.astro'
import ArchitectureStrip from '../components/ArchitectureStrip.astro'
import StatsStrip from '../components/StatsStrip.astro'
import DownloadCta from '../components/DownloadCta.astro'
import Footer from '../components/Footer.astro'
import CommandPalette from '../components/CommandPalette.astro'
---
<Base
@@ -15,9 +19,13 @@ import Footer from '../components/Footer.astro'
<Nav />
<main>
<Hero />
<FailoverPlayground />
<FeatureBlocks />
<ScreenshotTour />
<ArchitectureStrip />
<StatsStrip />
<DownloadCta />
</main>
<Footer />
<CommandPalette />
</Base>
+312
View File
@@ -0,0 +1,312 @@
import gsap from 'gsap'
import { ScrollTrigger } from 'gsap/ScrollTrigger'
gsap.registerPlugin(ScrollTrigger)
const prefersReduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches
function initReveal() {
const els = document.querySelectorAll<HTMLElement>('[data-reveal]')
if (!els.length) return
if (prefersReduced) {
els.forEach((el) => el.classList.add('revealed'))
return
}
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const el = entry.target as HTMLElement
const delay = parseInt(el.dataset.revealDelay || '0', 10)
setTimeout(() => el.classList.add('revealed'), delay)
observer.unobserve(el)
}
})
},
{ threshold: 0.12, rootMargin: '0px 0px -40px 0px' },
)
els.forEach((el) => observer.observe(el))
}
function initStagger() {
const groups = document.querySelectorAll<HTMLElement>('[data-stagger]')
if (!groups.length) return
if (prefersReduced) {
groups.forEach((g) => {
g.querySelectorAll<HTMLElement>('[data-stagger-child]').forEach((c) => c.classList.add('staggered'))
})
return
}
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const group = entry.target as HTMLElement
const children = group.querySelectorAll<HTMLElement>('[data-stagger-child]')
children.forEach((child, i) => {
setTimeout(() => child.classList.add('staggered'), i * 70)
})
observer.unobserve(group)
}
})
},
{ threshold: 0.1, rootMargin: '0px 0px -30px 0px' },
)
groups.forEach((g) => observer.observe(g))
}
function initNav() {
const header = document.querySelector<HTMLElement>('.nav-header')
if (!header) return
let ticking = false
function onScroll() {
if (ticking) return
ticking = true
requestAnimationFrame(() => {
header.classList.toggle('nav-scrolled', window.scrollY > 60)
ticking = false
})
}
window.addEventListener('scroll', onScroll, { passive: true })
onScroll()
}
function initParallax() {
if (prefersReduced) return
const targets = document.querySelectorAll<HTMLElement>('[data-parallax]')
if (!targets.length) return
let ticking = false
function onScroll() {
if (ticking) return
ticking = true
requestAnimationFrame(() => {
targets.forEach((el) => {
const speed = parseFloat(el.dataset.parallax || '0.12')
const rect = el.getBoundingClientRect()
el.style.transform = `translateY(${rect.top * speed}px)`
})
ticking = false
})
}
window.addEventListener('scroll', onScroll, { passive: true })
}
function initTerminalTyping() {
if (prefersReduced) return
const terminals = document.querySelectorAll<HTMLElement>('[data-terminal]')
if (!terminals.length) return
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const container = entry.target as HTMLElement
const lines = container.querySelectorAll<HTMLElement>('[data-line]')
lines.forEach((line, i) => {
setTimeout(() => {
line.classList.add('line-visible')
const out = line.querySelector<HTMLElement>('[data-output]')
if (out) setTimeout(() => out.classList.add('output-visible'), 280)
}, i * 550)
})
observer.unobserve(entry.target)
}
})
},
{ threshold: 0.3 },
)
terminals.forEach((el) => observer.observe(el))
}
function initCountUp() {
const els = document.querySelectorAll<HTMLElement>('[data-countup]')
if (!els.length) return
if (prefersReduced) {
els.forEach((el) => { el.textContent = el.dataset.countup || '' })
return
}
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const el = entry.target as HTMLElement
const target = parseInt(el.dataset.countup || '0', 10)
const suffix = el.dataset.countupSuffix || ''
const obj = { v: 0 }
gsap.to(obj, {
v: target,
duration: 1.2,
ease: 'power3.out',
onUpdate: () => { el.textContent = Math.round(obj.v).toString() + suffix },
onComplete: () => { el.textContent = target.toString() + suffix },
})
observer.unobserve(el)
}
})
},
{ threshold: 0.5 },
)
els.forEach((el) => observer.observe(el))
}
function initLiveDashboard() {
if (prefersReduced) return
const root = document.querySelector<HTMLElement>('[data-live-dashboard]')
if (!root) return
const cpuEls = root.querySelectorAll<HTMLElement>('[data-live-cpu]')
const ramEls = root.querySelectorAll<HTMLElement>('[data-live-ram]')
const barEls = root.querySelectorAll<HTMLElement>('[data-live-bar]')
const taskList = root.querySelector<HTMLElement>('[data-live-tasks]')
const pool = [
{ chip: 'RUNNING', cls: 'border-warning/30 bg-warning/10 text-warning', text: 'start VM 104', node: 'pve3', time: 'now' },
{ chip: 'OK', cls: 'border-success/25 bg-success/10 text-success', text: 'snapshot vm-100', node: 'pve1', time: 'now' },
{ chip: 'RUNNING', cls: 'border-warning/30 bg-warning/10 text-warning', text: 'migrate VM 102 → pve3', node: 'pve1 → pve3', time: 'now' },
{ chip: 'OK', cls: 'border-success/25 bg-success/10 text-success', text: 'backup ct-201 → pbs', node: 'pve2', time: 'now' },
{ chip: 'DONE', cls: 'border-white/10 bg-white/[0.04] text-muted-foreground', text: 'prune datastore main', node: 'pbs', time: 'now' },
]
let poolIdx = 0
function jitterBars() {
barEls.forEach((bar) => {
const base = parseFloat(bar.dataset.base || '30')
const next = Math.max(8, Math.min(92, base + (Math.random() - 0.5) * 10))
bar.style.width = next.toFixed(0) + '%'
bar.dataset.base = next.toFixed(1)
})
}
function jitterNumbers() {
cpuEls.forEach((el) => {
const base = parseFloat(el.dataset.base || '32')
const next = Math.max(6, Math.min(88, base + (Math.random() - 0.5) * 6))
el.textContent = next.toFixed(0) + '%'
el.dataset.base = next.toFixed(1)
el.classList.remove('ticker-flash')
void el.offsetWidth
el.classList.add('ticker-flash')
})
ramEls.forEach((el) => {
const base = parseFloat(el.dataset.base || '24')
const next = Math.max(8, Math.min(85, base + (Math.random() - 0.5) * 5))
el.textContent = next.toFixed(0) + '%'
el.dataset.base = next.toFixed(1)
})
}
function pushTask() {
if (!taskList) return
const item = pool[poolIdx % pool.length]
poolIdx++
const row = document.createElement('div')
row.className = 'flex items-center justify-between gap-3 px-3 py-2 opacity-0 translate-y-1 transition-all duration-500'
row.innerHTML = `<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 ${item.cls}">${item.chip}</span><p class="truncate font-mono text-[11px] tabular-nums text-foreground">${item.text}<span class="text-muted-foreground"> · ${item.node}</span></p></div><span class="shrink-0 font-mono text-[10px] tabular-nums text-muted-foreground">${item.time}</span>`
taskList.prepend(row)
requestAnimationFrame(() => {
row.classList.remove('opacity-0', 'translate-y-1')
})
const rows = taskList.children
if (rows.length > 4) taskList.removeChild(rows[rows.length - 1]!)
}
setInterval(() => { jitterBars(); jitterNumbers() }, 1800)
setInterval(pushTask, 3200)
}
function initCommandPalette() {
const overlay = document.getElementById('cmdk-overlay') as HTMLElement | null
const panel = document.getElementById('cmdk-panel') as HTMLElement | null
const input = document.getElementById('cmdk-input') as HTMLInputElement | null
const list = document.getElementById('cmdk-list') as HTMLElement | null
if (!overlay || !panel || !input || !list) return
const commands = [
{ label: 'Go to Features', desc: 'Failover, security, VMs', action: () => scrollToId('features') },
{ label: 'Go to Console', desc: 'noVNC · xterm.js', action: () => scrollToId('console') },
{ label: 'Go to Architecture', desc: 'Request path diagram', action: () => scrollToId('architecture') },
{ label: 'Go to Download', desc: 'Releases · coming soon', action: () => scrollToId('download') },
{ label: 'Kill pve1 (demo)', desc: 'Trigger the failover playground', action: () => { scrollToId('failover-playground'); setTimeout(() => document.getElementById('fp-kill-pve1')?.click(), 600) } },
{ label: 'Copy install command', desc: 'npm run tauri dev', action: () => navigator.clipboard.writeText('npm run tauri dev') },
]
let filtered = [...commands]
let selected = 0
let open = false
function scrollToId(id: string) {
document.getElementById(id)?.scrollIntoView({ behavior: prefersReduced ? 'auto' : 'smooth', block: 'start' })
}
function render() {
list!.innerHTML = ''
filtered.forEach((cmd, i) => {
const row = document.createElement('button')
row.type = 'button'
row.className = `flex w-full items-center justify-between gap-3 rounded-md px-3 py-2.5 text-left transition-colors ${i === selected ? 'bg-white/[0.06] text-foreground' : 'text-muted-foreground hover:bg-white/[0.04] hover:text-foreground'}`
row.innerHTML = `<span class="min-w-0"><span class="block text-[13px] font-medium leading-none">${cmd.label}</span><span class="mt-1 block font-mono text-[11px]">${cmd.desc}</span></span><span class="shrink-0 font-mono text-[10px] text-muted-foreground/60">${i === selected ? '↵' : ''}</span>`
row.addEventListener('click', () => { close(); cmd.action() })
list!.appendChild(row)
})
}
function openPalette() {
if (open) return
open = true
overlay!.classList.remove('hidden')
overlay!.classList.add('flex')
input!.value = ''
filtered = [...commands]
selected = 0
render()
requestAnimationFrame(() => input!.focus())
document.body.style.overflow = 'hidden'
}
function close() {
if (!open) return
open = false
overlay!.classList.add('hidden')
overlay!.classList.remove('flex')
document.body.style.overflow = ''
}
input!.addEventListener('input', () => {
const q = input!.value.toLowerCase().trim()
filtered = q ? commands.filter((c) => (c.label + c.desc).toLowerCase().includes(q)) : [...commands]
selected = 0
render()
})
overlay!.addEventListener('click', (e) => { if (e.target === overlay) close() })
window.addEventListener('keydown', (e) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
e.preventDefault()
if (open) close(); else openPalette()
return
}
if (!open) return
if (e.key === 'Escape') { e.preventDefault(); close() }
if (e.key === 'ArrowDown') { e.preventDefault(); selected = Math.min(selected + 1, filtered.length - 1); render() }
if (e.key === 'ArrowUp') { e.preventDefault(); selected = Math.max(selected - 1, 0); render() }
if (e.key === 'Enter') { e.preventDefault(); const cmd = filtered[selected]; if (cmd) { close(); cmd.action() } }
})
document.querySelectorAll<HTMLElement>('[data-cmdk-trigger]').forEach((el) => {
el.addEventListener('click', (ev) => { ev.preventDefault(); openPalette() })
})
}
function init() {
initReveal()
initStagger()
initNav()
initParallax()
initTerminalTyping()
initCountUp()
initLiveDashboard()
initCommandPalette()
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init)
else init()
+157 -94
View File
@@ -1,9 +1,6 @@
@import 'tailwindcss';
@theme {
/* ------------------------------------------------------------------
* Typography offline-safe variable fonts (bundled via fontsource)
* ------------------------------------------------------------------ */
--font-sans:
'Geist Variable', 'Geist', ui-sans-serif, system-ui, -apple-system,
'Segoe UI', sans-serif;
@@ -11,137 +8,203 @@
'JetBrains Mono Variable', 'JetBrains Mono', ui-monospace, 'SF Mono',
Menlo, monospace;
/* ------------------------------------------------------------------
* Radius scale tighter on inner elements, softer on containers
* ------------------------------------------------------------------ */
--radius: 0.625rem;
--radius-sm: 0.375rem;
--radius-md: 0.5rem;
--radius-lg: 0.625rem;
--radius-xl: 0.75rem;
/* ------------------------------------------------------------------
* Dark theme blue-black ink surfaces, cool neutrals, muted orange.
* The marketing site is dark-only: the app's `.dark` palette is the
* single theme (no light/dark toggle by design).
* ------------------------------------------------------------------ */
--color-background: oklch(0.16 0.011 250);
--color-foreground: oklch(0.965 0.004 250);
--color-card: oklch(0.19 0.012 250);
--color-card-foreground: oklch(0.965 0.004 250);
--color-popover: oklch(0.215 0.013 250);
--color-popover-foreground: oklch(0.965 0.004 250);
--color-primary: oklch(0.72 0.15 60);
--color-primary-foreground: oklch(0.18 0.02 50);
--color-secondary: oklch(0.235 0.011 250);
--color-secondary-foreground: oklch(0.94 0.004 250);
--color-muted: oklch(0.235 0.011 250);
--color-muted-foreground: oklch(0.655 0.012 250);
--color-accent: oklch(0.275 0.012 250);
--color-accent-foreground: oklch(0.965 0.004 250);
--color-destructive: oklch(0.645 0.18 25);
/* Dark — near-black with warm charcoal, not indigo wash */
--color-background: oklch(0.11 0.008 275);
--color-foreground: oklch(0.965 0.004 275);
--color-card: oklch(0.155 0.012 275);
--color-card-foreground: oklch(0.965 0.004 275);
--color-popover: oklch(0.18 0.014 275);
--color-popover-foreground: oklch(0.965 0.004 275);
--color-primary: oklch(0.72 0.16 52);
--color-primary-foreground: oklch(0.14 0.02 50);
--color-secondary: oklch(0.19 0.012 275);
--color-secondary-foreground: oklch(0.94 0.004 275);
--color-muted: oklch(0.19 0.012 275);
--color-muted-foreground: oklch(0.6 0.012 275);
--color-accent: oklch(0.22 0.012 275);
--color-accent-foreground: oklch(0.965 0.004 275);
--color-destructive: oklch(0.62 0.18 25);
--color-destructive-foreground: oklch(0.97 0.015 25);
--color-border: oklch(0.255 0.012 250);
--color-input: oklch(0.3 0.013 250);
--color-ring: oklch(0.72 0.15 60);
--color-border: oklch(0.21 0.01 275);
--color-input: oklch(0.24 0.01 275);
--color-ring: oklch(0.72 0.16 52);
/* Semantic status colors — data semantics, not UI accents */
--color-success: oklch(0.7 0.14 155);
--color-warning: oklch(0.76 0.13 80);
--color-info: oklch(0.68 0.11 245);
/* Tinted card shadow — carries the cool hue of the dark surface */
--shadow-card:
0 1px 2px 0 oklch(0.06 0.01 250 / 0.4), 0 2px 10px -2px oklch(0.06 0.01 250 / 0.35);
0 1px 0 0 oklch(1 0 0 / 0.04) inset, 0 1px 2px 0 oklch(0 0 0 / 0.4),
0 8px 24px -8px oklch(0 0 0 / 0.5);
}
/* Grid texture — 1px hairline grid at very low opacity */
.grid-bg {
background-image:
linear-gradient(to right, oklch(1 0 0 / 0.04) 1px, transparent 1px),
linear-gradient(to bottom, oklch(1 0 0 / 0.04) 1px, transparent 1px);
background-size: 32px 32px;
}
.grid-bg-strong {
background-image:
linear-gradient(to right, oklch(1 0 0 / 0.06) 1px, transparent 1px),
linear-gradient(to bottom, oklch(1 0 0 / 0.06) 1px, transparent 1px);
background-size: 24px 24px;
}
/* Hairline rule utility */
.hairline {
border-color: oklch(1 0 0 / 0.06);
}
/* Scroll reveal — variants */
[data-reveal] {
opacity: 0;
transform: translateY(20px);
transition:
opacity 0.7s cubic-bezier(0.16, 1, 0.3, 1),
transform 0.7s cubic-bezier(0.16, 1, 0.3, 1);
will-change: opacity, transform;
}
[data-reveal].revealed {
opacity: 1;
transform: translateY(0);
}
[data-reveal='left'] {
transform: translateX(-20px);
}
[data-reveal='left'].revealed {
transform: translateX(0);
}
[data-reveal='scale'] {
transform: scale(0.97);
}
[data-reveal='scale'].revealed {
transform: scale(1);
}
[data-stagger-child] {
opacity: 0;
transform: translateY(14px);
transition:
opacity 0.55s cubic-bezier(0.16, 1, 0.3, 1),
transform 0.55s cubic-bezier(0.16, 1, 0.3, 1);
will-change: opacity, transform;
}
[data-stagger-child].staggered {
opacity: 1;
transform: translateY(0);
}
.nav-header {
transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1);
}
/* Terminal typing */
[data-line] {
opacity: 0;
transform: translateX(-4px);
transition:
opacity 0.3s ease-out,
transform 0.3s ease-out;
}
[data-line].line-visible {
opacity: 1;
transform: translateX(0);
}
[data-output] {
opacity: 0;
transition: opacity 0.3s ease-out 0.1s;
}
[data-output].output-visible {
opacity: 1;
}
/* Flow arrows — subtle pulse */
@keyframes flow-pulse {
0%,
100% { opacity: 0.35; }
50% { opacity: 1; }
}
.flow-arrow {
animation: flow-pulse 2s ease-in-out infinite;
}
.flow-arrow:nth-child(2) { animation-delay: 0.3s; }
.flow-arrow:nth-child(3) { animation-delay: 0.6s; }
/* Marquee */
@keyframes marquee {
from { transform: translateX(0); }
to { transform: translateX(-50%); }
}
.marquee-track {
animation: marquee 30s linear infinite;
}
/* Ticker flash when value changes */
@keyframes ticker-flash {
0% { color: var(--color-primary); }
100% { color: inherit; }
}
.ticker-flash {
animation: ticker-flash 0.6s ease-out;
}
/* Command palette */
.cmdk-overlay {
backdrop-filter: blur(12px);
background: oklch(0.08 0.01 275 / 0.6);
}
.cmdk-panel {
box-shadow:
0 0 0 1px oklch(1 0 0 / 0.08),
0 20px 60px -12px oklch(0 0 0 / 0.6);
}
@layer base {
* {
@apply border-border;
}
html {
color-scheme: dark;
}
html {
scroll-behavior: smooth;
}
body {
@apply bg-background font-sans text-foreground antialiased;
}
/* Keep native form controls and webview widgets on theme */
::selection {
@apply bg-primary/20 text-foreground;
}
/* Monospace and code contexts always use tabular numerals */
code,
kbd,
samp,
pre {
font-variant-numeric: tabular-nums;
}
/* Consistent focus ring everywhere, including custom elements */
:focus-visible {
@apply outline-none ring-2 ring-ring ring-offset-2 ring-offset-background;
}
/* Native scrollbars tuned to the surface language */
* {
scrollbar-width: thin;
scrollbar-color: var(--color-border) transparent;
}
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
background: transparent;
}
* { @apply border-border; }
html { color-scheme: dark; scroll-behavior: smooth; }
body { @apply bg-background font-sans text-foreground antialiased; }
::selection { @apply bg-primary/20 text-foreground; }
code, kbd, samp, pre { font-variant-numeric: tabular-nums; }
:focus-visible { @apply outline-none ring-2 ring-ring ring-offset-2 ring-offset-background; }
* { scrollbar-width: thin; scrollbar-color: var(--color-border) transparent; }
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb {
background-color: var(--color-border);
border-radius: 999px;
border: 3px solid transparent;
background-clip: padding-box;
}
::-webkit-scrollbar-thumb:hover {
background-color: var(--color-muted-foreground);
background-clip: padding-box;
}
/* Very subtle fixed grain overlay to break digital flatness */
body::after {
content: '';
position: fixed;
inset: 0;
z-index: 60;
pointer-events: none;
opacity: 0.04;
opacity: 0.025;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='200' height='200' filter='url(%23n)'/%3E%3C/svg%3E");
background-repeat: repeat;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
html { scroll-behavior: auto; }
.marquee-track { animation: none !important; }
}
+7 -6
View File
@@ -23,7 +23,7 @@ import { useConnectionStore } from '@/stores/connectionStore'
import { useUIStore } from '@/stores/uiStore'
import { useWebSocket } from '@/hooks/useWebSocket'
import { isTauri, loadConnections, connectToServer, getConnectionStatus, getWebSocketURL, updateTrayMenu } from '@/lib/tauri'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import type { ProxmoxVM } from '@/types/proxmox'
const queryClient = new QueryClient({
@@ -68,13 +68,14 @@ function AppContent() {
// In browser mock mode there is nothing to load, so the app is ready
// immediately. In Tauri mode the persisted connections load on mount.
const [connectionsLoaded, setConnectionsLoaded] = useState(!isTauri())
const bootRan = useRef(false)
// Startup: load persisted connections and auto-reconnect the active one
// Startup: load persisted connections and auto-reconnect the active one.
// React StrictMode mounts this effect twice in development (setup, cleanup,
// setup): the first boot is cancelled by the cleanup, and the second setup
// runs the load again and completes it. Guarding against a double run with
// a ref would cancel the first boot and suppress the second, so the
// persisted connections would never hydrate.
useEffect(() => {
if (bootRan.current) return
bootRan.current = true
if (!isTauri()) return
let cancelled = false
+131 -5
View File
@@ -207,12 +207,65 @@ export const getNodes = async (connectionId: string): Promise<ProxmoxNode[]> =>
}
export const getVMs = async (connectionId: string): Promise<ProxmoxVM[]> => {
if (!isTauri()) return mockResponse([])
if (!isTauri()) {
return mockResponse([
{
vmid: 100, name: 'web-01', status: 'running', type: 'qemu', node: 'pve1',
cpu: 0.12, cpus: 4, mem: 4.2 * 1024 ** 3, maxmem: 8 * 1024 ** 3,
disk: 22 * 1024 ** 3, maxdisk: 64 * 1024 ** 3, uptime: 86400 * 9 + 4210,
netin: 1.2 * 1024 ** 3, netout: 3.4 * 1024 ** 3,
diskread: 12 * 1024 ** 3, diskwrite: 8.7 * 1024 ** 3, pid: 1024,
tags: 'web;production',
},
{
vmid: 101, name: 'db-01', status: 'running', type: 'qemu', node: 'pve2',
cpu: 0.38, cpus: 8, mem: 21.5 * 1024 ** 3, maxmem: 32 * 1024 ** 3,
disk: 148 * 1024 ** 3, maxdisk: 512 * 1024 ** 3, uptime: 86400 * 23 + 1102,
netin: 18.2 * 1024 ** 3, netout: 9.1 * 1024 ** 3,
diskread: 220 * 1024 ** 3, diskwrite: 190 * 1024 ** 3, pid: 2048,
tags: 'database;production',
},
{
vmid: 102, name: 'worker-01', status: 'stopped', type: 'qemu', node: 'pve1',
cpu: 0, cpus: 4, mem: 0, maxmem: 16 * 1024 ** 3,
disk: 30 * 1024 ** 3, maxdisk: 128 * 1024 ** 3, uptime: 0,
netin: 0, netout: 0, diskread: 40 * 1024 ** 3, diskwrite: 22 * 1024 ** 3,
},
{
vmid: 103, name: 'monitoring', status: 'running', type: 'lxc', node: 'pve3',
cpu: 0.06, cpus: 2, mem: 1.1 * 1024 ** 3, maxmem: 4 * 1024 ** 3,
disk: 9 * 1024 ** 3, maxdisk: 32 * 1024 ** 3, uptime: 86400 * 41 + 300,
netin: 6.2 * 1024 ** 3, netout: 4.8 * 1024 ** 3,
diskread: 15 * 1024 ** 3, diskwrite: 11 * 1024 ** 3, pid: 3072,
},
{
vmid: 104, name: 'ci-runner', status: 'running', type: 'qemu', node: 'pve3',
cpu: 0.71, cpus: 8, mem: 12.4 * 1024 ** 3, maxmem: 16 * 1024 ** 3,
disk: 61 * 1024 ** 3, maxdisk: 128 * 1024 ** 3, uptime: 86400 * 2 + 18700,
netin: 44 * 1024 ** 3, netout: 31 * 1024 ** 3,
diskread: 90 * 1024 ** 3, diskwrite: 120 * 1024 ** 3, pid: 4096,
tags: 'ci',
},
{
vmid: 105, name: 'home-assistant', status: 'running', type: 'lxc', node: 'pve1',
cpu: 0.03, cpus: 2, mem: 0.8 * 1024 ** 3, maxmem: 2 * 1024 ** 3,
disk: 6 * 1024 ** 3, maxdisk: 16 * 1024 ** 3, uptime: 86400 * 66 + 500,
netin: 2.1 * 1024 ** 3, netout: 1.7 * 1024 ** 3,
diskread: 4 * 1024 ** 3, diskwrite: 9 * 1024 ** 3, pid: 5120,
},
])
}
return invokeCommand<ProxmoxVM[]>('get_vms', { connectionId })
}
export const getStorage = async (connectionId: string): Promise<ProxmoxStorage[]> => {
if (!isTauri()) return mockResponse([])
if (!isTauri()) {
return mockResponse([
{ storage: 'local', type: 'dir', content: 'iso,vztmpl,backup', active: 1, enabled: 1, shared: 0, used: 18 * 1024 ** 3, total: 94 * 1024 ** 3, avail: 76 * 1024 ** 3, node: 'pve1' },
{ storage: 'local-lvm', type: 'lvmthin', content: 'images,rootdir', active: 1, enabled: 1, shared: 0, used: 96 * 1024 ** 3, total: 200 * 1024 ** 3, avail: 104 * 1024 ** 3, node: 'pve1' },
{ storage: 'nas-backup', type: 'nfs', content: 'backup', active: 1, enabled: 1, shared: 1, used: 410 * 1024 ** 3, total: 1024 * 1024 ** 3, avail: 614 * 1024 ** 3, node: 'pve2' },
])
}
return invokeCommand<ProxmoxStorage[]>('get_storage', { connectionId })
}
@@ -255,7 +308,37 @@ export const getStorageDetail = async (
}
export const getTasks = async (connectionId: string): Promise<ProxmoxTask[]> => {
if (!isTauri()) return mockResponse([])
if (!isTauri()) {
const now = Date.now() / 1000
const task = (
type: string,
id: string,
node: string,
minutesAgo: number,
status: string | undefined,
user = 'root@pam',
): ProxmoxTask => ({
upid: `UPID:${node}:0000${minutesAgo}:${type}:${id}:${user}:`,
node,
pid: 1000 + minutesAgo,
pstart: minutesAgo * 10,
starttime: now - minutesAgo * 60,
endtime: status ? now - minutesAgo * 60 + 45 : undefined,
type,
id,
user,
status,
exitstatus: status === 'OK' ? 'OK' : undefined,
})
return mockResponse([
{ ...task('qmstart', '100', 'pve1', 0.2, undefined), status: undefined },
task('vzdump', '101', 'pve2', 3, 'OK'),
task('qmmigrate', '102', 'pve1', 18, 'OK'),
task('aptupdate', '', 'pve3', 61, 'OK'),
task('vzdump', '104', 'pve3', 240, 'OK'),
task('cefsync', '', 'pve1', 610, 'OK'),
])
}
return invokeCommand<ProxmoxTask[]>('get_tasks', { connectionId })
}
@@ -420,7 +503,23 @@ export const getSnapshots = async (
vmid: number,
vmType: string,
): Promise<ProxmoxSnapshot[]> => {
if (!isTauri()) return mockResponse([])
if (!isTauri()) {
const now = Date.now() / 1000
return mockResponse([
{
name: 'pre-upgrade',
description: 'Before PostgreSQL 16 upgrade',
snaptime: Math.floor(now - 3 * 86400),
vmstate: 1,
},
{
name: 'weekly-auto',
description: '',
snaptime: Math.floor(now - 86400),
vmstate: 0,
},
])
}
return invokeCommand<ProxmoxSnapshot[]>('get_snapshots', { connectionId, node, vmid, vmType })
}
@@ -548,7 +647,34 @@ export const getBackups = async (
connectionId: string,
storage?: string,
): Promise<ProxmoxBackup[]> => {
if (!isTauri()) return mockResponse([])
if (!isTauri()) {
const now = Date.now() / 1000
const day = 86400
const backup = (
vmid: number,
daysAgo: number,
sizeGb: number,
store = 'nas-backup',
): ProxmoxBackup => ({
volid: `${store}:backup/vzdump-qemu-${vmid}-${new Date(now - daysAgo * day * 1000).toISOString().slice(0, 10)}_${String(Math.floor((now % 86400) / 3600)).padStart(2, '0')}0000.vma.zst`,
backupid: String(vmid),
'backup-type': 'qemu',
'backup-id': String(vmid),
'backup-time': Math.floor(now - daysAgo * day),
storage: store,
size: sizeGb * 1024 ** 3,
ctime: now - daysAgo * day,
})
void name
return mockResponse([
backup(101, 1, 148),
backup(101, 2, 147),
backup(100, 1, 22),
backup(104, 3, 61),
backup(105, 1, 6),
backup(102, 5, 30),
])
}
return invokeCommand<ProxmoxBackup[]>('get_backups', { connectionId, storage })
}