feat(backups): add storage management tab
All checks were successful
Deploy to Production / Trigger production update (push) Successful in 8s

New Storages tab in Backups: list cluster storages, add (dir/nfs/cifs/pbs
with type-specific fields), edit content/nodes, delete, and view native PVE
vzdump backup schedules — all over the previously-unused storage CRUD and
proxmox-jobs endpoints.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-14 18:48:35 +05:00
parent e79f9ea24c
commit dc95d39465
5 changed files with 410 additions and 0 deletions

View File

@@ -28,6 +28,7 @@ import {
} from '@/hooks/use-backups';
import { formatBytes } from '@/lib/format';
import { toast } from 'sonner';
import StoragesTab from './StoragesTab';
const ALL_NODES = '__all__';
// Sentinel for the target-storage selects: restore disks to the storage recorded
@@ -440,6 +441,7 @@ export default function BackupsPage() {
<TabsList>
<TabsTrigger value="files">{t('backups.files')}</TabsTrigger>
<TabsTrigger value="jobs">{t('backups.scheduled_jobs')}</TabsTrigger>
<TabsTrigger value="storages">{t('storages.title')}</TabsTrigger>
</TabsList>
<TabsContent value="files" className="space-y-4 mt-4">
@@ -631,6 +633,10 @@ export default function BackupsPage() {
</div>
)}
</TabsContent>
<TabsContent value="storages" className="mt-4">
<StoragesTab />
</TabsContent>
</Tabs>
{/* Restore dialog */}

View File

@@ -0,0 +1,309 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Plus, Pencil, Trash2, Database, CalendarClock } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useServers } from '@/hooks/use-nodes';
import {
useBackupStorages, useProxmoxBackupJobs,
useCreateStorage, useUpdateStorage, useDeleteStorage,
} from '@/hooks/use-backups';
import { formatBytes } from '@/lib/format';
import { toast } from 'sonner';
const STORAGE_TYPES = ['dir', 'nfs', 'cifs', 'pbs'];
interface StorageRow {
storage: string;
type: string;
content?: string;
nodes?: string;
path?: string;
server?: string;
export?: string;
share?: string;
datastore?: string;
disable?: number;
used?: number;
total?: number;
avail?: number;
}
interface StorageForm {
storage: string;
type: string;
content: string;
nodes: string;
path: string;
server: string;
export: string;
share: string;
datastore: string;
username: string;
password: string;
fingerprint: string;
}
const emptyForm: StorageForm = {
storage: '', type: 'dir', content: 'backup', nodes: '',
path: '', server: '', export: '', share: '', datastore: '',
username: '', password: '', fingerprint: '',
};
function StorageDialog({ serverId, open, onOpenChange, editing }: {
serverId: number; open: boolean; onOpenChange: (v: boolean) => void; editing: StorageRow | null;
}) {
const { t } = useTranslation();
const createStorage = useCreateStorage(serverId);
const updateStorage = useUpdateStorage(serverId);
const [form, setForm] = useState<StorageForm>(() => editing ? {
...emptyForm,
storage: editing.storage,
type: editing.type,
content: editing.content || 'backup',
nodes: editing.nodes || '',
path: editing.path || '',
server: editing.server || '',
export: editing.export || '',
share: editing.share || '',
datastore: editing.datastore || '',
} : emptyForm);
const set = <K extends keyof StorageForm>(k: K, v: StorageForm[K]) => setForm(p => ({ ...p, [k]: v }));
const submit = () => {
if (!editing && !form.storage.trim()) { toast.error(t('storages.id_required')); return; }
const done = (msg: string) => { toast.success(msg); onOpenChange(false); };
if (editing) {
// Only a subset is updatable; id and type are immutable in PVE.
const payload: Record<string, unknown> = { content: form.content.trim() || 'backup' };
if (form.nodes.trim()) payload.nodes = form.nodes.trim();
updateStorage.mutate({ storage: editing.storage, ...payload }, {
onSuccess: () => done(t('storages.saved')),
onError: (e: Error) => toast.error(e.message),
});
return;
}
const payload: Record<string, unknown> = {
storage: form.storage.trim(),
type: form.type,
content: form.content.trim() || 'backup',
};
if (form.nodes.trim()) payload.nodes = form.nodes.trim();
if (form.type === 'dir') payload.path = form.path.trim();
if (form.type === 'nfs') { payload.server = form.server.trim(); payload.export = form.export.trim(); }
if (form.type === 'cifs') {
payload.server = form.server.trim(); payload.share = form.share.trim();
if (form.username.trim()) payload.username = form.username.trim();
if (form.password) payload.password = form.password;
}
if (form.type === 'pbs') {
payload.server = form.server.trim(); payload.datastore = form.datastore.trim();
if (form.username.trim()) payload.username = form.username.trim();
if (form.password) payload.password = form.password;
if (form.fingerprint.trim()) payload.fingerprint = form.fingerprint.trim();
}
createStorage.mutate(payload, {
onSuccess: () => done(t('storages.created')),
onError: (e: Error) => toast.error(e.message),
});
};
const pending = createStorage.isPending || updateStorage.isPending;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader><DialogTitle>{editing ? `${t('storages.edit')} ${editing.storage}` : t('storages.add')}</DialogTitle></DialogHeader>
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<Label>{t('storages.id')}</Label>
<Input value={form.storage} onChange={e => set('storage', e.target.value)} disabled={!!editing} className="mt-1" placeholder="backup-nfs" />
</div>
<div>
<Label>{t('storages.type')}</Label>
<Select value={form.type} onValueChange={v => { if (v) set('type', v); }} disabled={!!editing}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectContent>{STORAGE_TYPES.map(ty => <SelectItem key={ty} value={ty}>{ty.toUpperCase()}</SelectItem>)}</SelectContent>
</Select>
</div>
</div>
{!editing && form.type === 'dir' && (
<div><Label>{t('storages.path')}</Label><Input value={form.path} onChange={e => set('path', e.target.value)} className="mt-1" placeholder="/mnt/backups" /></div>
)}
{!editing && form.type === 'nfs' && (
<div className="grid grid-cols-2 gap-3">
<div><Label>{t('storages.server')}</Label><Input value={form.server} onChange={e => set('server', e.target.value)} className="mt-1" placeholder="10.0.0.5" /></div>
<div><Label>{t('storages.export')}</Label><Input value={form.export} onChange={e => set('export', e.target.value)} className="mt-1" placeholder="/exports/backup" /></div>
</div>
)}
{!editing && form.type === 'cifs' && (
<div className="grid grid-cols-2 gap-3">
<div><Label>{t('storages.server')}</Label><Input value={form.server} onChange={e => set('server', e.target.value)} className="mt-1" /></div>
<div><Label>{t('storages.share')}</Label><Input value={form.share} onChange={e => set('share', e.target.value)} className="mt-1" /></div>
<div><Label>{t('storages.username')}</Label><Input value={form.username} onChange={e => set('username', e.target.value)} className="mt-1" /></div>
<div><Label>{t('storages.password')}</Label><Input type="password" value={form.password} onChange={e => set('password', e.target.value)} className="mt-1" /></div>
</div>
)}
{!editing && form.type === 'pbs' && (
<div className="grid grid-cols-2 gap-3">
<div><Label>{t('storages.server')}</Label><Input value={form.server} onChange={e => set('server', e.target.value)} className="mt-1" /></div>
<div><Label>{t('storages.datastore')}</Label><Input value={form.datastore} onChange={e => set('datastore', e.target.value)} className="mt-1" /></div>
<div><Label>{t('storages.username')}</Label><Input value={form.username} onChange={e => set('username', e.target.value)} className="mt-1" placeholder="user@pbs" /></div>
<div><Label>{t('storages.password')}</Label><Input type="password" value={form.password} onChange={e => set('password', e.target.value)} className="mt-1" /></div>
<div className="col-span-2"><Label>{t('storages.fingerprint')}</Label><Input value={form.fingerprint} onChange={e => set('fingerprint', e.target.value)} className="mt-1 font-mono" /></div>
</div>
)}
<div><Label>{t('storages.content')}</Label><Input value={form.content} onChange={e => set('content', e.target.value)} className="mt-1" placeholder="backup,images,iso" /></div>
<div><Label>{t('storages.nodes')}</Label><Input value={form.nodes} onChange={e => set('nodes', e.target.value)} className="mt-1" placeholder={t('storages.nodes_placeholder')} /></div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>{t('common.cancel')}</Button>
<Button onClick={submit} disabled={pending || (!editing && !form.storage.trim())}>
{editing ? t('common.save') : t('common.create')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export default function StoragesTab() {
const { t } = useTranslation();
const { data: servers = [] } = useServers();
const [serverId, setServerId] = useState(0);
useEffect(() => {
if (serverId === 0 && servers.length > 0) setServerId(servers[0].id);
}, [servers, serverId]);
const { data: storagesData, isLoading } = useBackupStorages(serverId);
const { data: jobsData } = useProxmoxBackupJobs(serverId);
const deleteStorage = useDeleteStorage(serverId);
const storages = (storagesData?.storages ?? []) as StorageRow[];
const nativeJobs = (jobsData?.jobs ?? []) as Array<Record<string, unknown>>;
const [dialogOpen, setDialogOpen] = useState(false);
const [editing, setEditing] = useState<StorageRow | null>(null);
const handleDelete = (s: string) => {
if (!confirm(`${t('storages.delete_confirm')} "${s}"?`)) return;
deleteStorage.mutate(s, {
onSuccess: () => toast.success(t('storages.deleted')),
onError: (e: Error) => toast.error(e.message),
});
};
return (
<div className="space-y-4">
<div className="flex items-center gap-2">
<Select value={serverId ? String(serverId) : ''} onValueChange={v => { if (v) setServerId(Number(v)); }}>
<SelectTrigger className="w-[220px]"><SelectValue placeholder={t('backups.select_server')} /></SelectTrigger>
<SelectContent>{servers.map(s => <SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>)}</SelectContent>
</Select>
<Button size="sm" className="ml-auto" onClick={() => { setEditing(null); setDialogOpen(true); }} disabled={!serverId}>
<Plus className="mr-1 h-4 w-4" />{t('storages.add')}
</Button>
</div>
<Card>
<CardHeader><CardTitle className="flex items-center gap-2 text-lg"><Database className="h-5 w-5" />{t('storages.title')}</CardTitle></CardHeader>
<CardContent>
{isLoading ? <p className="py-6 text-center text-sm text-muted-foreground">{t('common.loading')}</p>
: storages.length === 0 ? <p className="py-6 text-center text-sm text-muted-foreground">{t('common.no_data')}</p>
: (
<Table>
<TableHeader><TableRow>
<TableHead>{t('storages.id')}</TableHead>
<TableHead>{t('storages.type')}</TableHead>
<TableHead>{t('storages.content')}</TableHead>
<TableHead>{t('storages.nodes')}</TableHead>
<TableHead>{t('storages.usage')}</TableHead>
<TableHead className="text-right">{t('common.actions')}</TableHead>
</TableRow></TableHeader>
<TableBody>
{storages.map(s => (
<TableRow key={s.storage}>
<TableCell className="font-mono font-medium">
{s.storage}
{s.disable === 1 && <Badge variant="secondary" className="ml-2 text-[10px]">{t('storages.disabled')}</Badge>}
</TableCell>
<TableCell><Badge variant="outline">{s.type}</Badge></TableCell>
<TableCell className="text-xs">{s.content || '—'}</TableCell>
<TableCell className="text-xs text-muted-foreground">{s.nodes || t('storages.all_nodes')}</TableCell>
<TableCell className="text-xs text-muted-foreground">
{s.total ? `${formatBytes(s.used || 0)} / ${formatBytes(s.total)}` : '—'}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => { setEditing(s); setDialogOpen(true); }}>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => handleDelete(s.storage)}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
{/* Native PVE backup schedules (read-only) */}
<Card>
<CardHeader><CardTitle className="flex items-center gap-2 text-lg"><CalendarClock className="h-5 w-5" />{t('storages.native_jobs')}</CardTitle></CardHeader>
<CardContent>
{nativeJobs.length === 0 ? (
<p className="py-4 text-center text-sm text-muted-foreground">{t('storages.no_native_jobs')}</p>
) : (
<Table>
<TableHeader><TableRow>
<TableHead>ID</TableHead><TableHead>{t('storages.schedule')}</TableHead>
<TableHead>{t('storages.id')}</TableHead><TableHead>{t('backups.mode')}</TableHead>
<TableHead>{t('common.status')}</TableHead>
</TableRow></TableHeader>
<TableBody>
{nativeJobs.map((j, i) => (
<TableRow key={String(j.id ?? i)}>
<TableCell className="font-mono text-xs">{String(j.id ?? '—')}</TableCell>
<TableCell className="font-mono text-xs">{String(j.schedule ?? j.starttime ?? '—')}</TableCell>
<TableCell className="text-xs">{String(j.storage ?? '—')}</TableCell>
<TableCell className="text-xs">{String(j.mode ?? '—')}</TableCell>
<TableCell>
<Badge variant={j.enabled === 0 || j.enabled === '0' ? 'secondary' : 'default'}>
{j.enabled === 0 || j.enabled === '0' ? t('storages.disabled') : t('storages.enabled')}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
{dialogOpen && (
<StorageDialog
serverId={serverId}
open={dialogOpen}
onOpenChange={(v) => { setDialogOpen(v); if (!v) setEditing(null); }}
editing={editing}
/>
)}
</div>
);
}

View File

@@ -16,6 +16,41 @@ export function useBackupStorages(serverId: number) {
});
}
export function useProxmoxBackupJobs(serverId: number) {
return useQuery({
queryKey: backupKeys.proxmoxJobs(serverId),
queryFn: () => apiClient.get<{ jobs: unknown[]; server_name?: string; error?: string }>(`/proxmox/api/backups/proxmox-jobs/${serverId}`),
enabled: serverId > 0,
});
}
export function useCreateStorage(serverId: number) {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: Record<string, unknown>) =>
apiClient.post(`/proxmox/api/backups/storages/${serverId}`, data),
onSuccess: () => qc.invalidateQueries({ queryKey: backupKeys.storages(serverId) }),
});
}
export function useUpdateStorage(serverId: number) {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ storage, ...data }: { storage: string } & Record<string, unknown>) =>
apiClient.put(`/proxmox/api/backups/storages/${serverId}/${storage}`, data),
onSuccess: () => qc.invalidateQueries({ queryKey: backupKeys.storages(serverId) }),
});
}
export function useDeleteStorage(serverId: number) {
const qc = useQueryClient();
return useMutation({
mutationFn: (storage: string) =>
apiClient.delete(`/proxmox/api/backups/storages/${serverId}/${storage}`),
onSuccess: () => qc.invalidateQueries({ queryKey: backupKeys.storages(serverId) }),
});
}
export function useBackupList(serverId: number, node: string, storage: string) {
return useQuery({
queryKey: backupKeys.list(serverId, node, storage),

View File

@@ -390,6 +390,36 @@
"select_server": "Select server",
"import_btn": "Import Templates"
},
"storages": {
"title": "Storages",
"add": "Add storage",
"edit": "Edit storage",
"id": "Storage ID",
"type": "Type",
"content": "Content",
"nodes": "Nodes",
"nodes_placeholder": "empty = all nodes",
"all_nodes": "all",
"usage": "Usage",
"path": "Path",
"server": "Server",
"export": "Export",
"share": "Share",
"datastore": "Datastore",
"username": "Username",
"password": "Password",
"fingerprint": "Fingerprint",
"disabled": "Disabled",
"enabled": "Enabled",
"id_required": "Storage ID is required",
"created": "Storage created",
"saved": "Storage updated",
"deleted": "Storage deleted",
"delete_confirm": "Delete storage",
"native_jobs": "Native PVE backup schedules",
"no_native_jobs": "No native backup schedules",
"schedule": "Schedule"
},
"backups": {
"title": "Backups",
"files": "Backup Files",

View File

@@ -390,6 +390,36 @@
"select_server": "Выберите сервер",
"import_btn": "Импортировать шаблоны"
},
"storages": {
"title": "Хранилища",
"add": "Добавить хранилище",
"edit": "Изменить хранилище",
"id": "ID хранилища",
"type": "Тип",
"content": "Содержимое",
"nodes": "Узлы",
"nodes_placeholder": "пусто = все узлы",
"all_nodes": "все",
"usage": "Занято",
"path": "Путь",
"server": "Сервер",
"export": "Экспорт",
"share": "Шара",
"datastore": "Datastore",
"username": "Пользователь",
"password": "Пароль",
"fingerprint": "Отпечаток",
"disabled": "Отключено",
"enabled": "Включено",
"id_required": "Укажите ID хранилища",
"created": "Хранилище создано",
"saved": "Хранилище обновлено",
"deleted": "Хранилище удалено",
"delete_confirm": "Удалить хранилище",
"native_jobs": "Нативные задания бэкапов PVE",
"no_native_jobs": "Нативных заданий нет",
"schedule": "Расписание"
},
"backups": {
"title": "Бэкапы",
"files": "Файлы бэкапов",