feat: add PBS datastore management and harden macOS keychain storage
Add Proxmox Backup Server datastore management: overview, datastore detail, download/prune/verify/GC dialogs, usePbs hook, backend commands, and tests. Fix macOS keychain re-writes failing with 'item already exists': replace keyring 3 with keyring-core plus per-platform stores (macOS Keychain, Windows Credential Manager, Linux keyutils), recover by deleting the stale item and retrying once, and surface actionable messages for locked keychains.
This commit is contained in:
+45
@@ -12,6 +12,9 @@ import { BackupList } from '@/components/backups/BackupList'
|
||||
import { CommandPalette } from '@/components/command/CommandPalette'
|
||||
import { StorageOverview } from '@/components/storage/StorageOverview'
|
||||
import { StorageDetail } from '@/components/storage/StorageDetail'
|
||||
import { PbsOverview } from '@/components/pbs/PbsOverview'
|
||||
import { PbsDatastores } from '@/components/pbs/PbsDatastores'
|
||||
import { PbsDatastoreDetail } from '@/components/pbs/PbsDatastoreDetail'
|
||||
import { SettingsPage } from '@/components/settings/SettingsPage'
|
||||
import { ErrorBoundary } from '@/components/ErrorBoundary'
|
||||
import { ToastProvider, useToast } from '@/components/ui/toast'
|
||||
@@ -43,6 +46,9 @@ type View =
|
||||
| { type: 'backups' }
|
||||
| { type: 'storage' }
|
||||
| { type: 'storage-detail'; storage: string; node: string }
|
||||
| { type: 'pbs-overview' }
|
||||
| { type: 'pbs-datastores' }
|
||||
| { type: 'pbs-datastore-detail'; store: string }
|
||||
| { type: 'settings' }
|
||||
|
||||
function AppContent() {
|
||||
@@ -317,6 +323,23 @@ function AppContent() {
|
||||
onBack={() => handleNavigate({ type: 'storage' })}
|
||||
/>
|
||||
)
|
||||
case 'pbs-overview':
|
||||
return <PbsOverview connectionId={activeConnectionId} />
|
||||
case 'pbs-datastores':
|
||||
return (
|
||||
<PbsDatastores
|
||||
connectionId={activeConnectionId}
|
||||
onDatastoreClick={(store) => handleNavigate({ type: 'pbs-datastore-detail', store })}
|
||||
/>
|
||||
)
|
||||
case 'pbs-datastore-detail':
|
||||
return (
|
||||
<PbsDatastoreDetail
|
||||
connectionId={activeConnectionId}
|
||||
store={view.store}
|
||||
onBack={() => handleNavigate({ type: 'pbs-datastores' })}
|
||||
/>
|
||||
)
|
||||
default:
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
@@ -328,6 +351,28 @@ function AppContent() {
|
||||
|
||||
const activeConnection = connections.find((c) => c.id === activeConnectionId)
|
||||
|
||||
// When the active connection switches to a PBS server, leave any VE-only
|
||||
// view behind and land on the PBS overview. Shared views (tasks, settings)
|
||||
// and PBS views are left untouched.
|
||||
const activeServerType = activeConnection?.serverType ?? 'pve'
|
||||
useEffect(() => {
|
||||
if (activeServerType !== 'pbs') return
|
||||
const veOnlyViews = new Set([
|
||||
'dashboard',
|
||||
'vms',
|
||||
'vm-detail',
|
||||
'nodes',
|
||||
'node-detail',
|
||||
'containers',
|
||||
'backups',
|
||||
'storage',
|
||||
'storage-detail',
|
||||
])
|
||||
if (veOnlyViews.has(view.type)) {
|
||||
setView({ type: 'pbs-overview' })
|
||||
}
|
||||
}, [activeConnectionId, activeServerType, view.type])
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col bg-background">
|
||||
{activeConnection?.status === 'failover' && activeConnection.currentEndpointUrl && (
|
||||
|
||||
@@ -40,6 +40,9 @@ type View =
|
||||
| { type: 'backups' }
|
||||
| { type: 'storage' }
|
||||
| { type: 'storage-detail'; storage: string; node: string }
|
||||
| { type: 'pbs-overview' }
|
||||
| { type: 'pbs-datastores' }
|
||||
| { type: 'pbs-datastore-detail'; store: string }
|
||||
| { type: 'settings' }
|
||||
|
||||
type CommandCategory = 'recent' | 'vms' | 'actions' | 'navigation' | 'connections'
|
||||
@@ -194,8 +197,9 @@ export function CommandPalette({
|
||||
const activeConnectionId = useConnectionStore((s) => s.activeConnectionId)
|
||||
const connections = useConnectionStore((s) => s.connections)
|
||||
const setActiveConnection = useConnectionStore((s) => s.setActiveConnection)
|
||||
const serverType = connections.find((c) => c.id === activeConnectionId)?.serverType ?? 'pve'
|
||||
|
||||
const { data: vms = [] } = useVMs(activeConnectionId)
|
||||
const { data: vms = [] } = useVMs(serverType === 'pbs' ? null : activeConnectionId)
|
||||
|
||||
// VM mutation hooks
|
||||
const startVM = useStartVM()
|
||||
@@ -210,128 +214,179 @@ export function CommandPalette({
|
||||
const items: CommandItem[] = []
|
||||
|
||||
// -- Navigation --
|
||||
items.push(
|
||||
{
|
||||
id: 'nav-dashboard',
|
||||
label: 'Go to Dashboard',
|
||||
icon: LayoutDashboard,
|
||||
category: 'navigation',
|
||||
shortcut: '⌘1',
|
||||
keywords: ['dashboard', 'home', 'overview'],
|
||||
onExecute: () => onNavigate({ type: 'dashboard' }),
|
||||
},
|
||||
{
|
||||
id: 'nav-vms',
|
||||
label: 'Go to VMs',
|
||||
icon: Box,
|
||||
category: 'navigation',
|
||||
shortcut: '⌘2',
|
||||
keywords: ['vm', 'vms', 'virtual machines', 'containers'],
|
||||
onExecute: () => onNavigate({ type: 'vms' }),
|
||||
},
|
||||
{
|
||||
id: 'nav-tasks',
|
||||
label: 'Go to Tasks',
|
||||
icon: ListTodo,
|
||||
category: 'navigation',
|
||||
shortcut: '⌘3',
|
||||
keywords: ['tasks', 'jobs', 'queue'],
|
||||
onExecute: () => onNavigate({ type: 'tasks' }),
|
||||
},
|
||||
{
|
||||
id: 'nav-backups',
|
||||
label: 'Go to Backups',
|
||||
icon: Shield,
|
||||
category: 'navigation',
|
||||
shortcut: '⌘4',
|
||||
keywords: ['backups', 'restore', 'backup'],
|
||||
onExecute: () => onNavigate({ type: 'backups' }),
|
||||
},
|
||||
{
|
||||
id: 'nav-storage',
|
||||
label: 'Go to Storage',
|
||||
icon: HardDrive,
|
||||
category: 'navigation',
|
||||
shortcut: '⌘5',
|
||||
keywords: ['storage', 'disks', 'volumes'],
|
||||
onExecute: () => onNavigate({ type: 'storage' }),
|
||||
},
|
||||
{
|
||||
id: 'nav-settings',
|
||||
label: 'Go to Settings',
|
||||
icon: Settings,
|
||||
category: 'navigation',
|
||||
shortcut: '⌘6',
|
||||
keywords: ['settings', 'preferences', 'configuration'],
|
||||
onExecute: () => onNavigate({ type: 'settings' }),
|
||||
},
|
||||
{
|
||||
id: 'nav-add-connection',
|
||||
label: 'Add Connection',
|
||||
icon: Plus,
|
||||
category: 'navigation',
|
||||
keywords: ['add', 'connection', 'server', 'proxmox', 'new'],
|
||||
onExecute: () => onAddConnection(),
|
||||
},
|
||||
)
|
||||
|
||||
// -- VMs --
|
||||
for (const vm of vms) {
|
||||
items.push({
|
||||
id: `vm-detail-${vm.vmid}`,
|
||||
label: vm.name,
|
||||
description: `${vm.type.toUpperCase()} · VMID ${vm.vmid} · ${vm.node} · ${vm.status}`,
|
||||
icon: Server,
|
||||
category: 'vms',
|
||||
keywords: [vm.name, String(vm.vmid), vm.node, vm.type, vm.status],
|
||||
onExecute: () => onNavigate({ type: 'vm-detail', vm }),
|
||||
})
|
||||
if (serverType === 'pbs') {
|
||||
items.push(
|
||||
{
|
||||
id: 'nav-pbs-overview',
|
||||
label: 'Go to PBS Overview',
|
||||
icon: LayoutDashboard,
|
||||
category: 'navigation',
|
||||
shortcut: '⌘1',
|
||||
keywords: ['overview', 'pbs', 'backup server', 'dashboard', 'home'],
|
||||
onExecute: () => onNavigate({ type: 'pbs-overview' }),
|
||||
},
|
||||
{
|
||||
id: 'nav-pbs-datastores',
|
||||
label: 'Go to Datastores',
|
||||
icon: HardDrive,
|
||||
category: 'navigation',
|
||||
shortcut: '⌘2',
|
||||
keywords: ['datastores', 'datastore', 'storage', 'backups'],
|
||||
onExecute: () => onNavigate({ type: 'pbs-datastores' }),
|
||||
},
|
||||
{
|
||||
id: 'nav-tasks',
|
||||
label: 'Go to Tasks',
|
||||
icon: ListTodo,
|
||||
category: 'navigation',
|
||||
shortcut: '⌘3',
|
||||
keywords: ['tasks', 'jobs', 'queue'],
|
||||
onExecute: () => onNavigate({ type: 'tasks' }),
|
||||
},
|
||||
{
|
||||
id: 'nav-settings',
|
||||
label: 'Go to Settings',
|
||||
icon: Settings,
|
||||
category: 'navigation',
|
||||
shortcut: '⌘6',
|
||||
keywords: ['settings', 'preferences', 'configuration'],
|
||||
onExecute: () => onNavigate({ type: 'settings' }),
|
||||
},
|
||||
{
|
||||
id: 'nav-add-connection',
|
||||
label: 'Add Connection',
|
||||
icon: Plus,
|
||||
category: 'navigation',
|
||||
keywords: ['add', 'connection', 'server', 'proxmox', 'pbs', 'new'],
|
||||
onExecute: () => onAddConnection(),
|
||||
},
|
||||
)
|
||||
} else {
|
||||
items.push(
|
||||
{
|
||||
id: 'nav-dashboard',
|
||||
label: 'Go to Dashboard',
|
||||
icon: LayoutDashboard,
|
||||
category: 'navigation',
|
||||
shortcut: '⌘1',
|
||||
keywords: ['dashboard', 'home', 'overview'],
|
||||
onExecute: () => onNavigate({ type: 'dashboard' }),
|
||||
},
|
||||
{
|
||||
id: 'nav-vms',
|
||||
label: 'Go to VMs',
|
||||
icon: Box,
|
||||
category: 'navigation',
|
||||
shortcut: '⌘2',
|
||||
keywords: ['vm', 'vms', 'virtual machines', 'containers'],
|
||||
onExecute: () => onNavigate({ type: 'vms' }),
|
||||
},
|
||||
{
|
||||
id: 'nav-tasks',
|
||||
label: 'Go to Tasks',
|
||||
icon: ListTodo,
|
||||
category: 'navigation',
|
||||
shortcut: '⌘3',
|
||||
keywords: ['tasks', 'jobs', 'queue'],
|
||||
onExecute: () => onNavigate({ type: 'tasks' }),
|
||||
},
|
||||
{
|
||||
id: 'nav-backups',
|
||||
label: 'Go to Backups',
|
||||
icon: Shield,
|
||||
category: 'navigation',
|
||||
shortcut: '⌘4',
|
||||
keywords: ['backups', 'restore', 'backup'],
|
||||
onExecute: () => onNavigate({ type: 'backups' }),
|
||||
},
|
||||
{
|
||||
id: 'nav-storage',
|
||||
label: 'Go to Storage',
|
||||
icon: HardDrive,
|
||||
category: 'navigation',
|
||||
shortcut: '⌘5',
|
||||
keywords: ['storage', 'disks', 'volumes'],
|
||||
onExecute: () => onNavigate({ type: 'storage' }),
|
||||
},
|
||||
{
|
||||
id: 'nav-settings',
|
||||
label: 'Go to Settings',
|
||||
icon: Settings,
|
||||
category: 'navigation',
|
||||
shortcut: '⌘6',
|
||||
keywords: ['settings', 'preferences', 'configuration'],
|
||||
onExecute: () => onNavigate({ type: 'settings' }),
|
||||
},
|
||||
{
|
||||
id: 'nav-add-connection',
|
||||
label: 'Add Connection',
|
||||
icon: Plus,
|
||||
category: 'navigation',
|
||||
keywords: ['add', 'connection', 'server', 'proxmox', 'new'],
|
||||
onExecute: () => onAddConnection(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// -- VM Actions --
|
||||
for (const vm of vms) {
|
||||
if (vm.status === 'running') {
|
||||
items.push(
|
||||
{
|
||||
id: `action-stop-${vm.vmid}`,
|
||||
label: `Stop ${vm.name}`,
|
||||
description: `Force stop ${vm.type.toUpperCase()} VMID ${vm.vmid}`,
|
||||
icon: Square,
|
||||
category: 'actions',
|
||||
keywords: ['stop', 'halt', 'power off', vm.name, String(vm.vmid)],
|
||||
onExecute: () => stopVM.mutate({ node: vm.node, vmid: vm.vmid, vmType: vm.type }),
|
||||
},
|
||||
{
|
||||
id: `action-shutdown-${vm.vmid}`,
|
||||
label: `Shutdown ${vm.name}`,
|
||||
description: `Gracefully shutdown ${vm.type.toUpperCase()} VMID ${vm.vmid}`,
|
||||
icon: Power,
|
||||
category: 'actions',
|
||||
keywords: ['shutdown', 'graceful', 'power', vm.name, String(vm.vmid)],
|
||||
onExecute: () => shutdownVM.mutate({ node: vm.node, vmid: vm.vmid, vmType: vm.type }),
|
||||
},
|
||||
{
|
||||
id: `action-reboot-${vm.vmid}`,
|
||||
label: `Reboot ${vm.name}`,
|
||||
description: `Reboot ${vm.type.toUpperCase()} VMID ${vm.vmid}`,
|
||||
icon: RotateCw,
|
||||
category: 'actions',
|
||||
keywords: ['reboot', 'restart', vm.name, String(vm.vmid)],
|
||||
onExecute: () => rebootVM.mutate({ node: vm.node, vmid: vm.vmid, vmType: vm.type }),
|
||||
},
|
||||
)
|
||||
}
|
||||
if (vm.status === 'stopped' || vm.status === 'paused' || vm.status === 'suspended') {
|
||||
// -- VMs (not applicable to PBS servers) --
|
||||
if (serverType !== 'pbs') {
|
||||
for (const vm of vms) {
|
||||
items.push({
|
||||
id: `action-start-${vm.vmid}`,
|
||||
label: `Start ${vm.name}`,
|
||||
description: `Start ${vm.type.toUpperCase()} VMID ${vm.vmid}`,
|
||||
icon: Play,
|
||||
category: 'actions',
|
||||
keywords: ['start', 'boot', 'power on', vm.name, String(vm.vmid)],
|
||||
onExecute: () => startVM.mutate({ node: vm.node, vmid: vm.vmid, vmType: vm.type }),
|
||||
id: `vm-detail-${vm.vmid}`,
|
||||
label: vm.name,
|
||||
description: `${vm.type.toUpperCase()} · VMID ${vm.vmid} · ${vm.node} · ${vm.status}`,
|
||||
icon: Server,
|
||||
category: 'vms',
|
||||
keywords: [vm.name, String(vm.vmid), vm.node, vm.type, vm.status],
|
||||
onExecute: () => onNavigate({ type: 'vm-detail', vm }),
|
||||
})
|
||||
}
|
||||
|
||||
// -- VM Actions --
|
||||
for (const vm of vms) {
|
||||
if (vm.status === 'running') {
|
||||
items.push(
|
||||
{
|
||||
id: `action-stop-${vm.vmid}`,
|
||||
label: `Stop ${vm.name}`,
|
||||
description: `Force stop ${vm.type.toUpperCase()} VMID ${vm.vmid}`,
|
||||
icon: Square,
|
||||
category: 'actions',
|
||||
keywords: ['stop', 'halt', 'power off', vm.name, String(vm.vmid)],
|
||||
onExecute: () => stopVM.mutate({ node: vm.node, vmid: vm.vmid, vmType: vm.type }),
|
||||
},
|
||||
{
|
||||
id: `action-shutdown-${vm.vmid}`,
|
||||
label: `Shutdown ${vm.name}`,
|
||||
description: `Gracefully shutdown ${vm.type.toUpperCase()} VMID ${vm.vmid}`,
|
||||
icon: Power,
|
||||
category: 'actions',
|
||||
keywords: ['shutdown', 'graceful', 'power', vm.name, String(vm.vmid)],
|
||||
onExecute: () => shutdownVM.mutate({ node: vm.node, vmid: vm.vmid, vmType: vm.type }),
|
||||
},
|
||||
{
|
||||
id: `action-reboot-${vm.vmid}`,
|
||||
label: `Reboot ${vm.name}`,
|
||||
description: `Reboot ${vm.type.toUpperCase()} VMID ${vm.vmid}`,
|
||||
icon: RotateCw,
|
||||
category: 'actions',
|
||||
keywords: ['reboot', 'restart', vm.name, String(vm.vmid)],
|
||||
onExecute: () => rebootVM.mutate({ node: vm.node, vmid: vm.vmid, vmType: vm.type }),
|
||||
},
|
||||
)
|
||||
}
|
||||
if (vm.status === 'stopped' || vm.status === 'paused' || vm.status === 'suspended') {
|
||||
items.push({
|
||||
id: `action-start-${vm.vmid}`,
|
||||
label: `Start ${vm.name}`,
|
||||
description: `Start ${vm.type.toUpperCase()} VMID ${vm.vmid}`,
|
||||
icon: Play,
|
||||
category: 'actions',
|
||||
keywords: ['start', 'boot', 'power on', vm.name, String(vm.vmid)],
|
||||
onExecute: () => startVM.mutate({ node: vm.node, vmid: vm.vmid, vmType: vm.type }),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -- Connections --
|
||||
@@ -351,6 +406,7 @@ export function CommandPalette({
|
||||
}, [
|
||||
vms,
|
||||
connections,
|
||||
serverType,
|
||||
onNavigate,
|
||||
onAddConnection,
|
||||
startVM,
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { ShieldAlert, Loader2 } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useConnectionStore } from '@/stores/connectionStore'
|
||||
import { useToast } from '@/components/ui/toast'
|
||||
import {
|
||||
@@ -30,6 +31,7 @@ import type {
|
||||
AuthMode,
|
||||
CertificateInfo,
|
||||
LoginResult,
|
||||
ServerType,
|
||||
} from '@/types/connection'
|
||||
|
||||
interface ConnectionDialogProps {
|
||||
@@ -51,6 +53,7 @@ export function ConnectionDialog({ open, onOpenChange, editing }: ConnectionDial
|
||||
|
||||
const [step, setStep] = useState<DialogStep>('credentials')
|
||||
const [authMode, setAuthMode] = useState<AuthMode>('password')
|
||||
const [serverType, setServerType] = useState<ServerType>('pve')
|
||||
const [name, setName] = useState('')
|
||||
const [url, setUrl] = useState('')
|
||||
const [username, setUsername] = useState('')
|
||||
@@ -63,6 +66,7 @@ export function ConnectionDialog({ open, onOpenChange, editing }: ConnectionDial
|
||||
|
||||
const resetForm = () => {
|
||||
setStep('credentials')
|
||||
setServerType('pve')
|
||||
setName('')
|
||||
setUrl('')
|
||||
setUsername('')
|
||||
@@ -130,7 +134,7 @@ export function ConnectionDialog({ open, onOpenChange, editing }: ConnectionDial
|
||||
await loginWithPassword(cleanUrl, username, password)
|
||||
}
|
||||
} else if (apiToken) {
|
||||
await loginWithToken(cleanUrl, apiToken)
|
||||
await loginWithToken(cleanUrl, apiToken, editing.serverType ?? 'pve')
|
||||
}
|
||||
|
||||
const config: ConnectionConfig = {
|
||||
@@ -148,6 +152,7 @@ export function ConnectionDialog({ open, onOpenChange, editing }: ConnectionDial
|
||||
isCluster: editing.isCluster,
|
||||
authMode,
|
||||
username: authMode === 'password' ? username : undefined,
|
||||
serverType: editing.serverType ?? 'pve',
|
||||
nodes: editing.nodes,
|
||||
clusterId: editing.clusterId,
|
||||
}
|
||||
@@ -191,7 +196,7 @@ export function ConnectionDialog({ open, onOpenChange, editing }: ConnectionDial
|
||||
if (!apiToken) {
|
||||
throw new Error('API token is required')
|
||||
}
|
||||
result = await loginWithToken(cleanUrl, apiToken)
|
||||
result = await loginWithToken(cleanUrl, apiToken, serverType)
|
||||
}
|
||||
|
||||
if (!isTauri()) {
|
||||
@@ -211,6 +216,7 @@ export function ConnectionDialog({ open, onOpenChange, editing }: ConnectionDial
|
||||
isCluster: false,
|
||||
authMode,
|
||||
username: authMode === 'password' ? username : undefined,
|
||||
serverType,
|
||||
}
|
||||
|
||||
await addConnection(config)
|
||||
@@ -260,6 +266,7 @@ export function ConnectionDialog({ open, onOpenChange, editing }: ConnectionDial
|
||||
isCluster: false,
|
||||
authMode,
|
||||
username: authMode === 'password' ? username : undefined,
|
||||
serverType,
|
||||
}
|
||||
|
||||
// The backend requires the connection to exist before pinning its
|
||||
@@ -316,14 +323,58 @@ export function ConnectionDialog({ open, onOpenChange, editing }: ConnectionDial
|
||||
{step === 'credentials' && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editing ? 'Edit Connection' : 'Connect to Proxmox'}</DialogTitle>
|
||||
<DialogTitle>
|
||||
{editing
|
||||
? 'Edit Connection'
|
||||
: serverType === 'pbs'
|
||||
? 'Connect to Proxmox Backup Server'
|
||||
: 'Connect to Proxmox'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{editing
|
||||
? 'Update this connection. The server URL cannot be changed; add a new connection to target a different server.'
|
||||
: 'Sign in with your Proxmox credentials or API token'}
|
||||
: serverType === 'pbs'
|
||||
? 'Sign in with your Proxmox Backup Server credentials or API token'
|
||||
: 'Sign in with your Proxmox credentials or API token'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{!editing && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="server-type">Server Type</Label>
|
||||
<div
|
||||
id="server-type"
|
||||
role="group"
|
||||
className="inline-flex h-9 w-full items-center justify-center rounded-md bg-secondary p-1 text-muted-foreground"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setServerType('pve')}
|
||||
className={cn(
|
||||
'inline-flex flex-1 items-center justify-center whitespace-nowrap rounded-sm px-3 py-1 text-sm font-medium ring-offset-background transition-all duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||
serverType === 'pve'
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
Proxmox VE
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setServerType('pbs')}
|
||||
className={cn(
|
||||
'inline-flex flex-1 items-center justify-center whitespace-nowrap rounded-sm px-3 py-1 text-sm font-medium ring-offset-background transition-all duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||
serverType === 'pbs'
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
Proxmox Backup Server
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Tabs value={authMode} onValueChange={(v) => { setAuthMode(v as AuthMode); setError(null) }}>
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="password" className="flex-1">Username & Password</TabsTrigger>
|
||||
@@ -335,7 +386,7 @@ export function ConnectionDialog({ open, onOpenChange, editing }: ConnectionDial
|
||||
<Label htmlFor="url">Server URL</Label>
|
||||
<Input
|
||||
id="url"
|
||||
placeholder="https://192.168.1.10:8006"
|
||||
placeholder={serverType === 'pbs' ? 'https://192.168.1.10:8007' : 'https://192.168.1.10:8006'}
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
required
|
||||
@@ -345,7 +396,9 @@ export function ConnectionDialog({ open, onOpenChange, editing }: ConnectionDial
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{editing
|
||||
? 'Server URL cannot be changed while editing'
|
||||
: 'The URL of your Proxmox server (must use HTTPS)'}
|
||||
: serverType === 'pbs'
|
||||
? 'The URL of your Proxmox Backup Server (must use HTTPS)'
|
||||
: 'The URL of your Proxmox server (must use HTTPS)'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -447,7 +500,7 @@ export function ConnectionDialog({ open, onOpenChange, editing }: ConnectionDial
|
||||
<ShieldAlert className="h-5 w-5 text-destructive shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-destructive">
|
||||
This is a self-signed or untrusted certificate. Verify the fingerprint
|
||||
against your Proxmox server's SSL certificate before trusting.
|
||||
against your server's SSL certificate before trusting.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Button } from '@/components/ui/button'
|
||||
import { Plus, Server, LayoutDashboard, HardDrive, Box, ListTodo, Shield, Settings, Hexagon } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type ViewType = 'dashboard' | 'vms' | 'vm-detail' | 'nodes' | 'node-detail' | 'containers' | 'tasks' | 'backups' | 'storage' | 'storage-detail' | 'settings'
|
||||
type ViewType = 'dashboard' | 'vms' | 'vm-detail' | 'nodes' | 'node-detail' | 'containers' | 'tasks' | 'backups' | 'storage' | 'storage-detail' | 'settings' | 'pbs-overview' | 'pbs-datastores' | 'pbs-datastore-detail'
|
||||
|
||||
type NavigationTarget =
|
||||
| { type: ViewType }
|
||||
@@ -77,34 +77,54 @@ export function Sidebar({ onAddConnection, activeView, onNavigate }: SidebarProp
|
||||
<p className="ml-7 mt-1 text-[10px] text-muted-foreground">Offline</p>
|
||||
)}
|
||||
<div className="ml-4 mt-1 space-y-0.5">
|
||||
<SidebarItem
|
||||
icon={LayoutDashboard}
|
||||
label="Dashboard"
|
||||
active={activeView === 'dashboard'}
|
||||
onClick={() => onNavigate?.({ type: 'dashboard' })}
|
||||
/>
|
||||
<SidebarItem
|
||||
icon={Server}
|
||||
label="Nodes"
|
||||
active={activeView === 'nodes'}
|
||||
onClick={() => onNavigate?.({ type: 'nodes' })}
|
||||
/>
|
||||
<SidebarItem
|
||||
icon={Box}
|
||||
label="VMs"
|
||||
active={activeView === 'vms' || activeView === 'vm-detail'}
|
||||
onClick={() => onNavigate?.({ type: 'vms' })}
|
||||
/>
|
||||
<SidebarItem
|
||||
icon={Box}
|
||||
label="Containers"
|
||||
active={activeView === 'containers'}
|
||||
onClick={() => onNavigate?.({ type: 'containers' })}
|
||||
/>
|
||||
<SidebarItem icon={HardDrive} label="Storage" active={activeView === 'storage' || activeView === 'storage-detail'} onClick={() => onNavigate?.({ type: 'storage' })} />
|
||||
<SidebarItem icon={ListTodo} label="Tasks" active={activeView === 'tasks'} onClick={() => onNavigate?.({ type: 'tasks' })} />
|
||||
<SidebarItem icon={Shield} label="Backups" active={activeView === 'backups'} onClick={() => onNavigate?.({ type: 'backups' })} />
|
||||
{connection.nodes && connection.nodes.length > 0 && (
|
||||
{connection.serverType === 'pbs' ? (
|
||||
<>
|
||||
<SidebarItem
|
||||
icon={LayoutDashboard}
|
||||
label="Overview"
|
||||
active={activeView === 'pbs-overview'}
|
||||
onClick={() => onNavigate?.({ type: 'pbs-overview' })}
|
||||
/>
|
||||
<SidebarItem
|
||||
icon={HardDrive}
|
||||
label="Datastores"
|
||||
active={activeView === 'pbs-datastores' || activeView === 'pbs-datastore-detail'}
|
||||
onClick={() => onNavigate?.({ type: 'pbs-datastores' })}
|
||||
/>
|
||||
<SidebarItem icon={ListTodo} label="Tasks" active={activeView === 'tasks'} onClick={() => onNavigate?.({ type: 'tasks' })} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<SidebarItem
|
||||
icon={LayoutDashboard}
|
||||
label="Dashboard"
|
||||
active={activeView === 'dashboard'}
|
||||
onClick={() => onNavigate?.({ type: 'dashboard' })}
|
||||
/>
|
||||
<SidebarItem
|
||||
icon={Server}
|
||||
label="Nodes"
|
||||
active={activeView === 'nodes'}
|
||||
onClick={() => onNavigate?.({ type: 'nodes' })}
|
||||
/>
|
||||
<SidebarItem
|
||||
icon={Box}
|
||||
label="VMs"
|
||||
active={activeView === 'vms' || activeView === 'vm-detail'}
|
||||
onClick={() => onNavigate?.({ type: 'vms' })}
|
||||
/>
|
||||
<SidebarItem
|
||||
icon={Box}
|
||||
label="Containers"
|
||||
active={activeView === 'containers'}
|
||||
onClick={() => onNavigate?.({ type: 'containers' })}
|
||||
/>
|
||||
<SidebarItem icon={HardDrive} label="Storage" active={activeView === 'storage' || activeView === 'storage-detail'} onClick={() => onNavigate?.({ type: 'storage' })} />
|
||||
<SidebarItem icon={ListTodo} label="Tasks" active={activeView === 'tasks'} onClick={() => onNavigate?.({ type: 'tasks' })} />
|
||||
<SidebarItem icon={Shield} label="Backups" active={activeView === 'backups'} onClick={() => onNavigate?.({ type: 'backups' })} />
|
||||
</>
|
||||
)}
|
||||
{connection.serverType !== 'pbs' && connection.nodes && connection.nodes.length > 0 && (
|
||||
<>
|
||||
<p className="px-3 pb-1 pt-3 text-[10px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
|
||||
Cluster nodes
|
||||
|
||||
@@ -0,0 +1,567 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
|
||||
import { EmptyState } from '@/components/ui/empty-state'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
ArrowLeft,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
Download,
|
||||
Eraser,
|
||||
HardDrive,
|
||||
Recycle,
|
||||
RefreshCw,
|
||||
ShieldCheck,
|
||||
Trash,
|
||||
XCircle,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
usePbsDatastores,
|
||||
usePbsGroups,
|
||||
usePbsSnapshots,
|
||||
usePbsVerifyJobs,
|
||||
usePbsPruneJobs,
|
||||
usePbsGcJobs,
|
||||
usePbsDeleteSnapshot,
|
||||
usePbsDeleteGroup,
|
||||
queryKeys,
|
||||
} from '@/hooks/usePbs'
|
||||
import { VerifyDialog } from '@/components/pbs/dialogs/VerifyDialog'
|
||||
import { PruneDialog } from '@/components/pbs/dialogs/PruneDialog'
|
||||
import { GcDialog } from '@/components/pbs/dialogs/GcDialog'
|
||||
import { DownloadFilesDialog } from '@/components/pbs/dialogs/DownloadFilesDialog'
|
||||
import { formatBytes } from '@/lib/format'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import type { PbsBackupGroup, PbsJob, PbsSnapshot } from '@/types/pbs'
|
||||
|
||||
interface PbsDatastoreDetailProps {
|
||||
connectionId: string
|
||||
store: string
|
||||
onBack: () => void
|
||||
}
|
||||
|
||||
function formatTimestamp(seconds?: number): string {
|
||||
if (!seconds) return 'N/A'
|
||||
return new Date(seconds * 1000).toLocaleString()
|
||||
}
|
||||
|
||||
function getUsageColor(percent: number): string {
|
||||
if (percent >= 90) return 'bg-destructive'
|
||||
if (percent >= 70) return 'bg-warning'
|
||||
return 'bg-success'
|
||||
}
|
||||
|
||||
function LastRunStateBadge({ state }: { state?: string }) {
|
||||
if (!state) {
|
||||
return <span className="text-xs text-muted-foreground">—</span>
|
||||
}
|
||||
const ok = state.toUpperCase() === 'OK'
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-sm border px-2 py-0.5 text-xs font-medium',
|
||||
ok
|
||||
? 'border-success/25 bg-success/10 text-success'
|
||||
: 'border-destructive/25 bg-destructive/10 text-destructive',
|
||||
)}
|
||||
>
|
||||
{ok ? <CheckCircle className="h-3 w-3" /> : <XCircle className="h-3 w-3" />}
|
||||
{state}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function JobTable({
|
||||
title,
|
||||
icon: Icon,
|
||||
jobs,
|
||||
showKeep,
|
||||
}: {
|
||||
title: string
|
||||
icon: LucideIcon
|
||||
jobs?: PbsJob[]
|
||||
showKeep?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold tracking-tight">{title}</h3>
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
{!jobs || jobs.length === 0 ? (
|
||||
<EmptyState icon={Icon} title={`No ${title.toLowerCase()} configured`} />
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<th className="h-10 px-4 text-left text-xs font-medium uppercase tracking-wide text-muted-foreground">ID</th>
|
||||
<th className="h-10 px-4 text-left text-xs font-medium uppercase tracking-wide text-muted-foreground">Store</th>
|
||||
<th className="h-10 px-4 text-left text-xs font-medium uppercase tracking-wide text-muted-foreground">Schedule</th>
|
||||
<th className="h-10 px-4 text-left text-xs font-medium uppercase tracking-wide text-muted-foreground">Last Run</th>
|
||||
<th className="h-10 px-4 text-left text-xs font-medium uppercase tracking-wide text-muted-foreground">Next Run</th>
|
||||
{showKeep && (
|
||||
<th className="h-10 px-4 text-left text-xs font-medium uppercase tracking-wide text-muted-foreground">Retention</th>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{jobs.map((job) => (
|
||||
<tr key={job.id} className="border-b last:border-b-0 hover:bg-accent/50 transition-colors duration-150">
|
||||
<td className="px-4 py-3 font-mono text-xs text-muted-foreground">{job.id}</td>
|
||||
<td className="px-4 py-3 font-mono text-xs">{job.store ?? '—'}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-1.5 font-mono text-xs">
|
||||
<Clock className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
{job.schedule ?? '—'}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<LastRunStateBadge state={job.lastRunState} />
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-xs tabular-nums text-muted-foreground">
|
||||
{formatTimestamp(job.nextRun)}
|
||||
</td>
|
||||
{showKeep && (
|
||||
<td className="px-4 py-3 font-mono text-[11px] tabular-nums text-muted-foreground">
|
||||
{[
|
||||
job.keepLast != null && `last ${job.keepLast}`,
|
||||
job.keepDaily != null && `daily ${job.keepDaily}`,
|
||||
job.keepWeekly != null && `weekly ${job.keepWeekly}`,
|
||||
job.keepMonthly != null && `monthly ${job.keepMonthly}`,
|
||||
job.keepYearly != null && `yearly ${job.keepYearly}`,
|
||||
]
|
||||
.filter((part): part is string => !!part)
|
||||
.join(' · ') || '—'}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function PbsDatastoreDetail({ connectionId, store, onBack }: PbsDatastoreDetailProps) {
|
||||
const queryClient = useQueryClient()
|
||||
const [selectedGroup, setSelectedGroup] = useState<PbsBackupGroup | null>(null)
|
||||
const [verifyOpen, setVerifyOpen] = useState(false)
|
||||
const [pruneOpen, setPruneOpen] = useState(false)
|
||||
const [gcOpen, setGcOpen] = useState(false)
|
||||
const [downloadSnapshot, setDownloadSnapshot] = useState<PbsSnapshot | null>(null)
|
||||
const [confirmDeleteSnapshot, setConfirmDeleteSnapshot] = useState<PbsSnapshot | null>(null)
|
||||
const [confirmDeleteGroup, setConfirmDeleteGroup] = useState(false)
|
||||
|
||||
const { data: datastores } = usePbsDatastores(connectionId)
|
||||
const datastore = datastores?.find((d) => d.store === store)
|
||||
|
||||
const { data: groups, isLoading: groupsLoading, error: groupsError } = usePbsGroups(connectionId, store)
|
||||
const {
|
||||
data: snapshots,
|
||||
isLoading: snapshotsLoading,
|
||||
} = usePbsSnapshots(
|
||||
connectionId,
|
||||
store,
|
||||
selectedGroup?.backupId ?? null,
|
||||
selectedGroup?.backupType ?? null,
|
||||
)
|
||||
const { data: verifyJobs } = usePbsVerifyJobs(connectionId)
|
||||
const { data: pruneJobs } = usePbsPruneJobs(connectionId)
|
||||
const { data: gcJobs } = usePbsGcJobs(connectionId)
|
||||
|
||||
const deleteSnapshot = usePbsDeleteSnapshot()
|
||||
const deleteGroup = usePbsDeleteGroup()
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.pbsDatastores(connectionId) })
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.pbsGroups(connectionId, store) })
|
||||
if (selectedGroup) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.pbsSnapshots(
|
||||
connectionId,
|
||||
store,
|
||||
selectedGroup.backupId,
|
||||
selectedGroup.backupType,
|
||||
),
|
||||
})
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.pbsVerifyJobs(connectionId) })
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.pbsPruneJobs(connectionId) })
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.pbsGcJobs(connectionId) })
|
||||
}, [queryClient, connectionId, store, selectedGroup])
|
||||
|
||||
if (groupsLoading) {
|
||||
return (
|
||||
<div className="h-full overflow-auto p-6">
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Skeleton className="h-9 w-9" />
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-7 w-48" />
|
||||
<Skeleton className="h-4 w-64" />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className="h-40 w-full" />
|
||||
<Skeleton className="h-56 w-full" />
|
||||
<Skeleton className="h-40 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (groupsError) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-destructive">Failed to load backup groups</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const percent = datastore?.total && datastore.total > 0
|
||||
? ((datastore.used ?? 0) / datastore.total) * 100
|
||||
: 0
|
||||
const usageColor = getUsageColor(percent)
|
||||
const hasError = !!datastore?.error
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto p-6">
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="icon" onClick={onBack}>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<HardDrive className="h-6 w-6 shrink-0 text-muted-foreground" />
|
||||
<h2 className="font-mono text-2xl font-semibold tracking-tight">{store}</h2>
|
||||
{hasError && (
|
||||
<span className="inline-flex items-center gap-1 rounded-sm border border-destructive/25 bg-destructive/10 px-2 py-0.5 text-xs font-medium text-destructive">
|
||||
<XCircle className="h-3 w-3" />
|
||||
Error
|
||||
</span>
|
||||
)}
|
||||
{datastore?.maintenance && (
|
||||
<span className="inline-flex items-center gap-1 rounded-sm border border-warning/25 bg-warning/10 px-2 py-0.5 text-xs font-medium text-warning">
|
||||
Maintenance
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">Datastore overview</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={handleRefresh}>
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Datastore usage + actions */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Usage</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="outline" disabled={hasError} onClick={() => setVerifyOpen(true)}>
|
||||
<ShieldCheck className="h-3.5 w-3.5" />
|
||||
Verify
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" disabled={hasError} onClick={() => setPruneOpen(true)}>
|
||||
<Eraser className="h-3.5 w-3.5" />
|
||||
Prune
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" disabled={hasError} onClick={() => setGcOpen(true)}>
|
||||
<Recycle className="h-3.5 w-3.5" />
|
||||
Garbage Collection
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{hasError && (
|
||||
<p className="text-sm text-destructive">{datastore.error}</p>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Disk Usage</span>
|
||||
<span className="font-mono font-medium tabular-nums">{percent.toFixed(1)}%</span>
|
||||
</div>
|
||||
<div className="h-3 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-500 ${usageColor}`}
|
||||
style={{ width: `${Math.min(percent, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between font-mono text-xs tabular-nums text-muted-foreground">
|
||||
<span>{formatBytes(datastore?.used ?? 0)} used</span>
|
||||
<span>{formatBytes(datastore?.total ?? 0)} total</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="font-mono text-xs tabular-nums text-muted-foreground">
|
||||
{formatBytes(datastore?.avail ?? 0)} available
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Groups / Snapshots drill-down */}
|
||||
{selectedGroup ? (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="icon" onClick={() => setSelectedGroup(null)}>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-lg font-semibold tracking-tight">
|
||||
<span className="text-xs uppercase text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
|
||||
{selectedGroup.backupType}
|
||||
</span>{' '}
|
||||
<span className="font-mono">{selectedGroup.backupId}</span>
|
||||
</h3>
|
||||
{selectedGroup.comment && (
|
||||
<p className="text-sm text-muted-foreground">{selectedGroup.comment}</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => setConfirmDeleteGroup(true)}
|
||||
disabled={deleteGroup.isPending}
|
||||
>
|
||||
<Trash className="h-3.5 w-3.5" />
|
||||
Delete Group
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
{snapshotsLoading ? (
|
||||
<div className="space-y-3 p-5">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
) : !snapshots || snapshots.length === 0 ? (
|
||||
<EmptyState icon={HardDrive} title="No snapshots in this group" />
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<th className="h-10 px-4 text-left text-xs font-medium uppercase tracking-wide text-muted-foreground">Backup Time</th>
|
||||
<th className="h-10 px-4 text-right text-xs font-medium uppercase tracking-wide text-muted-foreground">Size</th>
|
||||
<th className="h-10 px-4 text-left text-xs font-medium uppercase tracking-wide text-muted-foreground">Protected</th>
|
||||
<th className="h-10 px-4 text-left text-xs font-medium uppercase tracking-wide text-muted-foreground">Verification</th>
|
||||
<th className="h-10 px-4 text-right text-xs font-medium uppercase tracking-wide text-muted-foreground">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{snapshots.map((snapshot) => (
|
||||
<tr
|
||||
key={snapshot.backupTime}
|
||||
className="border-b last:border-b-0 hover:bg-accent/50 transition-colors duration-150"
|
||||
>
|
||||
<td className="px-4 py-3 font-mono text-xs tabular-nums text-muted-foreground">
|
||||
{formatTimestamp(snapshot.backupTime)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right font-mono text-xs tabular-nums">
|
||||
{formatBytes(snapshot.size ?? 0)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{snapshot.protected ? (
|
||||
<span className="inline-flex items-center gap-1 rounded-sm border border-primary/30 bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
|
||||
Protected
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{snapshot.verification?.state ? (
|
||||
snapshot.verification.state === 'ok' ? (
|
||||
<span className="inline-flex items-center gap-1 rounded-sm border border-success/25 bg-success/10 px-2 py-0.5 text-xs font-medium text-success">
|
||||
<CheckCircle className="h-3 w-3" />
|
||||
Verified
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 rounded-sm border border-destructive/25 bg-destructive/10 px-2 py-0.5 text-xs font-medium text-destructive">
|
||||
<XCircle className="h-3 w-3" />
|
||||
Failed
|
||||
</span>
|
||||
)
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
title="Download files"
|
||||
onClick={() => setDownloadSnapshot(snapshot)}
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive"
|
||||
title="Delete snapshot"
|
||||
disabled={snapshot.protected}
|
||||
onClick={() => setConfirmDeleteSnapshot(snapshot)}
|
||||
>
|
||||
<Trash className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold tracking-tight">Backup Groups</h3>
|
||||
<span className="text-sm text-muted-foreground">{groups?.length ?? 0} groups</span>
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
{!groups || groups.length === 0 ? (
|
||||
<EmptyState icon={HardDrive} title="No backup groups found" />
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<th className="h-10 px-4 text-left text-xs font-medium uppercase tracking-wide text-muted-foreground">Type</th>
|
||||
<th className="h-10 px-4 text-left text-xs font-medium uppercase tracking-wide text-muted-foreground">Backup ID</th>
|
||||
<th className="h-10 px-4 text-right text-xs font-medium uppercase tracking-wide text-muted-foreground">Backups</th>
|
||||
<th className="h-10 px-4 text-left text-xs font-medium uppercase tracking-wide text-muted-foreground">Last Backup</th>
|
||||
<th className="h-10 px-4 text-left text-xs font-medium uppercase tracking-wide text-muted-foreground">Comment</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{groups.map((group) => (
|
||||
<tr
|
||||
key={`${group.backupType}-${group.backupId}`}
|
||||
className="border-b last:border-b-0 hover:bg-accent/50 transition-colors duration-150 cursor-pointer"
|
||||
onClick={() => setSelectedGroup(group)}
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-xs uppercase text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
|
||||
{group.backupType}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono">{group.backupId}</td>
|
||||
<td className="px-4 py-3 text-right font-mono text-xs tabular-nums text-muted-foreground">
|
||||
{group.backupCount ?? '—'}
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-xs tabular-nums text-muted-foreground">
|
||||
{formatTimestamp(group.lastBackup)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">{group.comment ?? '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Job lists */}
|
||||
<JobTable title="Verify Jobs" icon={ShieldCheck} jobs={verifyJobs} />
|
||||
<JobTable title="Prune Jobs" icon={Eraser} jobs={pruneJobs} showKeep />
|
||||
<JobTable title="GC Jobs" icon={Recycle} jobs={gcJobs} />
|
||||
</div>
|
||||
|
||||
{/* Dialogs */}
|
||||
<VerifyDialog open={verifyOpen} onOpenChange={setVerifyOpen} store={store} />
|
||||
<PruneDialog open={pruneOpen} onOpenChange={setPruneOpen} store={store} />
|
||||
<GcDialog open={gcOpen} onOpenChange={setGcOpen} store={store} />
|
||||
|
||||
{downloadSnapshot && (
|
||||
<DownloadFilesDialog
|
||||
open={!!downloadSnapshot}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setDownloadSnapshot(null)
|
||||
}}
|
||||
store={store}
|
||||
backupId={downloadSnapshot.backupId}
|
||||
backupType={downloadSnapshot.backupType}
|
||||
backupTime={downloadSnapshot.backupTime}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmDeleteSnapshot !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setConfirmDeleteSnapshot(null)
|
||||
}}
|
||||
title="Delete Snapshot"
|
||||
description={
|
||||
confirmDeleteSnapshot
|
||||
? `Are you sure you want to delete snapshot ${confirmDeleteSnapshot.backupType}/${confirmDeleteSnapshot.backupId}@${confirmDeleteSnapshot.backupTime}? This action cannot be undone.`
|
||||
: undefined
|
||||
}
|
||||
confirmLabel="Delete"
|
||||
isLoading={deleteSnapshot.isPending}
|
||||
onConfirm={() => {
|
||||
if (confirmDeleteSnapshot) {
|
||||
deleteSnapshot.mutate(
|
||||
{
|
||||
store,
|
||||
backupId: confirmDeleteSnapshot.backupId,
|
||||
backupType: confirmDeleteSnapshot.backupType,
|
||||
backupTime: confirmDeleteSnapshot.backupTime,
|
||||
},
|
||||
{ onSettled: () => setConfirmDeleteSnapshot(null) },
|
||||
)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmDeleteGroup}
|
||||
onOpenChange={setConfirmDeleteGroup}
|
||||
title="Delete Backup Group"
|
||||
description={
|
||||
selectedGroup
|
||||
? `Are you sure you want to delete the whole group ${selectedGroup.backupType}/${selectedGroup.backupId} including all snapshots? This action cannot be undone.`
|
||||
: undefined
|
||||
}
|
||||
confirmLabel="Delete"
|
||||
isLoading={deleteGroup.isPending}
|
||||
onConfirm={() => {
|
||||
if (selectedGroup) {
|
||||
deleteGroup.mutate(
|
||||
{
|
||||
store,
|
||||
backupId: selectedGroup.backupId,
|
||||
backupType: selectedGroup.backupType,
|
||||
},
|
||||
{
|
||||
onSettled: () => {
|
||||
setConfirmDeleteGroup(false)
|
||||
setSelectedGroup(null)
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { EmptyState } from '@/components/ui/empty-state'
|
||||
import { PageSkeleton, Skeleton } from '@/components/ui/skeleton'
|
||||
import { HardDrive, AlertTriangle, XCircle } from 'lucide-react'
|
||||
import { usePbsDatastores } from '@/hooks/usePbs'
|
||||
import { formatBytes } from '@/lib/format'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { PbsDatastore } from '@/types/pbs'
|
||||
|
||||
interface PbsDatastoresProps {
|
||||
connectionId: string
|
||||
onDatastoreClick?: (store: string) => void
|
||||
}
|
||||
|
||||
function getUsageColor(percent: number): string {
|
||||
if (percent >= 90) return 'bg-destructive'
|
||||
if (percent >= 70) return 'bg-warning'
|
||||
return 'bg-success'
|
||||
}
|
||||
|
||||
function PbsDatastoreCard({
|
||||
datastore,
|
||||
onClick,
|
||||
}: {
|
||||
datastore: PbsDatastore
|
||||
onClick: () => void
|
||||
}) {
|
||||
const percent = datastore.total && datastore.total > 0
|
||||
? ((datastore.used ?? 0) / datastore.total) * 100
|
||||
: 0
|
||||
const usageColor = getUsageColor(percent)
|
||||
const hasError = !!datastore.error
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
'cursor-pointer transition-all duration-150 hover:-translate-y-0.5 hover:shadow-card',
|
||||
hasError && 'border-destructive/40',
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<HardDrive className="h-5 w-5 text-muted-foreground" />
|
||||
<span className="truncate font-mono">{datastore.store}</span>
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
{datastore.backendType && (
|
||||
<span className="uppercase bg-muted px-1.5 py-0.5 rounded">
|
||||
{datastore.backendType}
|
||||
</span>
|
||||
)}
|
||||
{datastore.mountStatus && (
|
||||
<span className="font-mono">{datastore.mountStatus}</span>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{datastore.comment && (
|
||||
<p className="truncate text-xs text-muted-foreground">{datastore.comment}</p>
|
||||
)}
|
||||
|
||||
{/* Usage Bar */}
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Usage</span>
|
||||
<span className="font-mono font-medium tabular-nums">{percent.toFixed(1)}%</span>
|
||||
</div>
|
||||
<div className="h-2 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-500 ${usageColor}`}
|
||||
style={{ width: `${Math.min(percent, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Size Info */}
|
||||
<div className="flex justify-between font-mono text-xs tabular-nums text-muted-foreground">
|
||||
<span>{formatBytes(datastore.used ?? 0)} used</span>
|
||||
<span>{formatBytes(datastore.total ?? 0)} total</span>
|
||||
</div>
|
||||
<div className="font-mono text-xs tabular-nums text-muted-foreground">
|
||||
{formatBytes(datastore.avail ?? 0)} available
|
||||
</div>
|
||||
|
||||
{/* Status badges */}
|
||||
{(hasError || datastore.maintenance) && (
|
||||
<div className="flex flex-wrap gap-1.5 pt-1">
|
||||
{hasError && (
|
||||
<span className="inline-flex items-center gap-1 rounded-sm border border-destructive/25 bg-destructive/10 px-2 py-0.5 text-xs font-medium text-destructive">
|
||||
<XCircle className="h-3 w-3" />
|
||||
Error
|
||||
</span>
|
||||
)}
|
||||
{datastore.maintenance && (
|
||||
<span className="inline-flex items-center gap-1 rounded-sm border border-warning/25 bg-warning/10 px-2 py-0.5 text-xs font-medium text-warning">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
Maintenance
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export function PbsDatastores({ connectionId, onDatastoreClick }: PbsDatastoresProps) {
|
||||
const { data: datastores, isLoading, error } = usePbsDatastores(connectionId)
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<PageSkeleton filter>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{Array.from({ length: 6 }, (_, i) => (
|
||||
<Skeleton key={i} className="h-32" />
|
||||
))}
|
||||
</div>
|
||||
</PageSkeleton>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-destructive">Failed to load datastores</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto p-6">
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<HardDrive className="h-6 w-6 text-muted-foreground" />
|
||||
<h2 className="text-2xl font-semibold tracking-tight">Datastores</h2>
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
{datastores?.length ?? 0} datastores on this backup server
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Datastore Grid */}
|
||||
{!datastores || datastores.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={HardDrive}
|
||||
title="No datastores found"
|
||||
description="Create a datastore on the backup server to get started"
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{datastores.map((datastore) => (
|
||||
<PbsDatastoreCard
|
||||
key={datastore.store}
|
||||
datastore={datastore}
|
||||
onClick={() => onDatastoreClick?.(datastore.store)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import { useCallback } from 'react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { PageSkeleton } from '@/components/ui/skeleton'
|
||||
import { ResourceGauge } from '@/components/dashboard/ResourceGauge'
|
||||
import { AlertCircle, Cpu, Server, Clock, HardDrive, Database } from 'lucide-react'
|
||||
import {
|
||||
usePbsVersion,
|
||||
usePbsNodeStatus,
|
||||
usePbsDatastores,
|
||||
queryKeys,
|
||||
} from '@/hooks/usePbs'
|
||||
import { formatBytes, formatUptime } from '@/lib/format'
|
||||
import type { PbsDatastore } from '@/types/pbs'
|
||||
|
||||
interface PbsOverviewProps {
|
||||
connectionId: string
|
||||
}
|
||||
|
||||
function getUsageColor(percent: number): string {
|
||||
if (percent >= 90) return 'bg-destructive'
|
||||
if (percent >= 70) return 'bg-warning'
|
||||
return 'bg-success'
|
||||
}
|
||||
|
||||
function DatastoreSummaryRow({ datastore }: { datastore: PbsDatastore }) {
|
||||
const percent = datastore.total && datastore.total > 0
|
||||
? ((datastore.used ?? 0) / datastore.total) * 100
|
||||
: 0
|
||||
const usageColor = getUsageColor(percent)
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-md border border-border/70 bg-muted/30 px-3 py-2">
|
||||
<Database className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="truncate font-mono text-sm font-medium">{datastore.store}</span>
|
||||
<span className="shrink-0 font-mono text-xs tabular-nums text-muted-foreground">
|
||||
{percent.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1.5 h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-500 ${usageColor}`}
|
||||
style={{ width: `${Math.min(percent, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-1 flex justify-between font-mono text-[11px] tabular-nums text-muted-foreground">
|
||||
<span>{formatBytes(datastore.used ?? 0)} used</span>
|
||||
<span>{formatBytes(datastore.total ?? 0)} total</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function PbsOverview({ connectionId }: PbsOverviewProps) {
|
||||
const queryClient = useQueryClient()
|
||||
const { data: version, isLoading: versionLoading, error: versionError } = usePbsVersion(connectionId)
|
||||
const { data: nodeStatus, isLoading: nodeStatusLoading, error: nodeStatusError } = usePbsNodeStatus(connectionId)
|
||||
const { data: datastores, isLoading: datastoresLoading, error: datastoresError } = usePbsDatastores(connectionId)
|
||||
|
||||
const handleRetry = useCallback(() => {
|
||||
queryClient.refetchQueries({ queryKey: queryKeys.pbsVersion(connectionId) })
|
||||
queryClient.refetchQueries({ queryKey: queryKeys.pbsNodeStatus(connectionId) })
|
||||
queryClient.refetchQueries({ queryKey: queryKeys.pbsDatastores(connectionId) })
|
||||
}, [queryClient, connectionId])
|
||||
|
||||
if (versionLoading || nodeStatusLoading || datastoresLoading) {
|
||||
return <PageSkeleton />
|
||||
}
|
||||
|
||||
const hasError = versionError || nodeStatusError || datastoresError
|
||||
if (hasError) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center p-6">
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-lg border border-destructive/25 bg-destructive/10">
|
||||
<AlertCircle className="h-5 w-5 text-destructive" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold tracking-tight">Unable to load server overview</h3>
|
||||
<p className="mt-1 max-w-sm text-sm text-muted-foreground">
|
||||
{versionError?.message || nodeStatusError?.message || datastoresError?.message || 'An error occurred while loading data'}
|
||||
</p>
|
||||
<p className="mt-1 max-w-sm text-sm text-muted-foreground">
|
||||
Check the connection to your Proxmox Backup Server and try again.
|
||||
</p>
|
||||
<Button variant="outline" className="mt-4" onClick={handleRetry}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const cpus = nodeStatus?.cpuinfo?.cpus ?? 1
|
||||
const usedCpu = (nodeStatus?.cpu ?? 0) * cpus
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto p-6">
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-[1.625rem] font-semibold leading-tight tracking-[-0.02em]">
|
||||
Overview
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Proxmox Backup Server status and resource usage
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Summary Stats Row */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Version</CardTitle>
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-md border border-border bg-muted/40">
|
||||
<Server className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-1">
|
||||
<div className="font-mono text-2xl font-semibold leading-none tracking-tight tabular-nums">
|
||||
{version?.version ?? '—'}
|
||||
</div>
|
||||
<p className="mt-1.5 font-mono text-xs tabular-nums text-muted-foreground">
|
||||
release {version?.release ?? '—'} · repoid {version?.repoid ?? '—'}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Uptime</CardTitle>
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-md border border-border bg-muted/40">
|
||||
<Clock className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="font-mono text-2xl font-semibold leading-none tracking-tight tabular-nums">
|
||||
{formatUptime(nodeStatus?.uptime ?? 0)}
|
||||
</div>
|
||||
<p className="mt-1.5 truncate font-mono text-xs tabular-nums text-muted-foreground">
|
||||
{nodeStatus?.currentKernel?.release ?? '—'}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">CPU Load</CardTitle>
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-md border border-border bg-muted/40">
|
||||
<Cpu className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="font-mono text-2xl font-semibold leading-none tracking-tight tabular-nums">
|
||||
{nodeStatus?.loadavg?.[0]?.toFixed(2) ?? '—'}
|
||||
</div>
|
||||
<p className="mt-1.5 truncate font-mono text-xs tabular-nums text-muted-foreground">
|
||||
{nodeStatus?.cpuinfo?.model ?? '—'}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Resource Gauges */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<ResourceGauge
|
||||
label="CPU"
|
||||
used={usedCpu}
|
||||
total={cpus}
|
||||
icon="cpu"
|
||||
formatValue={(v) => `${v.toFixed(1)} cores`}
|
||||
/>
|
||||
<ResourceGauge
|
||||
label="Memory"
|
||||
used={nodeStatus?.memory?.used ?? 0}
|
||||
total={nodeStatus?.memory?.total ?? 0}
|
||||
icon="memory"
|
||||
formatValue={formatBytes}
|
||||
/>
|
||||
<ResourceGauge
|
||||
label="Root Storage"
|
||||
used={nodeStatus?.root?.used ?? 0}
|
||||
total={nodeStatus?.root?.total ?? 0}
|
||||
icon="disk"
|
||||
formatValue={formatBytes}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Datastore Usage Summary */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Datastores</CardTitle>
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-md border border-border bg-muted/40">
|
||||
<HardDrive className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{!datastores || datastores.length === 0 ? (
|
||||
<p className="col-span-full text-sm text-muted-foreground">No datastores configured.</p>
|
||||
) : (
|
||||
datastores.map((datastore) => (
|
||||
<DatastoreSummaryRow key={datastore.store} datastore={datastore} />
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { EmptyState } from '@/components/ui/empty-state'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Download, File, Lock, Loader2, AlertCircle } from 'lucide-react'
|
||||
import { useConnectionStore } from '@/stores/connectionStore'
|
||||
import { usePbsSnapshotFiles, usePbsDownloadFile } from '@/hooks/usePbs'
|
||||
import { formatBytes } from '@/lib/format'
|
||||
import type { PbsSnapshot } from '@/types/pbs'
|
||||
|
||||
interface DownloadFilesDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
store: string
|
||||
backupId: string
|
||||
backupType: PbsSnapshot['backupType']
|
||||
backupTime: number
|
||||
}
|
||||
|
||||
export function DownloadFilesDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
store,
|
||||
backupId,
|
||||
backupType,
|
||||
backupTime,
|
||||
}: DownloadFilesDialogProps) {
|
||||
const activeConnectionId = useConnectionStore((s) => s.activeConnectionId)
|
||||
const {
|
||||
data: files,
|
||||
isLoading,
|
||||
error,
|
||||
} = usePbsSnapshotFiles(
|
||||
open ? activeConnectionId : null,
|
||||
open ? store : null,
|
||||
open ? backupId : null,
|
||||
open ? backupType : null,
|
||||
open ? backupTime : null,
|
||||
)
|
||||
const downloadFile = usePbsDownloadFile()
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Download Files</DialogTitle>
|
||||
<DialogDescription>
|
||||
Files in snapshot {backupType}/{backupId}@{backupTime} on datastore "{store}".
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className="max-h-72">
|
||||
{isLoading ? (
|
||||
<div className="space-y-2 p-1">
|
||||
{Array.from({ length: 3 }, (_, i) => (
|
||||
<Skeleton key={i} className="h-12 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex items-center gap-2 rounded-md border border-destructive/25 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||
Failed to load snapshot files
|
||||
</div>
|
||||
) : !files || files.length === 0 ? (
|
||||
<EmptyState icon={File} title="No files in this snapshot" />
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{files.map((file) => (
|
||||
<div
|
||||
key={file.filename}
|
||||
className="flex flex-col gap-2 rounded-md border border-border/70 bg-muted/30 px-3 py-2"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<File className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-sm">{file.filename}</span>
|
||||
{file.cryptMode && file.cryptMode !== 'none' && (
|
||||
<span className="inline-flex shrink-0 items-center gap-1 rounded-sm border border-warning/25 bg-warning/10 px-1.5 py-0.5 text-[10px] font-medium uppercase text-warning">
|
||||
<Lock className="h-3 w-3" />
|
||||
{file.cryptMode}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-mono text-xs tabular-nums text-muted-foreground">
|
||||
{formatBytes(file.size ?? 0)}
|
||||
</span>
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={downloadFile.isPending}
|
||||
onClick={() =>
|
||||
downloadFile.mutate({
|
||||
store,
|
||||
backupId,
|
||||
backupType,
|
||||
backupTime,
|
||||
fileName: file.filename,
|
||||
decoded: false,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
Raw
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={downloadFile.isPending}
|
||||
onClick={() =>
|
||||
downloadFile.mutate({
|
||||
store,
|
||||
backupId,
|
||||
backupType,
|
||||
backupTime,
|
||||
fileName: file.filename,
|
||||
decoded: true,
|
||||
})
|
||||
}
|
||||
>
|
||||
{downloadFile.isPending && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
|
||||
Decoded
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={downloadFile.isPending}>
|
||||
Close
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { usePbsRunGc } from '@/hooks/usePbs'
|
||||
|
||||
interface GcDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
store: string
|
||||
}
|
||||
|
||||
export function GcDialog({ open, onOpenChange, store }: GcDialogProps) {
|
||||
const runGc = usePbsRunGc()
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Garbage Collection</DialogTitle>
|
||||
<DialogDescription>
|
||||
Run garbage collection on datastore "{store}"? This removes chunks that are no
|
||||
longer referenced by any backup.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={runGc.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => runGc.mutate({ store }, { onSuccess: () => onOpenChange(false) })}
|
||||
disabled={runGc.isPending}
|
||||
>
|
||||
{runGc.isPending && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
Run Garbage Collection
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { usePbsRunPrune } from '@/hooks/usePbs'
|
||||
|
||||
interface PruneDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
store: string
|
||||
}
|
||||
|
||||
function parseOptionalInt(value: string): number | undefined {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return undefined
|
||||
const n = Number(trimmed)
|
||||
return Number.isFinite(n) && n >= 0 ? Math.floor(n) : undefined
|
||||
}
|
||||
|
||||
const keepFields: { key: string; label: string; placeholder: string }[] = [
|
||||
{ key: 'keepLast', label: 'Keep last', placeholder: 'e.g. 7' },
|
||||
{ key: 'keepDaily', label: 'Keep daily', placeholder: 'e.g. 14' },
|
||||
{ key: 'keepWeekly', label: 'Keep weekly', placeholder: 'e.g. 8' },
|
||||
{ key: 'keepMonthly', label: 'Keep monthly', placeholder: 'e.g. 6' },
|
||||
{ key: 'keepYearly', label: 'Keep yearly', placeholder: 'e.g. 2' },
|
||||
]
|
||||
|
||||
export function PruneDialog({ open, onOpenChange, store }: PruneDialogProps) {
|
||||
const [keepValues, setKeepValues] = useState<Record<string, string>>({})
|
||||
const [dryRun, setDryRun] = useState(true)
|
||||
const runPrune = usePbsRunPrune()
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
runPrune.mutate(
|
||||
{
|
||||
store,
|
||||
keepLast: parseOptionalInt(keepValues.keepLast ?? ''),
|
||||
keepDaily: parseOptionalInt(keepValues.keepDaily ?? ''),
|
||||
keepWeekly: parseOptionalInt(keepValues.keepWeekly ?? ''),
|
||||
keepMonthly: parseOptionalInt(keepValues.keepMonthly ?? ''),
|
||||
keepYearly: parseOptionalInt(keepValues.keepYearly ?? ''),
|
||||
dryRun,
|
||||
},
|
||||
{ onSuccess: () => onOpenChange(false) },
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Prune Datastore</DialogTitle>
|
||||
<DialogDescription>
|
||||
Prune backup groups on datastore "{store}" according to the retention rules below.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{keepFields.map((field) => (
|
||||
<div key={field.key} className="space-y-2">
|
||||
<Label htmlFor={`prune-${field.key}`}>{field.label}</Label>
|
||||
<Input
|
||||
id={`prune-${field.key}`}
|
||||
type="number"
|
||||
min={0}
|
||||
step={1}
|
||||
value={keepValues[field.key] ?? ''}
|
||||
onChange={(e) =>
|
||||
setKeepValues((prev) => ({ ...prev, [field.key]: e.target.value }))
|
||||
}
|
||||
placeholder={field.placeholder}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="prune-dry-run"
|
||||
checked={dryRun}
|
||||
onChange={(e) => setDryRun(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-input accent-primary"
|
||||
/>
|
||||
<Label htmlFor="prune-dry-run" className="text-sm font-normal cursor-pointer">
|
||||
Dry run (do not actually remove anything)
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={runPrune.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={runPrune.isPending}>
|
||||
{runPrune.isPending && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
Run Prune
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { usePbsRunVerify } from '@/hooks/usePbs'
|
||||
|
||||
interface VerifyDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
store: string
|
||||
}
|
||||
|
||||
export function VerifyDialog({ open, onOpenChange, store }: VerifyDialogProps) {
|
||||
const runVerify = usePbsRunVerify()
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Verify Datastore</DialogTitle>
|
||||
<DialogDescription>
|
||||
Run verification on datastore "{store}"? Already-verified snapshots will be skipped.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={runVerify.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() =>
|
||||
runVerify.mutate({ store }, { onSuccess: () => onOpenChange(false) })
|
||||
}
|
||||
disabled={runVerify.isPending}
|
||||
>
|
||||
{runVerify.isPending && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
Run Verify
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
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 { PbsSnapshot } from '@/types/pbs'
|
||||
|
||||
// Query keys
|
||||
export const queryKeys = {
|
||||
pbsDatastores: (id: string) => ['pbsDatastores', id],
|
||||
pbsVersion: (id: string) => ['pbsVersion', id],
|
||||
pbsNodeStatus: (id: string) => ['pbsNodeStatus', id],
|
||||
pbsGroups: (id: string, store: string) => ['pbsGroups', id, store],
|
||||
pbsSnapshots: (id: string, store: string, backupId: string, backupType: string) => [
|
||||
'pbsSnapshots',
|
||||
id,
|
||||
store,
|
||||
backupId,
|
||||
backupType,
|
||||
],
|
||||
pbsSnapshotFiles: (id: string, store: string, backupId: string, backupType: string, backupTime: number) => [
|
||||
'pbsSnapshotFiles',
|
||||
id,
|
||||
store,
|
||||
backupId,
|
||||
backupType,
|
||||
backupTime,
|
||||
],
|
||||
pbsVerifyJobs: (id: string) => ['pbsVerifyJobs', id],
|
||||
pbsPruneJobs: (id: string) => ['pbsPruneJobs', id],
|
||||
pbsGcJobs: (id: string) => ['pbsGcJobs', id],
|
||||
}
|
||||
|
||||
// Queries
|
||||
export const usePbsDatastores = (connectionId: string | null) => {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.pbsDatastores(connectionId!),
|
||||
queryFn: () => api.getPbsDatastores(connectionId!),
|
||||
enabled: !!connectionId,
|
||||
refetchInterval: 30000, // 30 seconds
|
||||
})
|
||||
}
|
||||
|
||||
export const usePbsVersion = (connectionId: string | null) => {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.pbsVersion(connectionId!),
|
||||
queryFn: () => api.getPbsVersion(connectionId!),
|
||||
enabled: !!connectionId,
|
||||
refetchInterval: 30000, // 30 seconds
|
||||
})
|
||||
}
|
||||
|
||||
export const usePbsNodeStatus = (connectionId: string | null) => {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.pbsNodeStatus(connectionId!),
|
||||
queryFn: () => api.getPbsNodeStatus(connectionId!),
|
||||
enabled: !!connectionId,
|
||||
})
|
||||
}
|
||||
|
||||
export const usePbsGroups = (connectionId: string | null, store: string | null) => {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.pbsGroups(connectionId!, store!),
|
||||
queryFn: () => api.getPbsGroups(connectionId!, store!),
|
||||
enabled: !!connectionId && !!store,
|
||||
})
|
||||
}
|
||||
|
||||
export const usePbsSnapshots = (
|
||||
connectionId: string | null,
|
||||
store: string | null,
|
||||
backupId: string | null,
|
||||
backupType: PbsSnapshot['backupType'] | null,
|
||||
) => {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.pbsSnapshots(connectionId!, store!, backupId!, backupType!),
|
||||
queryFn: () => api.getPbsSnapshots(connectionId!, store!, backupId!, backupType!),
|
||||
enabled: !!connectionId && !!store && !!backupId && !!backupType,
|
||||
})
|
||||
}
|
||||
|
||||
export const usePbsSnapshotFiles = (
|
||||
connectionId: string | null,
|
||||
store: string | null,
|
||||
backupId: string | null,
|
||||
backupType: PbsSnapshot['backupType'] | null,
|
||||
backupTime: number | null,
|
||||
) => {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.pbsSnapshotFiles(connectionId!, store!, backupId!, backupType!, backupTime!),
|
||||
queryFn: () => api.getPbsSnapshotFiles(connectionId!, store!, backupId!, backupType!, backupTime!),
|
||||
enabled: !!connectionId && !!store && !!backupId && !!backupType && !!backupTime,
|
||||
})
|
||||
}
|
||||
|
||||
export const usePbsVerifyJobs = (connectionId: string | null) => {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.pbsVerifyJobs(connectionId!),
|
||||
queryFn: () => api.getPbsVerifyJobs(connectionId!),
|
||||
enabled: !!connectionId,
|
||||
})
|
||||
}
|
||||
|
||||
export const usePbsPruneJobs = (connectionId: string | null) => {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.pbsPruneJobs(connectionId!),
|
||||
queryFn: () => api.getPbsPruneJobs(connectionId!),
|
||||
enabled: !!connectionId,
|
||||
})
|
||||
}
|
||||
|
||||
export const usePbsGcJobs = (connectionId: string | null) => {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.pbsGcJobs(connectionId!),
|
||||
queryFn: () => api.getPbsGcJobs(connectionId!),
|
||||
enabled: !!connectionId,
|
||||
})
|
||||
}
|
||||
|
||||
// Mutations
|
||||
export const usePbsDeleteSnapshot = () => {
|
||||
const queryClient = useQueryClient()
|
||||
const { addToast } = useToast()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
store,
|
||||
backupId,
|
||||
backupType,
|
||||
backupTime,
|
||||
}: {
|
||||
store: string
|
||||
backupId: string
|
||||
backupType: PbsSnapshot['backupType']
|
||||
backupTime: number
|
||||
}) => {
|
||||
const connId = useConnectionStore.getState().activeConnectionId!
|
||||
return api.deletePbsSnapshot(connId, store, backupId, backupType, backupTime)
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
addToast('Snapshot deleted', 'success')
|
||||
const connId = useConnectionStore.getState().activeConnectionId
|
||||
if (connId) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.pbsSnapshots(connId, variables.store, variables.backupId, variables.backupType),
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.pbsDatastores(connId) })
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
addToast(error.message || 'Failed to delete snapshot', 'error')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const usePbsDeleteGroup = () => {
|
||||
const queryClient = useQueryClient()
|
||||
const { addToast } = useToast()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
store,
|
||||
backupId,
|
||||
backupType,
|
||||
}: {
|
||||
store: string
|
||||
backupId: string
|
||||
backupType: PbsSnapshot['backupType']
|
||||
}) => {
|
||||
const connId = useConnectionStore.getState().activeConnectionId!
|
||||
return api.deletePbsGroup(connId, store, backupId, backupType)
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
addToast('Backup group deleted', 'success')
|
||||
const connId = useConnectionStore.getState().activeConnectionId
|
||||
if (connId) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.pbsGroups(connId, variables.store) })
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.pbsDatastores(connId) })
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
addToast(error.message || 'Failed to delete backup group', 'error')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const usePbsRunVerify = () => {
|
||||
const queryClient = useQueryClient()
|
||||
const { addToast } = useToast()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ store }: { store: string }) => {
|
||||
const connId = useConnectionStore.getState().activeConnectionId!
|
||||
return api.runPbsVerify(connId, store)
|
||||
},
|
||||
onSuccess: () => {
|
||||
addToast('Verification started', 'success')
|
||||
const connId = useConnectionStore.getState().activeConnectionId
|
||||
if (connId) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.pbsDatastores(connId) })
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.pbsVerifyJobs(connId) })
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
addToast(error.message || 'Failed to start verification', 'error')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const usePbsRunPrune = () => {
|
||||
const queryClient = useQueryClient()
|
||||
const { addToast } = useToast()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
store,
|
||||
keepLast,
|
||||
keepDaily,
|
||||
keepWeekly,
|
||||
keepMonthly,
|
||||
keepYearly,
|
||||
dryRun,
|
||||
}: {
|
||||
store: string
|
||||
keepLast?: number
|
||||
keepDaily?: number
|
||||
keepWeekly?: number
|
||||
keepMonthly?: number
|
||||
keepYearly?: number
|
||||
dryRun?: boolean
|
||||
}) => {
|
||||
const connId = useConnectionStore.getState().activeConnectionId!
|
||||
return api.runPbsPrune(
|
||||
connId,
|
||||
store,
|
||||
keepLast,
|
||||
keepDaily,
|
||||
keepWeekly,
|
||||
keepMonthly,
|
||||
keepYearly,
|
||||
dryRun ?? false,
|
||||
)
|
||||
},
|
||||
onSuccess: () => {
|
||||
addToast('Prune started', 'success')
|
||||
const connId = useConnectionStore.getState().activeConnectionId
|
||||
if (connId) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.pbsDatastores(connId) })
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.pbsPruneJobs(connId) })
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
addToast(error.message || 'Failed to start prune', 'error')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const usePbsRunGc = () => {
|
||||
const queryClient = useQueryClient()
|
||||
const { addToast } = useToast()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ store }: { store: string }) => {
|
||||
const connId = useConnectionStore.getState().activeConnectionId!
|
||||
return api.runPbsGc(connId, store)
|
||||
},
|
||||
onSuccess: () => {
|
||||
addToast('Garbage collection started', 'success')
|
||||
const connId = useConnectionStore.getState().activeConnectionId
|
||||
if (connId) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.pbsDatastores(connId) })
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.pbsGcJobs(connId) })
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
addToast(error.message || 'Failed to start garbage collection', 'error')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const usePbsDownloadFile = () => {
|
||||
const { addToast } = useToast()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
store,
|
||||
backupId,
|
||||
backupType,
|
||||
backupTime,
|
||||
fileName,
|
||||
decoded,
|
||||
}: {
|
||||
store: string
|
||||
backupId: string
|
||||
backupType: PbsSnapshot['backupType']
|
||||
backupTime: number
|
||||
fileName: string
|
||||
decoded: boolean
|
||||
}): Promise<string | null> => {
|
||||
const connId = useConnectionStore.getState().activeConnectionId!
|
||||
// In browser mock mode there is no save dialog, so default the target
|
||||
// path to the file name. In Tauri mode the native save dialog picks it.
|
||||
let savePath = fileName
|
||||
if (api.isTauri()) {
|
||||
const { save } = await import('@tauri-apps/plugin-dialog')
|
||||
const chosen = await save({ defaultPath: fileName })
|
||||
if (chosen === null) return null // dialog cancelled — skip the download
|
||||
savePath = chosen
|
||||
}
|
||||
return api.downloadPbsSnapshotFile(
|
||||
connId,
|
||||
store,
|
||||
backupId,
|
||||
backupType,
|
||||
backupTime,
|
||||
fileName,
|
||||
decoded,
|
||||
savePath,
|
||||
)
|
||||
},
|
||||
onSuccess: (savePath, variables) => {
|
||||
if (savePath === null) return // dialog cancelled — no toast
|
||||
addToast(`Downloaded ${variables.fileName}`, 'success')
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
addToast(error.message || 'Failed to download file', 'error')
|
||||
},
|
||||
})
|
||||
}
|
||||
+290
-1
@@ -8,7 +8,17 @@ import type {
|
||||
LoadConnectionsResult,
|
||||
ConnectResult,
|
||||
ConnectionStatusInfo,
|
||||
ServerType,
|
||||
} from '@/types/connection'
|
||||
import type {
|
||||
PbsDatastore,
|
||||
PbsVersion,
|
||||
PbsNodeStatus,
|
||||
PbsBackupGroup,
|
||||
PbsSnapshot,
|
||||
PbsSnapshotFile,
|
||||
PbsJob,
|
||||
} from '@/types/pbs'
|
||||
import type {
|
||||
ProxmoxNode,
|
||||
ProxmoxVM,
|
||||
@@ -137,6 +147,7 @@ export const loginWithPassword = async (
|
||||
export const loginWithToken = async (
|
||||
url: string,
|
||||
token: string,
|
||||
serverType: ServerType = 'pve',
|
||||
): Promise<LoginResult> => {
|
||||
if (!isTauri()) {
|
||||
return mockResponse({
|
||||
@@ -145,7 +156,7 @@ export const loginWithToken = async (
|
||||
csrfToken: '',
|
||||
})
|
||||
}
|
||||
return invokeCommand<LoginResult>('login_with_token', { url, token })
|
||||
return invokeCommand<LoginResult>('login_with_token', { url, token, serverType })
|
||||
}
|
||||
|
||||
export const logout = async (connectionId: string): Promise<void> => {
|
||||
@@ -592,3 +603,281 @@ export const updateTrayMenu = async (
|
||||
if (!isTauri()) return
|
||||
return invokeCommand<void>('update_tray_menu', { connections })
|
||||
}
|
||||
|
||||
// Proxmox Backup Server (PBS)
|
||||
export const getPbsDatastores = async (connectionId: string): Promise<PbsDatastore[]> => {
|
||||
if (!isTauri()) {
|
||||
return mockResponse([
|
||||
{
|
||||
store: 'backup-store',
|
||||
comment: 'Main backup store',
|
||||
backendType: 'filesystem',
|
||||
mountStatus: 'mounted',
|
||||
total: 2_000_000_000_000,
|
||||
used: 800_000_000_000,
|
||||
avail: 1_200_000_000_000,
|
||||
},
|
||||
])
|
||||
}
|
||||
return invokeCommand<PbsDatastore[]>('get_pbs_datastores', { connectionId })
|
||||
}
|
||||
|
||||
export const getPbsVersion = async (connectionId: string): Promise<PbsVersion> => {
|
||||
if (!isTauri()) {
|
||||
return mockResponse({ version: '3.2.3', release: 'bookworm', repoid: 'dd6b00e2' })
|
||||
}
|
||||
return invokeCommand<PbsVersion>('get_pbs_version', { connectionId })
|
||||
}
|
||||
|
||||
export const getPbsNodeStatus = async (connectionId: string): Promise<PbsNodeStatus> => {
|
||||
if (!isTauri()) {
|
||||
return mockResponse({
|
||||
cpu: 0.15,
|
||||
loadavg: [0.42, 0.38, 0.31],
|
||||
uptime: 5 * 86400,
|
||||
memory: { free: 12_000_000_000, total: 32_000_000_000, used: 20_000_000_000 },
|
||||
root: { avail: 1_200_000_000_000, total: 2_000_000_000_000, used: 800_000_000_000 },
|
||||
swap: { free: 4_000_000_000, total: 4_000_000_000, used: 0 },
|
||||
cpuinfo: { cpus: 8, model: 'AMD Ryzen 7 5700G', sockets: 1 },
|
||||
currentKernel: {
|
||||
machine: 'x86_64',
|
||||
release: '6.8.12-4-pve',
|
||||
sysname: 'Linux',
|
||||
version: '#1 SMP PREEMPT_DYNAMIC',
|
||||
},
|
||||
})
|
||||
}
|
||||
return invokeCommand<PbsNodeStatus>('get_pbs_node_status', { connectionId })
|
||||
}
|
||||
|
||||
export const getPbsGroups = async (
|
||||
connectionId: string,
|
||||
store: string,
|
||||
): Promise<PbsBackupGroup[]> => {
|
||||
if (!isTauri()) {
|
||||
return mockResponse([
|
||||
{
|
||||
backupId: '100',
|
||||
backupType: 'vm',
|
||||
backupCount: 3,
|
||||
lastBackup: 1_700_000_000,
|
||||
comment: 'Web server',
|
||||
},
|
||||
{
|
||||
backupId: '200',
|
||||
backupType: 'ct',
|
||||
backupCount: 2,
|
||||
lastBackup: 1_690_000_000,
|
||||
comment: 'Container host',
|
||||
},
|
||||
])
|
||||
}
|
||||
return invokeCommand<PbsBackupGroup[]>('get_pbs_groups', { connectionId, store })
|
||||
}
|
||||
|
||||
export const getPbsSnapshots = async (
|
||||
connectionId: string,
|
||||
store: string,
|
||||
backupId: string,
|
||||
backupType: PbsSnapshot['backupType'],
|
||||
): Promise<PbsSnapshot[]> => {
|
||||
if (!isTauri()) {
|
||||
return mockResponse([
|
||||
{
|
||||
backupId,
|
||||
backupType,
|
||||
backupTime: 1_700_000_000,
|
||||
size: 1_500_000_000,
|
||||
protected: true,
|
||||
comment: 'Full backup',
|
||||
verification: { state: 'ok' },
|
||||
},
|
||||
{
|
||||
backupId,
|
||||
backupType,
|
||||
backupTime: 1_700_086_400,
|
||||
size: 900_000_000,
|
||||
comment: 'Incremental backup',
|
||||
},
|
||||
])
|
||||
}
|
||||
return invokeCommand<PbsSnapshot[]>('get_pbs_snapshots', { connectionId, store, backupId, backupType })
|
||||
}
|
||||
|
||||
export const getPbsSnapshotFiles = async (
|
||||
connectionId: string,
|
||||
store: string,
|
||||
backupId: string,
|
||||
backupType: PbsSnapshot['backupType'],
|
||||
backupTime: number,
|
||||
): Promise<PbsSnapshotFile[]> => {
|
||||
if (!isTauri()) {
|
||||
return mockResponse([
|
||||
{ filename: 'client.conf', size: 1024 },
|
||||
{ filename: 'drive-scsi0.img.fidx', size: 64 * 1024 * 1024 * 1024, cryptMode: 'none' },
|
||||
{ filename: 'index.json.blob', size: 2048 },
|
||||
])
|
||||
}
|
||||
return invokeCommand<PbsSnapshotFile[]>('get_pbs_snapshot_files', {
|
||||
connectionId,
|
||||
store,
|
||||
backupId,
|
||||
backupType,
|
||||
backupTime,
|
||||
})
|
||||
}
|
||||
|
||||
export const downloadPbsSnapshotFile = async (
|
||||
connectionId: string,
|
||||
store: string,
|
||||
backupId: string,
|
||||
backupType: PbsSnapshot['backupType'],
|
||||
backupTime: number,
|
||||
fileName: string,
|
||||
decoded: boolean,
|
||||
savePath: string,
|
||||
): Promise<string> => {
|
||||
if (!isTauri()) return mockResponse(savePath)
|
||||
return invokeCommand<string>('download_pbs_snapshot_file', {
|
||||
connectionId,
|
||||
store,
|
||||
backupId,
|
||||
backupType,
|
||||
backupTime,
|
||||
fileName,
|
||||
decoded,
|
||||
savePath,
|
||||
})
|
||||
}
|
||||
|
||||
export const deletePbsSnapshot = async (
|
||||
connectionId: string,
|
||||
store: string,
|
||||
backupId: string,
|
||||
backupType: PbsSnapshot['backupType'],
|
||||
backupTime: number,
|
||||
): Promise<void> => {
|
||||
if (!isTauri()) return mockResponse(undefined)
|
||||
return invokeCommand<void>('delete_pbs_snapshot', {
|
||||
connectionId,
|
||||
store,
|
||||
backupId,
|
||||
backupType,
|
||||
backupTime,
|
||||
})
|
||||
}
|
||||
|
||||
export const deletePbsGroup = async (
|
||||
connectionId: string,
|
||||
store: string,
|
||||
backupId: string,
|
||||
backupType: PbsSnapshot['backupType'],
|
||||
): Promise<void> => {
|
||||
if (!isTauri()) return mockResponse(undefined)
|
||||
return invokeCommand<void>('delete_pbs_group', { connectionId, store, backupId, backupType })
|
||||
}
|
||||
|
||||
export const runPbsVerify = async (connectionId: string, store: string): Promise<string> => {
|
||||
if (!isTauri()) {
|
||||
return mockResponse(`UPID:mock:00000000:00000000:00000000:verify:${store}::`)
|
||||
}
|
||||
return invokeCommand<string>('run_pbs_verify', { connectionId, store })
|
||||
}
|
||||
|
||||
export const runPbsPrune = async (
|
||||
connectionId: string,
|
||||
store: string,
|
||||
keepLast?: number,
|
||||
keepDaily?: number,
|
||||
keepWeekly?: number,
|
||||
keepMonthly?: number,
|
||||
keepYearly?: number,
|
||||
dryRun?: boolean,
|
||||
): Promise<string> => {
|
||||
if (!isTauri()) {
|
||||
return mockResponse(`UPID:mock:00000000:00000000:00000000:prune:${store}::`)
|
||||
}
|
||||
return invokeCommand<string>('run_pbs_prune', {
|
||||
connectionId,
|
||||
store,
|
||||
keepLast,
|
||||
keepDaily,
|
||||
keepWeekly,
|
||||
keepMonthly,
|
||||
keepYearly,
|
||||
dryRun,
|
||||
})
|
||||
}
|
||||
|
||||
export const runPbsGc = async (connectionId: string, store: string): Promise<string> => {
|
||||
if (!isTauri()) {
|
||||
return mockResponse(`UPID:mock:00000000:00000000:00000000:gc:${store}::`)
|
||||
}
|
||||
return invokeCommand<string>('run_pbs_gc', { connectionId, store })
|
||||
}
|
||||
|
||||
export const getPbsVerifyJobs = async (
|
||||
connectionId: string,
|
||||
store?: string,
|
||||
): Promise<PbsJob[]> => {
|
||||
if (!isTauri()) {
|
||||
return mockResponse([
|
||||
{
|
||||
id: 'verify-1',
|
||||
store: 'backup-store',
|
||||
schedule: 'sun 01:00',
|
||||
comment: 'Weekly verification',
|
||||
lastRunState: 'OK',
|
||||
lastRunEndtime: 1_700_000_000,
|
||||
nextRun: 1_700_600_000,
|
||||
maxDepth: 5,
|
||||
},
|
||||
])
|
||||
}
|
||||
return invokeCommand<PbsJob[]>('get_pbs_verify_jobs', { connectionId, store })
|
||||
}
|
||||
|
||||
export const getPbsPruneJobs = async (
|
||||
connectionId: string,
|
||||
store?: string,
|
||||
): Promise<PbsJob[]> => {
|
||||
if (!isTauri()) {
|
||||
return mockResponse([
|
||||
{
|
||||
id: 'prune-1',
|
||||
store: 'backup-store',
|
||||
schedule: 'sat 02:00',
|
||||
comment: 'Weekly pruning',
|
||||
lastRunState: 'OK',
|
||||
lastRunEndtime: 1_690_000_000,
|
||||
nextRun: 1_700_000_000,
|
||||
keepLast: 7,
|
||||
keepDaily: 14,
|
||||
keepWeekly: 8,
|
||||
keepMonthly: 6,
|
||||
keepYearly: 2,
|
||||
},
|
||||
])
|
||||
}
|
||||
return invokeCommand<PbsJob[]>('get_pbs_prune_jobs', { connectionId, store })
|
||||
}
|
||||
|
||||
export const getPbsGcJobs = async (
|
||||
connectionId: string,
|
||||
store?: string,
|
||||
): Promise<PbsJob[]> => {
|
||||
if (!isTauri()) {
|
||||
return mockResponse([
|
||||
{
|
||||
id: 'gc-1',
|
||||
store: 'backup-store',
|
||||
schedule: 'mon 03:00',
|
||||
comment: 'Weekly garbage collection',
|
||||
lastRunState: 'OK',
|
||||
lastRunEndtime: 1_690_000_000,
|
||||
nextRun: 1_700_000_000,
|
||||
},
|
||||
])
|
||||
}
|
||||
return invokeCommand<PbsJob[]>('get_pbs_gc_jobs', { connectionId, store })
|
||||
}
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
export type AuthMode = 'password' | 'token'
|
||||
|
||||
/** The kind of server a connection targets. Absent on persisted old
|
||||
* connections, where it is treated as 'pve'. */
|
||||
export type ServerType = 'pve' | 'pbs'
|
||||
|
||||
export interface ConnectionConfig {
|
||||
id: string
|
||||
name: string
|
||||
@@ -16,6 +20,8 @@ export interface ConnectionConfig {
|
||||
isCluster: boolean
|
||||
authMode: AuthMode
|
||||
username?: string
|
||||
/** The kind of server this connection targets ('pve' when absent). */
|
||||
serverType?: ServerType
|
||||
/** Nodes discovered in the cluster this connection is anchored on. */
|
||||
nodes?: DiscoveredNode[]
|
||||
/** The endpoint currently serving this connection (after failover). */
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
// Proxmox Backup Server (PBS) API types
|
||||
//
|
||||
// PBS exposes kebab-case JSON fields; the Rust backend maps them to camelCase
|
||||
// structs (serde `rename_all = "camelCase"`), so these types mirror the
|
||||
// backend's shapes. All fields are optional unless marked required.
|
||||
|
||||
export interface PbsDatastore {
|
||||
store: string // required
|
||||
comment?: string
|
||||
backendType?: string // 'filesystem' | 's3'
|
||||
mountStatus?: string // 'mounted' | 'notmounted' | 'nonremovable'
|
||||
maintenance?: string
|
||||
total?: number
|
||||
used?: number
|
||||
avail?: number
|
||||
error?: string
|
||||
estimatedFullDate?: number
|
||||
history?: number[]
|
||||
gcStatus?: {
|
||||
diskBytes?: number
|
||||
diskChunks?: number
|
||||
indexDataBytes?: number
|
||||
indexFileCount?: number
|
||||
pendingBytes?: number
|
||||
pendingChunks?: number
|
||||
removedBad?: number
|
||||
removedBytes?: number
|
||||
removedChunks?: number
|
||||
stillBad?: number
|
||||
cacheHits?: number
|
||||
cacheMisses?: number
|
||||
upid?: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface PbsVersion {
|
||||
version: string
|
||||
release: string
|
||||
repoid: string
|
||||
}
|
||||
|
||||
export interface PbsNodeStatus {
|
||||
cpu?: number
|
||||
loadavg?: number[]
|
||||
uptime?: number
|
||||
memory?: { free?: number; total?: number; used?: number }
|
||||
root?: { avail?: number; total?: number; used?: number }
|
||||
swap?: { free?: number; total?: number; used?: number }
|
||||
cpuinfo?: { cpus?: number; model?: string; sockets?: number }
|
||||
currentKernel?: { machine?: string; release?: string; sysname?: string; version?: string }
|
||||
}
|
||||
|
||||
export interface PbsBackupGroup {
|
||||
backupId: string // required, e.g. "100"
|
||||
backupType: 'vm' | 'ct' | 'host'
|
||||
backupCount?: number
|
||||
lastBackup?: number // unix epoch
|
||||
comment?: string
|
||||
files?: string[]
|
||||
}
|
||||
|
||||
export interface PbsSnapshot {
|
||||
backupId: string // required
|
||||
backupType: 'vm' | 'ct' | 'host'
|
||||
backupTime: number // required, unix epoch
|
||||
size?: number
|
||||
protected?: boolean
|
||||
comment?: string
|
||||
files?: string[]
|
||||
fingerprint?: string
|
||||
owner?: string
|
||||
verification?: { state?: 'ok' | 'failed'; upid?: string }
|
||||
}
|
||||
|
||||
export interface PbsSnapshotFile {
|
||||
filename: string
|
||||
size?: number
|
||||
cryptMode?: string
|
||||
}
|
||||
|
||||
export interface PbsJob {
|
||||
id: string // required
|
||||
store?: string
|
||||
schedule?: string
|
||||
comment?: string
|
||||
disable?: boolean
|
||||
lastRunState?: string
|
||||
lastRunEndtime?: number
|
||||
nextRun?: number
|
||||
keepLast?: number
|
||||
keepDaily?: number
|
||||
keepWeekly?: number
|
||||
keepMonthly?: number
|
||||
keepYearly?: number
|
||||
ignoreVerified?: boolean
|
||||
maxDepth?: number
|
||||
}
|
||||
Reference in New Issue
Block a user