feat: add marketing site and expand Proxmox management features

This commit is contained in:
Matt
2026-08-10 14:33:06 +00:00
parent 27b9aeb45e
commit 20dd2dc449
70 changed files with 12092 additions and 1282 deletions
+4
View File
@@ -0,0 +1,4 @@
node_modules/
dist/
.astro/
npm-debug.log*
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from 'astro/config'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
output: 'static',
vite: { plugins: [tailwindcss()] },
})
+7544
View File
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
{
"name": "clustri-site",
"type": "module",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview"
},
"dependencies": {
"@fontsource-variable/geist": "^5.3.0",
"@fontsource-variable/jetbrains-mono": "^5.3.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.3",
"astro": "^5.18.2",
"tailwindcss": "^4.3.3",
"typescript": "^7.0.2"
}
}
+14
View File
@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<polygon
points="30,16 23,28.124 9,28.124 2,16 9,3.876 23,3.876"
fill="none"
stroke="#e08a3c"
stroke-width="1.5"
/>
<polygon
points="23,16 19.5,22.062 12.5,22.062 9,16 12.5,9.938 19.5,9.938"
fill="none"
stroke="#e08a3c"
stroke-width="1.5"
/>
</svg>

After

Width:  |  Height:  |  Size: 350 B

+482
View File
@@ -0,0 +1,482 @@
---
// 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 },
{ label: 'VMs', icon: 'box', active: false },
{ label: 'Containers', icon: 'box', active: false },
{ label: 'Storage', icon: 'hdd', active: false },
{ 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',
dot: 'bg-success shadow-[0_0_6px] shadow-success/60',
chip: { label: 'primary', cls: 'border-primary/30 bg-primary/10 text-primary' },
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%' },
],
footer: ['up 42d 11h', '4 VMs'],
},
{
name: 'pve2',
dot: 'bg-warning',
chip: null,
statusLabel: 'failover',
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%' },
],
footer: ['up 9d 4h', '2 VMs'],
},
{
name: 'pve3',
dot: 'bg-success shadow-[0_0_6px] shadow-success/60',
chip: null,
statusLabel: 'online',
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%' },
],
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',
},
]
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' },
]
const quickActions = [
{ label: 'Start', icon: 'play' },
{ label: 'Stop', icon: 'stop' },
{ label: 'Migrate', icon: 'migrate' },
{ label: 'Console', icon: 'term' },
]
---
<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>) -->
<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>
</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="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] 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>
</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">
<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>
</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="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>
))
}
</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>
<span class="truncate font-mono text-[13px] font-medium text-muted-foreground">office-prod</span>
</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>
<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>
))
}
</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>
</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>
</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>
</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>
</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>
<!-- 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">
<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>
</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>
))
}
</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>
))
}
</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>
))
}
</div>
</div>
</main>
</div>
</div>
</div>
+152
View File
@@ -0,0 +1,152 @@
<!--
ArchitectureStrip — the request path from React to the Proxmox API.
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>
<!-- 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>
<!-- 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>
</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>
</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>
</div>
</div>
</section>
</div>
+46
View File
@@ -0,0 +1,46 @@
<!--
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">
<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">
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>
</div>
<!-- Close the section, consistent with the hero's closing hairline -->
<div class="mt-16 border-b border-border" aria-hidden="true"></div>
</section>
+336
View File
@@ -0,0 +1,336 @@
<!--
FeatureBlocks — the five feature sections of the Clustri marketing site.
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 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>
<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>
</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>
</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>
<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>
</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>
+78
View File
@@ -0,0 +1,78 @@
---
const year = new Date().getFullYear()
---
<!--
Footer — minimal, one row on desktop, stacked on mobile.
-->
<footer class="border-t border-border">
<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>
<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>
</div>
<p class="mt-3 font-mono text-[11px] text-muted-foreground">v0.1.0</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>
<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>
</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>
</div>
</footer>
+77
View File
@@ -0,0 +1,77 @@
---
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>
<!-- 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 />
</div>
</div>
</div>
<!-- Close the hero -->
<div class="mt-16 border-b border-border" aria-hidden="true"></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;
}
</style>
+52
View File
@@ -0,0 +1,52 @@
<!--
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">
<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"
>
<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>
</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>
<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>
</nav>
</header>
+21
View File
@@ -0,0 +1,21 @@
---
import '@fontsource-variable/geist'
import '@fontsource-variable/jetbrains-mono'
import '../styles/global.css'
interface Props { title?: string; description?: string }
const { title = 'Clustri', description = 'A desktop client for managing Proxmox VE servers and clusters.' } = Astro.props
---
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>{title}</title>
<meta name="description" content={description} />
</head>
<body>
<slot />
</body>
</html>
+23
View File
@@ -0,0 +1,23 @@
---
import Base from '../layouts/Base.astro'
import Nav from '../components/Nav.astro'
import Hero from '../components/Hero.astro'
import FeatureBlocks from '../components/FeatureBlocks.astro'
import ArchitectureStrip from '../components/ArchitectureStrip.astro'
import DownloadCta from '../components/DownloadCta.astro'
import Footer from '../components/Footer.astro'
---
<Base
title="Clustri — Ops Console for Proxmox VE"
description="Clustri is a cross-platform desktop client for managing Proxmox VE servers and clusters. Failover, security, VMs, storage, backups, and consoles in one app."
>
<Nav />
<main>
<Hero />
<FeatureBlocks />
<ArchitectureStrip />
<DownloadCta />
</main>
<Footer />
</Base>
+147
View File
@@ -0,0 +1,147 @@
@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;
--font-mono:
'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);
--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);
/* 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);
}
@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;
}
::-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;
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 {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
}
+3
View File
@@ -0,0 +1,3 @@
{
"extends": "astro/tsconfigs/strict"
}