feat: add marketing site and expand Proxmox management features
This commit is contained in:
@@ -8,6 +8,9 @@
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "oxlint",
|
||||
"preview": "vite preview",
|
||||
"site:build": "npm --prefix site run build",
|
||||
"site:dev": "npm --prefix site run dev",
|
||||
"site:preview": "npm --prefix site run preview",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.astro/
|
||||
npm-debug.log*
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'astro/config'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
export default defineConfig({
|
||||
output: 'static',
|
||||
vite: { plugins: [tailwindcss()] },
|
||||
})
|
||||
Generated
+7544
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 (01–05) 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>
|
||||
<!-- indentation survives Astro's whitespace trimming -->
|
||||
<p class="whitespace-pre text-muted-foreground"> 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 & 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "astro/tsconfigs/strict"
|
||||
}
|
||||
Generated
-119
@@ -429,7 +429,6 @@ dependencies = [
|
||||
"sha2",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-shell",
|
||||
"tempfile",
|
||||
"thiserror 2.0.19",
|
||||
"tokio",
|
||||
@@ -884,15 +883,6 @@ version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
|
||||
|
||||
[[package]]
|
||||
name = "encoding_rs"
|
||||
version = "0.8.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
@@ -1818,25 +1808,6 @@ version = "2.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
|
||||
|
||||
[[package]]
|
||||
name = "is-docker"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is-wsl"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5"
|
||||
dependencies = [
|
||||
"is-docker",
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
@@ -2422,33 +2393,12 @@ version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "open"
|
||||
version = "5.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a0b3d059e795d52b8a72fef45658620edd4d9c359b338564aa14391ffa511ed5"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"is-wsl",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "option-ext"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
||||
|
||||
[[package]]
|
||||
name = "os_pipe"
|
||||
version = "1.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pango"
|
||||
version = "0.18.3"
|
||||
@@ -3469,44 +3419,12 @@ dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shared_child"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e362d9935bc50f019969e2f9ecd66786612daae13e8f277be7bfb66e8bed3f7"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"sigchld",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||
|
||||
[[package]]
|
||||
name = "sigchld"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47106eded3c154e70176fc83df9737335c94ce22f821c32d17ed1db1f83badb1"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"os_pipe",
|
||||
"signal-hook",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook"
|
||||
version = "0.3.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"signal-hook-registry",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook-registry"
|
||||
version = "1.4.8"
|
||||
@@ -3918,43 +3836,6 @@ dependencies = [
|
||||
"tauri-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin"
|
||||
version = "2.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "74be5dd4bed9afbd145e5716b5fa2ec28cbc29c34ffa61c258c9273d896c8020"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"glob",
|
||||
"plist",
|
||||
"schemars 0.8.22",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri-utils",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-shell"
|
||||
version = "2.3.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8457dbf9e2bab1edd8df22bb2c20857a59a9868e79cb3eac5ed639eec4d0c73b"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"log",
|
||||
"open",
|
||||
"os_pipe",
|
||||
"regex",
|
||||
"schemars 0.8.22",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"shared_child",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"thiserror 2.0.19",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-runtime"
|
||||
version = "2.11.3"
|
||||
|
||||
@@ -10,7 +10,6 @@ tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = ["tray-icon"] }
|
||||
tauri-plugin-shell = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false }
|
||||
|
||||
@@ -14,8 +14,6 @@
|
||||
"core:window:allow-hide",
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-set-focus",
|
||||
"core:window:allow-is-visible",
|
||||
"shell:allow-execute",
|
||||
"shell:default"
|
||||
"core:window:allow-is-visible"
|
||||
]
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
{"default":{"identifier":"default","description":"Default capabilities for Clustri","local":true,"windows":["main"],"permissions":["core:default","core:event:default","core:event:allow-listen","core:event:allow-emit","core:event:allow-emit-to","core:window:default","core:window:allow-show","core:window:allow-hide","core:window:allow-close","core:window:allow-set-focus","core:window:allow-is-visible","shell:allow-execute","shell:default"]}}
|
||||
{"default":{"identifier":"default","description":"Default capabilities for Clustri","local":true,"windows":["main"],"permissions":["core:default","core:event:default","core:event:allow-listen","core:event:allow-emit","core:event:allow-emit-to","core:window:default","core:window:allow-show","core:window:allow-hide","core:window:allow-close","core:window:allow-set-focus","core:window:allow-is-visible"]}}
|
||||
@@ -134,216 +134,6 @@
|
||||
"description": "Reference a permission or permission set by identifier and extends its scope.",
|
||||
"type": "object",
|
||||
"allOf": [
|
||||
{
|
||||
"if": {
|
||||
"properties": {
|
||||
"identifier": {
|
||||
"anyOf": [
|
||||
{
|
||||
"description": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`",
|
||||
"type": "string",
|
||||
"const": "shell:default",
|
||||
"markdownDescription": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`"
|
||||
},
|
||||
{
|
||||
"description": "Enables the execute command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "shell:allow-execute",
|
||||
"markdownDescription": "Enables the execute command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the kill command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "shell:allow-kill",
|
||||
"markdownDescription": "Enables the kill command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the open command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "shell:allow-open",
|
||||
"markdownDescription": "Enables the open command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the spawn command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "shell:allow-spawn",
|
||||
"markdownDescription": "Enables the spawn command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the stdin_write command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "shell:allow-stdin-write",
|
||||
"markdownDescription": "Enables the stdin_write command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the execute command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "shell:deny-execute",
|
||||
"markdownDescription": "Denies the execute command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the kill command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "shell:deny-kill",
|
||||
"markdownDescription": "Denies the kill command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the open command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "shell:deny-open",
|
||||
"markdownDescription": "Denies the open command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the spawn command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "shell:deny-spawn",
|
||||
"markdownDescription": "Denies the spawn command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the stdin_write command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "shell:deny-stdin-write",
|
||||
"markdownDescription": "Denies the stdin_write command without any pre-configured scope."
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"then": {
|
||||
"properties": {
|
||||
"allow": {
|
||||
"items": {
|
||||
"title": "ShellScopeEntry",
|
||||
"description": "Shell scope entry.",
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"cmd",
|
||||
"name"
|
||||
],
|
||||
"properties": {
|
||||
"args": {
|
||||
"description": "The allowed arguments for the command execution.",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/ShellScopeEntryAllowedArgs"
|
||||
}
|
||||
]
|
||||
},
|
||||
"cmd": {
|
||||
"description": "The command name. It can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.",
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"name",
|
||||
"sidecar"
|
||||
],
|
||||
"properties": {
|
||||
"args": {
|
||||
"description": "The allowed arguments for the command execution.",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/ShellScopeEntryAllowedArgs"
|
||||
}
|
||||
]
|
||||
},
|
||||
"name": {
|
||||
"description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.",
|
||||
"type": "string"
|
||||
},
|
||||
"sidecar": {
|
||||
"description": "If this command is a sidecar command.",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"deny": {
|
||||
"items": {
|
||||
"title": "ShellScopeEntry",
|
||||
"description": "Shell scope entry.",
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"cmd",
|
||||
"name"
|
||||
],
|
||||
"properties": {
|
||||
"args": {
|
||||
"description": "The allowed arguments for the command execution.",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/ShellScopeEntryAllowedArgs"
|
||||
}
|
||||
]
|
||||
},
|
||||
"cmd": {
|
||||
"description": "The command name. It can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.",
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"name",
|
||||
"sidecar"
|
||||
],
|
||||
"properties": {
|
||||
"args": {
|
||||
"description": "The allowed arguments for the command execution.",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/ShellScopeEntryAllowedArgs"
|
||||
}
|
||||
]
|
||||
},
|
||||
"name": {
|
||||
"description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.",
|
||||
"type": "string"
|
||||
},
|
||||
"sidecar": {
|
||||
"description": "If this command is a sidecar command.",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
"identifier": {
|
||||
"description": "Identifier of the permission or permission set.",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/Identifier"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"identifier": {
|
||||
@@ -2401,72 +2191,6 @@
|
||||
"type": "string",
|
||||
"const": "core:window:deny-unminimize",
|
||||
"markdownDescription": "Denies the unminimize command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`",
|
||||
"type": "string",
|
||||
"const": "shell:default",
|
||||
"markdownDescription": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`"
|
||||
},
|
||||
{
|
||||
"description": "Enables the execute command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "shell:allow-execute",
|
||||
"markdownDescription": "Enables the execute command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the kill command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "shell:allow-kill",
|
||||
"markdownDescription": "Enables the kill command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the open command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "shell:allow-open",
|
||||
"markdownDescription": "Enables the open command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the spawn command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "shell:allow-spawn",
|
||||
"markdownDescription": "Enables the spawn command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the stdin_write command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "shell:allow-stdin-write",
|
||||
"markdownDescription": "Enables the stdin_write command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the execute command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "shell:deny-execute",
|
||||
"markdownDescription": "Denies the execute command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the kill command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "shell:deny-kill",
|
||||
"markdownDescription": "Denies the kill command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the open command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "shell:deny-open",
|
||||
"markdownDescription": "Denies the open command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the spawn command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "shell:deny-spawn",
|
||||
"markdownDescription": "Denies the spawn command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the stdin_write command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "shell:deny-stdin-write",
|
||||
"markdownDescription": "Denies the stdin_write command without any pre-configured scope."
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -2563,50 +2287,6 @@
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"ShellScopeEntryAllowedArg": {
|
||||
"description": "A command argument allowed to be executed by the webview API.",
|
||||
"anyOf": [
|
||||
{
|
||||
"description": "A non-configurable argument that is passed to the command in the order it was specified.",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "A variable that is set while calling the command from the webview API.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"validator"
|
||||
],
|
||||
"properties": {
|
||||
"raw": {
|
||||
"description": "Marks the validator as a raw regex, meaning the plugin should not make any modification at runtime.\n\nThis means the regex will not match on the entire string by default, which might be exploited if your regex allow unexpected input to be considered valid. When using this option, make sure your regex is correct.",
|
||||
"default": false,
|
||||
"type": "boolean"
|
||||
},
|
||||
"validator": {
|
||||
"description": "[regex] validator to require passed values to conform to an expected input.\n\nThis will require the argument value passed to this variable to match the `validator` regex before it will be executed.\n\nThe regex string is by default surrounded by `^...$` to match the full string. For example the `https?://\\w+` regex would be registered as `^https?://\\w+$`.\n\n[regex]: <https://docs.rs/regex/latest/regex/#syntax>",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"ShellScopeEntryAllowedArgs": {
|
||||
"description": "A set of command arguments allowed to be executed by the webview API.\n\nA value of `true` will allow any arguments to be passed to the command. `false` will disable all arguments. A list of [`ShellScopeEntryAllowedArg`] will set those arguments as the only valid arguments to be passed to the attached command configuration.",
|
||||
"anyOf": [
|
||||
{
|
||||
"description": "Use a simple boolean to allow all or disable all arguments to this command configuration.",
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"description": "A specific set of [`ShellScopeEntryAllowedArg`] that are valid to call for the command configuration.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/ShellScopeEntryAllowedArg"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -134,216 +134,6 @@
|
||||
"description": "Reference a permission or permission set by identifier and extends its scope.",
|
||||
"type": "object",
|
||||
"allOf": [
|
||||
{
|
||||
"if": {
|
||||
"properties": {
|
||||
"identifier": {
|
||||
"anyOf": [
|
||||
{
|
||||
"description": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`",
|
||||
"type": "string",
|
||||
"const": "shell:default",
|
||||
"markdownDescription": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`"
|
||||
},
|
||||
{
|
||||
"description": "Enables the execute command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "shell:allow-execute",
|
||||
"markdownDescription": "Enables the execute command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the kill command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "shell:allow-kill",
|
||||
"markdownDescription": "Enables the kill command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the open command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "shell:allow-open",
|
||||
"markdownDescription": "Enables the open command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the spawn command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "shell:allow-spawn",
|
||||
"markdownDescription": "Enables the spawn command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the stdin_write command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "shell:allow-stdin-write",
|
||||
"markdownDescription": "Enables the stdin_write command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the execute command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "shell:deny-execute",
|
||||
"markdownDescription": "Denies the execute command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the kill command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "shell:deny-kill",
|
||||
"markdownDescription": "Denies the kill command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the open command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "shell:deny-open",
|
||||
"markdownDescription": "Denies the open command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the spawn command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "shell:deny-spawn",
|
||||
"markdownDescription": "Denies the spawn command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the stdin_write command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "shell:deny-stdin-write",
|
||||
"markdownDescription": "Denies the stdin_write command without any pre-configured scope."
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"then": {
|
||||
"properties": {
|
||||
"allow": {
|
||||
"items": {
|
||||
"title": "ShellScopeEntry",
|
||||
"description": "Shell scope entry.",
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"cmd",
|
||||
"name"
|
||||
],
|
||||
"properties": {
|
||||
"args": {
|
||||
"description": "The allowed arguments for the command execution.",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/ShellScopeEntryAllowedArgs"
|
||||
}
|
||||
]
|
||||
},
|
||||
"cmd": {
|
||||
"description": "The command name. It can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.",
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"name",
|
||||
"sidecar"
|
||||
],
|
||||
"properties": {
|
||||
"args": {
|
||||
"description": "The allowed arguments for the command execution.",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/ShellScopeEntryAllowedArgs"
|
||||
}
|
||||
]
|
||||
},
|
||||
"name": {
|
||||
"description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.",
|
||||
"type": "string"
|
||||
},
|
||||
"sidecar": {
|
||||
"description": "If this command is a sidecar command.",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"deny": {
|
||||
"items": {
|
||||
"title": "ShellScopeEntry",
|
||||
"description": "Shell scope entry.",
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"cmd",
|
||||
"name"
|
||||
],
|
||||
"properties": {
|
||||
"args": {
|
||||
"description": "The allowed arguments for the command execution.",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/ShellScopeEntryAllowedArgs"
|
||||
}
|
||||
]
|
||||
},
|
||||
"cmd": {
|
||||
"description": "The command name. It can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.",
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"name",
|
||||
"sidecar"
|
||||
],
|
||||
"properties": {
|
||||
"args": {
|
||||
"description": "The allowed arguments for the command execution.",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/ShellScopeEntryAllowedArgs"
|
||||
}
|
||||
]
|
||||
},
|
||||
"name": {
|
||||
"description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.",
|
||||
"type": "string"
|
||||
},
|
||||
"sidecar": {
|
||||
"description": "If this command is a sidecar command.",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
"identifier": {
|
||||
"description": "Identifier of the permission or permission set.",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/Identifier"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"identifier": {
|
||||
@@ -2401,72 +2191,6 @@
|
||||
"type": "string",
|
||||
"const": "core:window:deny-unminimize",
|
||||
"markdownDescription": "Denies the unminimize command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`",
|
||||
"type": "string",
|
||||
"const": "shell:default",
|
||||
"markdownDescription": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`"
|
||||
},
|
||||
{
|
||||
"description": "Enables the execute command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "shell:allow-execute",
|
||||
"markdownDescription": "Enables the execute command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the kill command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "shell:allow-kill",
|
||||
"markdownDescription": "Enables the kill command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the open command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "shell:allow-open",
|
||||
"markdownDescription": "Enables the open command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the spawn command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "shell:allow-spawn",
|
||||
"markdownDescription": "Enables the spawn command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the stdin_write command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "shell:allow-stdin-write",
|
||||
"markdownDescription": "Enables the stdin_write command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the execute command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "shell:deny-execute",
|
||||
"markdownDescription": "Denies the execute command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the kill command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "shell:deny-kill",
|
||||
"markdownDescription": "Denies the kill command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the open command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "shell:deny-open",
|
||||
"markdownDescription": "Denies the open command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the spawn command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "shell:deny-spawn",
|
||||
"markdownDescription": "Denies the spawn command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the stdin_write command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "shell:deny-stdin-write",
|
||||
"markdownDescription": "Denies the stdin_write command without any pre-configured scope."
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -2563,50 +2287,6 @@
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"ShellScopeEntryAllowedArg": {
|
||||
"description": "A command argument allowed to be executed by the webview API.",
|
||||
"anyOf": [
|
||||
{
|
||||
"description": "A non-configurable argument that is passed to the command in the order it was specified.",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "A variable that is set while calling the command from the webview API.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"validator"
|
||||
],
|
||||
"properties": {
|
||||
"raw": {
|
||||
"description": "Marks the validator as a raw regex, meaning the plugin should not make any modification at runtime.\n\nThis means the regex will not match on the entire string by default, which might be exploited if your regex allow unexpected input to be considered valid. When using this option, make sure your regex is correct.",
|
||||
"default": false,
|
||||
"type": "boolean"
|
||||
},
|
||||
"validator": {
|
||||
"description": "[regex] validator to require passed values to conform to an expected input.\n\nThis will require the argument value passed to this variable to match the `validator` regex before it will be executed.\n\nThe regex string is by default surrounded by `^...$` to match the full string. For example the `https?://\\w+` regex would be registered as `^https?://\\w+$`.\n\n[regex]: <https://docs.rs/regex/latest/regex/#syntax>",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"ShellScopeEntryAllowedArgs": {
|
||||
"description": "A set of command arguments allowed to be executed by the webview API.\n\nA value of `true` will allow any arguments to be passed to the command. `false` will disable all arguments. A list of [`ShellScopeEntryAllowedArg`] will set those arguments as the only valid arguments to be passed to the attached command configuration.",
|
||||
"anyOf": [
|
||||
{
|
||||
"description": "Use a simple boolean to allow all or disable all arguments to this command configuration.",
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"description": "A specific set of [`ShellScopeEntryAllowedArg`] that are valid to call for the command configuration.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/ShellScopeEntryAllowedArg"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
+345
-56
@@ -2,7 +2,7 @@ use crate::error::Error;
|
||||
use crate::proxmox::{
|
||||
AddDiskConfig, AddNICConfig, Backup, BackupJob, BackupJobConfig, ClusterNode, ClusterStatus,
|
||||
CreateSnapshotConfig, Disk, EditNICConfig, NetworkInterface, Node, RestoreConfig, Snapshot,
|
||||
Storage, StorageContent, StorageDetail, Task, VM,
|
||||
Storage, StorageContent, StorageDetail, Task, UpdateVMConfig, VM,
|
||||
};
|
||||
use crate::{
|
||||
CertificateInfo, ConnectResult, ConnectionConfig, ConnectionStatusInfo, DiscoveredNode,
|
||||
@@ -11,9 +11,10 @@ use crate::{
|
||||
use percent_encoding::{utf8_percent_encode, AsciiSet, NON_ALPHANUMERIC};
|
||||
use reqwest::{Client, Method};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::Path;
|
||||
use std::sync::Mutex;
|
||||
use std::time::Duration;
|
||||
use url::Url;
|
||||
|
||||
/// Characters left unencoded when percent-encoding a volume id into a URL
|
||||
@@ -27,6 +28,29 @@ const VOLID_ENCODE_SET: &AsciiSet = &NON_ALPHANUMERIC
|
||||
.remove(b'_')
|
||||
.remove(b'~');
|
||||
|
||||
/// Characters left unencoded when percent-encoding a single URL path segment
|
||||
/// (node names, snapshot names). Same set as [`VOLID_ENCODE_SET`] but without
|
||||
/// the `:` that separates a storage prefix from a volid.
|
||||
const SEGMENT_ENCODE_SET: &AsciiSet = &NON_ALPHANUMERIC
|
||||
.remove(b'-')
|
||||
.remove(b'.')
|
||||
.remove(b'_')
|
||||
.remove(b'~');
|
||||
|
||||
/// Builds the HTTP client used for every connection. TLS verification is
|
||||
/// intentionally off at the transport layer (self-signed Proxmox servers are
|
||||
/// the norm); trust is enforced by the application-level TOFU pinning in
|
||||
/// `tls.rs`. Connect and total timeouts keep a dead server from hanging the
|
||||
/// UI forever.
|
||||
fn build_client() -> crate::Result<Client> {
|
||||
Client::builder()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.connect_timeout(Duration::from_secs(10))
|
||||
.timeout(Duration::from_secs(30))
|
||||
.build()
|
||||
.map_err(Error::HttpError)
|
||||
}
|
||||
|
||||
/// The result of loading persisted connections from disk.
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -99,7 +123,12 @@ where
|
||||
pub fn derive_node_url(primary_url: &str, node_ip: Option<&str>, node_name: &str) -> String {
|
||||
let host = node_ip.filter(|ip| !ip.is_empty()).unwrap_or(node_name);
|
||||
match Url::parse(primary_url) {
|
||||
Ok(url) => format!("{}://{}:{}", url.scheme(), host, url.port().unwrap_or(8006)),
|
||||
Ok(url) => {
|
||||
// pveproxy listens on 8006 for both http and https, but a scheme
|
||||
// default applies when no explicit port is given (`http` → 80).
|
||||
let default_port = if url.scheme() == "http" { 80 } else { 8006 };
|
||||
format!("{}://{}:{}", url.scheme(), host, url.port().unwrap_or(default_port))
|
||||
}
|
||||
Err(_) => format!("https://{}:8006", host),
|
||||
}
|
||||
}
|
||||
@@ -182,19 +211,62 @@ pub async fn api_request(
|
||||
});
|
||||
|
||||
if !status.is_success() {
|
||||
let message = body["errors"]
|
||||
.as_str()
|
||||
.or_else(|| body["message"].as_str())
|
||||
.or_else(|| body["data"].as_str())
|
||||
.or_else(|| body.as_str())
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| format!("Proxmox API error (HTTP {})", status.as_u16()));
|
||||
let message = error_message_from_body(&body, status.as_u16());
|
||||
return Err(Error::ApiError(message));
|
||||
}
|
||||
|
||||
Ok(body.get("data").cloned().unwrap_or(body))
|
||||
}
|
||||
|
||||
/// Builds the error message surfaced for a non-success API response.
|
||||
///
|
||||
/// The `errors` field is preferred over the generic `message` (Proxmox
|
||||
/// parameter-verification failures send a body like
|
||||
/// `{"errors":{"limit":"property is not defined in schema"},
|
||||
/// "message":"Parameter verification failed."}`, and the specific `errors`
|
||||
/// entry is what tells the user what went wrong). The precedence is:
|
||||
/// 1. `errors` as a string
|
||||
/// 2. `errors` as an object — the first `key: value` pair, formatted as
|
||||
/// `key: value` (string values verbatim, other values as JSON)
|
||||
/// 3. `message` as a string
|
||||
/// 4. `data` as a string
|
||||
/// 5. the raw body when it is itself a string
|
||||
/// 6. a generic `Proxmox API error (HTTP {status})` fallback
|
||||
fn error_message_from_body(body: &serde_json::Value, status: u16) -> String {
|
||||
if let Some(errors) = body.get("errors") {
|
||||
if let Some(text) = errors.as_str() {
|
||||
if !text.is_empty() {
|
||||
return text.to_string();
|
||||
}
|
||||
}
|
||||
if let Some(obj) = errors.as_object() {
|
||||
if let Some((key, value)) = obj.iter().next() {
|
||||
let value_text = value
|
||||
.as_str()
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| value.to_string());
|
||||
return format!("{}: {}", key, value_text);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(message) = body.get("message").and_then(|m| m.as_str()) {
|
||||
if !message.is_empty() {
|
||||
return message.to_string();
|
||||
}
|
||||
}
|
||||
if let Some(data) = body.get("data").and_then(|d| d.as_str()) {
|
||||
if !data.is_empty() {
|
||||
return data.to_string();
|
||||
}
|
||||
}
|
||||
if let Some(text) = body.as_str() {
|
||||
if !text.is_empty() {
|
||||
return text.to_string();
|
||||
}
|
||||
}
|
||||
format!("Proxmox API error (HTTP {})", status)
|
||||
}
|
||||
|
||||
struct Connection {
|
||||
config: ConnectionConfig,
|
||||
client: Client,
|
||||
@@ -393,13 +465,9 @@ impl ConnectionManager {
|
||||
|
||||
/// Builds the HTTP client and session state for a connection.
|
||||
fn build_connection(config: ConnectionConfig) -> crate::Result<Connection> {
|
||||
let client = Client::builder()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
.map_err(Error::HttpError)?;
|
||||
Ok(Connection {
|
||||
config,
|
||||
client,
|
||||
client: build_client()?,
|
||||
ticket: Mutex::new(None),
|
||||
csrf_token: Mutex::new(None),
|
||||
current_endpoint_index: Mutex::new(0),
|
||||
@@ -906,10 +974,7 @@ impl ConnectionManager {
|
||||
return Err(Error::InvalidUrl("URL cannot be empty".to_string()));
|
||||
}
|
||||
|
||||
let client = Client::builder()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
.map_err(|e| Error::HttpError(e))?;
|
||||
let client = build_client()?;
|
||||
|
||||
let login_url = format!("{}/access/ticket", url);
|
||||
|
||||
@@ -999,10 +1064,7 @@ impl ConnectionManager {
|
||||
));
|
||||
}
|
||||
|
||||
let client = Client::builder()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
.map_err(|e| Error::HttpError(e))?;
|
||||
let client = build_client()?;
|
||||
|
||||
// Validate the token by making an authenticated request
|
||||
let test_url = format!("{}/cluster/status", url);
|
||||
@@ -1117,6 +1179,22 @@ impl ConnectionManager {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Injects a password-mode session (ticket + CSRF token) into an existing
|
||||
/// connection's in-memory state without touching the OS keyring. Used by
|
||||
/// integration tests that authenticate against a live server where no
|
||||
/// keyring secret-service is available; any later login or ticket refresh
|
||||
/// overwrites the values.
|
||||
pub async fn set_session_ticket(
|
||||
&self,
|
||||
connection_id: &str,
|
||||
ticket: &str,
|
||||
csrf_token: &str,
|
||||
) -> crate::Result<()> {
|
||||
let conn = self.connection(connection_id)?;
|
||||
conn.set_session(ticket.to_string(), csrf_token.to_string());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn refresh_ticket(
|
||||
&self,
|
||||
connection_id: &str,
|
||||
@@ -1274,19 +1352,52 @@ impl ConnectionManager {
|
||||
let data = conn
|
||||
.request(Method::GET, "/cluster/resources", &query, None)
|
||||
.await?;
|
||||
parse_api("/cluster/resources?type=storage", data)
|
||||
// `/cluster/resources?type=storage` entries carry usage as `disk` /
|
||||
// `maxdisk` and an `available`/`unavailable` status string — not the
|
||||
// `used`/`total`/`avail`/`enabled`/`active` fields the struct models.
|
||||
// Each entry is mapped manually so the overview shows real numbers.
|
||||
let entries: Vec<serde_json::Value> =
|
||||
parse_api("/cluster/resources?type=storage", data)?;
|
||||
let mut storages = Vec::with_capacity(entries.len());
|
||||
for entry in entries {
|
||||
let disk = entry["disk"].as_u64().unwrap_or(0);
|
||||
let maxdisk = entry["maxdisk"].as_u64().unwrap_or(0);
|
||||
let status = entry["status"].as_str().unwrap_or("");
|
||||
let available = status == "available";
|
||||
storages.push(Storage {
|
||||
storage: entry["storage"].as_str().unwrap_or("").to_string(),
|
||||
r#type: entry["type"].as_str().unwrap_or("").to_string(),
|
||||
content: entry["content"].as_str().unwrap_or("").to_string(),
|
||||
active: u32::from(available),
|
||||
enabled: u32::from(available),
|
||||
shared: entry["shared"]
|
||||
.as_u64()
|
||||
.map(|v| v as u32)
|
||||
.or_else(|| entry["shared"].as_bool().map(u32::from))
|
||||
.unwrap_or(0),
|
||||
used: disk,
|
||||
total: maxdisk,
|
||||
avail: maxdisk.saturating_sub(disk),
|
||||
node: entry["node"].as_str().unwrap_or("").to_string(),
|
||||
});
|
||||
}
|
||||
Ok(storages)
|
||||
}
|
||||
|
||||
pub async fn get_storage_content(
|
||||
&self,
|
||||
connection_id: &str,
|
||||
storage: &str,
|
||||
node: Option<&str>,
|
||||
) -> crate::Result<Vec<StorageContent>> {
|
||||
let conn = self.connection(connection_id)?;
|
||||
// The content endpoint is node-scoped. Prefer the node configured on
|
||||
// the connection's primary endpoint; otherwise pick the first online
|
||||
// node from the cluster.
|
||||
let node = self.storage_node(connection_id).await?;
|
||||
// The content endpoint is node-scoped. Prefer an explicitly requested
|
||||
// node; otherwise use the node configured on the connection's primary
|
||||
// endpoint, falling back to the first online node from the cluster.
|
||||
let node = match node.filter(|n| !n.is_empty()) {
|
||||
Some(node) => node.to_string(),
|
||||
None => self.storage_node(connection_id).await?,
|
||||
};
|
||||
let path = format!("/nodes/{}/storage/{}/content", node, storage);
|
||||
let data = conn.request(Method::GET, &path, &[], None).await?;
|
||||
parse_api(&path, data)
|
||||
@@ -1306,9 +1417,11 @@ impl ConnectionManager {
|
||||
|
||||
pub async fn get_tasks(&self, connection_id: &str) -> crate::Result<Vec<Task>> {
|
||||
let conn = self.connection(connection_id)?;
|
||||
let query = [("limit", "50".to_string())];
|
||||
// The server rejects `/cluster/tasks?limit=...` with HTTP 400 (`limit`
|
||||
// is not in the endpoint schema); the default server-side limit of 50
|
||||
// applies when no query is sent.
|
||||
let data = conn
|
||||
.request(Method::GET, "/cluster/tasks", &query, None)
|
||||
.request(Method::GET, "/cluster/tasks", &[], None)
|
||||
.await?;
|
||||
parse_api("/cluster/tasks", data)
|
||||
}
|
||||
@@ -1713,8 +1826,11 @@ impl ConnectionManager {
|
||||
config: AddNICConfig,
|
||||
) -> crate::Result<()> {
|
||||
Self::validate_vm_type(vm_type)?;
|
||||
// NIC model validation applies to QEMU guests only; LXC containers
|
||||
// always use the veth device type and the model is ignored by the
|
||||
// server (the frontend sends `veth` for containers).
|
||||
const VALID_MODELS: [&str; 4] = ["virtio", "e1000", "rtl8139", "vmxnet3"];
|
||||
if !VALID_MODELS.contains(&config.model.as_str()) {
|
||||
if vm_type != "lxc" && !VALID_MODELS.contains(&config.model.as_str()) {
|
||||
return Err(Error::InvalidUrl(format!(
|
||||
"Invalid NIC model '{}': expected one of virtio, e1000, rtl8139, vmxnet3",
|
||||
config.model
|
||||
@@ -1726,6 +1842,19 @@ impl ConnectionManager {
|
||||
let data = conn.request(Method::GET, &path, &[], None).await?;
|
||||
let index = first_free_bus_index(&data, "net");
|
||||
let mac = config.macaddr.unwrap_or_else(|| "random".to_string());
|
||||
let value = if vm_type == "lxc" {
|
||||
// LXC net values use the `name=ethN,type=veth,bridge=...` form;
|
||||
// a user-supplied MAC (anything but `random`) is sent as
|
||||
// `hwaddr`, and `firewall=1` is added when firewall is enabled.
|
||||
let mut value = format!("name=eth{},type=veth,bridge={}", index, config.bridge);
|
||||
if !mac.is_empty() && mac != "random" {
|
||||
value.push_str(&format!(",hwaddr={}", mac));
|
||||
}
|
||||
if config.firewall.unwrap_or(false) {
|
||||
value.push_str(",firewall=1");
|
||||
}
|
||||
value
|
||||
} else {
|
||||
let mut value = format!("{}={}", config.model, mac);
|
||||
value.push_str(&format!(",bridge={}", config.bridge));
|
||||
if let Some(tag) = config.tag {
|
||||
@@ -1734,6 +1863,8 @@ impl ConnectionManager {
|
||||
if let Some(firewall) = config.firewall {
|
||||
value.push_str(&format!(",firewall={}", u32::from(firewall)));
|
||||
}
|
||||
value
|
||||
};
|
||||
let key = format!("net{}", index);
|
||||
let form = vec![(key.as_str(), value)];
|
||||
conn.request(Method::POST, &path, &[], Some(&form)).await?;
|
||||
@@ -1781,6 +1912,41 @@ impl ConnectionManager {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Updates a VM/container's basic configuration (`name`, `cores`, `memory`,
|
||||
/// `description`) by POSTing a form containing only the present fields.
|
||||
/// A config with every field `None` errors out instead of sending an empty
|
||||
/// POST.
|
||||
pub async fn update_vm_config(
|
||||
&self,
|
||||
connection_id: &str,
|
||||
node: &str,
|
||||
vmid: u32,
|
||||
vm_type: &str,
|
||||
config: UpdateVMConfig,
|
||||
) -> crate::Result<()> {
|
||||
Self::validate_vm_type(vm_type)?;
|
||||
let conn = self.connection(connection_id)?;
|
||||
let path = format!("/nodes/{}/{}/{}/config", node, vm_type, vmid);
|
||||
let mut form: Vec<(&str, String)> = Vec::new();
|
||||
if let Some(name) = config.name {
|
||||
form.push(("name", name));
|
||||
}
|
||||
if let Some(cores) = config.cores {
|
||||
form.push(("cores", cores.to_string()));
|
||||
}
|
||||
if let Some(memory) = config.memory {
|
||||
form.push(("memory", memory.to_string()));
|
||||
}
|
||||
if let Some(description) = config.description {
|
||||
form.push(("description", description));
|
||||
}
|
||||
if form.is_empty() {
|
||||
return Err(Error::ApiError("Nothing to update".to_string()));
|
||||
}
|
||||
conn.request(Method::POST, &path, &[], Some(&form)).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_snapshots(
|
||||
&self,
|
||||
connection_id: &str,
|
||||
@@ -1827,9 +1993,10 @@ impl ConnectionManager {
|
||||
) -> crate::Result<()> {
|
||||
Self::validate_vm_type(vm_type)?;
|
||||
let conn = self.connection(connection_id)?;
|
||||
// Snapshot names can contain URL-hostile characters, but Proxmox
|
||||
// expects the name inline in the path, so it is inserted verbatim.
|
||||
let path = format!("/nodes/{}/{}/{}/snapshot/{}", node, vm_type, vmid, name);
|
||||
// Snapshot names can contain URL-hostile characters, so the name is
|
||||
// percent-encoded into the path (everything but `A-Za-z0-9-._~`).
|
||||
let encoded_name = utf8_percent_encode(name, SEGMENT_ENCODE_SET).to_string();
|
||||
let path = format!("/nodes/{}/{}/{}/snapshot/{}", node, vm_type, vmid, encoded_name);
|
||||
conn.request(Method::DELETE, &path, &[], None).await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -1844,9 +2011,10 @@ impl ConnectionManager {
|
||||
) -> crate::Result<()> {
|
||||
Self::validate_vm_type(vm_type)?;
|
||||
let conn = self.connection(connection_id)?;
|
||||
let encoded_name = utf8_percent_encode(name, SEGMENT_ENCODE_SET).to_string();
|
||||
let path = format!(
|
||||
"/nodes/{}/{}/{}/snapshot/{}/rollback",
|
||||
node, vm_type, vmid, name
|
||||
node, vm_type, vmid, encoded_name
|
||||
);
|
||||
conn.request(Method::POST, &path, &[], None).await?;
|
||||
Ok(())
|
||||
@@ -1962,15 +2130,56 @@ impl ConnectionManager {
|
||||
connection_id: &str,
|
||||
storage: Option<&str>,
|
||||
) -> crate::Result<Vec<Backup>> {
|
||||
let conn = self.connection(connection_id)?;
|
||||
let node = self.storage_node(connection_id).await?;
|
||||
let storage = storage.unwrap_or("local");
|
||||
// A specific storage is queried verbatim and its errors propagate.
|
||||
// Without one, every enabled storage whose content list includes
|
||||
// `backup` is queried and the results are aggregated; a storage whose
|
||||
// content query fails during aggregation is skipped rather than
|
||||
// failing the whole call.
|
||||
match storage.map(str::trim).filter(|s| !s.is_empty()) {
|
||||
Some(storage) => self.backup_entries(connection_id, &node, storage).await,
|
||||
None => {
|
||||
let storages = self.get_storage(connection_id).await?;
|
||||
let mut seen = HashSet::new();
|
||||
let mut names = Vec::new();
|
||||
for storage in storages {
|
||||
if storage.enabled == 0 {
|
||||
continue;
|
||||
}
|
||||
let content_tokens: Vec<&str> =
|
||||
storage.content.split(',').map(str::trim).collect();
|
||||
if !content_tokens.contains(&"backup") {
|
||||
continue;
|
||||
}
|
||||
if seen.insert(storage.storage.clone()) {
|
||||
names.push(storage.storage);
|
||||
}
|
||||
}
|
||||
let mut backups = Vec::new();
|
||||
for name in names {
|
||||
if let Ok(entries) = self.backup_entries(connection_id, &node, &name).await {
|
||||
backups.extend(entries);
|
||||
}
|
||||
}
|
||||
Ok(backups)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetches the backup entries of a single storage on `node` from the
|
||||
/// node-scoped content endpoint. The content endpoint can mix backup
|
||||
/// entries with iso/vztmpl entries, so the requested `content=backup`
|
||||
/// filter is mirrored here and only entries tagged `backup` are mapped.
|
||||
async fn backup_entries(
|
||||
&self,
|
||||
connection_id: &str,
|
||||
node: &str,
|
||||
storage: &str,
|
||||
) -> crate::Result<Vec<Backup>> {
|
||||
let conn = self.connection(connection_id)?;
|
||||
let path = format!("/nodes/{}/storage/{}/content", node, storage);
|
||||
let query = [("content", "backup".to_string())];
|
||||
let data = conn.request(Method::GET, &path, &query, None).await?;
|
||||
// The content endpoint can mix backup entries with iso/vztmpl entries,
|
||||
// so the requested `content=backup` filter is mirrored here and only
|
||||
// entries tagged `backup` are mapped.
|
||||
let entries: Vec<serde_json::Value> = parse_api(&path, data)?;
|
||||
let mut backups = Vec::new();
|
||||
for entry in entries {
|
||||
@@ -2183,15 +2392,19 @@ fn parse_disk_attrs(value: &str) -> (String, u64, String) {
|
||||
(storage, size, format)
|
||||
}
|
||||
|
||||
/// True when `key` is a VM disk config key (`scsi0`, `virtio1`, `ide2`,
|
||||
/// `sata0`, `nvme0`, ...).
|
||||
/// True when `key` is a VM/container disk config key. QEMU guests use the
|
||||
/// `scsi0`/`virtio1`/`ide2`/`sata0`/`nvme0` bus keys; LXC containers use
|
||||
/// `rootfs` and mount-point keys `mp0`, `mp1`, ...
|
||||
fn is_disk_key(key: &str) -> bool {
|
||||
const BUSES: [&str; 5] = ["scsi", "virtio", "ide", "sata", "nvme"];
|
||||
BUSES.iter().any(|bus| {
|
||||
let bus_key = |bus: &str| {
|
||||
key.strip_prefix(bus).map_or(false, |suffix| {
|
||||
!suffix.is_empty() && suffix.bytes().all(|b| b.is_ascii_digit())
|
||||
})
|
||||
})
|
||||
};
|
||||
BUSES.iter().any(|bus| bus_key(bus))
|
||||
|| key == "rootfs"
|
||||
|| bus_key("mp")
|
||||
}
|
||||
|
||||
/// True when `key` is a NIC config key (`net0`, `net1`, ...).
|
||||
@@ -2215,8 +2428,13 @@ fn first_free_bus_index(config: &serde_json::Value, bus: &str) -> u32 {
|
||||
}
|
||||
|
||||
/// The parsed pieces of a NIC config value such as
|
||||
/// `virtio=BC:24:11:AA:BB:CC,bridge=vmbr0,tag=10,firewall=1`.
|
||||
/// `virtio=BC:24:11:AA:BB:CC,bridge=vmbr0,tag=10,firewall=1` (QEMU) or
|
||||
/// `name=eth0,type=veth,hwaddr=BC:24:11:8D:DF:95,bridge=vmbr0,ip=dhcp` (LXC).
|
||||
struct ParsedNetValue {
|
||||
/// True when the value uses the LXC `name=eth0,...` format.
|
||||
lxc: bool,
|
||||
/// The interface name segment of an LXC value (`eth0`); empty for QEMU.
|
||||
name: String,
|
||||
model: String,
|
||||
macaddr: String,
|
||||
bridge: Option<String>,
|
||||
@@ -2225,10 +2443,16 @@ struct ParsedNetValue {
|
||||
link_down: Option<u32>,
|
||||
}
|
||||
|
||||
/// Parses a NIC config value into its components. The first comma-separated
|
||||
/// segment is `model=macaddr`; the remaining segments are key=value
|
||||
/// attributes.
|
||||
/// Parses a NIC config value into its components.
|
||||
///
|
||||
/// QEMU values start with `model=macaddr` and continue with `key=value`
|
||||
/// attributes (`bridge`, `tag`, `firewall`, ...). LXC values start with
|
||||
/// `name=ethN` and express the device type and MAC as `type=veth` /
|
||||
/// `hwaddr=...` attributes, which are mapped onto `model` and `macaddr` so
|
||||
/// callers see a uniform shape.
|
||||
fn parse_net_value(value: &str) -> ParsedNetValue {
|
||||
let mut lxc = false;
|
||||
let mut name = String::new();
|
||||
let mut model = String::new();
|
||||
let mut macaddr = String::new();
|
||||
let mut bridge = None;
|
||||
@@ -2238,9 +2462,14 @@ fn parse_net_value(value: &str) -> ParsedNetValue {
|
||||
|
||||
let mut segments = value.split(',');
|
||||
if let Some(first) = segments.next() {
|
||||
if let Some((m, mac)) = first.split_once('=') {
|
||||
model = m.trim().to_string();
|
||||
macaddr = mac.trim().to_string();
|
||||
if let Some((key, val)) = first.split_once('=') {
|
||||
if key.trim() == "name" {
|
||||
lxc = true;
|
||||
name = val.trim().to_string();
|
||||
} else {
|
||||
model = key.trim().to_string();
|
||||
macaddr = val.trim().to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
for segment in segments {
|
||||
@@ -2251,12 +2480,19 @@ fn parse_net_value(value: &str) -> ParsedNetValue {
|
||||
"tag" => tag = val.parse().ok(),
|
||||
"firewall" => firewall = val.parse().ok(),
|
||||
"link_down" => link_down = val.parse().ok(),
|
||||
_ if lxc => match attr.trim() {
|
||||
"type" => model = val.to_string(),
|
||||
"hwaddr" => macaddr = val.to_string(),
|
||||
_ => {}
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ParsedNetValue {
|
||||
lxc,
|
||||
name,
|
||||
model,
|
||||
macaddr,
|
||||
bridge,
|
||||
@@ -2269,19 +2505,58 @@ fn parse_net_value(value: &str) -> ParsedNetValue {
|
||||
/// Re-encodes a NIC config value after applying the edited fields. The model
|
||||
/// and MAC address are always preserved; `config.bridge`, `config.tag` (a tag
|
||||
/// of `0` removes the tag) and `config.firewall` replace the existing values
|
||||
/// when present.
|
||||
/// when present. Any other comma-separated `key=value` attribute in the
|
||||
/// current value (e.g. `link_down`, `rate`, `queues`, `disconnect`) is
|
||||
/// carried over verbatim, so editing a NIC does not drop attributes the
|
||||
/// client does not model.
|
||||
///
|
||||
/// LXC values are re-encoded in their native form — `name=ethN,type=...,
|
||||
/// hwaddr=...,bridge=...[,firewall=...]` — with the same unknown-attribute
|
||||
/// pass-through. LXC has no VLAN tag, so a tag is never emitted.
|
||||
fn reencode_nic_value(current: &str, config: &EditNICConfig) -> String {
|
||||
let parsed = parse_net_value(current);
|
||||
let bridge = config.bridge.clone().or(parsed.bridge);
|
||||
let firewall = match config.firewall {
|
||||
Some(on) => Some(u32::from(on)),
|
||||
None => parsed.firewall,
|
||||
};
|
||||
|
||||
if parsed.lxc {
|
||||
let mut value = format!(
|
||||
"name={},type={},hwaddr={}",
|
||||
parsed.name, parsed.model, parsed.macaddr
|
||||
);
|
||||
if let Some(bridge) = bridge {
|
||||
value.push_str(&format!(",bridge={}", bridge));
|
||||
}
|
||||
if let Some(firewall) = firewall {
|
||||
value.push_str(&format!(",firewall={}", firewall));
|
||||
}
|
||||
// Carry over every other `key=value` attribute from the current value
|
||||
// verbatim, skipping the leading `name=` segment and the attributes
|
||||
// already re-emitted above (`tag` is never valid for LXC).
|
||||
let mut segments = current.split(',');
|
||||
segments.next();
|
||||
for segment in segments {
|
||||
if let Some((attr, _)) = segment.split_once('=') {
|
||||
if matches!(
|
||||
attr.trim(),
|
||||
"bridge" | "firewall" | "name" | "type" | "hwaddr" | "tag"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
value.push(',');
|
||||
value.push_str(segment.trim());
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
let tag = match config.tag {
|
||||
Some(0) => None,
|
||||
Some(t) => Some(t),
|
||||
None => parsed.tag.filter(|&t| t != 0),
|
||||
};
|
||||
let firewall = match config.firewall {
|
||||
Some(on) => Some(u32::from(on)),
|
||||
None => parsed.firewall,
|
||||
};
|
||||
|
||||
let mut value = format!("{}={}", parsed.model, parsed.macaddr);
|
||||
if let Some(bridge) = bridge {
|
||||
@@ -2293,5 +2568,19 @@ fn reencode_nic_value(current: &str, config: &EditNICConfig) -> String {
|
||||
if let Some(firewall) = firewall {
|
||||
value.push_str(&format!(",firewall={}", firewall));
|
||||
}
|
||||
// Carry over every other `key=value` attribute from the current value
|
||||
// verbatim, skipping the leading model=macaddr segment and the attributes
|
||||
// already re-emitted above.
|
||||
let mut segments = current.split(',');
|
||||
segments.next();
|
||||
for segment in segments {
|
||||
if let Some((attr, _)) = segment.split_once('=') {
|
||||
if matches!(attr.trim(), "bridge" | "tag" | "firewall") {
|
||||
continue;
|
||||
}
|
||||
value.push(',');
|
||||
value.push_str(segment.trim());
|
||||
}
|
||||
}
|
||||
value
|
||||
}
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
//! Local WebSocket proxy for VM and container consoles.
|
||||
//!
|
||||
//! The webview cannot open a `wss://` connection to a self-signed Proxmox
|
||||
//! server (the browser validates the certificate against the system trust
|
||||
//! store, which rejects it). The consoles therefore go through a loopback
|
||||
//! proxy: the backend opens the `wss://` connection to Proxmox (using the same
|
||||
//! accept-self-signed transport as the HTTP API) and forwards frames to a
|
||||
//! plain `ws://127.0.0.1:<port>` endpoint that the webview can reach. A random
|
||||
//! per-session token in the query string keeps other local processes from
|
||||
//! hijacking an open console.
|
||||
|
||||
use crate::connection::ConnectionManager;
|
||||
use crate::websocket::connect_ws;
|
||||
use crate::{Error, Result};
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use percent_encoding::{utf8_percent_encode, AsciiSet, NON_ALPHANUMERIC};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::net::SocketAddr;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::watch;
|
||||
use tokio::time::{timeout, Duration};
|
||||
use tokio_tungstenite::tungstenite::handshake::server::{ErrorResponse, Request, Response};
|
||||
use tokio_tungstenite::tungstenite::http::StatusCode;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Characters left unencoded when embedding a proxy ticket into a query
|
||||
/// string (everything but RFC 3986 unreserved characters is encoded).
|
||||
const QUERY_ENCODE_SET: &AsciiSet = &NON_ALPHANUMERIC
|
||||
.remove(b'-')
|
||||
.remove(b'.')
|
||||
.remove(b'_')
|
||||
.remove(b'~');
|
||||
|
||||
/// The local endpoint handed to the frontend for one console session.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConsoleProxyInfo {
|
||||
pub session_id: String,
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
struct ConsoleSession {
|
||||
shutdown: watch::Sender<()>,
|
||||
}
|
||||
|
||||
pub struct ConsoleProxyManager {
|
||||
sessions: HashMap<String, ConsoleSession>,
|
||||
}
|
||||
|
||||
impl ConsoleProxyManager {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
sessions: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens a console proxy for a guest and returns a local `ws://` URL.
|
||||
///
|
||||
/// `kind` is `"vnc"` (QEMU) or `"term"` (LXC). A session handles a single
|
||||
/// local client; when the client disconnects the Proxmox stream is torn
|
||||
/// down. Call [`ConsoleProxyManager::stop`] to cancel a session that is
|
||||
/// still waiting for its client.
|
||||
pub async fn start(
|
||||
&mut self,
|
||||
connection_id: &str,
|
||||
kind: &str,
|
||||
node: &str,
|
||||
vmid: u32,
|
||||
manager: &ConnectionManager,
|
||||
) -> Result<ConsoleProxyInfo> {
|
||||
let (proxy_port, ticket) = match kind {
|
||||
"vnc" => {
|
||||
let proxy = manager.create_vnc_proxy(connection_id, node, vmid).await?;
|
||||
(proxy.port, proxy.ticket)
|
||||
}
|
||||
"term" => {
|
||||
let proxy = manager.create_term_proxy(connection_id, node, vmid).await?;
|
||||
(proxy.port, proxy.ticket)
|
||||
}
|
||||
other => {
|
||||
return Err(Error::InvalidUrl(format!(
|
||||
"Invalid console type '{}': expected 'vnc' or 'term'",
|
||||
other
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
let origin = manager.get_websocket_url(connection_id, node).await?;
|
||||
let encoded_ticket = utf8_percent_encode(&ticket, QUERY_ENCODE_SET).to_string();
|
||||
let path = if kind == "vnc" {
|
||||
format!(
|
||||
"/api2/json/nodes/{}/qemu/{}/vncwebsocket?port={}&vncticket={}",
|
||||
node, vmid, proxy_port, encoded_ticket
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"/api2/json/nodes/{}/lxc/{}/proxy?port={}&ticket={}",
|
||||
node, vmid, proxy_port, encoded_ticket
|
||||
)
|
||||
};
|
||||
let ws_url = format!("{}{}", origin, path);
|
||||
|
||||
// Connect to Proxmox first so an unreachable server fails the command
|
||||
// instead of leaving a dangling local listener.
|
||||
let server = timeout(Duration::from_secs(20), connect_ws(&ws_url))
|
||||
.await
|
||||
.map_err(|_| Error::WebSocketError("Timed out opening console proxy".to_string()))?
|
||||
.map_err(|e| Error::WebSocketError(format!("Cannot open console proxy: {}", e)))?;
|
||||
|
||||
let listener = TcpListener::bind(SocketAddr::from(([127, 0, 0, 1], 0)))
|
||||
.await
|
||||
.map_err(|e| Error::WebSocketError(format!("Cannot bind console proxy: {}", e)))?;
|
||||
let local_port = listener
|
||||
.local_addr()
|
||||
.map_err(|e| Error::WebSocketError(format!("Cannot read console proxy port: {}", e)))?
|
||||
.port();
|
||||
|
||||
let session_id = Uuid::new_v4().to_string();
|
||||
let token = Uuid::new_v4().to_string();
|
||||
let (shutdown_tx, shutdown_rx) = watch::channel(());
|
||||
|
||||
let task_token = token.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut shutdown = shutdown_rx;
|
||||
// Wait for the single local client (or a stop request).
|
||||
let client = tokio::select! {
|
||||
_ = shutdown.changed() => None,
|
||||
accepted = listener.accept() => match accepted {
|
||||
Ok((tcp, _)) => {
|
||||
let token = task_token.clone();
|
||||
match tokio_tungstenite::accept_hdr_async(tcp, move |request: &Request, response: Response| -> std::result::Result<Response, ErrorResponse> {
|
||||
let query = request.uri().query().unwrap_or("");
|
||||
if query == format!("token={}", token) {
|
||||
Ok(response)
|
||||
} else {
|
||||
Err(Response::builder()
|
||||
.status(StatusCode::FORBIDDEN)
|
||||
.body(None)
|
||||
.expect("valid HTTP response"))
|
||||
}
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(client) => Some(client),
|
||||
Err(_) => None,
|
||||
}
|
||||
}
|
||||
Err(_) => None,
|
||||
},
|
||||
};
|
||||
|
||||
if let Some(client) = client {
|
||||
relay(client, server, &mut shutdown).await;
|
||||
}
|
||||
});
|
||||
|
||||
self.sessions.insert(
|
||||
session_id.clone(),
|
||||
ConsoleSession {
|
||||
shutdown: shutdown_tx,
|
||||
},
|
||||
);
|
||||
|
||||
Ok(ConsoleProxyInfo {
|
||||
session_id,
|
||||
url: format!("ws://127.0.0.1:{}/?token={}", local_port, token),
|
||||
})
|
||||
}
|
||||
|
||||
/// Cancels a console session. Sessions whose client already connected and
|
||||
/// disconnected are gone from the map, so a stop for a stale id is a
|
||||
/// no-op.
|
||||
pub async fn stop(&mut self, session_id: &str) -> Result<()> {
|
||||
if let Some(session) = self.sessions.remove(session_id) {
|
||||
let _ = session.shutdown.send(());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Bidirectionally copies WebSocket messages between the local client and the
|
||||
/// Proxmox console stream until either side closes or the session is stopped.
|
||||
async fn relay(
|
||||
client: tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>,
|
||||
server: tokio_tungstenite::WebSocketStream<
|
||||
tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
|
||||
>,
|
||||
shutdown: &mut watch::Receiver<()>,
|
||||
) {
|
||||
let (mut client_tx, mut client_rx) = client.split();
|
||||
let (mut server_tx, mut server_rx) = server.split();
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = shutdown.changed() => break,
|
||||
next = client_rx.next() => match next {
|
||||
Some(Ok(msg)) => {
|
||||
let closing = matches!(msg, Message::Close(_));
|
||||
if server_tx.send(msg).await.is_err() {
|
||||
break;
|
||||
}
|
||||
if closing {
|
||||
break;
|
||||
}
|
||||
}
|
||||
_ => break,
|
||||
},
|
||||
next = server_rx.next() => match next {
|
||||
Some(Ok(msg)) => {
|
||||
let closing = matches!(msg, Message::Close(_));
|
||||
if client_tx.send(msg).await.is_err() {
|
||||
break;
|
||||
}
|
||||
if closing {
|
||||
break;
|
||||
}
|
||||
}
|
||||
_ => break,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
+62
-4
@@ -3,10 +3,11 @@ use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use tauri::menu::{MenuBuilder, MenuItemBuilder};
|
||||
use tauri::tray::TrayIconBuilder;
|
||||
use tauri::Manager;
|
||||
use tauri::{Emitter, Manager};
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
mod connection;
|
||||
mod console_proxy;
|
||||
mod error;
|
||||
mod proxmox;
|
||||
pub mod tls;
|
||||
@@ -15,10 +16,11 @@ mod websocket;
|
||||
pub use connection::{
|
||||
api_request, derive_node_url, AuthContext, AuthMode, ConnectionManager, LoadResult,
|
||||
};
|
||||
pub use console_proxy::ConsoleProxyInfo;
|
||||
pub use error::Error;
|
||||
pub use proxmox::{
|
||||
AddDiskConfig, AddNICConfig, BackupJobConfig, CreateSnapshotConfig, EditNICConfig,
|
||||
RestoreConfig,
|
||||
RestoreConfig, UpdateVMConfig,
|
||||
};
|
||||
use websocket::WebSocketManager;
|
||||
|
||||
@@ -125,6 +127,7 @@ pub struct CertificateInfo {
|
||||
struct AppState {
|
||||
connection_manager: Arc<RwLock<ConnectionManager>>,
|
||||
ws_manager: Arc<RwLock<WebSocketManager>>,
|
||||
console_proxy: Arc<RwLock<console_proxy::ConsoleProxyManager>>,
|
||||
}
|
||||
|
||||
/// Returns the path of the persisted connections file.
|
||||
@@ -269,9 +272,12 @@ async fn get_storage_content(
|
||||
state: tauri::State<'_, AppState>,
|
||||
connection_id: String,
|
||||
storage: String,
|
||||
node: Option<String>,
|
||||
) -> Result<Vec<proxmox::StorageContent>> {
|
||||
let manager = state.connection_manager.read().await;
|
||||
manager.get_storage_content(&connection_id, &storage).await
|
||||
manager
|
||||
.get_storage_content(&connection_id, &storage, node.as_deref())
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -598,6 +604,21 @@ async fn migrate_vm(
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn update_vm_config(
|
||||
state: tauri::State<'_, AppState>,
|
||||
connection_id: String,
|
||||
node: String,
|
||||
vmid: u32,
|
||||
vm_type: String,
|
||||
config: proxmox::UpdateVMConfig,
|
||||
) -> Result<()> {
|
||||
let manager = state.connection_manager.read().await;
|
||||
manager
|
||||
.update_vm_config(&connection_id, &node, vmid, &vm_type, config)
|
||||
.await
|
||||
}
|
||||
|
||||
// Authentication commands
|
||||
#[tauri::command]
|
||||
async fn login_with_password(
|
||||
@@ -714,6 +735,31 @@ async fn is_websocket_connected(
|
||||
Ok(ws_manager.is_connected(&connection_id))
|
||||
}
|
||||
|
||||
// Console proxy commands
|
||||
#[tauri::command]
|
||||
async fn start_console_proxy(
|
||||
state: tauri::State<'_, AppState>,
|
||||
connection_id: String,
|
||||
kind: String,
|
||||
node: String,
|
||||
vmid: u32,
|
||||
) -> Result<ConsoleProxyInfo> {
|
||||
let manager = state.connection_manager.read().await;
|
||||
let mut proxy = state.console_proxy.write().await;
|
||||
proxy
|
||||
.start(&connection_id, &kind, &node, vmid, &manager)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn stop_console_proxy(
|
||||
state: tauri::State<'_, AppState>,
|
||||
session_id: String,
|
||||
) -> Result<()> {
|
||||
let mut proxy = state.console_proxy.write().await;
|
||||
proxy.stop(&session_id).await
|
||||
}
|
||||
|
||||
// Backup management commands
|
||||
#[tauri::command]
|
||||
async fn get_backup_jobs(
|
||||
@@ -862,10 +908,10 @@ async fn update_tray_menu(
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.manage(AppState {
|
||||
connection_manager: Arc::new(RwLock::new(ConnectionManager::new())),
|
||||
ws_manager: Arc::new(RwLock::new(WebSocketManager::new())),
|
||||
console_proxy: Arc::new(RwLock::new(console_proxy::ConsoleProxyManager::new())),
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
load_connections,
|
||||
@@ -909,12 +955,15 @@ pub fn run() {
|
||||
delete_snapshot,
|
||||
rollback_snapshot,
|
||||
migrate_vm,
|
||||
update_vm_config,
|
||||
create_vnc_proxy,
|
||||
create_term_proxy,
|
||||
get_websocket_url,
|
||||
connect_websocket,
|
||||
disconnect_websocket,
|
||||
is_websocket_connected,
|
||||
start_console_proxy,
|
||||
stop_console_proxy,
|
||||
get_backup_jobs,
|
||||
get_backups,
|
||||
create_backup_job,
|
||||
@@ -956,6 +1005,15 @@ pub fn run() {
|
||||
"quit" => {
|
||||
app.exit(0);
|
||||
}
|
||||
// Connection entries are dynamic menu items added by the
|
||||
// frontend via `update_tray_menu`. Clicking one switches
|
||||
// the active connection there, so forward the id as an
|
||||
// event instead of reaching into the backend state here.
|
||||
id if id.starts_with("connection_") => {
|
||||
if let Some(connection_id) = id.strip_prefix("connection_") {
|
||||
let _ = app.emit("tray-connection-click", connection_id.to_string());
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
})
|
||||
.build(app)?;
|
||||
|
||||
@@ -26,15 +26,25 @@ pub struct Node {
|
||||
pub node: String,
|
||||
#[serde(default)]
|
||||
pub status: String,
|
||||
#[serde(default)]
|
||||
pub cpu: f64,
|
||||
#[serde(default)]
|
||||
pub maxcpu: u32,
|
||||
#[serde(default)]
|
||||
pub mem: u64,
|
||||
#[serde(default)]
|
||||
pub maxmem: u64,
|
||||
#[serde(default)]
|
||||
pub disk: u64,
|
||||
#[serde(default)]
|
||||
pub maxdisk: u64,
|
||||
#[serde(default)]
|
||||
pub uptime: u64,
|
||||
#[serde(default)]
|
||||
pub level: String,
|
||||
#[serde(default)]
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub r#type: String,
|
||||
}
|
||||
|
||||
@@ -47,6 +57,7 @@ pub struct VM {
|
||||
pub name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub status: String,
|
||||
#[serde(default)]
|
||||
pub r#type: String,
|
||||
#[serde(default)]
|
||||
pub node: String,
|
||||
@@ -85,8 +96,11 @@ pub struct VM {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Storage {
|
||||
#[serde(default)]
|
||||
pub storage: String,
|
||||
#[serde(default)]
|
||||
pub r#type: String,
|
||||
#[serde(default)]
|
||||
pub content: String,
|
||||
#[serde(default)]
|
||||
pub active: u32,
|
||||
@@ -107,16 +121,23 @@ pub struct Storage {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Task {
|
||||
#[serde(default)]
|
||||
pub upid: String,
|
||||
#[serde(default)]
|
||||
pub node: String,
|
||||
#[serde(default)]
|
||||
pub pid: u32,
|
||||
#[serde(default)]
|
||||
pub pstart: u64,
|
||||
#[serde(default)]
|
||||
pub starttime: u64,
|
||||
#[serde(default)]
|
||||
pub endtime: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub r#type: String,
|
||||
#[serde(default)]
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub user: String,
|
||||
#[serde(default)]
|
||||
pub status: Option<String>,
|
||||
@@ -136,20 +157,28 @@ pub struct ClusterStatus {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ClusterNode {
|
||||
#[serde(default)]
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub nodeid: u32,
|
||||
#[serde(default)]
|
||||
pub online: u32,
|
||||
#[serde(default)]
|
||||
pub local: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub ip: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Snapshot {
|
||||
#[serde(default)]
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
#[serde(default)]
|
||||
pub snaptime: u64,
|
||||
#[serde(default)]
|
||||
pub vmstate: u32,
|
||||
#[serde(default)]
|
||||
pub parent: Option<String>,
|
||||
@@ -163,17 +192,14 @@ pub struct CreateSnapshotConfig {
|
||||
pub vmstate: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ApiResponse<T> {
|
||||
pub data: T,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NetworkInterface {
|
||||
#[serde(default)]
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub model: String,
|
||||
#[serde(default)]
|
||||
pub macaddr: String,
|
||||
#[serde(default)]
|
||||
pub bridge: Option<String>,
|
||||
@@ -204,10 +230,22 @@ pub struct EditNICConfig {
|
||||
pub firewall: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdateVMConfig {
|
||||
pub name: Option<String>,
|
||||
pub cores: Option<u32>,
|
||||
/// Memory size in MiB.
|
||||
pub memory: Option<u64>,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Backup {
|
||||
#[serde(default)]
|
||||
pub volid: String,
|
||||
#[serde(default)]
|
||||
pub backupid: String,
|
||||
#[serde(rename = "backup-type")]
|
||||
pub backup_type: String,
|
||||
@@ -215,8 +253,11 @@ pub struct Backup {
|
||||
pub backup_id: String,
|
||||
#[serde(rename = "backup-time")]
|
||||
pub backup_time: u64,
|
||||
#[serde(default)]
|
||||
pub storage: String,
|
||||
#[serde(default)]
|
||||
pub size: u64,
|
||||
#[serde(default)]
|
||||
pub ctime: u64,
|
||||
}
|
||||
|
||||
@@ -224,9 +265,15 @@ pub struct Backup {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BackupJob {
|
||||
pub id: String,
|
||||
/// The server sends `storage` (not `store`).
|
||||
#[serde(rename = "storage")]
|
||||
pub store: String,
|
||||
pub schedule: String,
|
||||
/// `all` is only present on jobs that back up every guest; jobs with an
|
||||
/// explicit `vmid` selection omit it.
|
||||
#[serde(default)]
|
||||
pub all: u32,
|
||||
#[serde(default)]
|
||||
pub enabled: u32,
|
||||
#[serde(default)]
|
||||
pub node: Option<String>,
|
||||
@@ -266,7 +313,9 @@ pub struct RestoreConfig {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct StorageContent {
|
||||
#[serde(default)]
|
||||
pub content: String,
|
||||
#[serde(default)]
|
||||
pub ctime: u64,
|
||||
#[serde(default)]
|
||||
pub format: Option<String>,
|
||||
@@ -274,12 +323,14 @@ pub struct StorageContent {
|
||||
pub size: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub subtype: Option<String>,
|
||||
#[serde(default)]
|
||||
pub volid: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct StorageDetail {
|
||||
#[serde(default)]
|
||||
pub storage: String,
|
||||
pub r#type: String,
|
||||
pub content: String,
|
||||
|
||||
@@ -17,6 +17,7 @@ use rustls::{ClientConfig, DigitallySignedStruct, SignatureScheme};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::sync::Arc;
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::time::{timeout, Duration};
|
||||
use tokio_rustls::TlsConnector;
|
||||
use url::Url;
|
||||
use x509_cert::der::Decode;
|
||||
@@ -27,7 +28,7 @@ use x509_cert::der::Decode;
|
||||
/// certificate's SHA-256 fingerprint is captured on each connection and
|
||||
/// compared against the pinned value recorded on first use (TOFU).
|
||||
#[derive(Debug)]
|
||||
struct AcceptAllVerifier;
|
||||
pub(crate) struct AcceptAllVerifier;
|
||||
|
||||
impl ServerCertVerifier for AcceptAllVerifier {
|
||||
fn verify_server_cert(
|
||||
@@ -70,7 +71,7 @@ impl ServerCertVerifier for AcceptAllVerifier {
|
||||
///
|
||||
/// `install_default` is idempotent and thread-safe; subsequent calls return
|
||||
/// `Err` with the already-installed provider, which is ignored here.
|
||||
fn ensure_crypto_provider() {
|
||||
pub(crate) fn ensure_crypto_provider() {
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
}
|
||||
|
||||
@@ -109,8 +110,9 @@ async fn capture_leaf_certificate_der(url: &str) -> crate::Result<Vec<u8>> {
|
||||
.first()
|
||||
.ok_or_else(|| Error::InvalidUrl(format!("Cannot resolve host '{}'", addr)))?;
|
||||
|
||||
let tcp = TcpStream::connect(address)
|
||||
let tcp = timeout(Duration::from_secs(10), TcpStream::connect(address))
|
||||
.await
|
||||
.map_err(|_| Error::CertificateError(format!("Timed out connecting to '{}'", addr)))?
|
||||
.map_err(|e| Error::CertificateError(format!("Cannot connect to '{}': {}", addr, e)))?;
|
||||
|
||||
let config = ClientConfig::builder()
|
||||
@@ -119,9 +121,10 @@ async fn capture_leaf_certificate_der(url: &str) -> crate::Result<Vec<u8>> {
|
||||
.with_no_client_auth();
|
||||
|
||||
let connector = TlsConnector::from(Arc::new(config));
|
||||
let tls = connector.connect(server_name, tcp).await.map_err(|e| {
|
||||
Error::CertificateError(format!("TLS handshake with '{}' failed: {}", addr, e))
|
||||
})?;
|
||||
let tls = timeout(Duration::from_secs(15), connector.connect(server_name, tcp))
|
||||
.await
|
||||
.map_err(|_| Error::CertificateError(format!("Timed out during TLS handshake with '{}'", addr)))?
|
||||
.map_err(|e| Error::CertificateError(format!("TLS handshake with '{}' failed: {}", addr, e)))?;
|
||||
|
||||
let (_, session) = tls.get_ref();
|
||||
let leaf = session
|
||||
|
||||
@@ -1,14 +1,52 @@
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tauri::Emitter;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::time::{sleep, Duration};
|
||||
use tokio_tungstenite::connect_async;
|
||||
use tokio::time::{sleep, timeout, Duration};
|
||||
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
use tokio_tungstenite::Connector;
|
||||
|
||||
use crate::error::Error;
|
||||
|
||||
/// Connects to a Proxmox WebSocket URL, accepting self-signed certificates.
|
||||
///
|
||||
/// The regular `wss://` flow validates against the system trust store, which
|
||||
/// rejects the self-signed certificates typical of home-lab Proxmox servers.
|
||||
/// As with the HTTP transport, the application enforces trust at its own layer
|
||||
/// (TOFU pinning in `tls.rs`), so the transport here accepts any certificate.
|
||||
pub async fn connect_ws(
|
||||
url: &str,
|
||||
) -> crate::Result<tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>>
|
||||
{
|
||||
let request = url
|
||||
.into_client_request()
|
||||
.map_err(|e| Error::WebSocketError(e.to_string()))?;
|
||||
let is_wss = request.uri().scheme_str() == Some("wss");
|
||||
|
||||
let connector = if is_wss {
|
||||
crate::tls::ensure_crypto_provider();
|
||||
let config = rustls::ClientConfig::builder()
|
||||
.dangerous()
|
||||
.with_custom_certificate_verifier(Arc::new(crate::tls::AcceptAllVerifier))
|
||||
.with_no_client_auth();
|
||||
Some(Connector::Rustls(Arc::new(config)))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let (ws, _) = timeout(
|
||||
Duration::from_secs(15),
|
||||
tokio_tungstenite::connect_async_tls_with_config(request, None, false, connector),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| Error::WebSocketError(format!("Timed out connecting to '{}'", url)))?
|
||||
.map_err(|e| Error::WebSocketError(e.to_string()))?;
|
||||
Ok(ws)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TaskUpdate {
|
||||
pub connection_id: String,
|
||||
@@ -60,7 +98,7 @@ impl WebSocketManager {
|
||||
app_handle: tauri::AppHandle,
|
||||
) -> crate::Result<()> {
|
||||
// Disconnect any existing connection for this ID
|
||||
self.disconnect(&connection_id).await;
|
||||
let _ = self.disconnect(&connection_id).await;
|
||||
|
||||
let (shutdown_tx, mut shutdown_rx) = mpsc::channel::<()>(1);
|
||||
|
||||
@@ -134,9 +172,7 @@ async fn connect_and_run(
|
||||
url: &str,
|
||||
app_handle: &tauri::AppHandle,
|
||||
) -> crate::Result<()> {
|
||||
let (ws_stream, _) = connect_async(url)
|
||||
.await
|
||||
.map_err(|e| Error::WebSocketError(e.to_string()))?;
|
||||
let ws_stream = connect_ws(url).await?;
|
||||
|
||||
let cid = connection_id.to_string();
|
||||
let (mut write, mut read) = ws_stream.split();
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
/home/user/projects/dev/ProxmoxDesktop/src-tauri/target/debug/build/libc-19124a20af635abc/build_script_build-19124a20af635abc.d: /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/libc-0.2.189/build.rs
|
||||
/home/user/projects/dev/clustri/src-tauri/target/debug/build/libc-19124a20af635abc/build_script_build-19124a20af635abc.d: /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/libc-0.2.189/build.rs
|
||||
|
||||
/home/user/projects/dev/ProxmoxDesktop/src-tauri/target/debug/build/libc-19124a20af635abc/build_script_build-19124a20af635abc: /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/libc-0.2.189/build.rs
|
||||
/home/user/projects/dev/clustri/src-tauri/target/debug/build/libc-19124a20af635abc/build_script_build-19124a20af635abc: /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/libc-0.2.189/build.rs
|
||||
|
||||
/home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/libc-0.2.189/build.rs:
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
/home/user/projects/dev/ProxmoxDesktop/src-tauri/target/debug/build/proc-macro2-28cd7e73fe64eada/build_script_build-28cd7e73fe64eada.d: /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/proc-macro2-1.0.107/build.rs
|
||||
/home/user/projects/dev/clustri/src-tauri/target/debug/build/proc-macro2-28cd7e73fe64eada/build_script_build-28cd7e73fe64eada.d: /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/proc-macro2-1.0.107/build.rs
|
||||
|
||||
/home/user/projects/dev/ProxmoxDesktop/src-tauri/target/debug/build/proc-macro2-28cd7e73fe64eada/build_script_build-28cd7e73fe64eada: /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/proc-macro2-1.0.107/build.rs
|
||||
/home/user/projects/dev/clustri/src-tauri/target/debug/build/proc-macro2-28cd7e73fe64eada/build_script_build-28cd7e73fe64eada: /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/proc-macro2-1.0.107/build.rs
|
||||
|
||||
/home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/proc-macro2-1.0.107/build.rs:
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
/home/user/projects/dev/ProxmoxDesktop/src-tauri/target/debug/build/quote-6dff9724e4e81362/build_script_build-6dff9724e4e81362.d: /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/quote-1.0.47/build.rs
|
||||
/home/user/projects/dev/clustri/src-tauri/target/debug/build/quote-6dff9724e4e81362/build_script_build-6dff9724e4e81362.d: /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/quote-1.0.47/build.rs
|
||||
|
||||
/home/user/projects/dev/ProxmoxDesktop/src-tauri/target/debug/build/quote-6dff9724e4e81362/build_script_build-6dff9724e4e81362: /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/quote-1.0.47/build.rs
|
||||
/home/user/projects/dev/clustri/src-tauri/target/debug/build/quote-6dff9724e4e81362/build_script_build-6dff9724e4e81362: /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/quote-1.0.47/build.rs
|
||||
|
||||
/home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/quote-1.0.47/build.rs:
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
/home/user/projects/dev/ProxmoxDesktop/src-tauri/target/debug/build/serde_core-e613c711ddfe8493/build_script_build-e613c711ddfe8493.d: /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/serde_core-1.0.229/build.rs
|
||||
/home/user/projects/dev/clustri/src-tauri/target/debug/build/serde_core-e613c711ddfe8493/build_script_build-e613c711ddfe8493.d: /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/serde_core-1.0.229/build.rs
|
||||
|
||||
/home/user/projects/dev/ProxmoxDesktop/src-tauri/target/debug/build/serde_core-e613c711ddfe8493/build_script_build-e613c711ddfe8493: /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/serde_core-1.0.229/build.rs
|
||||
/home/user/projects/dev/clustri/src-tauri/target/debug/build/serde_core-e613c711ddfe8493/build_script_build-e613c711ddfe8493: /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/serde_core-1.0.229/build.rs
|
||||
|
||||
/home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/serde_core-1.0.229/build.rs:
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/home/user/projects/dev/ProxmoxDesktop/src-tauri/target/debug/deps/unicode_ident-8443eb632a3fbe4c.d: /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/unicode-ident-1.0.24/src/lib.rs /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/unicode-ident-1.0.24/src/tables.rs
|
||||
/home/user/projects/dev/clustri/src-tauri/target/debug/deps/unicode_ident-8443eb632a3fbe4c.d: /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/unicode-ident-1.0.24/src/lib.rs /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/unicode-ident-1.0.24/src/tables.rs
|
||||
|
||||
/home/user/projects/dev/ProxmoxDesktop/src-tauri/target/debug/deps/libunicode_ident-8443eb632a3fbe4c.rlib: /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/unicode-ident-1.0.24/src/lib.rs /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/unicode-ident-1.0.24/src/tables.rs
|
||||
/home/user/projects/dev/clustri/src-tauri/target/debug/deps/libunicode_ident-8443eb632a3fbe4c.rlib: /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/unicode-ident-1.0.24/src/lib.rs /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/unicode-ident-1.0.24/src/tables.rs
|
||||
|
||||
/home/user/projects/dev/ProxmoxDesktop/src-tauri/target/debug/deps/libunicode_ident-8443eb632a3fbe4c.rmeta: /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/unicode-ident-1.0.24/src/lib.rs /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/unicode-ident-1.0.24/src/tables.rs
|
||||
/home/user/projects/dev/clustri/src-tauri/target/debug/deps/libunicode_ident-8443eb632a3fbe4c.rmeta: /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/unicode-ident-1.0.24/src/lib.rs /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/unicode-ident-1.0.24/src/tables.rs
|
||||
|
||||
/home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/unicode-ident-1.0.24/src/lib.rs:
|
||||
/home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/unicode-ident-1.0.24/src/tables.rs:
|
||||
|
||||
@@ -20,7 +20,8 @@
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self' ipc: http://ipc.localhost http://tauri.localhost https://tauri.localhost ws://127.0.0.1:* http://localhost:* https: wss:; object-src 'none'; base-uri 'self'; frame-ancestors 'none'",
|
||||
"devCsp": "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self' ipc: http://ipc.localhost http://tauri.localhost https://tauri.localhost ws://127.0.0.1:* http://localhost:* ws://localhost:* https: wss:; object-src 'none'; base-uri 'self'"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
|
||||
@@ -127,7 +127,7 @@ async fn server_error_message_is_mapped_to_api_error() {
|
||||
when.method(GET).path("/api2/json/version");
|
||||
then.status(500)
|
||||
.header("content-type", "application/json")
|
||||
.body(r#"{"message":"boom"}"#);
|
||||
.body(r#"{"errors":"specific failure","message":"boom"}"#);
|
||||
});
|
||||
|
||||
let error = api_request(
|
||||
@@ -142,9 +142,48 @@ async fn server_error_message_is_mapped_to_api_error() {
|
||||
.await
|
||||
.expect_err("request should fail");
|
||||
|
||||
// A string `errors` field takes precedence over the generic `message`.
|
||||
assert!(
|
||||
matches!(error, Error::ApiError(ref message) if message == "boom"),
|
||||
"expected ApiError with message 'boom', got: {}",
|
||||
matches!(error, Error::ApiError(ref message) if message == "specific failure"),
|
||||
"expected ApiError with message 'specific failure', got: {}",
|
||||
error
|
||||
);
|
||||
mock.assert();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn server_error_errors_object_first_pair_is_surfaced() {
|
||||
let server = MockServer::start();
|
||||
let mock = server.mock(|when, then| {
|
||||
when.method(GET).path("/api2/json/version");
|
||||
then.status(400)
|
||||
.header("content-type", "application/json")
|
||||
.body(
|
||||
r#"{"errors":{"limit":"property is not defined in schema"},"message":"Parameter verification failed."}"#,
|
||||
);
|
||||
});
|
||||
|
||||
let error = api_request(
|
||||
&Client::new(),
|
||||
&server.base_url(),
|
||||
RMethod::GET,
|
||||
"/version",
|
||||
&token_auth(),
|
||||
&[],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect_err("request should fail");
|
||||
|
||||
// An `errors` object surfaces its first `key: value` pair, not the generic
|
||||
// `message` fallback.
|
||||
assert!(
|
||||
matches!(
|
||||
error,
|
||||
Error::ApiError(ref message)
|
||||
if message == "limit: property is not defined in schema"
|
||||
),
|
||||
"expected ApiError with 'limit: property is not defined in schema', got: {}",
|
||||
error
|
||||
);
|
||||
mock.assert();
|
||||
|
||||
+141
-19
@@ -8,7 +8,7 @@
|
||||
//! called directly on an added connection without `connect()`.
|
||||
|
||||
use httpmock::prelude::*;
|
||||
use clustri::{ConnectionConfig, ConnectionManager, EndpointConfig, Error};
|
||||
use clustri::{ConnectionConfig, ConnectionManager, EndpointConfig, Error, UpdateVMConfig};
|
||||
|
||||
/// Builds a `ConnectionManager` with a single token-mode connection whose
|
||||
/// primary endpoint points at the mock server. `node` pins the storage node.
|
||||
@@ -168,14 +168,17 @@ async fn get_storage_maps_cluster_resources() {
|
||||
.body(
|
||||
serde_json::json!({
|
||||
"data": [
|
||||
// The real cluster-resource shape: usage is `disk`/`maxdisk`
|
||||
// and liveness is the `status` string. There are no
|
||||
// `used`/`total`/`avail`/`enabled`/`active` keys.
|
||||
{"storage": "local", "node": "pve1", "type": "dir",
|
||||
"content": "iso,vztmpl", "enabled": 1, "shared": 0, "active": 1,
|
||||
"total": 858993459200u64, "used": 429496729600u64, "avail": 429496729600u64,
|
||||
"content": "iso,vztmpl", "shared": 0, "plugintype": "dir",
|
||||
"disk": 429496729600u64, "maxdisk": 858993459200u64,
|
||||
"status": "available"},
|
||||
{"storage": "backup", "node": "pve1", "type": "nfs",
|
||||
"content": "backup", "enabled": 1, "shared": 1, "active": 1,
|
||||
"total": 1717986918400u64, "used": 644245094400u64,
|
||||
"avail": 1073741824000u64, "status": "available"}
|
||||
{"storage": "backup", "node": "pve2", "type": "nfs",
|
||||
"content": "backup", "shared": 1, "plugintype": "nfs",
|
||||
"disk": 644245094400u64, "maxdisk": 1717986918400u64,
|
||||
"status": "unavailable"}
|
||||
]
|
||||
})
|
||||
.to_string(),
|
||||
@@ -192,14 +195,22 @@ async fn get_storage_maps_cluster_resources() {
|
||||
assert_eq!(storages[0].storage, "local");
|
||||
assert_eq!(storages[0].r#type, "dir");
|
||||
assert_eq!(storages[0].content, "iso,vztmpl");
|
||||
assert_eq!(storages[0].active, 1);
|
||||
assert_eq!(storages[0].enabled, 1);
|
||||
assert_eq!(storages[0].shared, 0);
|
||||
// `disk`/`maxdisk`/`status` map onto used/total/avail/enabled/active.
|
||||
assert_eq!(storages[0].used, 429496729600u64);
|
||||
assert_eq!(storages[0].total, 858993459200u64);
|
||||
assert_eq!(storages[0].avail, 429496729600u64);
|
||||
assert_eq!(storages[0].enabled, 1);
|
||||
assert_eq!(storages[0].active, 1);
|
||||
assert_eq!(storages[0].shared, 0);
|
||||
assert_eq!(storages[0].node, "pve1");
|
||||
// A storage whose status is not "available" reports enabled/active 0.
|
||||
assert_eq!(storages[1].enabled, 0);
|
||||
assert_eq!(storages[1].active, 0);
|
||||
assert_eq!(storages[1].used, 644245094400u64);
|
||||
assert_eq!(storages[1].total, 1717986918400u64);
|
||||
assert_eq!(storages[1].avail, 1073741824000u64);
|
||||
assert_eq!(storages[1].shared, 1);
|
||||
assert_eq!(storages[1].node, "pve2");
|
||||
mock.assert();
|
||||
}
|
||||
|
||||
@@ -227,7 +238,7 @@ async fn get_storage_content_uses_configured_node() {
|
||||
|
||||
let (manager, _dir) = setup_manager(&server.base_url(), token, Some("pve1")).await;
|
||||
let contents = manager
|
||||
.get_storage_content("conn", "local")
|
||||
.get_storage_content("conn", "local", Some("pve1"))
|
||||
.await
|
||||
.expect("content should be fetched");
|
||||
|
||||
@@ -282,7 +293,7 @@ async fn get_storage_content_falls_back_to_online_node() {
|
||||
|
||||
let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
|
||||
let contents = manager
|
||||
.get_storage_content("conn", "local")
|
||||
.get_storage_content("conn", "local", None)
|
||||
.await
|
||||
.expect("content should be fetched");
|
||||
|
||||
@@ -292,6 +303,36 @@ async fn get_storage_content_falls_back_to_online_node() {
|
||||
content_mock.assert();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_storage_content_uses_explicit_node() {
|
||||
let server = MockServer::start();
|
||||
let token = "root@pam!explicit-node-token";
|
||||
let mock = server.mock(|when, then| {
|
||||
when.method(GET)
|
||||
.path("/api2/json/nodes/pve2/storage/local/content");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(
|
||||
serde_json::json!({
|
||||
"data": [{"volid": "local:iso/explicit.iso", "content": "iso",
|
||||
"ctime": 1700000000}]
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
});
|
||||
|
||||
// The connection pins `pve1`, but an explicitly requested node must win.
|
||||
let (manager, _dir) = setup_manager(&server.base_url(), token, Some("pve1")).await;
|
||||
let contents = manager
|
||||
.get_storage_content("conn", "local", Some("pve2"))
|
||||
.await
|
||||
.expect("content should be fetched");
|
||||
|
||||
assert_eq!(contents.len(), 1);
|
||||
assert_eq!(contents[0].volid, "local:iso/explicit.iso");
|
||||
mock.assert();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_storage_detail_maps_status() {
|
||||
let server = MockServer::start();
|
||||
@@ -303,9 +344,11 @@ async fn get_storage_detail_maps_status() {
|
||||
.header("content-type", "application/json")
|
||||
.body(
|
||||
serde_json::json!({
|
||||
"data": {"storage": "local", "type": "dir", "content": "iso,vztmpl",
|
||||
// The real status response has no `storage` or `node` keys;
|
||||
// both fields must default to empty strings.
|
||||
"data": {"type": "dir", "content": "iso,vztmpl",
|
||||
"active": 1, "enabled": 1, "shared": 0, "used": 429496729600u64,
|
||||
"total": 858993459200u64, "avail": 429496729600u64, "node": "pve1"}
|
||||
"total": 858993459200u64, "avail": 429496729600u64}
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
@@ -317,7 +360,10 @@ async fn get_storage_detail_maps_status() {
|
||||
.await
|
||||
.expect("detail should be fetched");
|
||||
|
||||
assert_eq!(detail.storage, "local");
|
||||
// The storage and node are not part of the status response, so they
|
||||
// default to empty strings.
|
||||
assert_eq!(detail.storage, "");
|
||||
assert_eq!(detail.node, "");
|
||||
assert_eq!(detail.r#type, "dir");
|
||||
assert_eq!(detail.content, "iso,vztmpl");
|
||||
assert_eq!(detail.active, 1);
|
||||
@@ -325,7 +371,6 @@ async fn get_storage_detail_maps_status() {
|
||||
assert_eq!(detail.used, 429496729600u64);
|
||||
assert_eq!(detail.total, 858993459200u64);
|
||||
assert_eq!(detail.avail, 429496729600u64);
|
||||
assert_eq!(detail.node, "pve1");
|
||||
mock.assert();
|
||||
}
|
||||
|
||||
@@ -335,8 +380,7 @@ async fn get_tasks_maps_cluster_tasks() {
|
||||
let token = "root@pam!tasks-token";
|
||||
let mock = server.mock(|when, then| {
|
||||
when.method(GET)
|
||||
.path("/api2/json/cluster/tasks")
|
||||
.query_param("limit", "50");
|
||||
.path("/api2/json/cluster/tasks");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(
|
||||
@@ -527,12 +571,90 @@ async fn lifecycle_with_invalid_type_errors() {
|
||||
error
|
||||
);
|
||||
assert_eq!(
|
||||
probe.hits(),
|
||||
probe.calls(),
|
||||
0,
|
||||
"no HTTP request should be made for an invalid vm type"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_vm_config_posts_only_present_fields() {
|
||||
let server = MockServer::start();
|
||||
let token = "root@pam!update-config-token";
|
||||
let mock = server.mock(|when, then| {
|
||||
when.method(POST)
|
||||
.path("/api2/json/nodes/pve1/qemu/100/config")
|
||||
.header("Authorization", format!("PVEAPIToken={}", token))
|
||||
// The form body carries exactly the present fields.
|
||||
.body_includes("name=web01")
|
||||
.body_includes("cores=4")
|
||||
.body_includes("memory=8192")
|
||||
.body_excludes("description");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(r#"{"data":null}"#);
|
||||
});
|
||||
|
||||
let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
|
||||
manager
|
||||
.update_vm_config(
|
||||
"conn",
|
||||
"pve1",
|
||||
100,
|
||||
"qemu",
|
||||
UpdateVMConfig {
|
||||
name: Some("web01".to_string()),
|
||||
cores: Some(4),
|
||||
memory: Some(8192),
|
||||
description: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("config update should succeed");
|
||||
|
||||
mock.assert();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_vm_config_with_all_none_errors_without_request() {
|
||||
let server = MockServer::start();
|
||||
let probe = server.mock(|when, then| {
|
||||
when.method(POST)
|
||||
.path("/api2/json/nodes/pve1/qemu/100/config");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(r#"{"data":null}"#);
|
||||
});
|
||||
|
||||
let (manager, _dir) = setup_manager(&server.base_url(), "root@pam!empty-config-token", None).await;
|
||||
let error = manager
|
||||
.update_vm_config(
|
||||
"conn",
|
||||
"pve1",
|
||||
100,
|
||||
"qemu",
|
||||
UpdateVMConfig {
|
||||
name: None,
|
||||
cores: None,
|
||||
memory: None,
|
||||
description: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect_err("an empty config must be rejected");
|
||||
|
||||
assert!(
|
||||
matches!(error, Error::ApiError(ref message) if message == "Nothing to update"),
|
||||
"expected ApiError 'Nothing to update', got: {}",
|
||||
error
|
||||
);
|
||||
assert_eq!(
|
||||
probe.calls(),
|
||||
0,
|
||||
"no HTTP request should be made for an empty config"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_methods_error_with_connection_not_found() {
|
||||
let server = MockServer::start();
|
||||
|
||||
@@ -334,6 +334,50 @@ async fn edit_nic_preserves_model_and_mac() {
|
||||
post_mock.assert();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn edit_nic_tag_clear_preserves_other_attributes() {
|
||||
let server = MockServer::start();
|
||||
let token = "root@pam!edit-nic-tag-token";
|
||||
let get_mock = server.mock(|when, then| {
|
||||
when.method(GET)
|
||||
.path("/api2/json/nodes/pve1/qemu/100/config");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(
|
||||
serde_json::json!({
|
||||
"data": {"net0": "virtio=AA:BB:CC:DD:EE:FF,bridge=vmbr0,tag=10,link_down=1"}
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
});
|
||||
let post_mock = server.mock(|when, then| {
|
||||
when.method(POST)
|
||||
.path("/api2/json/nodes/pve1/qemu/100/config")
|
||||
// urlencoded: `=` and `,` become `%3D` and `%2C`; the tag is gone
|
||||
// while the unmodeled `link_down` attribute is carried over.
|
||||
.body_includes("net0=virtio%3DAA%3ABB%3ACC%3ADD%3AEE%3AFF%2Cbridge%3Dvmbr0%2Clink_down%3D1")
|
||||
.body_excludes("tag");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(r#"{"data":null}"#);
|
||||
});
|
||||
|
||||
let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
|
||||
let config = EditNICConfig {
|
||||
bridge: None,
|
||||
model: None,
|
||||
tag: Some(0),
|
||||
firewall: None,
|
||||
};
|
||||
manager
|
||||
.edit_nic("conn", "pve1", 100, "qemu", "net0", config)
|
||||
.await
|
||||
.expect("nic should be edited");
|
||||
|
||||
get_mock.assert();
|
||||
post_mock.assert();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remove_nic_posts_delete() {
|
||||
let server = MockServer::start();
|
||||
@@ -388,17 +432,26 @@ async fn lxc_vm_types_use_lxc_path() {
|
||||
|
||||
let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
|
||||
|
||||
// Containers use `rootfs`/`mpN` keys rather than `scsiN`/`virtioN`; they
|
||||
// are not returned by get_disks, and network keys without an explicit
|
||||
// model are still parsed.
|
||||
// Containers use `rootfs`/`mpN` keys; they map onto Disk entries with the
|
||||
// container-specific device names, and LXC network keys without an
|
||||
// explicit model are still parsed.
|
||||
let disks = manager
|
||||
.get_disks("conn", "pve1", 201, "lxc")
|
||||
.await
|
||||
.expect("disks should be fetched");
|
||||
assert!(
|
||||
disks.is_empty(),
|
||||
"container disks are not in the scsi/virtio/... key set"
|
||||
);
|
||||
assert_eq!(disks.len(), 2);
|
||||
let rootfs = disks
|
||||
.iter()
|
||||
.find(|d| d.device == "rootfs")
|
||||
.expect("rootfs disk should be parsed");
|
||||
assert_eq!(rootfs.storage, "local");
|
||||
assert_eq!(rootfs.size, 8589934592); // 8G
|
||||
let mp0 = disks
|
||||
.iter()
|
||||
.find(|d| d.device == "mp0")
|
||||
.expect("mp0 disk should be parsed");
|
||||
assert_eq!(mp0.storage, "local");
|
||||
assert_eq!(mp0.size, 4294967296); // 4G
|
||||
|
||||
let nics = manager
|
||||
.get_network_interfaces("conn", "pve1", 201, "lxc")
|
||||
@@ -407,6 +460,9 @@ async fn lxc_vm_types_use_lxc_path() {
|
||||
assert_eq!(nics.len(), 1);
|
||||
assert_eq!(nics[0].name, "net0");
|
||||
assert_eq!(nics[0].bridge.as_deref(), Some("vmbr0"));
|
||||
// LXC format: `name=eth0` lead means no QEMU model=mac first segment.
|
||||
assert_eq!(nics[0].model, "");
|
||||
assert_eq!(nics[0].macaddr, "");
|
||||
|
||||
manager
|
||||
.resize_disk("conn", "pve1", 201, "lxc", "rootfs", 17179869184)
|
||||
@@ -417,6 +473,140 @@ async fn lxc_vm_types_use_lxc_path() {
|
||||
resize_mock.assert();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn add_nic_lxc_writes_lxc_net_format() {
|
||||
let server = MockServer::start();
|
||||
let token = "root@pam!add-nic-lxc-token";
|
||||
let get_mock = server.mock(|when, then| {
|
||||
when.method(GET)
|
||||
.path("/api2/json/nodes/pve1/lxc/201/config");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(
|
||||
serde_json::json!({
|
||||
"data": {"memory": 2048}
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
});
|
||||
let post_mock = server.mock(|when, then| {
|
||||
when.method(POST)
|
||||
.path("/api2/json/nodes/pve1/lxc/201/config")
|
||||
// urlencoded: `name=eth0,type=veth,bridge=vmbr0,hwaddr=BC:24:11:8D:DF:95,firewall=1`
|
||||
.body_includes(
|
||||
"net0=name%3Deth0%2Ctype%3Dveth%2Cbridge%3Dvmbr0%2Chwaddr%3DBC%3A24%3A11%3A8D%3ADF%3A95%2Cfirewall%3D1",
|
||||
);
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(r#"{"data":null}"#);
|
||||
});
|
||||
|
||||
let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
|
||||
let config = AddNICConfig {
|
||||
bridge: "vmbr0".to_string(),
|
||||
// The model is ignored for LXC (containers always use veth), so an
|
||||
// otherwise-invalid model still succeeds.
|
||||
model: "veth".to_string(),
|
||||
macaddr: Some("BC:24:11:8D:DF:95".to_string()),
|
||||
tag: None,
|
||||
firewall: Some(true),
|
||||
};
|
||||
manager
|
||||
.add_nic("conn", "pve1", 201, "lxc", config)
|
||||
.await
|
||||
.expect("nic should be added");
|
||||
|
||||
get_mock.assert();
|
||||
post_mock.assert();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn add_nic_lxc_random_mac_and_no_firewall_omits_hwaddr_and_firewall() {
|
||||
let server = MockServer::start();
|
||||
let token = "root@pam!add-nic-lxc-plain-token";
|
||||
let get_mock = server.mock(|when, then| {
|
||||
when.method(GET)
|
||||
.path("/api2/json/nodes/pve1/lxc/201/config");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(r#"{"data":{"memory":2048}}"#);
|
||||
});
|
||||
let post_mock = server.mock(|when, then| {
|
||||
when.method(POST)
|
||||
.path("/api2/json/nodes/pve1/lxc/201/config")
|
||||
.body_includes("net0=name%3Deth0%2Ctype%3Dveth%2Cbridge%3Dvmbr0")
|
||||
.body_excludes("hwaddr")
|
||||
.body_excludes("firewall");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(r#"{"data":null}"#);
|
||||
});
|
||||
|
||||
let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
|
||||
let config = AddNICConfig {
|
||||
bridge: "vmbr0".to_string(),
|
||||
model: "veth".to_string(),
|
||||
// `random` (or a missing MAC) means "let Proxmox assign one".
|
||||
macaddr: Some("random".to_string()),
|
||||
tag: None,
|
||||
firewall: None,
|
||||
};
|
||||
manager
|
||||
.add_nic("conn", "pve1", 201, "lxc", config)
|
||||
.await
|
||||
.expect("nic should be added");
|
||||
|
||||
get_mock.assert();
|
||||
post_mock.assert();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn edit_nic_lxc_reencodes_lxc_format() {
|
||||
let server = MockServer::start();
|
||||
let token = "root@pam!edit-nic-lxc-token";
|
||||
let get_mock = server.mock(|when, then| {
|
||||
when.method(GET)
|
||||
.path("/api2/json/nodes/pve1/lxc/201/config");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(
|
||||
serde_json::json!({
|
||||
"data": {"net0": "name=eth0,type=veth,hwaddr=BC:24:11:8D:DF:95,bridge=vmbr0,ip=dhcp,firewall=0"}
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
});
|
||||
let post_mock = server.mock(|when, then| {
|
||||
when.method(POST)
|
||||
.path("/api2/json/nodes/pve1/lxc/201/config")
|
||||
// urlencoded; the LXC form is rebuilt (`name=eth0,type=veth,
|
||||
// hwaddr=...`) with the new bridge/firewall and the unknown
|
||||
// `ip=dhcp` attribute carried over, but no tag.
|
||||
.body_includes(
|
||||
"net0=name%3Deth0%2Ctype%3Dveth%2Chwaddr%3DBC%3A24%3A11%3A8D%3ADF%3A95%2Cbridge%3Dvmbr1%2Cfirewall%3D1%2Cip%3Ddhcp",
|
||||
)
|
||||
.body_excludes("tag");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(r#"{"data":null}"#);
|
||||
});
|
||||
|
||||
let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
|
||||
let config = EditNICConfig {
|
||||
bridge: Some("vmbr1".to_string()),
|
||||
model: None,
|
||||
tag: None,
|
||||
firewall: Some(true),
|
||||
};
|
||||
manager
|
||||
.edit_nic("conn", "pve1", 201, "lxc", "net0", config)
|
||||
.await
|
||||
.expect("nic should be edited");
|
||||
|
||||
get_mock.assert();
|
||||
post_mock.assert();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_vm_type_errors() {
|
||||
let server = MockServer::start();
|
||||
|
||||
@@ -211,10 +211,12 @@ async fn get_backup_jobs_maps_list() {
|
||||
.body(
|
||||
serde_json::json!({
|
||||
"data": [
|
||||
{"id": "backup-1", "store": "backup", "schedule": "0 2 * * *",
|
||||
// The server sends `storage` (not `store`).
|
||||
{"id": "backup-1", "storage": "backup", "schedule": "0 2 * * *",
|
||||
"all": 1, "enabled": 1, "node": "pve1", "compress": "zstd",
|
||||
"mode": "snapshot", "quiet": 0},
|
||||
{"id": "backup-2", "store": "local", "schedule": "30 3 * * 1",
|
||||
// A vmid-selected job omits `all` entirely.
|
||||
{"id": "backup-2", "storage": "local", "schedule": "30 3 * * 1",
|
||||
"all": 0, "enabled": 0, "vmid": "100,101"}
|
||||
]
|
||||
})
|
||||
@@ -238,7 +240,9 @@ async fn get_backup_jobs_maps_list() {
|
||||
assert_eq!(jobs[0].compress.as_deref(), Some("zstd"));
|
||||
assert_eq!(jobs[0].mode.as_deref(), Some("snapshot"));
|
||||
assert_eq!(jobs[0].quiet, Some(0));
|
||||
// Tolerance: the second job omits the optional node/mode fields.
|
||||
// Tolerance: the second job omits the optional node/mode fields and has no
|
||||
// `all` key, which defaults to 0.
|
||||
assert_eq!(jobs[1].store, "local");
|
||||
assert_eq!(jobs[1].node, None);
|
||||
assert_eq!(jobs[1].compress, None);
|
||||
assert_eq!(jobs[1].mode, None);
|
||||
@@ -248,6 +252,72 @@ async fn get_backup_jobs_maps_list() {
|
||||
mock.assert();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn backup_job_parses_realistic_pve_91_shape() {
|
||||
let server = MockServer::start();
|
||||
let token = "root@pam!jobs-real-token";
|
||||
let mock = server.mock(|when, then| {
|
||||
when.method(GET)
|
||||
.path("/api2/json/cluster/backup")
|
||||
.header("Authorization", format!("PVEAPIToken={}", token));
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(
|
||||
serde_json::json!({
|
||||
"data": [
|
||||
// A job as PVE 9.1 actually emits it: `storage` key,
|
||||
// no `all`, and a pile of fields the struct does not
|
||||
// model (pool, notes-template, prune-backups, fleecing,
|
||||
// next-run, notification-mode, ...).
|
||||
{
|
||||
"id": "backup-pve-1",
|
||||
"storage": "kashyyk",
|
||||
"schedule": "0 2 * * *",
|
||||
"enabled": 1,
|
||||
"node": "pve1",
|
||||
"mode": "snapshot",
|
||||
"compress": "zstd",
|
||||
"vmid": "100,101,102",
|
||||
"pool": "prod",
|
||||
"notes-template": "{{guestname}}",
|
||||
"prune-backups": {"keep-last": 3, "keep-daily": 7},
|
||||
"fleecing": {"enabled": 1, "storage": "local-lvm"},
|
||||
"next-run": 1760000000,
|
||||
"notification-mode": "auto",
|
||||
"bwlimit": 0,
|
||||
"quiet": 0,
|
||||
"starttime": "2026-08-01 02:00:00",
|
||||
"stdexcludes": 0,
|
||||
"remove": 0
|
||||
}
|
||||
]
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
});
|
||||
|
||||
let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
|
||||
let jobs = manager
|
||||
.get_backup_jobs("conn")
|
||||
.await
|
||||
.expect("backup jobs should be fetched");
|
||||
|
||||
assert_eq!(jobs.len(), 1);
|
||||
assert_eq!(jobs[0].id, "backup-pve-1");
|
||||
// `storage` on the wire maps onto `store`.
|
||||
assert_eq!(jobs[0].store, "kashyyk");
|
||||
assert_eq!(jobs[0].schedule, "0 2 * * *");
|
||||
// `all` is never sent for a vmid-selected job and defaults to 0.
|
||||
assert_eq!(jobs[0].all, 0);
|
||||
assert_eq!(jobs[0].enabled, 1);
|
||||
assert_eq!(jobs[0].node.as_deref(), Some("pve1"));
|
||||
assert_eq!(jobs[0].mode.as_deref(), Some("snapshot"));
|
||||
assert_eq!(jobs[0].compress.as_deref(), Some("zstd"));
|
||||
assert_eq!(jobs[0].vmid.as_deref(), Some("100,101,102"));
|
||||
assert_eq!(jobs[0].quiet, Some(0));
|
||||
mock.assert();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_backup_job_posts_form() {
|
||||
let server = MockServer::start();
|
||||
@@ -361,10 +431,27 @@ async fn get_backups_filters_and_maps() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_backups_defaults_to_local_storage() {
|
||||
async fn get_backups_aggregates_over_single_backup_storage() {
|
||||
let server = MockServer::start();
|
||||
let token = "root@pam!backups-default-token";
|
||||
let mock = server.mock(|when, then| {
|
||||
let resources_mock = server.mock(|when, then| {
|
||||
when.method(GET)
|
||||
.path("/api2/json/cluster/resources")
|
||||
.query_param("type", "storage");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(
|
||||
serde_json::json!({
|
||||
"data": [
|
||||
{"storage": "local", "node": "pve1", "type": "dir",
|
||||
"content": "backup,iso", "shared": 0,
|
||||
"status": "available"}
|
||||
]
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
});
|
||||
let content_mock = server.mock(|when, then| {
|
||||
when.method(GET)
|
||||
.path("/api2/json/nodes/pve1/storage/local/content")
|
||||
.query_param("content", "backup");
|
||||
@@ -380,7 +467,228 @@ async fn get_backups_defaults_to_local_storage() {
|
||||
.expect("backups should be fetched");
|
||||
|
||||
assert!(backups.is_empty());
|
||||
mock.assert();
|
||||
resources_mock.assert();
|
||||
content_mock.assert();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_backups_aggregates_all_backup_storages_when_none_specified() {
|
||||
let server = MockServer::start();
|
||||
let token = "root@pam!backups-aggregate-token";
|
||||
let resources_mock = server.mock(|when, then| {
|
||||
when.method(GET)
|
||||
.path("/api2/json/cluster/resources")
|
||||
.query_param("type", "storage");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(
|
||||
serde_json::json!({
|
||||
"data": [
|
||||
{"storage": "backup1", "node": "pve1", "type": "nfs",
|
||||
"content": "backup", "shared": 1,
|
||||
"status": "available"},
|
||||
{"storage": "backup2", "node": "pve2", "type": "nfs",
|
||||
"content": "backup,iso", "shared": 1,
|
||||
"status": "available"},
|
||||
{"storage": "local", "node": "pve1", "type": "dir",
|
||||
"content": "iso,vztmpl", "shared": 0,
|
||||
"status": "available"}
|
||||
]
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
});
|
||||
let backup1_mock = server.mock(|when, then| {
|
||||
when.method(GET)
|
||||
.path("/api2/json/nodes/pve1/storage/backup1/content")
|
||||
.query_param("content", "backup");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(
|
||||
serde_json::json!({
|
||||
"data": [
|
||||
{"volid": "backup1:backup/vzdump-qemu-100-2024_01_01-00_00_00.vma.zst",
|
||||
"backupid": "vzdump-qemu-100-2024_01_01-00_00_00.vma.zst",
|
||||
"backup-type": "qemu", "backup-id": "100",
|
||||
"backup-time": 1700000000, "storage": "backup1",
|
||||
"size": 1073741824u64, "ctime": 1700000001, "content": "backup"}
|
||||
]
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
});
|
||||
let backup2_mock = server.mock(|when, then| {
|
||||
when.method(GET)
|
||||
.path("/api2/json/nodes/pve1/storage/backup2/content")
|
||||
.query_param("content", "backup");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(
|
||||
serde_json::json!({
|
||||
"data": [
|
||||
{"volid": "backup2:backup/vzdump-qemu-201-2024_01_02-00_00_00.vma.zst",
|
||||
"backupid": "vzdump-qemu-201-2024_01_02-00_00_00.vma.zst",
|
||||
"backup-type": "qemu", "backup-id": "201",
|
||||
"backup-time": 1700000100, "storage": "backup2",
|
||||
"size": 2147483648u64, "ctime": 1700000101, "content": "backup"}
|
||||
]
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
});
|
||||
// A storage without `backup` content must never be queried.
|
||||
let local_probe = server.mock(|when, then| {
|
||||
when.method(GET)
|
||||
.path("/api2/json/nodes/pve1/storage/local/content")
|
||||
.query_param("content", "backup");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(r#"{"data":[]}"#);
|
||||
});
|
||||
|
||||
let (manager, _dir) = setup_manager(&server.base_url(), token, Some("pve1")).await;
|
||||
let backups = manager
|
||||
.get_backups("conn", None)
|
||||
.await
|
||||
.expect("backups should be aggregated");
|
||||
|
||||
assert_eq!(backups.len(), 2);
|
||||
assert_eq!(
|
||||
backups[0].volid,
|
||||
"backup1:backup/vzdump-qemu-100-2024_01_01-00_00_00.vma.zst"
|
||||
);
|
||||
assert_eq!(
|
||||
backups[1].volid,
|
||||
"backup2:backup/vzdump-qemu-201-2024_01_02-00_00_00.vma.zst"
|
||||
);
|
||||
resources_mock.assert();
|
||||
backup1_mock.assert();
|
||||
backup2_mock.assert();
|
||||
assert_eq!(
|
||||
local_probe.calls(),
|
||||
0,
|
||||
"a storage without backup content must not be queried"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_backups_specific_storage_only() {
|
||||
let server = MockServer::start();
|
||||
let token = "root@pam!backups-specific-token";
|
||||
let content_mock = server.mock(|when, then| {
|
||||
when.method(GET)
|
||||
.path("/api2/json/nodes/pve1/storage/backup1/content")
|
||||
.query_param("content", "backup");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(
|
||||
serde_json::json!({
|
||||
"data": [
|
||||
{"volid": "backup1:backup/vzdump-qemu-100-2024_01_01-00_00_00.vma.zst",
|
||||
"backupid": "vzdump-qemu-100-2024_01_01-00_00_00.vma.zst",
|
||||
"backup-type": "qemu", "backup-id": "100",
|
||||
"backup-time": 1700000000, "storage": "backup1",
|
||||
"size": 1073741824u64, "ctime": 1700000001, "content": "backup"}
|
||||
]
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
});
|
||||
// The storage list must not be consulted when a specific storage is given.
|
||||
let resources_probe = server.mock(|when, then| {
|
||||
when.method(GET)
|
||||
.path("/api2/json/cluster/resources")
|
||||
.query_param("type", "storage");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(r#"{"data":[]}"#);
|
||||
});
|
||||
|
||||
let (manager, _dir) = setup_manager(&server.base_url(), token, Some("pve1")).await;
|
||||
let backups = manager
|
||||
.get_backups("conn", Some("backup1"))
|
||||
.await
|
||||
.expect("backups should be fetched");
|
||||
|
||||
assert_eq!(backups.len(), 1);
|
||||
assert_eq!(
|
||||
backups[0].volid,
|
||||
"backup1:backup/vzdump-qemu-100-2024_01_01-00_00_00.vma.zst"
|
||||
);
|
||||
content_mock.assert();
|
||||
assert_eq!(
|
||||
resources_probe.calls(),
|
||||
0,
|
||||
"the storage list must not be queried for a specific storage"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_backups_aggregation_skips_erroring_storage() {
|
||||
let server = MockServer::start();
|
||||
let token = "root@pam!backups-skip-token";
|
||||
let resources_mock = server.mock(|when, then| {
|
||||
when.method(GET)
|
||||
.path("/api2/json/cluster/resources")
|
||||
.query_param("type", "storage");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(
|
||||
serde_json::json!({
|
||||
"data": [
|
||||
{"storage": "bad", "node": "pve1", "type": "nfs",
|
||||
"content": "backup", "shared": 1,
|
||||
"status": "available"},
|
||||
{"storage": "good", "node": "pve1", "type": "nfs",
|
||||
"content": "backup", "shared": 1,
|
||||
"status": "available"}
|
||||
]
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
});
|
||||
let bad_mock = server.mock(|when, then| {
|
||||
when.method(GET)
|
||||
.path("/api2/json/nodes/pve1/storage/bad/content")
|
||||
.query_param("content", "backup");
|
||||
then.status(500)
|
||||
.header("content-type", "application/json")
|
||||
.body(r#"{"data":null}"#);
|
||||
});
|
||||
let good_mock = server.mock(|when, then| {
|
||||
when.method(GET)
|
||||
.path("/api2/json/nodes/pve1/storage/good/content")
|
||||
.query_param("content", "backup");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(
|
||||
serde_json::json!({
|
||||
"data": [
|
||||
{"volid": "good:backup/vzdump-qemu-100-2024_01_01-00_00_00.vma.zst",
|
||||
"backupid": "vzdump-qemu-100-2024_01_01-00_00_00.vma.zst",
|
||||
"backup-type": "qemu", "backup-id": "100",
|
||||
"backup-time": 1700000000, "storage": "good",
|
||||
"size": 1073741824u64, "ctime": 1700000001, "content": "backup"}
|
||||
]
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
});
|
||||
|
||||
let (manager, _dir) = setup_manager(&server.base_url(), token, Some("pve1")).await;
|
||||
let backups = manager
|
||||
.get_backups("conn", None)
|
||||
.await
|
||||
.expect("aggregation should succeed despite one erroring storage");
|
||||
|
||||
assert_eq!(backups.len(), 1);
|
||||
assert_eq!(
|
||||
backups[0].volid,
|
||||
"good:backup/vzdump-qemu-100-2024_01_01-00_00_00.vma.zst"
|
||||
);
|
||||
resources_mock.assert();
|
||||
bad_mock.assert();
|
||||
good_mock.assert();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -426,7 +426,7 @@ async fn status_info_when_disconnected() {
|
||||
assert_eq!(info.current_endpoint_url, server.base_url());
|
||||
assert!(info.nodes.is_empty());
|
||||
assert_eq!(
|
||||
version_mock.hits(),
|
||||
version_mock.calls(),
|
||||
0,
|
||||
"a disconnected connection must not hit the network"
|
||||
);
|
||||
|
||||
@@ -169,7 +169,7 @@ async fn non_transport_error_does_not_rotate() {
|
||||
);
|
||||
primary_mock.assert();
|
||||
assert_eq!(
|
||||
fallback_mock.hits(),
|
||||
fallback_mock.calls(),
|
||||
0,
|
||||
"a non-transport error must not trigger failover"
|
||||
);
|
||||
@@ -192,7 +192,7 @@ async fn request_succeeds_on_primary_again_when_it_returns() {
|
||||
|
||||
assert!(vms.is_empty());
|
||||
primary_mock.assert();
|
||||
assert_eq!(fallback_mock.hits(), 0);
|
||||
assert_eq!(fallback_mock.calls(), 0);
|
||||
assert_eq!(
|
||||
manager
|
||||
.runtime_status("conn")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -485,5 +485,5 @@ async fn disconnect_clears_session_and_status() {
|
||||
.await
|
||||
.expect("reconnect after disconnect should succeed");
|
||||
assert_eq!(result.status, "connected");
|
||||
mock.assert_hits(2);
|
||||
mock.assert_calls(2);
|
||||
}
|
||||
|
||||
+69
-5
@@ -7,6 +7,7 @@ import { VMDetail } from '@/components/vms/VMDetail'
|
||||
import { ContainerList } from '@/components/vms/ContainerList'
|
||||
import { NodesPage } from '@/components/nodes/NodesPage'
|
||||
import { TaskList } from '@/components/tasks/TaskList'
|
||||
import { TaskStatusBar } from '@/components/tasks/TaskStatusBar'
|
||||
import { BackupList } from '@/components/backups/BackupList'
|
||||
import { CommandPalette } from '@/components/command/CommandPalette'
|
||||
import { StorageOverview } from '@/components/storage/StorageOverview'
|
||||
@@ -18,8 +19,8 @@ import { Server, AlertTriangle } from 'lucide-react'
|
||||
import { useConnectionStore } from '@/stores/connectionStore'
|
||||
import { useUIStore } from '@/stores/uiStore'
|
||||
import { useWebSocket } from '@/hooks/useWebSocket'
|
||||
import { isTauri, loadConnections, connectToServer, getConnectionStatus, updateTrayMenu } from '@/lib/tauri'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { isTauri, loadConnections, connectToServer, getConnectionStatus, getWebSocketURL, updateTrayMenu } from '@/lib/tauri'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { ProxmoxVM } from '@/types/proxmox'
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
@@ -121,6 +122,23 @@ function AppContent() {
|
||||
)
|
||||
}, [connections])
|
||||
|
||||
// Tray connection clicks switch the active connection (mirrored to the
|
||||
// backend by the store so it persists across launches).
|
||||
useEffect(() => {
|
||||
if (!isTauri()) return
|
||||
let unlisten: (() => void) | undefined
|
||||
import('@tauri-apps/api/event')
|
||||
.then(async ({ listen }) => {
|
||||
unlisten = await listen<string>('tray-connection-click', (event) => {
|
||||
setActiveConnection(event.payload)
|
||||
})
|
||||
})
|
||||
.catch(() => {})
|
||||
return () => {
|
||||
unlisten?.()
|
||||
}
|
||||
}, [setActiveConnection])
|
||||
|
||||
// Poll the backend connection status while a connection is active so the
|
||||
// sidebar node list and failover state stay in sync with the cluster.
|
||||
useEffect(() => {
|
||||
@@ -157,7 +175,36 @@ function AppContent() {
|
||||
}, [connectionsLoaded, connections.length, connectionDialogOpen])
|
||||
|
||||
// WebSocket integration – connects when a connection is active
|
||||
useWebSocket(activeConnectionId)
|
||||
const { connect: connectRelay, disconnect: disconnectRelay } = useWebSocket(activeConnectionId)
|
||||
|
||||
// Start the backend event relay once a connection is connected, so task and
|
||||
// node events invalidate queries in near real-time. Polling remains the
|
||||
// fallback when the relay is not connected.
|
||||
const activeStatus = useMemo(
|
||||
() => connections.find((c) => c.id === activeConnectionId)?.status,
|
||||
[connections, activeConnectionId],
|
||||
)
|
||||
useEffect(() => {
|
||||
if (!isTauri() || !activeConnectionId) return
|
||||
if (activeStatus !== 'connected' && activeStatus !== 'failover') {
|
||||
disconnectRelay().catch(() => {})
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
const startRelay = async () => {
|
||||
try {
|
||||
const origin = await getWebSocketURL(activeConnectionId, '')
|
||||
if (cancelled) return
|
||||
await connectRelay(`${origin}/api2/json/events`)
|
||||
} catch (err) {
|
||||
console.error('[App] Failed to start event relay:', err)
|
||||
}
|
||||
}
|
||||
startRelay()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [activeConnectionId, activeStatus, connectRelay, disconnectRelay])
|
||||
|
||||
// Global keyboard shortcut: Cmd/Ctrl+K to open command palette
|
||||
useEffect(() => {
|
||||
@@ -204,6 +251,7 @@ function AppContent() {
|
||||
switch (viewName) {
|
||||
case 'dashboard': handleNavigate({ type: 'dashboard' }); break
|
||||
case 'vms': handleNavigate({ type: 'vms' }); break
|
||||
case 'nodes': handleNavigate({ type: 'nodes' }); break
|
||||
case 'tasks': handleNavigate({ type: 'tasks' }); break
|
||||
case 'backups': handleNavigate({ type: 'backups' }); break
|
||||
case 'storage': handleNavigate({ type: 'storage' }); break
|
||||
@@ -226,12 +274,18 @@ function AppContent() {
|
||||
/>
|
||||
)
|
||||
case 'nodes':
|
||||
return <NodesPage connectionId={activeConnectionId} />
|
||||
return (
|
||||
<NodesPage
|
||||
connectionId={activeConnectionId}
|
||||
onVMClick={(vm) => handleNavigate({ type: 'vm-detail', vm })}
|
||||
/>
|
||||
)
|
||||
case 'node-detail':
|
||||
return (
|
||||
<NodesPage
|
||||
connectionId={activeConnectionId}
|
||||
initialNodeName={view.nodeName}
|
||||
onVMClick={(vm) => handleNavigate({ type: 'vm-detail', vm })}
|
||||
/>
|
||||
)
|
||||
case 'containers':
|
||||
@@ -291,8 +345,18 @@ function AppContent() {
|
||||
activeView={view.type}
|
||||
onNavigate={(v) => handleNavigate(v as View)}
|
||||
/>
|
||||
<main className="flex-1 overflow-hidden">
|
||||
<main className="flex min-h-0 flex-1 flex-col">
|
||||
{activeConnectionId && (
|
||||
<div className="shrink-0 border-b px-3 py-1.5">
|
||||
<TaskStatusBar
|
||||
connectionId={activeConnectionId}
|
||||
onClick={() => handleNavigate({ type: 'tasks' })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
{renderMainContent()}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<ConnectionDialog
|
||||
|
||||
@@ -42,7 +42,6 @@ function formatTimestamp(seconds: number): string {
|
||||
|
||||
export function BackupList({ connectionId }: BackupListProps) {
|
||||
const { data: backupJobs, isLoading: jobsLoading, error: jobsError } = useBackupJobs(connectionId)
|
||||
const { data: backups, isLoading: backupsLoading, error: backupsError } = useBackups(connectionId)
|
||||
const { data: storage } = useStorage(connectionId)
|
||||
|
||||
const deleteBackupJob = useDeleteBackupJob()
|
||||
@@ -56,6 +55,13 @@ export function BackupList({ connectionId }: BackupListProps) {
|
||||
const [confirmDeleteJob, setConfirmDeleteJob] = useState<string | null>(null)
|
||||
const [confirmDeleteBackup, setConfirmDeleteBackup] = useState<string | null>(null)
|
||||
|
||||
// Backups are storage-scoped on the backend, so the active filter must be
|
||||
// passed through or only the default storage would ever be listed.
|
||||
const { data: backups, isLoading: backupsLoading, error: backupsError } = useBackups(
|
||||
connectionId,
|
||||
storageFilter === 'all' ? undefined : storageFilter,
|
||||
)
|
||||
|
||||
const storageOptions = useMemo(() => {
|
||||
if (!storage) return []
|
||||
return [...new Set(storage.map((s) => s.storage))].sort()
|
||||
@@ -363,7 +369,12 @@ export function BackupList({ connectionId }: BackupListProps) {
|
||||
confirmLabel="Delete"
|
||||
isLoading={deleteBackupJob.isPending}
|
||||
onConfirm={() => {
|
||||
if (confirmDeleteJob) deleteBackupJob.mutate({ id: confirmDeleteJob })
|
||||
if (confirmDeleteJob) {
|
||||
deleteBackupJob.mutate(
|
||||
{ id: confirmDeleteJob },
|
||||
{ onSettled: () => setConfirmDeleteJob(null) },
|
||||
)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -377,7 +388,12 @@ export function BackupList({ connectionId }: BackupListProps) {
|
||||
confirmLabel="Delete"
|
||||
isLoading={deleteBackup.isPending}
|
||||
onConfirm={() => {
|
||||
if (confirmDeleteBackup) deleteBackup.mutate({ volid: confirmDeleteBackup })
|
||||
if (confirmDeleteBackup) {
|
||||
deleteBackup.mutate(
|
||||
{ volid: confirmDeleteBackup },
|
||||
{ onSettled: () => setConfirmDeleteBackup(null) },
|
||||
)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -321,7 +321,7 @@ export function CommandPalette({
|
||||
},
|
||||
)
|
||||
}
|
||||
if (vm.status === 'stopped' || vm.status === 'paused') {
|
||||
if (vm.status === 'stopped' || vm.status === 'paused' || vm.status === 'suspended') {
|
||||
items.push({
|
||||
id: `action-start-${vm.vmid}`,
|
||||
label: `Start ${vm.name}`,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useRef, useCallback } from 'react'
|
||||
import { Terminal } from 'xterm'
|
||||
import { FitAddon } from 'xterm-addon-fit'
|
||||
import 'xterm/css/xterm.css'
|
||||
import { isTauri, startConsoleProxy, stopConsoleProxy } from '@/lib/tauri'
|
||||
|
||||
interface TerminalConsoleProps {
|
||||
connectionId: string
|
||||
@@ -16,8 +17,14 @@ export function TerminalConsole({ connectionId, node, vmid, onError, onConnected
|
||||
const termRef = useRef<Terminal | null>(null)
|
||||
const fitAddonRef = useRef<FitAddon | null>(null)
|
||||
const wsRef = useRef<WebSocket | null>(null)
|
||||
const sessionRef = useRef<string | null>(null)
|
||||
|
||||
const cleanup = useCallback(() => {
|
||||
if (sessionRef.current) {
|
||||
const sid = sessionRef.current
|
||||
sessionRef.current = null
|
||||
stopConsoleProxy(sid).catch(() => {})
|
||||
}
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close()
|
||||
wsRef.current = null
|
||||
@@ -64,40 +71,22 @@ export function TerminalConsole({ connectionId, node, vmid, onError, onConnected
|
||||
return
|
||||
}
|
||||
|
||||
// Get WebSocket URL for terminal proxy
|
||||
// Resolve the WebSocket URL: in Tauri mode a Rust-side loopback proxy
|
||||
// bridges the self-signed Proxmox WebSocket; in dev mode there is a
|
||||
// mock shell below so the URL is unused.
|
||||
let wsUrl: string
|
||||
|
||||
try {
|
||||
const { isTauri, createTermProxy, getWebSocketURL } = await import('@/lib/tauri')
|
||||
|
||||
if (isTauri()) {
|
||||
const [proxyInfo, baseUrl] = await Promise.all([
|
||||
createTermProxy(connectionId, node, vmid),
|
||||
getWebSocketURL(connectionId, node),
|
||||
])
|
||||
|
||||
wsUrl = `${baseUrl}/api2/json/nodes/${node}/lxc/${vmid}/proxy?port=${proxyInfo.port}&ticket=${encodeURIComponent(proxyInfo.ticket)}`
|
||||
const info = await startConsoleProxy(connectionId, 'term', node, vmid)
|
||||
if (cancelled) return
|
||||
sessionRef.current = info.sessionId
|
||||
wsUrl = info.url
|
||||
} else {
|
||||
// Dev mode: construct a mock URL
|
||||
wsUrl = `wss://localhost:8006/api2/json/nodes/${node}/lxc/${vmid}/proxy?port=6100&ticket=mock-ticket`
|
||||
}
|
||||
} catch {
|
||||
// Fallback for dev mode
|
||||
wsUrl = `wss://localhost:8006/api2/json/nodes/${node}/lxc/${vmid}/proxy?port=6100&ticket=mock-ticket`
|
||||
}
|
||||
|
||||
if (cancelled) return
|
||||
|
||||
// In dev mode, show a mock terminal since we can't connect to real WebSocket
|
||||
let isTauriMode = false
|
||||
try {
|
||||
const { isTauri } = await import('@/lib/tauri')
|
||||
isTauriMode = isTauri()
|
||||
} catch {
|
||||
// not in tauri
|
||||
}
|
||||
|
||||
if (!isTauriMode && !cancelled) {
|
||||
if (!isTauri()) {
|
||||
// Dev mode mock terminal
|
||||
terminal.writeln('\x1b[1;32m╔══════════════════════════════════════════╗\x1b[0m')
|
||||
terminal.writeln('\x1b[1;32m║ Clustri - Terminal Console ║\x1b[0m')
|
||||
@@ -165,9 +154,7 @@ export function TerminalConsole({ connectionId, node, vmid, onError, onConnected
|
||||
}
|
||||
}
|
||||
|
||||
if (cancelled) return
|
||||
|
||||
// Production: connect via WebSocket
|
||||
// Production: connect via the local proxy WebSocket
|
||||
const ws = new WebSocket(wsUrl)
|
||||
ws.binaryType = 'arraybuffer'
|
||||
wsRef.current = ws
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef, useCallback } from 'react'
|
||||
import { isTauri, startConsoleProxy, stopConsoleProxy } from '@/lib/tauri'
|
||||
|
||||
interface VNCConsoleProps {
|
||||
connectionId: string
|
||||
@@ -14,8 +15,14 @@ export function VNCConsole({ connectionId, node, vmid, onError, onConnected }: V
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const rfbRef = useRef<unknown>(null)
|
||||
const stateRef = useRef<ConnectionState>('connecting')
|
||||
const sessionRef = useRef<string | null>(null)
|
||||
|
||||
const cleanup = useCallback(() => {
|
||||
if (sessionRef.current) {
|
||||
const sid = sessionRef.current
|
||||
sessionRef.current = null
|
||||
stopConsoleProxy(sid).catch(() => {})
|
||||
}
|
||||
if (rfbRef.current) {
|
||||
const rfb = rfbRef.current as { disconnect: () => void }
|
||||
rfb.disconnect()
|
||||
@@ -32,28 +39,19 @@ export function VNCConsole({ connectionId, node, vmid, onError, onConnected }: V
|
||||
try {
|
||||
stateRef.current = 'connecting'
|
||||
|
||||
// Get WebSocket URL for VNC
|
||||
// In Tauri mode the console stream goes through a Rust-side loopback
|
||||
// proxy so self-signed Proxmox certificates are accepted; the returned
|
||||
// local ws:// URL is handed to noVNC.
|
||||
let wsUrl: string
|
||||
|
||||
try {
|
||||
// Try real IPC first
|
||||
const { isTauri, createVNCProxy, getWebSocketURL } = await import('@/lib/tauri')
|
||||
|
||||
if (isTauri()) {
|
||||
const [proxyInfo, baseUrl] = await Promise.all([
|
||||
createVNCProxy(connectionId, node, vmid),
|
||||
getWebSocketURL(connectionId, node),
|
||||
])
|
||||
|
||||
wsUrl = `${baseUrl}/api2/json/nodes/${node}/qemu/${vmid}/vncwebsocket?port=${proxyInfo.port}&vncticket=${encodeURIComponent(proxyInfo.ticket)}`
|
||||
const info = await startConsoleProxy(connectionId, 'vnc', node, vmid)
|
||||
if (cancelled) return
|
||||
sessionRef.current = info.sessionId
|
||||
wsUrl = info.url
|
||||
} else {
|
||||
// Dev mode: construct a mock URL
|
||||
wsUrl = `wss://localhost:8006/api2/json/nodes/${node}/qemu/${vmid}/vncwebsocket?port=6000&vncticket=mock-ticket`
|
||||
}
|
||||
} catch {
|
||||
// Fallback for dev mode
|
||||
wsUrl = `wss://localhost:8006/api2/json/nodes/${node}/qemu/${vmid}/vncwebsocket?port=6000&vncticket=mock-ticket`
|
||||
}
|
||||
|
||||
if (cancelled) return
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useMemo } from 'react'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Cpu, MemoryStick, HardDrive, Clock, Server, Box } from 'lucide-react'
|
||||
import { AreaChart, Area, ResponsiveContainer } from 'recharts'
|
||||
import type { ProxmoxNode, ProxmoxVM } from '@/types/proxmox'
|
||||
import { formatBytes, formatUptime } from '@/lib/format'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -25,26 +24,11 @@ function getStatusLabel(status: 'online' | 'offline'): string {
|
||||
return status === 'online' ? 'Online' : 'Offline'
|
||||
}
|
||||
|
||||
/** Generate deterministic mock sparkline data from node stats */
|
||||
function generateSparklineData(baseValue: number, points: number = 12): { value: number }[] {
|
||||
const data: { value: number }[] = []
|
||||
for (let i = 0; i < points; i++) {
|
||||
// Deterministic pseudo-random variation around the base value
|
||||
const seed = Math.sin(i * 2.1 + baseValue * 0.01) * 0.3
|
||||
const variation = baseValue * seed
|
||||
data.push({ value: Math.max(0, Math.min(1, baseValue + variation)) })
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
function NodeCard({ node, vmCount }: { node: ProxmoxNode; vmCount: number }) {
|
||||
const cpuPercent = node.maxcpu > 0 ? node.cpu : 0
|
||||
const memPercent = node.maxmem > 0 ? node.mem / node.maxmem : 0
|
||||
const diskPercent = node.maxdisk > 0 ? node.disk / node.maxdisk : 0
|
||||
|
||||
const cpuData = useMemo(() => generateSparklineData(cpuPercent), [cpuPercent])
|
||||
const memData = useMemo(() => generateSparklineData(memPercent), [memPercent])
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
|
||||
@@ -59,54 +43,6 @@ function NodeCard({ node, vmCount }: { node: ProxmoxNode; vmCount: number }) {
|
||||
</span>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{/* Sparklines */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<div className="mb-1 flex items-center gap-1.5">
|
||||
<Cpu className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground">CPU</span>
|
||||
</div>
|
||||
<div className="h-8">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={cpuData}>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
stroke="var(--color-primary)"
|
||||
fill="var(--color-primary)"
|
||||
fillOpacity={0.18}
|
||||
strokeWidth={1.5}
|
||||
dot={false}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1 flex items-center gap-1.5">
|
||||
<MemoryStick className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground">RAM</span>
|
||||
</div>
|
||||
<div className="h-8">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={memData}>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
stroke="var(--color-primary)"
|
||||
fill="var(--color-primary)"
|
||||
fillOpacity={0.18}
|
||||
strokeWidth={1.5}
|
||||
dot={false}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats row */}
|
||||
<div className="grid grid-cols-3 gap-2 font-mono text-xs tabular-nums text-muted-foreground">
|
||||
<div className="flex items-center gap-1.5">
|
||||
|
||||
@@ -33,7 +33,7 @@ export function QuickActions({ onRefresh, isRefreshing, onNavigate }: QuickActio
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex h-auto flex-col items-center gap-1.5 px-2 py-3"
|
||||
onClick={() => onNavigate?.('dashboard')}
|
||||
onClick={() => onNavigate?.('nodes')}
|
||||
>
|
||||
<Server className="h-4 w-4" />
|
||||
<span className="text-xs font-medium">Nodes</span>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback } from 'react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useNodes, useVMs, useTasks, queryKeys } from '@/hooks/useProxmox'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
@@ -31,10 +31,20 @@ export function Dashboard({ connectionId, onNavigate }: DashboardProps) {
|
||||
|
||||
const formatPercent = (value: number) => `${(value * 100).toFixed(1)}%`
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.nodes(connectionId) })
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.vms(connectionId) })
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tasks(connectionId) })
|
||||
// Track actual refresh activity rather than piggybacking on tasksLoading,
|
||||
// which is true whenever the tasks query is fetching (e.g. background
|
||||
// polling) and would keep the Refresh button spinning continuously.
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
|
||||
const handleRefresh = useCallback(async () => {
|
||||
setRefreshing(true)
|
||||
try {
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.nodes(connectionId) })
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.vms(connectionId) })
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.tasks(connectionId) })
|
||||
} finally {
|
||||
setRefreshing(false)
|
||||
}
|
||||
}, [queryClient, connectionId])
|
||||
|
||||
const handleRetry = useCallback(() => {
|
||||
@@ -186,7 +196,7 @@ export function Dashboard({ connectionId, onNavigate }: DashboardProps) {
|
||||
<ActivityFeed tasks={tasks} isLoading={tasksLoading} />
|
||||
</div>
|
||||
<div>
|
||||
<QuickActions onRefresh={handleRefresh} isRefreshing={tasksLoading} onNavigate={onNavigate} />
|
||||
<QuickActions onRefresh={handleRefresh} isRefreshing={refreshing} onNavigate={onNavigate} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,12 +3,13 @@ import { StatusBadge } from '@/components/ui/status-badge'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Server, Cpu, MemoryStick, HardDrive } from 'lucide-react'
|
||||
import { useVMs } from '@/hooks/useProxmox'
|
||||
import type { ProxmoxNode } from '@/types/proxmox'
|
||||
import type { ProxmoxNode, ProxmoxVM } from '@/types/proxmox'
|
||||
import { formatBytes, formatUptime } from '@/lib/format'
|
||||
|
||||
interface NodeDetailProps {
|
||||
node: ProxmoxNode
|
||||
connectionId: string
|
||||
onVMClick?: (vm: ProxmoxVM) => void
|
||||
}
|
||||
|
||||
function ResourceBar({ label, used, total, icon: Icon }: {
|
||||
@@ -47,7 +48,7 @@ function ResourceBar({ label, used, total, icon: Icon }: {
|
||||
)
|
||||
}
|
||||
|
||||
export function NodeDetail({ node, connectionId }: NodeDetailProps) {
|
||||
export function NodeDetail({ node, connectionId, onVMClick }: NodeDetailProps) {
|
||||
const { data: vms, isLoading: vmsLoading } = useVMs(connectionId)
|
||||
|
||||
const nodeVMs = vms?.filter((vm) => vm.node === node.node) ?? []
|
||||
@@ -136,6 +137,7 @@ export function NodeDetail({ node, connectionId }: NodeDetailProps) {
|
||||
<div
|
||||
key={`${vm.type}-${vm.vmid}`}
|
||||
className="flex items-center justify-between rounded-md border border-border/70 px-3 py-2.5 transition-colors duration-150 hover:bg-accent/50 cursor-pointer"
|
||||
onClick={() => onVMClick?.(vm)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -7,11 +7,13 @@ import { useNodes } from '@/hooks/useProxmox'
|
||||
import { NodeDetail } from '@/components/nodes/NodeDetail'
|
||||
import { formatBytes, formatUptime } from '@/lib/format'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { ProxmoxVM } from '@/types/proxmox'
|
||||
|
||||
interface NodesPageProps {
|
||||
connectionId: string
|
||||
initialNodeName?: string
|
||||
onNavigate?: (view: string) => void
|
||||
onVMClick?: (vm: ProxmoxVM) => void
|
||||
}
|
||||
|
||||
function nodePercent(used: number, total: number): number {
|
||||
@@ -38,7 +40,7 @@ function MiniStat({
|
||||
)
|
||||
}
|
||||
|
||||
export function NodesPage({ connectionId, initialNodeName }: NodesPageProps) {
|
||||
export function NodesPage({ connectionId, initialNodeName, onVMClick }: NodesPageProps) {
|
||||
const { data: nodes, isLoading, error } = useNodes(connectionId)
|
||||
const [selectedNodeName, setSelectedNodeName] = useState<string | null>(null)
|
||||
|
||||
@@ -153,7 +155,7 @@ export function NodesPage({ connectionId, initialNodeName }: NodesPageProps) {
|
||||
{/* Selected node detail */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{selectedNode && (
|
||||
<NodeDetail node={selectedNode} connectionId={connectionId} />
|
||||
<NodeDetail node={selectedNode} connectionId={connectionId} onVMClick={onVMClick} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,13 +4,16 @@ import { useToast } from '@/components/ui/toast'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
|
||||
import { ConnectionDialog } from '@/components/connections/ConnectionDialog'
|
||||
import { Plus, Pencil, Trash2 } from 'lucide-react'
|
||||
import { removeConnection as removeConnectionIPC, disconnectFromServer, logout } from '@/lib/tauri'
|
||||
import { Plus, Pencil, Trash2, Unplug, LogOut } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { ConnectionConfig } from '@/types/connection'
|
||||
|
||||
export function ConnectionManager() {
|
||||
const connections = useConnectionStore((s) => s.connections)
|
||||
const removeConnection = useConnectionStore((s) => s.removeConnection)
|
||||
const setConnectionStatus = useConnectionStore((s) => s.setConnectionStatus)
|
||||
const setAuthStatus = useConnectionStore((s) => s.setAuthStatus)
|
||||
const { addToast } = useToast()
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<ConnectionConfig | null>(null)
|
||||
@@ -30,10 +33,41 @@ export function ConnectionManager() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = (id: string, name: string) => {
|
||||
const handleDelete = async (id: string, name: string) => {
|
||||
try {
|
||||
// Remove from the backend first so the deletion persists and the keyring
|
||||
// credentials are cleaned up, then mirror it in the store.
|
||||
await removeConnectionIPC(id)
|
||||
removeConnection(id)
|
||||
addToast(`Connection "${name}" removed`, 'success')
|
||||
} catch (err) {
|
||||
addToast(err instanceof Error ? err.message : 'Failed to remove connection', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDisconnect = async (connection: ConnectionConfig) => {
|
||||
try {
|
||||
await disconnectFromServer(connection.id)
|
||||
setConnectionStatus(connection.id, 'disconnected')
|
||||
addToast(`Connection "${connection.name}" disconnected`, 'success')
|
||||
} catch (err) {
|
||||
addToast(err instanceof Error ? err.message : 'Failed to disconnect', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const handleLogout = async (connection: ConnectionConfig) => {
|
||||
try {
|
||||
await logout(connection.id)
|
||||
setAuthStatus('unauthenticated')
|
||||
setConnectionStatus(connection.id, 'disconnected')
|
||||
addToast(`Logged out of "${connection.name}"`, 'success')
|
||||
} catch (err) {
|
||||
addToast(err instanceof Error ? err.message : 'Failed to log out', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const isActive = (connection: ConnectionConfig) =>
|
||||
connection.status === 'connected' || connection.status === 'failover'
|
||||
|
||||
const deleteTarget = connections.find((c) => c.id === deleteConfirmId) ?? null
|
||||
|
||||
@@ -75,6 +109,28 @@ export function ConnectionManager() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{isActive(connection) && (
|
||||
<>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8"
|
||||
title="Log out"
|
||||
onClick={() => handleLogout(connection)}
|
||||
>
|
||||
<LogOut className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8"
|
||||
title="Disconnect"
|
||||
onClick={() => handleDisconnect(connection)}
|
||||
>
|
||||
<Unplug className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
|
||||
@@ -72,6 +72,7 @@ export function StorageDetail({ connectionId, storage, node, onBack }: StorageDe
|
||||
)
|
||||
const { data: contentList, isLoading: contentLoading } = useStorageContent(
|
||||
connectionId,
|
||||
node,
|
||||
storage
|
||||
)
|
||||
|
||||
@@ -127,11 +128,11 @@ export function StorageDetail({ connectionId, storage, node, onBack }: StorageDe
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="h-6 w-6 text-muted-foreground" />
|
||||
<h2 className="font-mono text-2xl font-semibold tracking-tight">{detail.storage}</h2>
|
||||
<h2 className="font-mono text-2xl font-semibold tracking-tight">{storage}</h2>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<span className="font-mono">{detail.type.toUpperCase()}</span> storage on{' '}
|
||||
<span className="font-mono">{detail.node}</span>
|
||||
<span className="font-mono">{node}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -197,7 +198,7 @@ export function StorageDetail({ connectionId, storage, node, onBack }: StorageDe
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-x-4 gap-y-5">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Name</p>
|
||||
<p className="font-mono text-sm font-medium">{detail.storage}</p>
|
||||
<p className="font-mono text-sm font-medium">{storage}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Type</p>
|
||||
@@ -205,7 +206,7 @@ export function StorageDetail({ connectionId, storage, node, onBack }: StorageDe
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Node</p>
|
||||
<p className="font-mono text-sm font-medium">{detail.node}</p>
|
||||
<p className="font-mono text-sm font-medium">{node}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Enabled</p>
|
||||
|
||||
@@ -15,17 +15,28 @@ export function StorageOverview({ connectionId, onStorageClick }: StorageOvervie
|
||||
const { data: storageList, isLoading, error } = useStorage(connectionId)
|
||||
const [search, setSearch] = useState('')
|
||||
|
||||
// The backend returns one entry per (storage, node) pair, so shared storages
|
||||
// appear once per node (e.g. local-lvm on each node). Dedupe by name for
|
||||
// display, keeping the first entry's node for the click-through.
|
||||
const dedupedStorage = useMemo(() => {
|
||||
const seen = new Set<string>()
|
||||
return (storageList ?? []).filter((s) => {
|
||||
if (seen.has(s.storage)) return false
|
||||
seen.add(s.storage)
|
||||
return true
|
||||
})
|
||||
}, [storageList])
|
||||
|
||||
const filteredStorage = useMemo(() => {
|
||||
if (!storageList) return []
|
||||
if (!search) return storageList
|
||||
if (!search) return dedupedStorage
|
||||
const query = search.toLowerCase()
|
||||
return storageList.filter(
|
||||
return dedupedStorage.filter(
|
||||
(s) =>
|
||||
s.storage.toLowerCase().includes(query) ||
|
||||
s.type.toLowerCase().includes(query) ||
|
||||
s.node.toLowerCase().includes(query)
|
||||
)
|
||||
}, [storageList, search])
|
||||
}, [dedupedStorage, search])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -54,7 +65,7 @@ export function StorageOverview({ connectionId, onStorageClick }: StorageOvervie
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold tracking-tight">Storage Pools</h2>
|
||||
<p className="text-muted-foreground">
|
||||
{storageList?.length ?? 0} storage pools across the cluster
|
||||
{dedupedStorage.length} storage pools across the cluster
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -71,7 +82,7 @@ export function StorageOverview({ connectionId, onStorageClick }: StorageOvervie
|
||||
|
||||
{/* Storage Grid */}
|
||||
{filteredStorage.length === 0 ? (
|
||||
storageList?.length === 0 ? (
|
||||
dedupedStorage.length === 0 ? (
|
||||
<EmptyState icon={HardDrive} title="No storage pools found" />
|
||||
) : (
|
||||
<EmptyState icon={Search} title="No storage pools match your search" />
|
||||
|
||||
@@ -34,8 +34,13 @@ export function ConfirmDialog({
|
||||
}: ConfirmDialogProps) {
|
||||
const handleConfirm = () => {
|
||||
onConfirm()
|
||||
// When the parent is running an async operation (isLoading), leave the
|
||||
// dialog open so the spinner and disabled state give feedback; the parent
|
||||
// closes it via onSettled/onSuccess.
|
||||
if (!isLoading) {
|
||||
onOpenChange(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
PlayCircle,
|
||||
ArrowRightLeft,
|
||||
} from 'lucide-react'
|
||||
import { useStartVM, useStopVM, useShutdownVM, useRebootVM, useSuspendVM, useResumeVM, useClusterStatus } from '@/hooks/useProxmox'
|
||||
import { useStartVM, useStopVM, useShutdownVM, useRebootVM, useSuspendVM, useResumeVM, useClusterStatus, useVMs } from '@/hooks/useProxmox'
|
||||
import { OverviewTab } from '@/components/vms/tabs/OverviewTab'
|
||||
import { HardwareTab } from '@/components/vms/tabs/HardwareTab'
|
||||
import { DisksTab } from '@/components/vms/tabs/DisksTab'
|
||||
@@ -54,8 +54,17 @@ export function VMDetail({ vm, connectionId, onBack }: VMDetailProps) {
|
||||
const resumeVM = useResumeVM()
|
||||
const clusterStatus = useClusterStatus(connectionId)
|
||||
|
||||
const isRunning = vm.status === 'running'
|
||||
const isStopped = vm.status === 'stopped'
|
||||
// Re-derive the live guest so the header status and lifecycle buttons track
|
||||
// the actual VM state after start/stop/resume instead of the snapshot
|
||||
// captured at navigation time. node/vmid stay stable from the prop; the
|
||||
// prop object is the fallback while the list is loading.
|
||||
const { data: vms } = useVMs(connectionId)
|
||||
const liveVM = vms?.find((v) => v.vmid === vm.vmid && v.type === vm.type)
|
||||
const status = liveVM?.status ?? vm.status
|
||||
|
||||
const isRunning = status === 'running'
|
||||
const isStopped = status === 'stopped'
|
||||
const isSuspended = status === 'suspended'
|
||||
const isBusy = startVM.isPending || stopVM.isPending || shutdownVM.isPending || rebootVM.isPending || suspendVM.isPending || resumeVM.isPending
|
||||
|
||||
const isCluster = clusterStatus.data?.type === 'cluster' && (clusterStatus.data?.nodes?.length ?? 0) > 1
|
||||
@@ -126,7 +135,7 @@ export function VMDetail({ vm, connectionId, onBack }: VMDetailProps) {
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-2xl font-semibold tracking-tight">{vm.name}</h2>
|
||||
<StatusBadge status={vm.status} />
|
||||
<StatusBadge status={status} />
|
||||
</div>
|
||||
<p className="mt-0.5 text-sm text-muted-foreground">
|
||||
VMID <span className="font-mono tabular-nums">{vm.vmid}</span> ·{' '}
|
||||
@@ -138,7 +147,7 @@ export function VMDetail({ vm, connectionId, onBack }: VMDetailProps) {
|
||||
|
||||
{/* Lifecycle Actions */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{isStopped || !isRunning ? (
|
||||
{isStopped && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleStart}
|
||||
@@ -147,7 +156,7 @@ export function VMDetail({ vm, connectionId, onBack }: VMDetailProps) {
|
||||
<Play />
|
||||
Start
|
||||
</Button>
|
||||
) : null}
|
||||
)}
|
||||
|
||||
{isRunning && (
|
||||
<>
|
||||
@@ -193,7 +202,30 @@ export function VMDetail({ vm, connectionId, onBack }: VMDetailProps) {
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{vm.status === 'paused' && (
|
||||
{isSuspended && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleShutdown}
|
||||
disabled={isBusy}
|
||||
>
|
||||
<Power />
|
||||
Shutdown
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={handleStop}
|
||||
disabled={isBusy}
|
||||
>
|
||||
<Square />
|
||||
Stop
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{(status === 'paused' || isSuspended) && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
|
||||
@@ -20,6 +20,7 @@ interface AddNICDialogProps {
|
||||
}
|
||||
|
||||
export function AddNICDialog({ open, onOpenChange, vm }: AddNICDialogProps) {
|
||||
const isLxc = vm.type === 'lxc'
|
||||
const [bridge, setBridge] = useState('vmbr0')
|
||||
const [model, setModel] = useState('virtio')
|
||||
const [macaddr, setMacaddr] = useState('')
|
||||
@@ -36,9 +37,11 @@ export function AddNICDialog({ open, onOpenChange, vm }: AddNICDialogProps) {
|
||||
vmType: vm.type,
|
||||
config: {
|
||||
bridge,
|
||||
model,
|
||||
// Container interfaces always use the veth type; the model and VLAN
|
||||
// tag concepts only apply to QEMU VMs.
|
||||
model: isLxc ? 'veth' : model,
|
||||
macaddr: macaddr || undefined,
|
||||
tag: tag ? parseInt(tag, 10) : undefined,
|
||||
tag: isLxc ? undefined : tag ? parseInt(tag, 10) : undefined,
|
||||
firewall,
|
||||
},
|
||||
},
|
||||
@@ -75,6 +78,11 @@ export function AddNICDialog({ open, onOpenChange, vm }: AddNICDialogProps) {
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{isLxc ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Container interfaces use the veth type.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="model">Model</Label>
|
||||
<select
|
||||
@@ -88,6 +96,7 @@ export function AddNICDialog({ open, onOpenChange, vm }: AddNICDialogProps) {
|
||||
<option value="rtl8139">Realtek RTL8139</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="macaddr">MAC Address (optional)</Label>
|
||||
<Input
|
||||
@@ -97,6 +106,7 @@ export function AddNICDialog({ open, onOpenChange, vm }: AddNICDialogProps) {
|
||||
placeholder="auto-generated if empty"
|
||||
/>
|
||||
</div>
|
||||
{!isLxc && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tag">VLAN Tag (optional)</Label>
|
||||
<Input
|
||||
@@ -109,6 +119,7 @@ export function AddNICDialog({ open, onOpenChange, vm }: AddNICDialogProps) {
|
||||
placeholder="none"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
id="firewall"
|
||||
|
||||
@@ -38,7 +38,10 @@ export function EditNICDialog({ open, onOpenChange, vm, nic }: EditNICDialogProp
|
||||
config: {
|
||||
bridge: bridge || undefined,
|
||||
model,
|
||||
tag: tag ? parseInt(tag, 10) : undefined,
|
||||
// An emptied tag field clears the tag: the backend treats tag: 0 as
|
||||
// "remove the tag". `undefined` would be indistinguishable from no
|
||||
// change, so a set tag could never be removed.
|
||||
tag: tag === '' ? 0 : parseInt(tag, 10),
|
||||
firewall,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -28,7 +28,10 @@ export function DisksTab({ vm, connectionId }: DisksTabProps) {
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!deleteDisk) return
|
||||
removeDisk.mutate({ node: vm.node, vmid: vm.vmid, vmType: vm.type, disk: deleteDisk.device })
|
||||
removeDisk.mutate(
|
||||
{ node: vm.node, vmid: vm.vmid, vmType: vm.type, disk: deleteDisk.device },
|
||||
{ onSettled: () => setDeleteDisk(null) },
|
||||
)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { useToast } from '@/components/ui/toast'
|
||||
import { useUpdateVMConfig } from '@/hooks/useProxmox'
|
||||
import { Cpu, MemoryStick, Pencil, Save, X } from 'lucide-react'
|
||||
import type { ProxmoxVM } from '@/types/proxmox'
|
||||
import { formatBytes } from '@/lib/format'
|
||||
@@ -24,6 +25,7 @@ interface HardwareTabProps {
|
||||
export function HardwareTab({ vm }: HardwareTabProps) {
|
||||
const [editOpen, setEditOpen] = useState(false)
|
||||
const { addToast } = useToast()
|
||||
const updateVMConfig = useUpdateVMConfig()
|
||||
const [formData, setFormData] = useState({
|
||||
name: vm.name,
|
||||
description: vm.tags ?? '',
|
||||
@@ -32,8 +34,34 @@ export function HardwareTab({ vm }: HardwareTabProps) {
|
||||
})
|
||||
|
||||
const handleSave = () => {
|
||||
addToast('VM configuration editing is not yet implemented', 'warning')
|
||||
setEditOpen(false)
|
||||
// The form holds memory in GB; Proxmox's `memory` param is MiB.
|
||||
const cores = parseInt(formData.cores, 10)
|
||||
const memoryMiB = Math.round(parseFloat(formData.memory) * 1024)
|
||||
|
||||
if (Number.isNaN(cores) || cores < 1) {
|
||||
addToast('CPU cores must be at least 1', 'error')
|
||||
return
|
||||
}
|
||||
if (Number.isNaN(memoryMiB) || memoryMiB < 128) {
|
||||
addToast('Memory must be at least 128 MiB', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
updateVMConfig.mutate(
|
||||
{
|
||||
node: vm.node,
|
||||
vmid: vm.vmid,
|
||||
vmType: vm.type,
|
||||
config: {
|
||||
name: formData.name,
|
||||
cores,
|
||||
memory: memoryMiB,
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: () => setEditOpen(false),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -26,7 +26,10 @@ export function NetworkTab({ vm, connectionId }: NetworkTabProps) {
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!deleteNic) return
|
||||
removeNIC.mutate({ node: vm.node, vmid: vm.vmid, vmType: vm.type, nic: deleteNic.name })
|
||||
removeNIC.mutate(
|
||||
{ node: vm.node, vmid: vm.vmid, vmType: vm.type, nic: deleteNic.name },
|
||||
{ onSettled: () => setDeleteNic(null) },
|
||||
)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
|
||||
@@ -37,7 +37,10 @@ export function SnapshotsTab({ vm, connectionId }: SnapshotsTabProps) {
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!deleteTarget) return
|
||||
deleteSnapshot.mutate({ node: vm.node, vmid: vm.vmid, vmType: vm.type, name: deleteTarget.name })
|
||||
deleteSnapshot.mutate(
|
||||
{ node: vm.node, vmid: vm.vmid, vmType: vm.type, name: deleteTarget.name },
|
||||
{ onSettled: () => setDeleteTarget(null) },
|
||||
)
|
||||
}
|
||||
|
||||
const handleRollback = () => {
|
||||
|
||||
+50
-39
@@ -2,22 +2,20 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import * as api from '@/lib/tauri'
|
||||
import { useConnectionStore } from '@/stores/connectionStore'
|
||||
import { useToast } from '@/components/ui/toast'
|
||||
import type { AddDiskConfig, AddNICConfig, EditNICConfig, CreateSnapshotConfig, BackupJobConfig, RestoreConfig } from '@/types/proxmox'
|
||||
import type { AddDiskConfig, AddNICConfig, EditNICConfig, UpdateVMConfig, CreateSnapshotConfig, BackupJobConfig, RestoreConfig } from '@/types/proxmox'
|
||||
|
||||
// Query keys
|
||||
export const queryKeys = {
|
||||
nodes: (connectionId: string) => ['nodes', connectionId],
|
||||
vms: (connectionId: string) => ['vms', connectionId],
|
||||
storage: (connectionId: string) => ['storage', connectionId],
|
||||
storageContent: (connectionId: string, storage: string) => ['storageContent', connectionId, storage],
|
||||
storageContent: (connectionId: string, node: string, storage: string) => ['storageContent', connectionId, node, storage],
|
||||
storageDetail: (connectionId: string, node: string, storage: string) => ['storageDetail', connectionId, node, storage],
|
||||
tasks: (connectionId: string) => ['tasks', connectionId],
|
||||
cluster: (connectionId: string) => ['cluster', connectionId],
|
||||
disks: (connectionId: string, node: string, vmid: number) => ['disks', connectionId, node, vmid],
|
||||
networks: (connectionId: string, node: string, vmid: number) => ['networks', connectionId, node, vmid],
|
||||
snapshots: (connectionId: string, node: string, vmid: number) => ['snapshots', connectionId, node, vmid],
|
||||
vncProxy: (connectionId: string, node: string, vmid: number) => ['vncProxy', connectionId, node, vmid],
|
||||
termProxy: (connectionId: string, node: string, vmid: number) => ['termProxy', connectionId, node, vmid],
|
||||
disks: (connectionId: string, node: string, vmid: number, vmType: string) => ['disks', connectionId, node, vmid, vmType],
|
||||
networks: (connectionId: string, node: string, vmid: number, vmType: string) => ['networks', connectionId, node, vmid, vmType],
|
||||
snapshots: (connectionId: string, node: string, vmid: number, vmType: string) => ['snapshots', connectionId, node, vmid, vmType],
|
||||
backupJobs: (connectionId: string) => ['backupJobs', connectionId],
|
||||
backups: (connectionId: string, storage?: string) => ['backups', connectionId, storage],
|
||||
}
|
||||
@@ -50,11 +48,11 @@ export const useStorage = (connectionId: string | null) => {
|
||||
})
|
||||
}
|
||||
|
||||
export const useStorageContent = (connectionId: string | null, storage: string | null) => {
|
||||
export const useStorageContent = (connectionId: string | null, node: string | null, storage: string | null) => {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.storageContent(connectionId!, storage!),
|
||||
queryFn: () => api.getStorageContent(connectionId!, storage!),
|
||||
enabled: !!connectionId && !!storage,
|
||||
queryKey: queryKeys.storageContent(connectionId!, node!, storage!),
|
||||
queryFn: () => api.getStorageContent(connectionId!, storage!, node!),
|
||||
enabled: !!connectionId && !!node && !!storage,
|
||||
refetchInterval: 30000,
|
||||
})
|
||||
}
|
||||
@@ -222,7 +220,7 @@ export const useResumeVM = () => {
|
||||
// Disk management hooks
|
||||
export const useDisks = (connectionId: string, node: string, vmid: number, vmType: string) => {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.disks(connectionId, node, vmid),
|
||||
queryKey: queryKeys.disks(connectionId, node, vmid, vmType),
|
||||
queryFn: () => api.getDisks(connectionId, node, vmid, vmType),
|
||||
enabled: !!connectionId && !!node && vmid > 0,
|
||||
})
|
||||
@@ -252,7 +250,7 @@ export const useAddDisk = () => {
|
||||
const connId = useConnectionStore.getState().activeConnectionId
|
||||
if (connId) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.disks(connId, variables.node, variables.vmid),
|
||||
queryKey: queryKeys.disks(connId, variables.node, variables.vmid, variables.vmType),
|
||||
})
|
||||
}
|
||||
},
|
||||
@@ -288,7 +286,7 @@ export const useResizeDisk = () => {
|
||||
const connId = useConnectionStore.getState().activeConnectionId
|
||||
if (connId) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.disks(connId, variables.node, variables.vmid),
|
||||
queryKey: queryKeys.disks(connId, variables.node, variables.vmid, variables.vmType),
|
||||
})
|
||||
}
|
||||
},
|
||||
@@ -322,7 +320,7 @@ export const useRemoveDisk = () => {
|
||||
const connId = useConnectionStore.getState().activeConnectionId
|
||||
if (connId) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.disks(connId, variables.node, variables.vmid),
|
||||
queryKey: queryKeys.disks(connId, variables.node, variables.vmid, variables.vmType),
|
||||
})
|
||||
}
|
||||
},
|
||||
@@ -358,7 +356,7 @@ export const useMoveDisk = () => {
|
||||
const connId = useConnectionStore.getState().activeConnectionId
|
||||
if (connId) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.disks(connId, variables.node, variables.vmid),
|
||||
queryKey: queryKeys.disks(connId, variables.node, variables.vmid, variables.vmType),
|
||||
})
|
||||
}
|
||||
},
|
||||
@@ -371,7 +369,7 @@ export const useMoveDisk = () => {
|
||||
// Network management hooks
|
||||
export const useNetworkInterfaces = (connectionId: string, node: string, vmid: number, vmType: string) => {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.networks(connectionId, node, vmid),
|
||||
queryKey: queryKeys.networks(connectionId, node, vmid, vmType),
|
||||
queryFn: () => api.getNetworkInterfaces(connectionId, node, vmid, vmType),
|
||||
enabled: !!connectionId && !!node && vmid > 0,
|
||||
})
|
||||
@@ -401,7 +399,7 @@ export const useAddNIC = () => {
|
||||
const connId = useConnectionStore.getState().activeConnectionId
|
||||
if (connId) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.networks(connId, variables.node, variables.vmid),
|
||||
queryKey: queryKeys.networks(connId, variables.node, variables.vmid, variables.vmType),
|
||||
})
|
||||
}
|
||||
},
|
||||
@@ -437,7 +435,7 @@ export const useEditNIC = () => {
|
||||
const connId = useConnectionStore.getState().activeConnectionId
|
||||
if (connId) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.networks(connId, variables.node, variables.vmid),
|
||||
queryKey: queryKeys.networks(connId, variables.node, variables.vmid, variables.vmType),
|
||||
})
|
||||
}
|
||||
},
|
||||
@@ -471,7 +469,7 @@ export const useRemoveNIC = () => {
|
||||
const connId = useConnectionStore.getState().activeConnectionId
|
||||
if (connId) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.networks(connId, variables.node, variables.vmid),
|
||||
queryKey: queryKeys.networks(connId, variables.node, variables.vmid, variables.vmType),
|
||||
})
|
||||
}
|
||||
},
|
||||
@@ -484,7 +482,7 @@ export const useRemoveNIC = () => {
|
||||
// Snapshot management hooks
|
||||
export const useSnapshots = (connectionId: string, node: string, vmid: number, vmType: string) => {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.snapshots(connectionId, node, vmid),
|
||||
queryKey: queryKeys.snapshots(connectionId, node, vmid, vmType),
|
||||
queryFn: () => api.getSnapshots(connectionId, node, vmid, vmType),
|
||||
enabled: !!connectionId && !!node && vmid > 0,
|
||||
})
|
||||
@@ -514,7 +512,7 @@ export const useCreateSnapshot = () => {
|
||||
const connId = useConnectionStore.getState().activeConnectionId
|
||||
if (connId) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.snapshots(connId, variables.node, variables.vmid),
|
||||
queryKey: queryKeys.snapshots(connId, variables.node, variables.vmid, variables.vmType),
|
||||
})
|
||||
}
|
||||
},
|
||||
@@ -548,7 +546,7 @@ export const useDeleteSnapshot = () => {
|
||||
const connId = useConnectionStore.getState().activeConnectionId
|
||||
if (connId) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.snapshots(connId, variables.node, variables.vmid),
|
||||
queryKey: queryKeys.snapshots(connId, variables.node, variables.vmid, variables.vmType),
|
||||
})
|
||||
}
|
||||
},
|
||||
@@ -582,7 +580,7 @@ export const useRollbackSnapshot = () => {
|
||||
const connId = useConnectionStore.getState().activeConnectionId
|
||||
if (connId) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.snapshots(connId, variables.node, variables.vmid),
|
||||
queryKey: queryKeys.snapshots(connId, variables.node, variables.vmid, variables.vmType),
|
||||
})
|
||||
}
|
||||
},
|
||||
@@ -627,22 +625,35 @@ export const useMigrateVM = () => {
|
||||
})
|
||||
}
|
||||
|
||||
// Console proxy hooks
|
||||
export const useVNCProxy = (connectionId: string, node: string, vmid: number) => {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.vncProxy(connectionId, node, vmid),
|
||||
queryFn: () => api.createVNCProxy(connectionId, node, vmid),
|
||||
enabled: false, // Manual trigger only
|
||||
retry: false,
|
||||
})
|
||||
}
|
||||
export const useUpdateVMConfig = () => {
|
||||
const queryClient = useQueryClient()
|
||||
const { addToast } = useToast()
|
||||
|
||||
export const useTermProxy = (connectionId: string, node: string, vmid: number) => {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.termProxy(connectionId, node, vmid),
|
||||
queryFn: () => api.createTermProxy(connectionId, node, vmid),
|
||||
enabled: false, // Manual trigger only
|
||||
retry: false,
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
node,
|
||||
vmid,
|
||||
vmType,
|
||||
config,
|
||||
}: {
|
||||
node: string
|
||||
vmid: number
|
||||
vmType: string
|
||||
config: UpdateVMConfig
|
||||
}) => {
|
||||
const connId = useConnectionStore.getState().activeConnectionId!
|
||||
return api.updateVMConfig(connId, node, vmid, vmType, config)
|
||||
},
|
||||
onSuccess: () => {
|
||||
addToast('VM configuration updated', 'success')
|
||||
const connId = useConnectionStore.getState().activeConnectionId
|
||||
if (connId) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.vms(connId) })
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
addToast(error.message || 'Failed to update VM configuration', 'error')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -5,8 +5,8 @@
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes <= 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB']
|
||||
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1)
|
||||
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`
|
||||
}
|
||||
|
||||
|
||||
+91
-138
@@ -25,11 +25,11 @@ import type {
|
||||
AddDiskConfig,
|
||||
AddNICConfig,
|
||||
EditNICConfig,
|
||||
UpdateVMConfig,
|
||||
CreateSnapshotConfig,
|
||||
BackupJobConfig,
|
||||
RestoreConfig,
|
||||
VNCProxyResponse,
|
||||
TermProxyResponse,
|
||||
ConsoleProxyInfo,
|
||||
} from '@/types/proxmox'
|
||||
|
||||
// Check if we're running in Tauri
|
||||
@@ -42,39 +42,49 @@ const mockResponse = <T>(data: T): Promise<T> => {
|
||||
return new Promise((resolve) => setTimeout(() => resolve(data), 300))
|
||||
}
|
||||
|
||||
// Invoke a backend command, normalizing the rejection into a real Error so
|
||||
// callers see the backend's message instead of a bare string (Tauri rejects
|
||||
// with a string for command errors).
|
||||
async function invokeCommand<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
|
||||
if (!isTauri()) {
|
||||
throw new Error('Tauri backend not available in browser mode')
|
||||
}
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
try {
|
||||
return await invoke<T>(cmd, args)
|
||||
} catch (e) {
|
||||
throw new Error(typeof e === 'string' ? e : e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
}
|
||||
|
||||
// Connection management
|
||||
export const loadConnections = async (): Promise<LoadConnectionsResult> => {
|
||||
if (!isTauri()) {
|
||||
return mockResponse({ activeConnectionId: null, connections: [] })
|
||||
}
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
return invoke('load_connections')
|
||||
return invokeCommand<LoadConnectionsResult>('load_connections')
|
||||
}
|
||||
|
||||
export const addConnection = async (config: ConnectionConfig): Promise<void> => {
|
||||
if (!isTauri()) return mockResponse(undefined)
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
return invoke('add_connection', { config })
|
||||
return invokeCommand<void>('add_connection', { config })
|
||||
}
|
||||
|
||||
export const removeConnection = async (id: string): Promise<void> => {
|
||||
if (!isTauri()) return mockResponse(undefined)
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
return invoke('remove_connection', { id })
|
||||
return invokeCommand<void>('remove_connection', { id })
|
||||
}
|
||||
|
||||
export const updateConnection = async (config: ConnectionConfig): Promise<void> => {
|
||||
if (!isTauri()) return mockResponse(undefined)
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
return invoke('update_connection', { config })
|
||||
return invokeCommand<void>('update_connection', { config })
|
||||
}
|
||||
|
||||
export const connectToServer = async (id: string): Promise<ConnectResult> => {
|
||||
if (!isTauri()) {
|
||||
return mockResponse({ connectionId: id, mergedInto: null, status: 'connected' })
|
||||
}
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
return invoke('connect_to_server', { id })
|
||||
return invokeCommand<ConnectResult>('connect_to_server', { id })
|
||||
}
|
||||
|
||||
export const getConnectionStatus = async (
|
||||
@@ -89,20 +99,17 @@ export const getConnectionStatus = async (
|
||||
nodes: [],
|
||||
})
|
||||
}
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
return invoke('get_connection_status', { connectionId })
|
||||
return invokeCommand<ConnectionStatusInfo>('get_connection_status', { connectionId })
|
||||
}
|
||||
|
||||
export const disconnectFromServer = async (id: string): Promise<void> => {
|
||||
if (!isTauri()) return mockResponse(undefined)
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
return invoke('disconnect_from_server', { id })
|
||||
return invokeCommand<void>('disconnect_from_server', { id })
|
||||
}
|
||||
|
||||
export const setActiveConnection = async (id: string): Promise<void> => {
|
||||
if (!isTauri()) return mockResponse(undefined)
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
return invoke('set_active_connection', { id })
|
||||
return invokeCommand<void>('set_active_connection', { id })
|
||||
}
|
||||
|
||||
// Authentication
|
||||
@@ -118,8 +125,7 @@ export const loginWithPassword = async (
|
||||
csrfToken: 'mock-csrf-' + Date.now(),
|
||||
})
|
||||
}
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
return invoke('login_with_password', { url, username, password })
|
||||
return invokeCommand<LoginResult>('login_with_password', { url, username, password })
|
||||
}
|
||||
|
||||
export const loginWithToken = async (
|
||||
@@ -133,22 +139,12 @@ export const loginWithToken = async (
|
||||
csrfToken: '',
|
||||
})
|
||||
}
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
return invoke('login_with_token', { url, token })
|
||||
return invokeCommand<LoginResult>('login_with_token', { url, token })
|
||||
}
|
||||
|
||||
export const logout = async (connectionId: string): Promise<void> => {
|
||||
if (!isTauri()) return mockResponse(undefined)
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
return invoke('logout', { connectionId })
|
||||
}
|
||||
|
||||
export const getStoredCredentials = async (
|
||||
connectionId: string,
|
||||
): Promise<string | null> => {
|
||||
if (!isTauri()) return mockResponse(null)
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
return invoke('get_stored_credentials', { connectionId })
|
||||
return invokeCommand<void>('logout', { connectionId })
|
||||
}
|
||||
|
||||
export const getCertificateInfo = async (url: string): Promise<CertificateInfo> => {
|
||||
@@ -162,14 +158,12 @@ export const getCertificateInfo = async (url: string): Promise<CertificateInfo>
|
||||
selfSigned: true,
|
||||
})
|
||||
}
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
return invoke('get_certificate_info', { url })
|
||||
return invokeCommand<CertificateInfo>('get_certificate_info', { url })
|
||||
}
|
||||
|
||||
export const trustCertificate = async (id: string, fingerprint: string): Promise<void> => {
|
||||
if (!isTauri()) return mockResponse(undefined)
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
return invoke('trust_certificate', { id, fingerprint })
|
||||
return invokeCommand<void>('trust_certificate', { id, fingerprint })
|
||||
}
|
||||
|
||||
// API calls
|
||||
@@ -192,25 +186,23 @@ export const getNodes = async (connectionId: string): Promise<ProxmoxNode[]> =>
|
||||
},
|
||||
])
|
||||
}
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
return invoke('get_nodes', { connectionId })
|
||||
return invokeCommand<ProxmoxNode[]>('get_nodes', { connectionId })
|
||||
}
|
||||
|
||||
export const getVMs = async (connectionId: string): Promise<ProxmoxVM[]> => {
|
||||
if (!isTauri()) return mockResponse([])
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
return invoke('get_vms', { connectionId })
|
||||
return invokeCommand<ProxmoxVM[]>('get_vms', { connectionId })
|
||||
}
|
||||
|
||||
export const getStorage = async (connectionId: string): Promise<ProxmoxStorage[]> => {
|
||||
if (!isTauri()) return mockResponse([])
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
return invoke('get_storage', { connectionId })
|
||||
return invokeCommand<ProxmoxStorage[]>('get_storage', { connectionId })
|
||||
}
|
||||
|
||||
export const getStorageContent = async (
|
||||
connectionId: string,
|
||||
storage: string,
|
||||
node?: string,
|
||||
): Promise<ProxmoxStorageContent[]> => {
|
||||
if (!isTauri()) {
|
||||
return mockResponse([
|
||||
@@ -220,8 +212,7 @@ export const getStorageContent = async (
|
||||
{ content: 'iso', ctime: Date.now() / 1000 - 172800, size: 4 * 1024 * 1024 * 1024, volid: 'local:iso/ubuntu-22.04-desktop-amd64.iso' },
|
||||
])
|
||||
}
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
return invoke('get_storage_content', { connectionId, storage })
|
||||
return invokeCommand<ProxmoxStorageContent[]>('get_storage_content', { connectionId, storage, node })
|
||||
}
|
||||
|
||||
export const getStorageDetail = async (
|
||||
@@ -243,14 +234,12 @@ export const getStorageDetail = async (
|
||||
node,
|
||||
})
|
||||
}
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
return invoke('get_storage_detail', { connectionId, node, storage })
|
||||
return invokeCommand<ProxmoxStorageDetail>('get_storage_detail', { connectionId, node, storage })
|
||||
}
|
||||
|
||||
export const getTasks = async (connectionId: string): Promise<ProxmoxTask[]> => {
|
||||
if (!isTauri()) return mockResponse([])
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
return invoke('get_tasks', { connectionId })
|
||||
return invokeCommand<ProxmoxTask[]>('get_tasks', { connectionId })
|
||||
}
|
||||
|
||||
export const getClusterStatus = async (connectionId: string): Promise<ProxmoxClusterStatus> => {
|
||||
@@ -262,45 +251,38 @@ export const getClusterStatus = async (connectionId: string): Promise<ProxmoxClu
|
||||
nodes: [],
|
||||
})
|
||||
}
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
return invoke('get_cluster_status', { connectionId })
|
||||
return invokeCommand<ProxmoxClusterStatus>('get_cluster_status', { connectionId })
|
||||
}
|
||||
|
||||
// VM lifecycle
|
||||
export const startVM = async (connectionId: string, node: string, vmid: number, vmType: string): Promise<void> => {
|
||||
if (!isTauri()) return mockResponse(undefined)
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
return invoke('start_vm', { connectionId, node, vmid, vmType })
|
||||
return invokeCommand<void>('start_vm', { connectionId, node, vmid, vmType })
|
||||
}
|
||||
|
||||
export const stopVM = async (connectionId: string, node: string, vmid: number, vmType: string): Promise<void> => {
|
||||
if (!isTauri()) return mockResponse(undefined)
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
return invoke('stop_vm', { connectionId, node, vmid, vmType })
|
||||
return invokeCommand<void>('stop_vm', { connectionId, node, vmid, vmType })
|
||||
}
|
||||
|
||||
export const shutdownVM = async (connectionId: string, node: string, vmid: number, vmType: string): Promise<void> => {
|
||||
if (!isTauri()) return mockResponse(undefined)
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
return invoke('shutdown_vm', { connectionId, node, vmid, vmType })
|
||||
return invokeCommand<void>('shutdown_vm', { connectionId, node, vmid, vmType })
|
||||
}
|
||||
|
||||
export const rebootVM = async (connectionId: string, node: string, vmid: number, vmType: string): Promise<void> => {
|
||||
if (!isTauri()) return mockResponse(undefined)
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
return invoke('reboot_vm', { connectionId, node, vmid, vmType })
|
||||
return invokeCommand<void>('reboot_vm', { connectionId, node, vmid, vmType })
|
||||
}
|
||||
|
||||
export const suspendVM = async (connectionId: string, node: string, vmid: number, vmType: string): Promise<void> => {
|
||||
if (!isTauri()) return mockResponse(undefined)
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
return invoke('suspend_vm', { connectionId, node, vmid, vmType })
|
||||
return invokeCommand<void>('suspend_vm', { connectionId, node, vmid, vmType })
|
||||
}
|
||||
|
||||
export const resumeVM = async (connectionId: string, node: string, vmid: number, vmType: string): Promise<void> => {
|
||||
if (!isTauri()) return mockResponse(undefined)
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
return invoke('resume_vm', { connectionId, node, vmid, vmType })
|
||||
return invokeCommand<void>('resume_vm', { connectionId, node, vmid, vmType })
|
||||
}
|
||||
|
||||
// Disk management
|
||||
@@ -316,8 +298,7 @@ export const getDisks = async (
|
||||
{ device: 'scsi1', size: 64 * 1024 * 1024 * 1024, storage: 'local-lvm', format: 'qcow2' },
|
||||
])
|
||||
}
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
return invoke('get_disks', { connectionId, node, vmid, vmType })
|
||||
return invokeCommand<ProxmoxDisk[]>('get_disks', { connectionId, node, vmid, vmType })
|
||||
}
|
||||
|
||||
export const addDisk = async (
|
||||
@@ -328,8 +309,7 @@ export const addDisk = async (
|
||||
config: AddDiskConfig,
|
||||
): Promise<void> => {
|
||||
if (!isTauri()) return mockResponse(undefined)
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
return invoke('add_disk', { connectionId, node, vmid, vmType, config })
|
||||
return invokeCommand<void>('add_disk', { connectionId, node, vmid, vmType, config })
|
||||
}
|
||||
|
||||
export const resizeDisk = async (
|
||||
@@ -341,8 +321,7 @@ export const resizeDisk = async (
|
||||
size: number,
|
||||
): Promise<void> => {
|
||||
if (!isTauri()) return mockResponse(undefined)
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
return invoke('resize_disk', { connectionId, node, vmid, vmType, disk, size })
|
||||
return invokeCommand<void>('resize_disk', { connectionId, node, vmid, vmType, disk, size })
|
||||
}
|
||||
|
||||
export const removeDisk = async (
|
||||
@@ -353,8 +332,7 @@ export const removeDisk = async (
|
||||
disk: string,
|
||||
): Promise<void> => {
|
||||
if (!isTauri()) return mockResponse(undefined)
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
return invoke('remove_disk', { connectionId, node, vmid, vmType, disk })
|
||||
return invokeCommand<void>('remove_disk', { connectionId, node, vmid, vmType, disk })
|
||||
}
|
||||
|
||||
export const moveDisk = async (
|
||||
@@ -366,8 +344,7 @@ export const moveDisk = async (
|
||||
storage: string,
|
||||
): Promise<void> => {
|
||||
if (!isTauri()) return mockResponse(undefined)
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
return invoke('move_disk', { connectionId, node, vmid, vmType, disk, storage })
|
||||
return invokeCommand<void>('move_disk', { connectionId, node, vmid, vmType, disk, storage })
|
||||
}
|
||||
|
||||
// Network management
|
||||
@@ -382,8 +359,7 @@ export const getNetworkInterfaces = async (
|
||||
{ name: 'net0', model: 'virtio', macaddr: 'BC:24:11:AA:BB:CC', bridge: 'vmbr0', firewall: 1 },
|
||||
])
|
||||
}
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
return invoke('get_network_interfaces', { connectionId, node, vmid, vmType })
|
||||
return invokeCommand<ProxmoxNetwork[]>('get_network_interfaces', { connectionId, node, vmid, vmType })
|
||||
}
|
||||
|
||||
export const addNIC = async (
|
||||
@@ -394,8 +370,7 @@ export const addNIC = async (
|
||||
config: AddNICConfig,
|
||||
): Promise<void> => {
|
||||
if (!isTauri()) return mockResponse(undefined)
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
return invoke('add_nic', { connectionId, node, vmid, vmType, config })
|
||||
return invokeCommand<void>('add_nic', { connectionId, node, vmid, vmType, config })
|
||||
}
|
||||
|
||||
export const editNIC = async (
|
||||
@@ -407,8 +382,7 @@ export const editNIC = async (
|
||||
config: EditNICConfig,
|
||||
): Promise<void> => {
|
||||
if (!isTauri()) return mockResponse(undefined)
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
return invoke('edit_nic', { connectionId, node, vmid, vmType, nic, config })
|
||||
return invokeCommand<void>('edit_nic', { connectionId, node, vmid, vmType, nic, config })
|
||||
}
|
||||
|
||||
export const removeNIC = async (
|
||||
@@ -419,8 +393,7 @@ export const removeNIC = async (
|
||||
nic: string,
|
||||
): Promise<void> => {
|
||||
if (!isTauri()) return mockResponse(undefined)
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
return invoke('remove_nic', { connectionId, node, vmid, vmType, nic })
|
||||
return invokeCommand<void>('remove_nic', { connectionId, node, vmid, vmType, nic })
|
||||
}
|
||||
|
||||
// Snapshot management
|
||||
@@ -431,8 +404,7 @@ export const getSnapshots = async (
|
||||
vmType: string,
|
||||
): Promise<ProxmoxSnapshot[]> => {
|
||||
if (!isTauri()) return mockResponse([])
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
return invoke('get_snapshots', { connectionId, node, vmid, vmType })
|
||||
return invokeCommand<ProxmoxSnapshot[]>('get_snapshots', { connectionId, node, vmid, vmType })
|
||||
}
|
||||
|
||||
export const createSnapshot = async (
|
||||
@@ -443,8 +415,7 @@ export const createSnapshot = async (
|
||||
config: CreateSnapshotConfig,
|
||||
): Promise<void> => {
|
||||
if (!isTauri()) return mockResponse(undefined)
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
return invoke('create_snapshot', { connectionId, node, vmid, vmType, config })
|
||||
return invokeCommand<void>('create_snapshot', { connectionId, node, vmid, vmType, config })
|
||||
}
|
||||
|
||||
export const deleteSnapshot = async (
|
||||
@@ -455,8 +426,7 @@ export const deleteSnapshot = async (
|
||||
name: string,
|
||||
): Promise<void> => {
|
||||
if (!isTauri()) return mockResponse(undefined)
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
return invoke('delete_snapshot', { connectionId, node, vmid, vmType, name })
|
||||
return invokeCommand<void>('delete_snapshot', { connectionId, node, vmid, vmType, name })
|
||||
}
|
||||
|
||||
export const rollbackSnapshot = async (
|
||||
@@ -467,8 +437,7 @@ export const rollbackSnapshot = async (
|
||||
name: string,
|
||||
): Promise<void> => {
|
||||
if (!isTauri()) return mockResponse(undefined)
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
return invoke('rollback_snapshot', { connectionId, node, vmid, vmType, name })
|
||||
return invokeCommand<void>('rollback_snapshot', { connectionId, node, vmid, vmType, name })
|
||||
}
|
||||
|
||||
// VM migration
|
||||
@@ -481,40 +450,37 @@ export const migrateVM = async (
|
||||
online: boolean,
|
||||
): Promise<void> => {
|
||||
if (!isTauri()) return mockResponse(undefined)
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
return invoke('migrate_vm', { connectionId, node, vmid, vmType, targetNode, online })
|
||||
return invokeCommand<void>('migrate_vm', { connectionId, node, vmid, vmType, targetNode, online })
|
||||
}
|
||||
|
||||
// VM configuration
|
||||
export const updateVMConfig = async (
|
||||
connectionId: string,
|
||||
node: string,
|
||||
vmid: number,
|
||||
vmType: string,
|
||||
config: UpdateVMConfig,
|
||||
): Promise<void> => {
|
||||
if (!isTauri()) return mockResponse(undefined)
|
||||
return invokeCommand<void>('update_vm_config', { connectionId, node, vmid, vmType, config })
|
||||
}
|
||||
|
||||
// Console proxy
|
||||
export const createVNCProxy = async (
|
||||
export const startConsoleProxy = async (
|
||||
connectionId: string,
|
||||
kind: 'vnc' | 'term',
|
||||
node: string,
|
||||
vmid: number,
|
||||
): Promise<VNCProxyResponse> => {
|
||||
): Promise<ConsoleProxyInfo> => {
|
||||
if (!isTauri()) {
|
||||
return mockResponse({
|
||||
ticket: 'mock-vnc-ticket-' + Date.now(),
|
||||
port: 6000 + (vmid % 1000),
|
||||
cert: '',
|
||||
})
|
||||
return mockResponse({ sessionId: crypto.randomUUID(), url: '' })
|
||||
}
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
return invoke('create_vnc_proxy', { connectionId, node, vmid })
|
||||
return invokeCommand<ConsoleProxyInfo>('start_console_proxy', { connectionId, kind, node, vmid })
|
||||
}
|
||||
|
||||
export const createTermProxy = async (
|
||||
connectionId: string,
|
||||
node: string,
|
||||
vmid: number,
|
||||
): Promise<TermProxyResponse> => {
|
||||
if (!isTauri()) {
|
||||
return mockResponse({
|
||||
ticket: 'mock-term-ticket-' + Date.now(),
|
||||
port: 6100 + (vmid % 1000),
|
||||
})
|
||||
}
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
return invoke('create_term_proxy', { connectionId, node, vmid })
|
||||
export const stopConsoleProxy = async (sessionId: string): Promise<void> => {
|
||||
if (!isTauri()) return mockResponse(undefined)
|
||||
return invokeCommand<void>('stop_console_proxy', { sessionId })
|
||||
}
|
||||
|
||||
export const getWebSocketURL = async (
|
||||
@@ -524,27 +490,23 @@ export const getWebSocketURL = async (
|
||||
if (!isTauri()) {
|
||||
return mockResponse(`wss://localhost:8006`)
|
||||
}
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
return invoke('get_websocket_url', { connectionId, node })
|
||||
return invokeCommand<string>('get_websocket_url', { connectionId, node })
|
||||
}
|
||||
|
||||
// WebSocket management
|
||||
export const connectWebSocket = async (connectionId: string, url: string): Promise<void> => {
|
||||
if (!isTauri()) return mockResponse(undefined)
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
return invoke('connect_websocket', { connectionId, url })
|
||||
return invokeCommand<void>('connect_websocket', { connectionId, url })
|
||||
}
|
||||
|
||||
export const disconnectWebSocket = async (connectionId: string): Promise<void> => {
|
||||
if (!isTauri()) return mockResponse(undefined)
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
return invoke('disconnect_websocket', { connectionId })
|
||||
return invokeCommand<void>('disconnect_websocket', { connectionId })
|
||||
}
|
||||
|
||||
export const isWebSocketConnected = async (connectionId: string): Promise<boolean> => {
|
||||
if (!isTauri()) return mockResponse(false)
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
return invoke('is_websocket_connected', { connectionId })
|
||||
return invokeCommand<boolean>('is_websocket_connected', { connectionId })
|
||||
}
|
||||
|
||||
// Backup management
|
||||
@@ -562,8 +524,7 @@ export const getBackupJobs = async (connectionId: string): Promise<ProxmoxBackup
|
||||
},
|
||||
])
|
||||
}
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
return invoke('get_backup_jobs', { connectionId })
|
||||
return invokeCommand<ProxmoxBackupJob[]>('get_backup_jobs', { connectionId })
|
||||
}
|
||||
|
||||
export const getBackups = async (
|
||||
@@ -571,8 +532,7 @@ export const getBackups = async (
|
||||
storage?: string,
|
||||
): Promise<ProxmoxBackup[]> => {
|
||||
if (!isTauri()) return mockResponse([])
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
return invoke('get_backups', { connectionId, storage })
|
||||
return invokeCommand<ProxmoxBackup[]>('get_backups', { connectionId, storage })
|
||||
}
|
||||
|
||||
export const createBackupJob = async (
|
||||
@@ -580,8 +540,7 @@ export const createBackupJob = async (
|
||||
config: BackupJobConfig,
|
||||
): Promise<void> => {
|
||||
if (!isTauri()) return mockResponse(undefined)
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
return invoke('create_backup_job', { connectionId, config })
|
||||
return invokeCommand<void>('create_backup_job', { connectionId, config })
|
||||
}
|
||||
|
||||
export const updateBackupJob = async (
|
||||
@@ -590,14 +549,12 @@ export const updateBackupJob = async (
|
||||
config: BackupJobConfig,
|
||||
): Promise<void> => {
|
||||
if (!isTauri()) return mockResponse(undefined)
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
return invoke('update_backup_job', { connectionId, id, config })
|
||||
return invokeCommand<void>('update_backup_job', { connectionId, id, config })
|
||||
}
|
||||
|
||||
export const deleteBackupJob = async (connectionId: string, id: string): Promise<void> => {
|
||||
if (!isTauri()) return mockResponse(undefined)
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
return invoke('delete_backup_job', { connectionId, id })
|
||||
return invokeCommand<void>('delete_backup_job', { connectionId, id })
|
||||
}
|
||||
|
||||
export const runBackup = async (
|
||||
@@ -605,8 +562,7 @@ export const runBackup = async (
|
||||
config: BackupJobConfig,
|
||||
): Promise<void> => {
|
||||
if (!isTauri()) return mockResponse(undefined)
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
return invoke('run_backup', { connectionId, config })
|
||||
return invokeCommand<void>('run_backup', { connectionId, config })
|
||||
}
|
||||
|
||||
export const restoreBackup = async (
|
||||
@@ -615,14 +571,12 @@ export const restoreBackup = async (
|
||||
config: RestoreConfig,
|
||||
): Promise<void> => {
|
||||
if (!isTauri()) return mockResponse(undefined)
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
return invoke('restore_backup', { connectionId, volid, config })
|
||||
return invokeCommand<void>('restore_backup', { connectionId, volid, config })
|
||||
}
|
||||
|
||||
export const deleteBackup = async (connectionId: string, volid: string): Promise<void> => {
|
||||
if (!isTauri()) return mockResponse(undefined)
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
return invoke('delete_backup', { connectionId, volid })
|
||||
return invokeCommand<void>('delete_backup', { connectionId, volid })
|
||||
}
|
||||
|
||||
// Tray menu
|
||||
@@ -630,6 +584,5 @@ export const updateTrayMenu = async (
|
||||
connections: { id: string; name: string; status: string }[],
|
||||
): Promise<void> => {
|
||||
if (!isTauri()) return
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
return invoke('update_tray_menu', { connections })
|
||||
return invokeCommand<void>('update_tray_menu', { connections })
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { create } from 'zustand'
|
||||
import type { ConnectionConfig, ConnectionStatus, DiscoveredNode } from '@/types/connection'
|
||||
import type { ConnectionConfig, ConnectionStatus } from '@/types/connection'
|
||||
|
||||
export type AuthStatus = 'authenticated' | 'expired' | 'unauthenticated'
|
||||
|
||||
@@ -17,7 +17,6 @@ interface ConnectionState {
|
||||
hydrate: (connections: ConnectionConfig[], activeConnectionId: string | null) => void
|
||||
setActiveConnection: (id: string | null) => void
|
||||
setConnectionStatus: (id: string, status: ConnectionStatus) => void
|
||||
setConnectionNodes: (id: string, nodes: DiscoveredNode[]) => void
|
||||
setLoading: (loading: boolean) => void
|
||||
setError: (error: string | null) => void
|
||||
setAuthStatus: (status: AuthStatus) => void
|
||||
@@ -52,8 +51,17 @@ export const useConnectionStore = create<ConnectionState>((set) => ({
|
||||
hydrate: (connections, activeConnectionId) =>
|
||||
set({ connections, activeConnectionId }),
|
||||
|
||||
setActiveConnection: (id) =>
|
||||
set({ activeConnectionId: id }),
|
||||
setActiveConnection: (id) => {
|
||||
set({ activeConnectionId: id })
|
||||
// Persist the choice so the backend reconnects this connection on the next
|
||||
// launch. Fire-and-forget: switching is a UI action and a transient failure
|
||||
// should not block it.
|
||||
if (id) {
|
||||
import('@/lib/tauri')
|
||||
.then(({ setActiveConnection: persistActive }) => persistActive(id))
|
||||
.catch(() => {})
|
||||
}
|
||||
},
|
||||
|
||||
setConnectionStatus: (id, status) =>
|
||||
set((state) => ({
|
||||
@@ -62,13 +70,6 @@ export const useConnectionStore = create<ConnectionState>((set) => ({
|
||||
),
|
||||
})),
|
||||
|
||||
setConnectionNodes: (id, nodes) =>
|
||||
set((state) => ({
|
||||
connections: state.connections.map((c) =>
|
||||
c.id === id ? { ...c, nodes } : c
|
||||
),
|
||||
})),
|
||||
|
||||
setLoading: (loading) => set({ isLoading: loading }),
|
||||
|
||||
setError: (error) => set({ error }),
|
||||
|
||||
@@ -206,6 +206,14 @@ export interface EditNICConfig {
|
||||
firewall?: boolean
|
||||
}
|
||||
|
||||
export interface UpdateVMConfig {
|
||||
name?: string
|
||||
cores?: number
|
||||
/** Memory size in MiB. */
|
||||
memory?: number
|
||||
description?: string
|
||||
}
|
||||
|
||||
export interface CreateSnapshotConfig {
|
||||
name: string
|
||||
description?: string
|
||||
@@ -224,6 +232,11 @@ export interface TermProxyResponse {
|
||||
port: number
|
||||
}
|
||||
|
||||
export interface ConsoleProxyInfo {
|
||||
sessionId: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface WebSocketInfo {
|
||||
url: string
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user