feat: SSH keys management, backups fixes, instances/nodes/templates improvements, dev mode support
Some checks failed
Backend Tests / pytest (Python 3.12) (push) Has been cancelled

This commit is contained in:
markmorado
2026-05-07 00:30:21 +02:00
parent a92cb0df33
commit 323d1e57fa
40 changed files with 4112 additions and 288 deletions

View File

@@ -1,36 +1,326 @@
import { useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Archive, Play, Calendar } from 'lucide-react';
import { Archive, Play, Calendar, RotateCcw, Trash2, Loader2, Plus, Pencil } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { useServers } from '@/hooks/use-nodes';
import { useBackupJobs, useBackupList, useBackupStorages, useToggleBackupJob, useRunBackupJob } from '@/hooks/use-backups';
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogClose } from '@/components/ui/dialog';
import { useNodes, useServers } from '@/hooks/use-nodes';
import { useVirtualMachines } from '@/hooks/use-instances';
import {
useBackupJobs,
useBackupList,
useBackupStorages,
useToggleBackupJob,
useRunBackupJob,
useDeleteBackup,
useRestoreBackup,
useCreateBackupJob,
useUpdateBackupJob,
useDeleteBackupJob,
type BackupJobInput,
} from '@/hooks/use-backups';
import { formatBytes } from '@/lib/format';
import { toast } from 'sonner';
const ALL_NODES = '__all__';
type BackupItem = {
volid: string;
vmid: number;
size: number;
ctime: number;
format: string;
notes?: string;
node?: string;
};
type BackupJob = {
id: number;
server_id: number;
node: string;
vmids: (number | string)[];
storage: string;
mode: string;
compress: string;
notes?: string | null;
keep_last: number;
keep_daily: number;
keep_weekly: number;
keep_monthly: number;
cron_expression: string;
enabled: boolean;
};
const CRON_PRESETS: { label: string; value: string }[] = [
{ label: 'daily_2am', value: '0 2 * * *' },
{ label: 'daily_midnight', value: '0 0 * * *' },
{ label: 'every_6h', value: '0 */6 * * *' },
{ label: 'weekly_sunday', value: '0 3 * * 0' },
{ label: 'monthly_1st', value: '0 4 1 * *' },
];
const emptyJobForm = (serverId: number, node: string, storage: string): BackupJobInput => ({
server_id: serverId,
node,
vmids: [],
storage,
mode: 'snapshot',
compress: 'zstd',
notes: '',
keep_last: 3,
keep_daily: 7,
keep_weekly: 4,
keep_monthly: 6,
cron_expression: '0 2 * * *',
enabled: true,
});
// Detect VM type from a backup volid like
// local:backup/vzdump-qemu-100-2024_01_01-00_00_00.vma.zst
// local:backup/vzdump-lxc-101-...tar.zst
function detectVmType(volid: string): 'qemu' | 'lxc' {
const v = (volid || '').toLowerCase();
if (v.includes('vzdump-lxc-') || v.includes('-lxc-')) return 'lxc';
return 'qemu';
}
export default function BackupsPage() {
const { t } = useTranslation();
const [tab, setTab] = useState('files');
const [selectedServer, setSelectedServer] = useState<string>('');
const [selectedNode, setSelectedNode] = useState('');
const [selectedNode, setSelectedNode] = useState<string>(ALL_NODES);
const [selectedStorage, setSelectedStorage] = useState('');
const { data: servers = [] } = useServers();
const sid = selectedServer ? Number(selectedServer) : 0;
const { data: nodesData } = useNodes(sid);
const { data: storagesData } = useBackupStorages(sid);
const { data: backupsData } = useBackupList(sid, selectedNode, selectedStorage);
const nodeParam = selectedNode === ALL_NODES ? '' : selectedNode;
const { data: backupsData } = useBackupList(sid, nodeParam, selectedStorage);
const { data: jobsData } = useBackupJobs();
const toggleJob = useToggleBackupJob();
const runJob = useRunBackupJob();
const deleteBackup = useDeleteBackup();
const restoreBackup = useRestoreBackup();
const createJob = useCreateBackupJob();
const updateJob = useUpdateBackupJob();
const deleteJob = useDeleteBackupJob();
const storages = (storagesData?.storages || []) as { storage: string; type: string; content: string; used: number; total: number }[];
const backups = (backupsData?.backups || []) as { volid: string; vmid: number; size: number; ctime: number; format: string; notes?: string }[];
const jobs = (jobsData?.jobs || []) as { id: number; server_id: number; vmids: number[]; storage: string; mode: string; cron_expression: string; enabled: boolean; notes?: string }[];
// --- Job dialog state ---
const [jobDialogOpen, setJobDialogOpen] = useState(false);
const [editingJobId, setEditingJobId] = useState<number | null>(null);
const [jobForm, setJobForm] = useState<BackupJobInput>(() => emptyJobForm(0, '', ''));
// Selection state for the VMID picker. `selectAll` overrides the explicit Set.
const [selectedVmids, setSelectedVmids] = useState<Set<number>>(new Set());
const [selectAllVmids, setSelectAllVmids] = useState(false);
const [vmFilter, setVmFilter] = useState('');
const [deleteJobTarget, setDeleteJobTarget] = useState<BackupJob | null>(null);
// --- Restore dialog state ---
const [restoreTarget, setRestoreTarget] = useState<BackupItem | null>(null);
const [restoreNewVmid, setRestoreNewVmid] = useState('');
const [restoreStart, setRestoreStart] = useState(false);
const [restoreUnique, setRestoreUnique] = useState(true);
// --- Delete confirmation state ---
const [deleteTarget, setDeleteTarget] = useState<BackupItem | null>(null);
const nodes = useMemo(() => (nodesData?.nodes || []), [nodesData]);
const storages = useMemo(() => {
const list = (storagesData?.storages || []) as { storage: string; type: string; content: string; used: number; total: number }[];
return list.filter(s => (s.content || '').split(',').includes('backup'));
}, [storagesData]);
const backups = (backupsData?.backups || []) as BackupItem[];
const jobs = (jobsData?.jobs || []) as BackupJob[];
// Servers/storages for the job dialog (across all servers, not only the file-tab selection)
const jobServerId = jobForm.server_id;
const { data: jobNodesData } = useNodes(jobServerId || 0);
const { data: jobStoragesData } = useBackupStorages(jobServerId || 0);
const jobNodes = (jobNodesData?.nodes || []) as { node: string }[];
const jobStorages = useMemo(() => {
const list = (jobStoragesData?.storages || []) as { storage: string; type: string; content: string }[];
return list.filter(s => (s.content || '').split(',').includes('backup'));
}, [jobStoragesData]);
// VM/CT list for the picker (across all servers; filtered by server+node below)
const { data: allVms = [] } = useVirtualMachines();
const pickerVms = useMemo(() => {
if (!jobForm.server_id) return [];
let list = allVms.filter(v => v.server_id === jobForm.server_id && !v.template);
if (jobForm.node) list = list.filter(v => v.node === jobForm.node);
if (vmFilter.trim()) {
const q = vmFilter.trim().toLowerCase();
list = list.filter(v => String(v.vmid).includes(q) || (v.name || '').toLowerCase().includes(q));
}
return list.sort((a, b) => a.vmid - b.vmid);
}, [allVms, jobForm.server_id, jobForm.node, vmFilter]);
const serverNameById = useMemo(() => {
const map = new Map<number, string>();
for (const s of servers) map.set(s.id, s.name);
return map;
}, [servers]);
// Auto-select first server when list arrives
useEffect(() => {
if (!selectedServer && servers.length > 0) {
setSelectedServer(String(servers[0].id));
}
}, [servers, selectedServer]);
// Auto-select first backup-capable storage when none selected (or current is unavailable)
useEffect(() => {
if (!sid) return;
if (storages.length === 0) {
if (selectedStorage) setSelectedStorage('');
return;
}
if (!selectedStorage || !storages.some(s => s.storage === selectedStorage)) {
setSelectedStorage(storages[0].storage);
}
}, [storages, sid, selectedStorage]);
const openRestore = (b: BackupItem) => {
setRestoreTarget(b);
setRestoreNewVmid('');
setRestoreStart(false);
setRestoreUnique(true);
};
const submitRestore = () => {
if (!restoreTarget || !sid) return;
const node = restoreTarget.node || (selectedNode === ALL_NODES ? '' : selectedNode) || nodes[0]?.node;
if (!node) {
toast.error(t('backups.node_required'));
return;
}
const vmType = detectVmType(restoreTarget.volid);
const newVmidNum = restoreNewVmid ? Number(restoreNewVmid) : undefined;
if (restoreNewVmid && (!Number.isFinite(newVmidNum) || (newVmidNum as number) <= 0)) {
toast.error(t('backups.invalid_vmid'));
return;
}
restoreBackup.mutate(
{
server_id: sid,
node,
vmid: newVmidNum ?? restoreTarget.vmid,
archive: restoreTarget.volid,
storage: selectedStorage,
vm_type: vmType,
new_vmid: newVmidNum,
start: restoreStart,
...(vmType === 'qemu' ? { unique: restoreUnique } : {}),
},
{
onSuccess: () => {
toast.success(t('backups.restore_started'));
setRestoreTarget(null);
},
onError: (e: any) => toast.error(e?.message || t('backups.restore_failed')),
},
);
};
const openCreateJob = () => {
const initialServer = sid || (servers[0]?.id ?? 0);
setEditingJobId(null);
setJobForm(emptyJobForm(initialServer, '', ''));
setSelectedVmids(new Set());
setSelectAllVmids(false);
setVmFilter('');
setJobDialogOpen(true);
};
const openEditJob = (job: BackupJob) => {
setEditingJobId(job.id);
setJobForm({
server_id: job.server_id,
node: job.node,
vmids: job.vmids,
storage: job.storage,
mode: job.mode,
compress: job.compress,
notes: job.notes || '',
keep_last: job.keep_last,
keep_daily: job.keep_daily,
keep_weekly: job.keep_weekly,
keep_monthly: job.keep_monthly,
cron_expression: job.cron_expression,
enabled: job.enabled,
});
const isAll = (job.vmids || []).some(v => String(v).toLowerCase() === 'all');
setSelectAllVmids(isAll);
setSelectedVmids(
isAll
? new Set()
: new Set((job.vmids || []).map(v => Number(v)).filter(n => Number.isFinite(n))),
);
setVmFilter('');
setJobDialogOpen(true);
};
const submitJob = () => {
const vmids: (number | string)[] = selectAllVmids
? ['all']
: Array.from(selectedVmids).sort((a, b) => a - b);
if (!jobForm.server_id) { toast.error(t('backups.select_server')); return; }
if (!jobForm.node) { toast.error(t('backups.node_required')); return; }
if (!jobForm.storage) { toast.error(t('backups.select_storage')); return; }
if (vmids.length === 0) { toast.error(t('backups.vmids_required')); return; }
if (!jobForm.cron_expression?.trim()) { toast.error(t('backups.cron_required')); return; }
const payload: BackupJobInput = { ...jobForm, vmids };
if (editingJobId != null) {
updateJob.mutate(
{ id: editingJobId, data: payload },
{
onSuccess: () => { toast.success(t('backups.job_updated')); setJobDialogOpen(false); },
onError: (e: any) => toast.error(e?.message || t('backups.job_save_failed')),
},
);
} else {
createJob.mutate(payload, {
onSuccess: () => { toast.success(t('backups.job_created')); setJobDialogOpen(false); },
onError: (e: any) => toast.error(e?.message || t('backups.job_save_failed')),
});
}
};
const submitDeleteJob = () => {
if (!deleteJobTarget) return;
deleteJob.mutate(deleteJobTarget.id, {
onSuccess: () => { toast.success(t('backups.job_deleted')); setDeleteJobTarget(null); },
onError: (e: any) => toast.error(e?.message || t('backups.job_delete_failed')),
});
};
const submitDelete = () => {
if (!deleteTarget || !sid) return;
const node = deleteTarget.node || (selectedNode === ALL_NODES ? '' : selectedNode) || nodes[0]?.node;
if (!node) {
toast.error(t('backups.node_required'));
return;
}
deleteBackup.mutate(
{ server_id: sid, node, storage: selectedStorage, volid: deleteTarget.volid },
{
onSuccess: () => {
toast.success(t('backups.delete_success'));
setDeleteTarget(null);
},
onError: (e: any) => toast.error(e?.message || t('backups.delete_failed')),
},
);
};
return (
<div className="space-y-6">
@@ -45,15 +335,21 @@ export default function BackupsPage() {
<TabsContent value="files" className="space-y-4 mt-4">
{/* Filters */}
<div className="flex flex-wrap gap-3">
<Select value={selectedServer} onValueChange={v => { if (v !== null) { setSelectedServer(v); setSelectedNode(''); setSelectedStorage(''); } }}>
<Select value={selectedServer} onValueChange={v => { if (v !== null) { setSelectedServer(v); setSelectedNode(ALL_NODES); setSelectedStorage(''); } }}>
<SelectTrigger className="w-[200px]"><SelectValue placeholder={t('backups.select_server')} /></SelectTrigger>
<SelectContent>
{servers.map(s => <SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>)}
</SelectContent>
</Select>
<Input className="w-[160px]" placeholder={t('backups.node')} value={selectedNode} onChange={e => setSelectedNode(e.target.value)} />
<Select value={selectedStorage} onValueChange={v => { if (v !== null) setSelectedStorage(v); }}>
<SelectTrigger className="w-[200px]"><SelectValue placeholder={t('backups.select_storage')} /></SelectTrigger>
<Select value={selectedNode} onValueChange={v => { if (v !== null) setSelectedNode(v); }} disabled={!sid}>
<SelectTrigger className="w-[200px]"><SelectValue placeholder={t('backups.node')} /></SelectTrigger>
<SelectContent>
<SelectItem value={ALL_NODES}>{t('backups.all_nodes')}</SelectItem>
{nodes.map(n => <SelectItem key={n.node} value={n.node}>{n.node}</SelectItem>)}
</SelectContent>
</Select>
<Select value={selectedStorage} onValueChange={v => { if (v !== null) setSelectedStorage(v); }} disabled={!sid || storages.length === 0}>
<SelectTrigger className="w-[220px]"><SelectValue placeholder={t('backups.select_storage')} /></SelectTrigger>
<SelectContent>
{storages.map(s => <SelectItem key={s.storage} value={s.storage}>{s.storage} ({s.type})</SelectItem>)}
</SelectContent>
@@ -67,24 +363,55 @@ export default function BackupsPage() {
<TableHeader>
<TableRow>
<TableHead>VMID</TableHead>
<TableHead>{t('backups.type')}</TableHead>
<TableHead>{t('backups.node')}</TableHead>
<TableHead>{t('backups.volume')}</TableHead>
<TableHead>{t('backups.size')}</TableHead>
<TableHead>{t('backups.date')}</TableHead>
<TableHead>{t('backups.format')}</TableHead>
<TableHead>{t('backups.notes')}</TableHead>
<TableHead className="text-right">{t('common.actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{backups.map((b, i) => (
<TableRow key={i}>
<TableCell className="font-mono">{b.vmid}</TableCell>
<TableCell className="font-mono text-xs max-w-[300px] truncate">{b.volid}</TableCell>
<TableCell>{formatBytes(b.size)}</TableCell>
<TableCell>{new Date(b.ctime * 1000).toLocaleString()}</TableCell>
<TableCell><Badge variant="outline">{b.format}</Badge></TableCell>
<TableCell className="text-xs text-muted-foreground">{b.notes || '—'}</TableCell>
</TableRow>
))}
{backups.map((b, i) => {
const vmType = detectVmType(b.volid);
return (
<TableRow key={i}>
<TableCell className="font-mono">{b.vmid}</TableCell>
<TableCell>
<Badge variant="outline" className="uppercase text-xs">{vmType}</Badge>
</TableCell>
<TableCell className="font-mono text-xs">{b.node || '—'}</TableCell>
<TableCell className="font-mono text-xs max-w-[300px] truncate">{b.volid}</TableCell>
<TableCell>{formatBytes(b.size)}</TableCell>
<TableCell>{new Date(b.ctime * 1000).toLocaleString()}</TableCell>
<TableCell><Badge variant="outline">{b.format}</Badge></TableCell>
<TableCell className="text-xs text-muted-foreground">{b.notes || '—'}</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
<Button
variant="ghost"
size="sm"
title={t('backups.restore')}
onClick={() => openRestore(b)}
>
<RotateCcw className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
title={t('common.delete')}
onClick={() => setDeleteTarget(b)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
@@ -97,23 +424,32 @@ export default function BackupsPage() {
</TabsContent>
<TabsContent value="jobs" className="space-y-4 mt-4">
<div className="flex justify-end">
<Button size="sm" onClick={openCreateJob}>
<Plus className="h-4 w-4 mr-1" /> {t('backups.create_job')}
</Button>
</div>
{jobs.length > 0 ? (
<div className="rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead>{t('backups.server')}</TableHead>
<TableHead>{t('backups.node')}</TableHead>
<TableHead>{t('backups.vmids')}</TableHead>
<TableHead>{t('backups.storage_col')}</TableHead>
<TableHead>{t('backups.schedule')}</TableHead>
<TableHead>{t('common.status')}</TableHead>
<TableHead>{t('common.actions')}</TableHead>
<TableHead className="text-right">{t('common.actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{jobs.map(job => (
<TableRow key={job.id}>
<TableCell>{job.id}</TableCell>
<TableCell>{serverNameById.get(job.server_id) || job.server_id}</TableCell>
<TableCell className="font-mono text-xs">{job.node}</TableCell>
<TableCell className="font-mono text-xs">{job.vmids?.join(', ') || '—'}</TableCell>
<TableCell>{job.storage}</TableCell>
<TableCell className="font-mono text-xs">{job.cron_expression}</TableCell>
@@ -122,14 +458,20 @@ export default function BackupsPage() {
{job.enabled ? t('backups.enabled') : t('backups.disabled')}
</Badge>
</TableCell>
<TableCell>
<div className="flex gap-1">
<Button variant="ghost" size="sm" onClick={() => toggleJob.mutate(job.id)}>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
<Button variant="ghost" size="sm" title={job.enabled ? t('backups.disable') : t('backups.enable')} onClick={() => toggleJob.mutate(job.id)}>
{job.enabled ? t('backups.disable') : t('backups.enable')}
</Button>
<Button variant="ghost" size="sm" onClick={() => { runJob.mutate(job.id); toast.success(t('backups.job_started')); }}>
<Button variant="ghost" size="sm" title={t('backups.run_now')} onClick={() => { runJob.mutate(job.id); toast.success(t('backups.job_started')); }}>
<Play className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="sm" title={t('common.edit')} onClick={() => openEditJob(job)}>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="sm" className="text-destructive hover:text-destructive" title={t('common.delete')} onClick={() => setDeleteJobTarget(job)}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
@@ -145,6 +487,336 @@ export default function BackupsPage() {
)}
</TabsContent>
</Tabs>
{/* Restore dialog */}
<Dialog open={!!restoreTarget} onOpenChange={(o) => { if (!o) setRestoreTarget(null); }}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{t('backups.restore_title')}</DialogTitle>
</DialogHeader>
{restoreTarget && (
<div className="space-y-3 text-sm">
<p className="text-muted-foreground">{t('backups.restore_warning')}</p>
<div className="rounded-md border p-3 space-y-1 bg-muted/40">
<div><span className="text-muted-foreground">{t('backups.volume')}:</span> <span className="font-mono text-xs break-all">{restoreTarget.volid}</span></div>
<div><span className="text-muted-foreground">VMID:</span> <span className="font-mono">{restoreTarget.vmid}</span></div>
<div><span className="text-muted-foreground">{t('backups.type')}:</span> <Badge variant="outline" className="uppercase ml-1">{detectVmType(restoreTarget.volid)}</Badge></div>
<div><span className="text-muted-foreground">{t('backups.size')}:</span> {formatBytes(restoreTarget.size)}</div>
</div>
<div>
<Label>{t('backups.new_vmid_optional')}</Label>
<Input
className="mt-1"
type="number"
min={100}
placeholder={String(restoreTarget.vmid)}
value={restoreNewVmid}
onChange={(e) => setRestoreNewVmid(e.target.value)}
/>
<p className="mt-1 text-xs text-muted-foreground">{t('backups.new_vmid_hint')}</p>
</div>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={restoreStart}
onChange={(e) => setRestoreStart(e.target.checked)}
/>
<span>{t('backups.start_after_restore')}</span>
</label>
{detectVmType(restoreTarget.volid) === 'qemu' && (
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={restoreUnique}
onChange={(e) => setRestoreUnique(e.target.checked)}
/>
<span>{t('backups.unique_mac')}</span>
</label>
)}
</div>
)}
<DialogFooter>
<DialogClose render={<Button variant="outline" />}>{t('common.cancel')}</DialogClose>
<Button onClick={submitRestore} disabled={restoreBackup.isPending}>
{restoreBackup.isPending && <Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" />}
{t('backups.restore')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Job create/edit dialog */}
<Dialog open={jobDialogOpen} onOpenChange={(o) => { if (!o) setJobDialogOpen(false); }}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{editingJobId != null ? t('backups.edit_job') : t('backups.create_job')}</DialogTitle>
</DialogHeader>
<div className="space-y-3 text-sm">
<div className="grid grid-cols-2 gap-3">
<div>
<Label>{t('backups.server')}</Label>
<Select
value={jobForm.server_id ? String(jobForm.server_id) : ''}
onValueChange={(v) => { if (v !== null) setJobForm({ ...jobForm, server_id: Number(v), node: '', storage: '' }); }}
>
<SelectTrigger className="mt-1"><SelectValue placeholder={t('backups.select_server')} /></SelectTrigger>
<SelectContent>
{servers.map(s => <SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div>
<Label>{t('backups.node')}</Label>
<Select
value={jobForm.node}
onValueChange={(v) => { if (v !== null) setJobForm({ ...jobForm, node: v }); }}
>
<SelectTrigger className="mt-1"><SelectValue placeholder={t('backups.node')} /></SelectTrigger>
<SelectContent>
{jobNodes.map(n => <SelectItem key={n.node} value={n.node}>{n.node}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="col-span-2">
<Label>{t('backups.storage')}</Label>
<Select
value={jobForm.storage}
onValueChange={(v) => { if (v !== null) setJobForm({ ...jobForm, storage: v }); }}
>
<SelectTrigger className="mt-1"><SelectValue placeholder={t('backups.select_storage')} /></SelectTrigger>
<SelectContent>
{jobStorages.map(s => <SelectItem key={s.storage} value={s.storage}>{s.storage}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div>
<Label>{t('backups.mode')}</Label>
<Select value={jobForm.mode} onValueChange={(v) => { if (v !== null) setJobForm({ ...jobForm, mode: v }); }}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="snapshot">snapshot</SelectItem>
<SelectItem value="suspend">suspend</SelectItem>
<SelectItem value="stop">stop</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>{t('backups.compress')}</Label>
<Select value={jobForm.compress} onValueChange={(v) => { if (v !== null) setJobForm({ ...jobForm, compress: v }); }}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="zstd">zstd</SelectItem>
<SelectItem value="gzip">gzip</SelectItem>
<SelectItem value="lzo">lzo</SelectItem>
<SelectItem value="0">{t('backups.none')}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div>
<div className="flex items-center justify-between mb-1">
<Label>{t('backups.vmids')}</Label>
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<input
type="checkbox"
checked={selectAllVmids}
onChange={(e) => { setSelectAllVmids(e.target.checked); if (e.target.checked) setSelectedVmids(new Set()); }}
/>
<span>{t('backups.all_vms')}</span>
</label>
</div>
<div className="rounded-md border">
<div className="p-2 border-b">
<Input
placeholder={t('common.search')}
value={vmFilter}
onChange={(e) => setVmFilter(e.target.value)}
disabled={selectAllVmids}
className="h-8"
/>
</div>
<div className="max-h-56 overflow-y-auto p-2 space-y-1">
{!jobForm.server_id || !jobForm.node ? (
<p className="text-xs text-muted-foreground py-3 text-center">{t('backups.pick_server_node_first')}</p>
) : pickerVms.length === 0 ? (
<p className="text-xs text-muted-foreground py-3 text-center">{t('backups.no_vms_found')}</p>
) : (
<>
<div className="flex justify-between text-xs text-muted-foreground px-1 pb-1">
<button
type="button"
className="hover:text-foreground disabled:opacity-50"
disabled={selectAllVmids}
onClick={() => setSelectedVmids(new Set(pickerVms.map(v => v.vmid)))}
>
{t('backups.select_all_visible')}
</button>
<button
type="button"
className="hover:text-foreground disabled:opacity-50"
disabled={selectAllVmids}
onClick={() => setSelectedVmids(new Set())}
>
{t('backups.clear_selection')}
</button>
</div>
{pickerVms.map(vm => {
const checked = selectAllVmids || selectedVmids.has(vm.vmid);
return (
<label
key={`${vm.server_id}-${vm.vmid}`}
className="flex items-center gap-2 px-2 py-1 rounded hover:bg-muted cursor-pointer text-sm"
>
<input
type="checkbox"
checked={checked}
disabled={selectAllVmids}
onChange={(e) => {
setSelectedVmids(prev => {
const next = new Set(prev);
if (e.target.checked) next.add(vm.vmid);
else next.delete(vm.vmid);
return next;
});
}}
/>
<Badge variant="outline" className="uppercase text-[10px] px-1">{vm.type}</Badge>
<span className="font-mono text-xs w-12">{vm.vmid}</span>
<span className="truncate">{vm.name || '—'}</span>
<span className="ml-auto text-xs text-muted-foreground font-mono">{vm.node}</span>
</label>
);
})}
</>
)}
</div>
<div className="px-2 py-1 border-t text-xs text-muted-foreground">
{selectAllVmids
? t('backups.all_vms_selected')
: t('backups.selected_count', { count: selectedVmids.size })}
</div>
</div>
</div>
<div>
<Label>{t('backups.schedule')} (cron)</Label>
<div className="mt-1 flex gap-2">
<Input
className="font-mono"
value={jobForm.cron_expression}
onChange={(e) => setJobForm({ ...jobForm, cron_expression: e.target.value })}
placeholder="0 2 * * *"
/>
<Select value="" onValueChange={(v) => { if (v) setJobForm({ ...jobForm, cron_expression: v }); }}>
<SelectTrigger className="w-[180px]"><SelectValue placeholder={t('backups.preset')} /></SelectTrigger>
<SelectContent>
{CRON_PRESETS.map(p => (
<SelectItem key={p.value} value={p.value}>{t(`backups.cron.${p.label}`)}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<p className="mt-1 text-xs text-muted-foreground">{t('backups.cron_hint')}</p>
</div>
<div className="grid grid-cols-4 gap-3 items-end">
<div>
<Label className="whitespace-nowrap">keep-last</Label>
<Input className="mt-1" type="number" min={0} value={jobForm.keep_last ?? 0}
onChange={(e) => setJobForm({ ...jobForm, keep_last: Number(e.target.value) })} />
</div>
<div>
<Label className="whitespace-nowrap">keep-daily</Label>
<Input className="mt-1" type="number" min={0} value={jobForm.keep_daily ?? 0}
onChange={(e) => setJobForm({ ...jobForm, keep_daily: Number(e.target.value) })} />
</div>
<div>
<Label className="whitespace-nowrap">keep-weekly</Label>
<Input className="mt-1" type="number" min={0} value={jobForm.keep_weekly ?? 0}
onChange={(e) => setJobForm({ ...jobForm, keep_weekly: Number(e.target.value) })} />
</div>
<div>
<Label className="whitespace-nowrap">keep-monthly</Label>
<Input className="mt-1" type="number" min={0} value={jobForm.keep_monthly ?? 0}
onChange={(e) => setJobForm({ ...jobForm, keep_monthly: Number(e.target.value) })} />
</div>
</div>
<div>
<Label>{t('backups.notes')}</Label>
<Input className="mt-1" value={jobForm.notes ?? ''} onChange={(e) => setJobForm({ ...jobForm, notes: e.target.value })} />
</div>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={!!jobForm.enabled}
onChange={(e) => setJobForm({ ...jobForm, enabled: e.target.checked })}
/>
<span>{t('backups.enabled')}</span>
</label>
</div>
<DialogFooter>
<DialogClose render={<Button variant="outline" />}>{t('common.cancel')}</DialogClose>
<Button onClick={submitJob} disabled={createJob.isPending || updateJob.isPending}>
{(createJob.isPending || updateJob.isPending) && <Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" />}
{editingJobId != null ? t('common.save') : t('common.create')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Job delete confirmation */}
<Dialog open={!!deleteJobTarget} onOpenChange={(o) => { if (!o) setDeleteJobTarget(null); }}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{t('backups.delete_job_title')}</DialogTitle>
</DialogHeader>
{deleteJobTarget && (
<div className="space-y-3 text-sm">
<p className="text-muted-foreground">{t('backups.delete_job_confirm')}</p>
<div className="rounded-md border p-3 space-y-1 bg-muted/40">
<div><span className="text-muted-foreground">ID:</span> {deleteJobTarget.id}</div>
<div><span className="text-muted-foreground">{t('backups.schedule')}:</span> <span className="font-mono text-xs">{deleteJobTarget.cron_expression}</span></div>
<div><span className="text-muted-foreground">{t('backups.vmids')}:</span> <span className="font-mono text-xs">{deleteJobTarget.vmids?.join(', ')}</span></div>
</div>
</div>
)}
<DialogFooter>
<DialogClose render={<Button variant="outline" />}>{t('common.cancel')}</DialogClose>
<Button variant="destructive" onClick={submitDeleteJob} disabled={deleteJob.isPending}>
{deleteJob.isPending && <Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" />}
{t('common.delete')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete backup confirmation */}
<Dialog open={!!deleteTarget} onOpenChange={(o) => { if (!o) setDeleteTarget(null); }}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{t('backups.delete_title')}</DialogTitle>
</DialogHeader>
{deleteTarget && (
<div className="space-y-3 text-sm">
<p className="text-muted-foreground">{t('backups.delete_confirm')}</p>
<div className="rounded-md border p-3 space-y-1 bg-muted/40">
<div><span className="text-muted-foreground">{t('backups.volume')}:</span> <span className="font-mono text-xs break-all">{deleteTarget.volid}</span></div>
<div><span className="text-muted-foreground">{t('backups.size')}:</span> {formatBytes(deleteTarget.size)}</div>
</div>
</div>
)}
<DialogFooter>
<DialogClose render={<Button variant="outline" />}>{t('common.cancel')}</DialogClose>
<Button variant="destructive" onClick={submitDelete} disabled={deleteBackup.isPending}>
{deleteBackup.isPending && <Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" />}
{t('common.delete')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -2,7 +2,7 @@ import { useState } from 'react';
import { useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next';
import { useMutation } from '@tanstack/react-query';
import { Server, Cpu, CheckCircle, ChevronLeft, ChevronRight, Loader2, Monitor, Container, HardDrive } from 'lucide-react';
import { Server, Cpu, CheckCircle, ChevronLeft, ChevronRight, Loader2, Monitor, Container, HardDrive, KeyRound } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@@ -13,6 +13,9 @@ import { useServers } from '@/hooks/use-nodes';
import { useTemplates, useTemplateGroups } from '@/hooks/use-templates';
import { useIPAMNetworks } from '@/hooks/use-ipam';
import { useLXCTemplates, useLXCStorages, useDeployLXC } from '@/hooks/use-lxc-templates';
import { useMySSHKeys, useUserSSHKeys } from '@/hooks/use-ssh-keys';
import { useProfile } from '@/hooks/use-settings';
import { useUsers } from '@/hooks/use-users';
import { apiClient } from '@/lib/api-client';
import { useDeployTasksStore } from '@/stores/deploy-tasks-store';
import type { VMDeployRequest, OSTemplate, ProxmoxServer, LXCTemplate } from '@/types';
@@ -97,6 +100,21 @@ export default function CreateInstanceWizard({ onClose }: { onClose?: () => void
const { data: lxcTemplates = [], isLoading: lxcLoading } = useLXCTemplates(selectedServer?.id);
const { data: lxcStorages = [] } = useLXCStorages(selectedServer?.id);
// ── SSH Keys & ownership ──────────────────────────────────────────────────
const { data: profile } = useProfile();
const isAdmin = !!profile?.is_admin;
const { data: allUsers = [] } = useUsers();
const [ownerId, setOwnerId] = useState<number | null>(null);
const effectiveOwnerId = isAdmin && ownerId ? ownerId : null;
const { data: myKeys = [] } = useMySSHKeys();
const { data: targetUserKeys = [] } = useUserSSHKeys(effectiveOwnerId);
const [selectedKeyIds, setSelectedKeyIds] = useState<number[]>([]);
const availableKeys = isAdmin && effectiveOwnerId
? [...myKeys, ...targetUserKeys.filter(k => !myKeys.find(m => m.id === k.id))]
: myKeys;
const toggleKey = (id: number) =>
setSelectedKeyIds(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]);
const deploy = useMutation({
mutationFn: (data: VMDeployRequest) =>
apiClient.post<{ task_id: number; status: string; name: string }>('/templates/api/deploy', data),
@@ -156,6 +174,8 @@ export default function CreateInstanceWizard({ onClose }: { onClose?: () => void
ipam_network_id: lxcConfig.ipam_network_id || undefined,
password: lxcConfig.password || undefined,
ssh_keys: lxcConfig.ssh_keys || undefined,
ssh_key_ids: selectedKeyIds.length ? selectedKeyIds : undefined,
owner_id: effectiveOwnerId ?? undefined,
nameserver: lxcConfig.nameserver || undefined,
unprivileged: lxcConfig.unprivileged,
start_after_create: lxcConfig.start_after_create,
@@ -197,6 +217,8 @@ export default function CreateInstanceWizard({ onClose }: { onClose?: () => void
cloud_init_user: config.cloud_init_user || undefined,
cloud_init_password: config.cloud_init_password || undefined,
ssh_keys: config.ssh_keys || undefined,
ssh_key_ids: selectedKeyIds.length ? selectedKeyIds : undefined,
owner_id: effectiveOwnerId ?? undefined,
});
}
};
@@ -487,8 +509,16 @@ export default function CreateInstanceWizard({ onClose }: { onClose?: () => void
<Label>{t('wizard.ci_password')}</Label>
<Input type="password" value={config.cloud_init_password} onChange={e => setConfig(p => ({ ...p, cloud_init_password: e.target.value }))} />
</div>
{isAdmin && (
<SSHOwnerSelector
users={allUsers}
value={ownerId}
onChange={(v) => { setOwnerId(v); setSelectedKeyIds([]); }}
/>
)}
<SSHKeyPicker keys={availableKeys} selected={selectedKeyIds} onToggle={toggleKey} />
<div>
<Label>SSH Keys</Label>
<Label>SSH Keys ({t('common.optional')})</Label>
<textarea
className="w-full rounded-md border bg-transparent px-3 py-2 text-sm font-mono min-h-[60px]"
value={config.ssh_keys}
@@ -628,8 +658,16 @@ export default function CreateInstanceWizard({ onClose }: { onClose?: () => void
placeholder={t('wizard.lxc_password_placeholder')}
/>
</div>
{isAdmin && (
<SSHOwnerSelector
users={allUsers}
value={ownerId}
onChange={(v) => { setOwnerId(v); setSelectedKeyIds([]); }}
/>
)}
<SSHKeyPicker keys={availableKeys} selected={selectedKeyIds} onToggle={toggleKey} />
<div>
<Label>SSH Keys</Label>
<Label>SSH Keys ({t('common.optional')})</Label>
<textarea
className="w-full rounded-md border bg-transparent px-3 py-2 text-sm font-mono min-h-[60px]"
value={lxcConfig.ssh_keys}
@@ -716,3 +754,71 @@ function Row({ label, value }: { label: string; value: string }) {
</div>
);
}
function SSHOwnerSelector({
users, value, onChange,
}: {
users: { id: number; username: string; full_name?: string | null }[];
value: number | null;
onChange: (v: number | null) => void;
}) {
const { t } = useTranslation();
return (
<div>
<Label>{t('wizard.ssh_keys_owner')}</Label>
<Select value={value ? String(value) : '__none__'} onValueChange={v => onChange(v === '__none__' ? null : Number(v))}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="__none__">{t('wizard.ssh_keys_owner_self')}</SelectItem>
{users.map(u => (
<SelectItem key={u.id} value={String(u.id)}>
{u.username}{u.full_name ? ` (${u.full_name})` : ''}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
function SSHKeyPicker({
keys, selected, onToggle,
}: {
keys: { id: number; name: string; fingerprint?: string | null; comment?: string | null }[];
selected: number[];
onToggle: (id: number) => void;
}) {
const { t } = useTranslation();
return (
<div>
<Label className="flex items-center gap-2">
<KeyRound className="h-3.5 w-3.5" />
{t('wizard.ssh_keys_select')}
</Label>
{keys.length === 0 ? (
<p className="text-xs text-muted-foreground mt-1">{t('wizard.no_saved_keys')}</p>
) : (
<div className="mt-1 space-y-1 max-h-40 overflow-y-auto rounded-md border p-2">
{keys.map(k => (
<label
key={k.id}
className="flex items-start gap-2 text-xs cursor-pointer hover:bg-accent rounded px-2 py-1"
>
<input
type="checkbox"
className="h-4 w-4 mt-0.5"
checked={selected.includes(k.id)}
onChange={() => onToggle(k.id)}
/>
<div className="min-w-0 flex-1">
<div className="font-medium truncate">{k.name}</div>
{k.fingerprint && <div className="font-mono text-muted-foreground truncate">{k.fingerprint}</div>}
{k.comment && <div className="text-muted-foreground truncate">{k.comment}</div>}
</div>
</label>
))}
</div>
)}
</div>
);
}

View File

@@ -65,7 +65,8 @@ import {
} from '@/components/ui/table';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { StatusDot } from '@/components/shared/status-dot';
import { useVirtualMachines, useBulkOperation, usePowerAction, useVMStatusSync } from '@/hooks/use-instances';
import { useVirtualMachines, useBulkOperation, usePowerAction, useVMStatusSync, useInstancesMetricsSync } from '@/hooks/use-instances';
import { useServers } from '@/hooks/use-nodes';
import { formatBytes, vmTypeLabel, formatUptime } from '@/lib/format';
import { apiClient } from '@/lib/api-client';
import { useDeployTasksStore } from '@/stores/deploy-tasks-store';
@@ -220,6 +221,7 @@ export default function InstancesPage() {
const { t } = useTranslation();
const { data: vms, isLoading } = useVirtualMachines();
useVMStatusSync();
const metricsMap = useInstancesMetricsSync();
const bulkOp = useBulkOperation();
// ── Deploy task polling ──────────────────────────────────────────
@@ -255,8 +257,11 @@ export default function InstancesPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeTasks.length, activeTasks.map((t) => t.id).join(',')]);
const { data: servers = [] } = useServers();
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState<string>('all');
const [serverFilter, setServerFilter] = useState<string>('all');
const [nodeFilter, setNodeFilter] = useState<string>(() => {
return localStorage.getItem('instances-node-filter') ?? 'all';
});
@@ -271,6 +276,7 @@ export default function InstancesPage() {
if (typeTab === 'vm' && vm.type !== 'qemu') return false;
if (typeTab === 'lxc' && vm.type !== 'lxc') return false;
if (statusFilter !== 'all' && vm.status !== statusFilter) return false;
if (serverFilter !== 'all' && vm.server_id !== Number(serverFilter)) return false;
if (nodeFilter !== 'all' && vm.node !== nodeFilter) return false;
if (search) {
const q = search.toLowerCase();
@@ -281,7 +287,7 @@ export default function InstancesPage() {
}
return true;
});
}, [vms, typeTab, statusFilter, nodeFilter, search]);
}, [vms, typeTab, statusFilter, serverFilter, nodeFilter, search]);
// Ghost rows from active deploy/clone tasks (prepended before real VMs).
// - reinstall / change_password tasks are NOT shown as ghosts (they live as overlays on real rows)
@@ -322,11 +328,35 @@ export default function InstancesPage() {
const allRows = useMemo<VMInstance[]>(() => [...ghostRows, ...filteredVMs], [ghostRows, filteredVMs]);
// Unique nodes for filter
// Merge live WS metrics into rows for real-time rendering
const liveRows = useMemo<VMInstance[]>(() => {
if (!metricsMap.size) return allRows;
return allRows.map((vm) => {
const live = metricsMap.get(`${vm.server_id}:${vm.vmid}`);
if (!live) return vm;
return {
...vm,
status: (live.status as VMInstance['status']) ?? vm.status,
cpu: live.cpu ?? vm.cpu,
mem: live.mem ?? vm.mem,
maxmem: live.maxmem ?? vm.maxmem,
disk: live.disk ?? vm.disk,
maxdisk: live.maxdisk ?? vm.maxdisk,
uptime: live.uptime ?? vm.uptime,
netin: live.netin ?? vm.netin,
netout: live.netout ?? vm.netout,
};
});
}, [allRows, metricsMap]);
// Unique nodes for filter — scoped to selected server
const uniqueNodes = useMemo(() => {
if (!vms) return [];
return [...new Set(vms.map((v) => v.node))].sort();
}, [vms]);
const source = serverFilter !== 'all'
? vms.filter((v) => v.server_id === Number(serverFilter))
: vms;
return [...new Set(source.map((v) => v.node))].sort();
}, [vms, serverFilter]);
// Counts (ghosts not counted in tabs)
const counts = useMemo(() => {
@@ -616,7 +646,7 @@ export default function InstancesPage() {
// ==================== TanStack Table ====================
const table = useReactTable({
data: allRows,
data: liveRows,
columns,
state: { sorting, rowSelection },
onSortingChange: setSorting,
@@ -680,6 +710,20 @@ export default function InstancesPage() {
</SelectContent>
</Select>
{servers.length > 1 && (
<Select value={serverFilter} onValueChange={v => { if (v !== null) { setServerFilter(v); setNodeFilter('all'); } }}>
<SelectTrigger className="w-[150px]">
<SelectValue placeholder={t('backups.select_server', 'Server')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('instances.all_nodes', 'All servers')}</SelectItem>
{servers.map((s) => (
<SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>
))}
</SelectContent>
</Select>
)}
<Select value={nodeFilter} onValueChange={v => { if (v !== null) { setNodeFilter(v); localStorage.setItem('instances-node-filter', v); } }}>
<SelectTrigger className="w-[130px]">
<SelectValue placeholder={t('common.node', 'Node')} />

View File

@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { Link } from 'react-router';
import { useTranslation } from 'react-i18next';
import { Server, Monitor, Container, Plus, Pencil, Trash2, Wifi, Loader2 } from 'lucide-react';
@@ -8,7 +8,7 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogClose } from '@/components/ui/dialog';
import { useServers, useCreateServer, useUpdateServer, useDeleteServer, useTestServer } from '@/hooks/use-nodes';
import { useServers, useCreateServer, useUpdateServer, useDeleteServer, useTestServerCredentials } from '@/hooks/use-nodes';
import { useVirtualMachines } from '@/hooks/use-instances';
import type { ProxmoxServerCreate } from '@/types';
import { toast } from 'sonner';
@@ -48,7 +48,6 @@ function ServerFormDialog({
initialData,
onSubmit,
isPending,
serverId,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
@@ -60,18 +59,17 @@ function ServerFormDialog({
}) {
const { t } = useTranslation();
const [form, setForm] = useState<ServerFormData>(initialData);
const testServer = useTestServer();
const testServer = useTestServerCredentials();
// Sync initialData when dialog opens
const handleOpen = (v: boolean) => {
if (v) setForm(initialData);
onOpenChange(v);
};
// Sync initialData whenever dialog opens (also covers programmatic open changes)
useEffect(() => {
if (open) setForm(initialData);
}, [open, initialData]);
const set = (field: keyof ServerFormData, value: string | boolean) =>
setForm(prev => ({ ...prev, [field]: value }));
const handleSubmit = () => {
const buildPayload = (): ProxmoxServerCreate => {
const payload: ProxmoxServerCreate = {
name: form.name,
hostname: form.hostname || form.ip_address,
@@ -88,12 +86,19 @@ function ServerFormDialog({
payload.api_token_name = form.api_token_name;
payload.api_token_value = form.api_token_value;
}
onSubmit(payload);
return payload;
};
const handleSubmit = () => {
onSubmit(buildPayload());
};
const handleTest = () => {
if (!serverId) return;
testServer.mutate(serverId, {
if (form.use_password ? !form.password : (!form.api_token_name || !form.api_token_value)) {
toast.error(t('nodes.connection_failed'));
return;
}
testServer.mutate(buildPayload(), {
onSuccess: (res) => {
if (res.success) toast.success(t('nodes.connection_ok'));
else toast.error(t('nodes.connection_failed') + (res.message ? `: ${res.message}` : ''));
@@ -103,7 +108,7 @@ function ServerFormDialog({
};
return (
<Dialog open={open} onOpenChange={handleOpen}>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
<DialogHeader><DialogTitle>{title}</DialogTitle></DialogHeader>
<div className="space-y-3">
@@ -172,12 +177,15 @@ function ServerFormDialog({
</div>
</div>
<DialogFooter className="gap-2">
{serverId && (
<Button variant="outline" size="sm" onClick={handleTest} disabled={testServer.isPending}>
{testServer.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Wifi className="h-4 w-4 mr-1" />}
{t('nodes.test_connection')}
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={handleTest}
disabled={testServer.isPending || !form.ip_address}
>
{testServer.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Wifi className="h-4 w-4 mr-1" />}
{t('nodes.test_connection')}
</Button>
<DialogClose render={<Button variant="outline" />}>{t('common.cancel')}</DialogClose>
<Button onClick={handleSubmit} disabled={isPending || !form.name || !form.ip_address}>
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}

View File

@@ -0,0 +1,286 @@
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { Plus, Trash2, Download, Pencil, KeyRound, Copy } from 'lucide-react';
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 {
Dialog, DialogClose, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger,
} from '@/components/ui/dialog';
import {
type SSHKey,
useMySSHKeys, useCreateMySSHKey, useUpdateMySSHKey, useDeleteMySSHKey, useDownloadMyPrivateKey,
useUserSSHKeys, useAdminCreateUserSSHKey, useAdminUpdateUserSSHKey,
useAdminDeleteUserSSHKey, useAdminDownloadUserPrivateKey,
} from '@/hooks/use-ssh-keys';
interface Props {
/** When provided + admin=true, manage another user's keys via admin endpoints. */
userId?: number | null;
admin?: boolean;
}
function getErrMsg(err: unknown): string {
if (err instanceof Error) return err.message;
return 'Error';
}
export function SSHKeysManager({ userId = null, admin = false }: Props) {
const { t } = useTranslation();
const myList = useMySSHKeys();
const adminList = useUserSSHKeys(admin && userId ? userId : null);
const myCreate = useCreateMySSHKey();
const myUpdate = useUpdateMySSHKey();
const myDelete = useDeleteMySSHKey();
const myDownload = useDownloadMyPrivateKey();
const adminCreate = useAdminCreateUserSSHKey();
const adminUpdate = useAdminUpdateUserSSHKey();
const adminDelete = useAdminDeleteUserSSHKey();
const adminDownload = useAdminDownloadUserPrivateKey();
const isAdminMode = admin && !!userId;
const list = isAdminMode ? adminList : myList;
const keys: SSHKey[] = list.data ?? [];
const [editing, setEditing] = useState<SSHKey | null>(null);
const [createOpen, setCreateOpen] = useState(false);
const onCreate = (data: { name: string; public_key: string; private_key?: string; comment?: string }) => {
if (isAdminMode && userId) {
adminCreate.mutate(
{ userId, data },
{
onSuccess: () => { setCreateOpen(false); toast.success(t('ssh_keys.created')); },
onError: e => toast.error(getErrMsg(e)),
},
);
} else {
myCreate.mutate(data, {
onSuccess: () => { setCreateOpen(false); toast.success(t('ssh_keys.created')); },
onError: e => toast.error(getErrMsg(e)),
});
}
};
const onUpdate = (id: number, data: { name?: string; public_key?: string; private_key?: string; comment?: string }) => {
if (isAdminMode && userId) {
adminUpdate.mutate(
{ userId, keyId: id, data },
{
onSuccess: () => { setEditing(null); toast.success(t('common.saved')); },
onError: e => toast.error(getErrMsg(e)),
},
);
} else {
myUpdate.mutate(
{ id, data },
{
onSuccess: () => { setEditing(null); toast.success(t('common.saved')); },
onError: e => toast.error(getErrMsg(e)),
},
);
}
};
const onDelete = (id: number) => {
if (!confirm(t('ssh_keys.confirm_delete'))) return;
if (isAdminMode && userId) {
adminDelete.mutate({ userId, keyId: id }, {
onSuccess: () => toast.success(t('ssh_keys.deleted')),
onError: e => toast.error(getErrMsg(e)),
});
} else {
myDelete.mutate(id, {
onSuccess: () => toast.success(t('ssh_keys.deleted')),
onError: e => toast.error(getErrMsg(e)),
});
}
};
const onDownload = (key: SSHKey) => {
const onSuccess = (res: { name: string; private_key: string }) => {
const blob = new Blob([res.private_key], { type: 'application/octet-stream' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${res.name.replace(/[^a-z0-9._-]+/gi, '_')}.key`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
};
if (isAdminMode && userId) {
adminDownload.mutate({ userId, keyId: key.id }, {
onSuccess,
onError: e => toast.error(getErrMsg(e)),
});
} else {
myDownload.mutate(key.id, {
onSuccess,
onError: e => toast.error(getErrMsg(e)),
});
}
};
const copyPublic = (k: SSHKey) => {
navigator.clipboard.writeText(k.public_key).then(
() => toast.success(t('ssh_keys.copied')),
() => toast.error(t('ssh_keys.copy_failed')),
);
};
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<KeyRound className="h-4 w-4" />
<span className="text-sm font-medium">{t('ssh_keys.title')}</span>
<Badge variant="outline" className="text-xs">{keys.length}</Badge>
</div>
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogTrigger render={<Button size="sm"><Plus className="mr-1 h-4 w-4" />{t('ssh_keys.add')}</Button>} />
<SSHKeyDialog
mode="create"
onSubmit={onCreate}
submitting={isAdminMode ? adminCreate.isPending : myCreate.isPending}
/>
</Dialog>
</div>
{list.isLoading ? (
<p className="text-sm text-muted-foreground">{t('common.loading')}</p>
) : keys.length === 0 ? (
<p className="text-sm text-muted-foreground py-4 text-center">{t('ssh_keys.empty')}</p>
) : (
<div className="space-y-2">
{keys.map(k => (
<div key={k.id} className="rounded-md border p-3 space-y-2">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium truncate">{k.name}</span>
{k.has_private_key && (
<Badge variant="outline" className="text-xs">{t('ssh_keys.has_private')}</Badge>
)}
</div>
{k.comment && (
<p className="text-xs text-muted-foreground mt-0.5">{k.comment}</p>
)}
{k.fingerprint && (
<p className="text-xs font-mono text-muted-foreground mt-1 truncate">{k.fingerprint}</p>
)}
</div>
<div className="flex shrink-0 gap-1">
<Button size="sm" variant="ghost" onClick={() => copyPublic(k)} title={t('ssh_keys.copy_public')}>
<Copy className="h-3.5 w-3.5" />
</Button>
{k.has_private_key && (
<Button size="sm" variant="ghost" onClick={() => onDownload(k)} title={t('ssh_keys.download_private')}>
<Download className="h-3.5 w-3.5" />
</Button>
)}
<Button size="sm" variant="ghost" onClick={() => setEditing(k)} title={t('common.edit')}>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button size="sm" variant="ghost" onClick={() => onDelete(k.id)} title={t('common.delete')}>
<Trash2 className="h-3.5 w-3.5 text-destructive" />
</Button>
</div>
</div>
<p className="text-xs font-mono break-all bg-muted/40 rounded px-2 py-1">
{k.public_key.length > 120 ? `${k.public_key.slice(0, 120)}…` : k.public_key}
</p>
</div>
))}
</div>
)}
{editing && (
<Dialog open={!!editing} onOpenChange={o => !o && setEditing(null)}>
<SSHKeyDialog
mode="edit"
initial={editing}
onSubmit={data => onUpdate(editing.id, data)}
submitting={isAdminMode ? adminUpdate.isPending : myUpdate.isPending}
/>
</Dialog>
)}
</div>
);
}
function SSHKeyDialog({
mode, initial, onSubmit, submitting,
}: {
mode: 'create' | 'edit';
initial?: SSHKey;
onSubmit: (data: { name: string; public_key: string; private_key?: string; comment?: string }) => void;
submitting: boolean;
}) {
const { t } = useTranslation();
const [name, setName] = useState(initial?.name ?? '');
const [publicKey, setPublicKey] = useState(initial?.public_key ?? '');
const [privateKey, setPrivateKey] = useState('');
const [comment, setComment] = useState(initial?.comment ?? '');
const valid = useMemo(() => name.trim().length > 0 && publicKey.trim().length >= 20, [name, publicKey]);
const submit = () => {
onSubmit({
name: name.trim(),
public_key: publicKey.trim(),
private_key: privateKey.trim() || undefined,
comment: comment.trim() || undefined,
});
};
return (
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{mode === 'create' ? t('ssh_keys.add') : t('ssh_keys.edit')}</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<div>
<Label className="text-xs">{t('common.name')}</Label>
<Input value={name} onChange={e => setName(e.target.value)} className="mt-1" placeholder="laptop / work / ci" />
</div>
<div>
<Label className="text-xs">{t('ssh_keys.public_key')} <span className="text-destructive">*</span></Label>
<textarea
className="mt-1 w-full rounded-md border bg-transparent px-3 py-2 text-xs font-mono min-h-[100px]"
value={publicKey}
onChange={e => setPublicKey(e.target.value)}
placeholder="ssh-ed25519 AAAA... user@host"
/>
</div>
<div>
<Label className="text-xs">
{t('ssh_keys.private_key')} <span className="text-muted-foreground">({t('common.optional')})</span>
</Label>
<textarea
className="mt-1 w-full rounded-md border bg-transparent px-3 py-2 text-xs font-mono min-h-[120px]"
value={privateKey}
onChange={e => setPrivateKey(e.target.value)}
placeholder={mode === 'edit' ? t('ssh_keys.private_key_replace_hint') : '-----BEGIN OPENSSH PRIVATE KEY-----\n...'}
/>
<p className="text-xs text-muted-foreground mt-1">{t('ssh_keys.private_key_hint')}</p>
</div>
<div>
<Label className="text-xs">{t('ssh_keys.comment')}</Label>
<Input value={comment} onChange={e => setComment(e.target.value)} className="mt-1" />
</div>
</div>
<DialogFooter>
<DialogClose render={<Button variant="outline" />}>{t('common.cancel')}</DialogClose>
<Button onClick={submit} disabled={!valid || submitting}>
{mode === 'create' ? t('common.create') : t('common.save')}
</Button>
</DialogFooter>
</DialogContent>
);
}

View File

@@ -14,6 +14,7 @@ import {
useNotificationChannels, useUpdateSMTP, useUpdateTelegram,
useAppVersion, useCheckUpdates,
} from '@/hooks/use-settings';
import { SSHKeysManager } from './SSHKeysManager';
export default function SettingsPage() {
const { t } = useTranslation();
@@ -23,12 +24,19 @@ export default function SettingsPage() {
<Tabs defaultValue="profile">
<TabsList>
<TabsTrigger value="profile">{t('settings.profile')}</TabsTrigger>
<TabsTrigger value="ssh-keys">{t('ssh_keys.title')}</TabsTrigger>
<TabsTrigger value="panel">{t('settings.panel')}</TabsTrigger>
<TabsTrigger value="security">{t('settings.security')}</TabsTrigger>
<TabsTrigger value="notifications">{t('settings.notifications')}</TabsTrigger>
<TabsTrigger value="about">{t('settings.about')}</TabsTrigger>
</TabsList>
<TabsContent value="profile"><ProfileTab /></TabsContent>
<TabsContent value="ssh-keys">
<Card>
<CardHeader><CardTitle className="text-sm">{t('ssh_keys.title')}</CardTitle></CardHeader>
<CardContent><SSHKeysManager /></CardContent>
</Card>
</TabsContent>
<TabsContent value="panel"><PanelTab /></TabsContent>
<TabsContent value="security"><SecurityTab /></TabsContent>
<TabsContent value="notifications"><NotificationsTab /></TabsContent>

View File

@@ -16,6 +16,7 @@ export default function TemplatesPage() {
const [search, setSearch] = useState('');
const [groupFilter, setGroupFilter] = useState<string>('');
const [serverFilter, setServerFilter] = useState<string>('');
const [typeFilter, setTypeFilter] = useState<string>('');
const [importDialogOpen, setImportDialogOpen] = useState(false);
const [importServerId, setImportServerId] = useState<string>('');
@@ -23,6 +24,7 @@ export default function TemplatesPage() {
const { data: templates = [], isLoading } = useTemplates(
groupFilter ? Number(groupFilter) : undefined,
serverFilter ? Number(serverFilter) : undefined,
typeFilter || undefined,
);
const { data: servers = [] } = useServers();
const autoImport = useAutoImportTemplates();
@@ -72,6 +74,14 @@ export default function TemplatesPage() {
{servers.map(s => <SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>)}
</SelectContent>
</Select>
<Select value={typeFilter} onValueChange={v => { if (v !== null) setTypeFilter(v); }}>
<SelectTrigger className="w-[160px]"><SelectValue placeholder={t('templates.all_types')} /></SelectTrigger>
<SelectContent>
<SelectItem value="">{t('templates.all_types')}</SelectItem>
<SelectItem value="qemu">{t('templates.type_kvm')}</SelectItem>
<SelectItem value="lxc">{t('templates.type_lxc')}</SelectItem>
</SelectContent>
</Select>
</div>
{/* Templates grid */}
@@ -109,13 +119,16 @@ export default function TemplatesPage() {
</Button>
</div>
<div className="flex items-center gap-2 flex-wrap">
<Badge variant={tpl.vm_type === 'lxc' ? 'secondary' : 'default'} className="text-xs">
{tpl.vm_type === 'lxc' ? t('templates.type_lxc') : t('templates.type_kvm')}
</Badge>
<Badge variant="outline" className="text-xs">{tpl.default_cores} vCPU</Badge>
<Badge variant="outline" className="text-xs">{tpl.default_memory} MB</Badge>
<Badge variant="outline" className="text-xs">{tpl.default_disk} GB</Badge>
</div>
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>{tpl.server_name}</span>
<span>VMID: {tpl.vmid}</span>
<span>{tpl.vmid != null ? `VMID: ${tpl.vmid}` : t('templates.file_template')}</span>
</div>
</CardContent>
</Card>

File diff suppressed because it is too large Load Diff