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
Some checks failed
Backend Tests / pytest (Python 3.12) (push) Has been cancelled
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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')} />
|
||||
|
||||
@@ -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" />}
|
||||
|
||||
286
frontend/src/features/settings/SSHKeysManager.tsx
Normal file
286
frontend/src/features/settings/SSHKeysManager.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user