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

@@ -53,7 +53,7 @@ function DialogContent({
<DialogPrimitive.Popup
data-slot="dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}

View File

@@ -6,7 +6,53 @@ import { Select as SelectPrimitive } from "@base-ui/react/select"
import { cn } from "@/lib/utils"
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
const Select = SelectPrimitive.Root
// Recursively walks through children and collects { value, label } pairs from
// every <SelectItem> found, so that <Select.Value> can render the item's label
// (e.g. node name) instead of the raw value (e.g. numeric id).
function collectSelectItems(
node: React.ReactNode,
acc: Array<{ value: unknown; label: React.ReactNode }>,
seen: Set<unknown>,
): void {
React.Children.forEach(node, (child) => {
if (!React.isValidElement(child)) return
const props = child.props as { value?: unknown; children?: React.ReactNode }
// Identify SelectItem either by component reference or by data-slot marker.
const isItem =
child.type === SelectItem ||
(typeof child.type !== "string" &&
(child.type as { displayName?: string })?.displayName === "SelectItem")
if (isItem && props.value !== undefined && !seen.has(props.value)) {
seen.add(props.value)
acc.push({ value: props.value, label: props.children })
return
}
if (props.children) {
collectSelectItems(props.children, acc, seen)
}
})
}
function Select<Value, Multiple extends boolean | undefined = false>(
props: SelectPrimitive.Root.Props<Value, Multiple>,
) {
const { children, items, ...rest } = props
const autoItems = React.useMemo(() => {
if (items) return items
const acc: Array<{ value: unknown; label: React.ReactNode }> = []
collectSelectItems(children, acc, new Set<unknown>())
return acc.length ? acc : undefined
}, [children, items])
return (
<SelectPrimitive.Root
items={autoItems}
{...(rest as SelectPrimitive.Root.Props<Value, Multiple>)}
>
{children}
</SelectPrimitive.Root>
)
}
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
return (

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

View File

@@ -19,8 +19,13 @@ export function useBackupStorages(serverId: number) {
export function useBackupList(serverId: number, node: string, storage: string) {
return useQuery({
queryKey: backupKeys.list(serverId, node, storage),
queryFn: () => apiClient.get<{ backups: unknown[] }>(`/proxmox/api/backups/list/${serverId}?node=${node}&storage=${storage}`),
enabled: serverId > 0 && !!node && !!storage,
queryFn: () => {
const params = new URLSearchParams();
params.set('storage', storage);
if (node) params.set('node', node);
return apiClient.get<{ backups: unknown[] }>(`/proxmox/api/backups/list/${serverId}?${params.toString()}`);
},
enabled: serverId > 0 && !!storage,
});
}
@@ -44,7 +49,11 @@ export function useDeleteBackup() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: { server_id: number; node: string; storage: string; volid: string }) =>
apiClient.delete(`/proxmox/api/backups/${data.server_id}/backup`),
apiClient.delete(`/proxmox/api/backups/${data.server_id}/backup`, {
node: data.node,
storage: data.storage,
volid: data.volid,
}),
onSuccess: () => qc.invalidateQueries({ queryKey: ['backups'] }),
});
}
@@ -56,6 +65,48 @@ export function useRestoreBackup() {
});
}
export type BackupJobInput = {
server_id: number;
node: string;
vmids: (number | string)[];
storage: string;
mode?: string;
compress?: string;
notes?: string;
keep_last?: number;
keep_daily?: number;
keep_weekly?: number;
keep_monthly?: number;
cron_expression: string;
enabled?: boolean;
};
export function useCreateBackupJob() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: BackupJobInput) =>
apiClient.post<{ success: boolean; job: unknown }>('/proxmox/api/backups/jobs', data),
onSuccess: () => qc.invalidateQueries({ queryKey: backupKeys.jobs }),
});
}
export function useUpdateBackupJob() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: number; data: Partial<BackupJobInput> }) =>
apiClient.put<{ success: boolean; job: unknown }>(`/proxmox/api/backups/jobs/${id}`, data),
onSuccess: () => qc.invalidateQueries({ queryKey: backupKeys.jobs }),
});
}
export function useDeleteBackupJob() {
const qc = useQueryClient();
return useMutation({
mutationFn: (jobId: number) => apiClient.delete<{ success: boolean }>(`/proxmox/api/backups/jobs/${jobId}`),
onSuccess: () => qc.invalidateQueries({ queryKey: backupKeys.jobs }),
});
}
export function useToggleBackupJob() {
const qc = useQueryClient();
return useMutation({

View File

@@ -1,5 +1,5 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useEffect } from 'react';
import { useEffect, useState } from 'react';
import { apiClient } from '@/lib/api-client';
import { getTasksWebSocket } from '@/lib/websocket';
import type { VMInstance, Snapshot, VMConfig } from '@/types';
@@ -18,21 +18,25 @@ export const vmKeys = {
};
// ==================== VM List ====================
// No HTTP polling: live metrics arrive via WebSocket (`useInstancesMetricsSync`).
// We still refetch on reconnect/focus to recover from missed events.
export function useVirtualMachines() {
return useQuery({
queryKey: vmKeys.all,
queryFn: () => apiClient.get<VMInstance[]>('/proxmox/api/virtual-machines'),
refetchInterval: 30000,
refetchInterval: false,
refetchOnWindowFocus: false,
staleTime: Infinity,
});
}
/** Subscribes to WebSocket vm_status_update events and patches the VM list cache in real time. */
/** Subscribes to WebSocket vm_status_update / vm_deleted / vm_created events and patches the VM list cache in real time. */
export function useVMStatusSync() {
const qc = useQueryClient();
useEffect(() => {
const ws = getTasksWebSocket();
ws.connect();
const unsub = ws.onType('vm_status_update', (data: unknown) => {
const unsubStatus = ws.onType('vm_status_update', (data: unknown) => {
const msg = data as { vmid: number; server_id: number; status: string };
qc.setQueryData<VMInstance[]>(vmKeys.all, (old) =>
old?.map((vm) =>
@@ -42,10 +46,56 @@ export function useVMStatusSync() {
)
);
});
return () => unsub();
const unsubDeleted = ws.onType('vm_deleted', (data: unknown) => {
const msg = data as { vmid: number; server_id: number };
qc.setQueryData<VMInstance[]>(vmKeys.all, (old) =>
old?.filter((vm) => !(vm.server_id === msg.server_id && vm.vmid === msg.vmid))
);
// Also invalidate any per-VM detail queries so stale pages refresh
qc.invalidateQueries({ queryKey: vmKeys.resourcesAll });
});
const unsubCreated = ws.onType('vm_created', () => {
// A new VM appeared on Proxmox; refetch the full list
qc.invalidateQueries({ queryKey: vmKeys.all });
qc.invalidateQueries({ queryKey: vmKeys.resourcesAll });
});
return () => {
unsubStatus();
unsubDeleted();
unsubCreated();
};
}, [qc]);
}
export type LiveInstanceMetrics = {
server_id: number; vmid: number; node?: string; status?: string;
cpu?: number; mem?: number; maxmem?: number; disk?: number; maxdisk?: number;
uptime?: number; netin?: number; netout?: number;
};
/**
* Subscribes to live VM/LXC metrics via WebSocket channel `instances_list_metrics`.
* Returns a Map keyed by `"server_id:vmid"` that updates every ~1 s.
* Uses its own useState — bypasses TanStack Query structural sharing entirely.
*/
export function useInstancesMetricsSync(): Map<string, LiveInstanceMetrics> {
const [metricsMap, setMetricsMap] = useState(() => new Map<string, LiveInstanceMetrics>());
useEffect(() => {
const ws = getTasksWebSocket();
ws.connect();
const apply = (data: unknown) => {
const items = (data as { data?: { items?: LiveInstanceMetrics[] } })?.data?.items;
if (!items?.length) return;
const next = new Map<string, LiveInstanceMetrics>();
for (const it of items) next.set(`${it.server_id}:${it.vmid}`, it);
setMetricsMap(next);
};
const unsub = ws.subscribe('instances_list_metrics', apply);
return () => unsub();
}, []);
return metricsMap;
}
export function useAllResources() {
return useQuery({
queryKey: vmKeys.resourcesAll,

View File

@@ -99,6 +99,13 @@ export function useDeleteServer() {
export function useTestServer() {
return useMutation({
mutationFn: (id: number) => apiClient.post<{ success: boolean; message?: string }>(`/proxmox/api/servers/${id}/test`, {}),
mutationFn: (id: number) => apiClient.post<{ success: boolean; status?: string; message?: string }>(`/proxmox/api/servers/${id}/test`, {}),
});
}
export function useTestServerCredentials() {
return useMutation({
mutationFn: (data: ProxmoxServerCreate) =>
apiClient.post<{ success: boolean; status?: string; message?: string }>(`/proxmox/api/servers/test`, data),
});
}

View File

@@ -0,0 +1,132 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/lib/api-client';
export interface SSHKey {
id: number;
user_id: number;
name: string;
public_key: string;
fingerprint: string | null;
comment: string | null;
has_private_key: boolean;
created_at: string;
}
export interface CreateSSHKeyPayload {
name: string;
public_key: string;
private_key?: string;
comment?: string;
}
export interface UpdateSSHKeyPayload {
name?: string;
public_key?: string;
private_key?: string;
comment?: string;
}
export const sshKeyKeys = {
my: ['ssh-keys', 'my'] as const,
byUser: (userId: number) => ['ssh-keys', 'user', userId] as const,
};
// ── Self-service ────────────────────────────────────────────────────────────
export function useMySSHKeys() {
return useQuery({
queryKey: sshKeyKeys.my,
queryFn: () => apiClient.get<SSHKey[]>('/api/ssh-keys/my'),
});
}
export function useCreateMySSHKey() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: CreateSSHKeyPayload) =>
apiClient.post<SSHKey>('/api/ssh-keys/my', data),
onSuccess: () => qc.invalidateQueries({ queryKey: sshKeyKeys.my }),
});
}
export function useUpdateMySSHKey() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: number; data: UpdateSSHKeyPayload }) =>
apiClient.put<SSHKey>(`/api/ssh-keys/my/${id}`, data),
onSuccess: () => qc.invalidateQueries({ queryKey: sshKeyKeys.my }),
});
}
export function useDeleteMySSHKey() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number) =>
apiClient.delete<{ success: boolean; message: string }>(`/api/ssh-keys/my/${id}`),
onSuccess: () => qc.invalidateQueries({ queryKey: sshKeyKeys.my }),
});
}
export function useDownloadMyPrivateKey() {
return useMutation({
mutationFn: (id: number) =>
apiClient.get<{ id: number; name: string; private_key: string }>(
`/api/ssh-keys/my/${id}/private`,
),
});
}
// ── Admin: per-user ─────────────────────────────────────────────────────────
export function useUserSSHKeys(userId: number | null | undefined) {
return useQuery({
queryKey: userId ? sshKeyKeys.byUser(userId) : ['ssh-keys', 'user', 'none'],
queryFn: () =>
apiClient.get<SSHKey[]>(`/api/ssh-keys/users/${userId}`),
enabled: !!userId,
});
}
export function useAdminCreateUserSSHKey() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ userId, data }: { userId: number; data: CreateSSHKeyPayload }) =>
apiClient.post<SSHKey>(`/api/ssh-keys/users/${userId}`, data),
onSuccess: (_data, vars) => {
qc.invalidateQueries({ queryKey: sshKeyKeys.byUser(vars.userId) });
},
});
}
export function useAdminUpdateUserSSHKey() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ userId, keyId, data }: { userId: number; keyId: number; data: UpdateSSHKeyPayload }) =>
apiClient.put<SSHKey>(`/api/ssh-keys/users/${userId}/${keyId}`, data),
onSuccess: (_data, vars) => {
qc.invalidateQueries({ queryKey: sshKeyKeys.byUser(vars.userId) });
},
});
}
export function useAdminDeleteUserSSHKey() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ userId, keyId }: { userId: number; keyId: number }) =>
apiClient.delete<{ success: boolean; message: string }>(
`/api/ssh-keys/users/${userId}/${keyId}`,
),
onSuccess: (_data, vars) => {
qc.invalidateQueries({ queryKey: sshKeyKeys.byUser(vars.userId) });
},
});
}
export function useAdminDownloadUserPrivateKey() {
return useMutation({
mutationFn: ({ userId, keyId }: { userId: number; keyId: number }) =>
apiClient.get<{ id: number; name: string; private_key: string }>(
`/api/ssh-keys/users/${userId}/${keyId}/private`,
),
});
}

View File

@@ -4,7 +4,7 @@ import type { OSTemplate, OSTemplateGroup } from '@/types';
export const templateKeys = {
groups: ['template-groups'] as const,
templates: (groupId?: number, serverId?: number) => ['templates', groupId, serverId] as const,
templates: (groupId?: number, serverId?: number, vmType?: string) => ['templates', groupId, serverId, vmType] as const,
template: (id: number) => ['template', id] as const,
discover: (serverId: number) => ['template-discover', serverId] as const,
};
@@ -16,13 +16,14 @@ export function useTemplateGroups() {
});
}
export function useTemplates(groupId?: number, serverId?: number) {
export function useTemplates(groupId?: number, serverId?: number, vmType?: string) {
const params = new URLSearchParams();
if (groupId) params.set('group_id', String(groupId));
if (serverId) params.set('server_id', String(serverId));
if (vmType) params.set('vm_type', vmType);
const qs = params.toString();
return useQuery({
queryKey: templateKeys.templates(groupId, serverId),
queryKey: templateKeys.templates(groupId, serverId, vmType),
queryFn: () => apiClient.get<OSTemplate[]>(`/templates/api/templates${qs ? `?${qs}` : ''}`),
});
}

View File

@@ -5,6 +5,7 @@ import type { User } from '@/types';
export const userKeys = {
users: ['users'] as const,
user: (id: number) => ['user', id] as const,
userServers: (id: number) => ['user-server-assignments', id] as const,
roles: ['roles'] as const,
permissions: ['permissions-v2'] as const,
sessions: ['sessions'] as const,
@@ -13,17 +14,80 @@ export const userKeys = {
securityEvents: ['security-events'] as const,
};
interface Role {
export interface Role {
id: number;
name: string;
display_name: string;
description?: string;
permissions: string[];
permissions: Record<string, boolean>;
is_system: boolean;
is_active: boolean;
user_count: number;
created_at?: string;
}
export interface SessionInfo {
id: number;
user_id: number;
username: string;
ip_address?: string;
device_info?: string;
created_at?: string;
last_activity?: string;
expires_at?: string;
}
export interface BlockedIp {
id: number;
ip_address: string;
reason?: string;
blocked_by?: string;
blocked_at?: string;
expires_at?: string;
is_permanent: boolean;
attempts_count?: number;
is_active: boolean;
}
export interface SecuritySettings {
max_login_attempts: number;
lockout_duration_minutes: number;
session_timeout_minutes: number;
ip_block_threshold: number;
ip_block_duration_minutes: number;
password_min_length: number;
password_require_uppercase: boolean;
password_require_lowercase: boolean;
password_require_numbers: boolean;
password_require_special: boolean;
}
export interface SecurityEvent {
id: number;
event_type: string;
ip_address?: string;
username?: string;
message?: string;
created_at?: string;
}
export interface ServerAssignment {
id: number;
name: string;
ip_address: string;
is_online: boolean;
workspaces: { id: number; name: string }[];
compatible: boolean;
assigned: boolean;
}
export interface UserServerAssignmentsResponse {
servers: ServerAssignment[];
user_workspaces: { id: number; name: string }[];
}
// ==================== Users ====================
export function useUsers() {
return useQuery({
queryKey: userKeys.users,
@@ -39,21 +103,43 @@ export function useUser(userId: number) {
});
}
export interface CreateUserPayload {
username: string;
email: string;
password: string;
full_name?: string;
role_id?: number | null;
is_active?: boolean;
is_admin?: boolean;
}
export function useCreateUser() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: { username: string; email: string; password: string; full_name?: string; role_id?: number; is_active?: boolean; is_admin?: boolean }) =>
apiClient.post<User>('/admin/api/users', data),
mutationFn: (data: CreateUserPayload) => apiClient.post<User>('/admin/api/users', data),
onSuccess: () => qc.invalidateQueries({ queryKey: userKeys.users }),
});
}
export interface UpdateUserPayload {
email?: string;
full_name?: string;
password?: string;
role_id?: number | null;
is_active?: boolean;
is_admin?: boolean;
require_password_change?: boolean;
}
export function useUpdateUser() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, ...data }: { id: number; email?: string; full_name?: string; role_id?: number; is_active?: boolean; is_admin?: boolean }) =>
mutationFn: ({ id, ...data }: { id: number } & UpdateUserPayload) =>
apiClient.put<User>(`/admin/api/users/${id}`, data),
onSuccess: () => qc.invalidateQueries({ queryKey: userKeys.users }),
onSuccess: (_, vars) => {
qc.invalidateQueries({ queryKey: userKeys.users });
qc.invalidateQueries({ queryKey: userKeys.user(vars.id) });
},
});
}
@@ -68,8 +154,10 @@ export function useDeleteUser() {
export function useResetPassword() {
const qc = useQueryClient();
return useMutation({
mutationFn: (userId: number) =>
apiClient.post<{ message: string }>(`/admin/api/users/${userId}/reset-password`, {}),
mutationFn: ({ userId, newPassword }: { userId: number; newPassword: string }) =>
apiClient.post<{ message: string }>(`/admin/api/users/${userId}/reset-password`, {
new_password: newPassword,
}),
onSuccess: () => qc.invalidateQueries({ queryKey: userKeys.users }),
});
}
@@ -77,11 +165,51 @@ export function useResetPassword() {
export function useUnlockUser() {
const qc = useQueryClient();
return useMutation({
mutationFn: (userId: number) => apiClient.post<{ message: string }>(`/admin/api/users/${userId}/unlock`),
mutationFn: (userId: number) =>
apiClient.post<{ message: string }>(`/admin/api/users/${userId}/unlock`),
onSuccess: () => qc.invalidateQueries({ queryKey: userKeys.users }),
});
}
export function useTerminateUserSessions() {
const qc = useQueryClient();
return useMutation({
mutationFn: (userId: number) =>
apiClient.post<{ message: string }>(`/admin/api/users/${userId}/terminate-sessions`),
onSuccess: () => qc.invalidateQueries({ queryKey: userKeys.sessions }),
});
}
// ==================== User → Servers ====================
export function useUserServerAssignments(userId: number) {
return useQuery({
queryKey: userKeys.userServers(userId),
queryFn: () =>
apiClient.get<UserServerAssignmentsResponse>(
`/admin/api/users/${userId}/server-assignments`
),
enabled: userId > 0,
});
}
export function useSetUserServers() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ userId, serverIds }: { userId: number; serverIds: number[] }) =>
apiClient.put<{ message: string; server_ids: number[] }>(
`/admin/api/users/${userId}/servers`,
{ server_ids: serverIds }
),
onSuccess: (_, vars) => {
qc.invalidateQueries({ queryKey: userKeys.userServers(vars.userId) });
qc.invalidateQueries({ queryKey: userKeys.users });
},
});
}
// ==================== Roles ====================
export function useRoles() {
return useQuery({
queryKey: userKeys.roles,
@@ -92,8 +220,12 @@ export function useRoles() {
export function useCreateRole() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: { name: string; display_name: string; description?: string; permissions: string[] }) =>
apiClient.post<Role>('/admin/api/roles', data),
mutationFn: (data: {
name: string;
display_name: string;
description?: string;
permissions: Record<string, boolean>;
}) => apiClient.post<Role>('/admin/api/roles', data),
onSuccess: () => qc.invalidateQueries({ queryKey: userKeys.roles }),
});
}
@@ -101,8 +233,16 @@ export function useCreateRole() {
export function useUpdateRole() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, ...data }: { id: number; display_name?: string; description?: string; permissions?: string[]; is_active?: boolean }) =>
apiClient.put<Role>(`/admin/api/roles/${id}`, data),
mutationFn: ({
id,
...data
}: {
id: number;
display_name?: string;
description?: string;
permissions?: Record<string, boolean>;
is_active?: boolean;
}) => apiClient.put<Role>(`/admin/api/roles/${id}`, data),
onSuccess: () => qc.invalidateQueries({ queryKey: userKeys.roles }),
});
}
@@ -118,15 +258,18 @@ export function useDeleteRole() {
export function usePermissions() {
return useQuery({
queryKey: userKeys.permissions,
queryFn: () => apiClient.get<Record<string, unknown>>('/admin/api/permissions/v2'),
queryFn: () =>
apiClient.get<Record<string, Record<string, string>>>('/admin/api/permissions/v2'),
staleTime: Infinity,
});
}
// ==================== Sessions ====================
export function useSessions() {
return useQuery({
queryKey: userKeys.sessions,
queryFn: () => apiClient.get<any[]>('/admin/api/sessions'),
queryFn: () => apiClient.get<SessionInfo[]>('/admin/api/sessions'),
refetchInterval: 15000,
});
}
@@ -134,7 +277,70 @@ export function useSessions() {
export function useTerminateSession() {
const qc = useQueryClient();
return useMutation({
mutationFn: (sessionId: string) => apiClient.delete(`/admin/api/sessions/${sessionId}`),
mutationFn: (sessionId: number) => apiClient.delete(`/admin/api/sessions/${sessionId}`),
onSuccess: () => qc.invalidateQueries({ queryKey: userKeys.sessions }),
});
}
export function useTerminateAllSessions() {
const qc = useQueryClient();
return useMutation({
mutationFn: () => apiClient.post<{ message: string }>('/admin/api/sessions/terminate-all'),
onSuccess: () => qc.invalidateQueries({ queryKey: userKeys.sessions }),
});
}
// ==================== Security ====================
export function useSecuritySettings() {
return useQuery({
queryKey: userKeys.securitySettings,
queryFn: () => apiClient.get<SecuritySettings>('/admin/api/security/settings'),
});
}
export function useUpdateSecuritySettings() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: Partial<SecuritySettings>) =>
apiClient.put<{ message: string; updated: string[] }>(
'/admin/api/security/settings',
data
),
onSuccess: () => qc.invalidateQueries({ queryKey: userKeys.securitySettings }),
});
}
export function useBlockedIps() {
return useQuery({
queryKey: userKeys.blockedIps,
queryFn: () => apiClient.get<BlockedIp[]>('/admin/api/security/blocked-ips'),
refetchInterval: 30000,
});
}
export function useBlockIp() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: { ip_address: string; reason?: string; duration_minutes?: number }) =>
apiClient.post<{ message: string; id: number }>('/admin/api/security/blocked-ips', data),
onSuccess: () => qc.invalidateQueries({ queryKey: userKeys.blockedIps }),
});
}
export function useUnblockIp() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number) => apiClient.delete(`/admin/api/security/blocked-ips/${id}`),
onSuccess: () => qc.invalidateQueries({ queryKey: userKeys.blockedIps }),
});
}
export function useSecurityEvents(limit = 50) {
return useQuery({
queryKey: [...userKeys.securityEvents, limit],
queryFn: () =>
apiClient.get<SecurityEvent[]>(`/admin/api/security/events?limit=${limit}`),
refetchInterval: 30000,
});
}

View File

@@ -19,6 +19,7 @@ import {
Languages,
ChevronDown,
Building2,
Check,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
@@ -208,7 +209,7 @@ function WorkspaceSwitcher() {
<FolderKanban className="mr-2 h-3.5 w-3.5" />
<span className="truncate">{w.name}</span>
{w.id === activeWorkspaceId && (
<span className="ml-auto text-primary">✓</span>
<Check className="ml-auto h-3.5 w-3.5 text-primary" />
)}
</DropdownMenuItem>
))}

View File

@@ -146,8 +146,11 @@ class ApiClient {
});
}
delete<T>(path: string) {
return this.request<T>(path, { method: 'DELETE' });
delete<T>(path: string, body?: unknown) {
return this.request<T>(path, {
method: 'DELETE',
body: body ? JSON.stringify(body) : undefined,
});
}
}

View File

@@ -35,7 +35,8 @@ export class WebSocketManager {
}
connect() {
if (this.ws?.readyState === WebSocket.OPEN) return;
// Don't create a new socket if one is already open or connecting
if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) return;
const token = localStorage.getItem('access_token');
const separator = this.options.url.includes('?') ? '&' : '?';
@@ -49,6 +50,13 @@ export class WebSocketManager {
this._isConnected = true;
this.reconnectAttempts = 0;
this.startPing();
// Re-send subscribe messages for all active channels (handles initial
// subscribe-before-open and reconnect cases).
for (const channel of this.subscribers.keys()) {
try {
this.ws?.send(JSON.stringify({ type: 'subscribe', channel }));
} catch { /* ignore */ }
}
this.options.onOpen?.();
};
@@ -107,15 +115,20 @@ export class WebSocketManager {
subscribe(channel: string, handler: MessageHandler) {
if (!this.subscribers.has(channel)) {
this.subscribers.set(channel, new Set());
// Only send a subscribe message the first time someone subscribes to
// this channel. If WS is not OPEN yet, onopen() will replay it.
this.send({ type: 'subscribe', channel });
}
this.subscribers.get(channel)!.add(handler);
// Send subscribe message
this.send({ type: 'subscribe', channel });
// Return unsubscribe function
return () => {
this.subscribers.get(channel)?.delete(handler);
const handlers = this.subscribers.get(channel);
handlers?.delete(handler);
if (handlers && handlers.size === 0) {
this.subscribers.delete(channel);
this.send({ type: 'unsubscribe', channel });
}
};
}

View File

@@ -40,6 +40,8 @@
"common": {
"loading": "Loading...",
"save": "Save",
"saved": "Saved",
"optional": "optional",
"cancel": "Cancel",
"delete": "Delete",
"edit": "Edit",
@@ -172,6 +174,10 @@
"network": "Network",
"password": "Password",
"ssh_keys": "SSH Keys",
"ssh_keys_select": "Select saved SSH keys",
"ssh_keys_owner": "Instance owner",
"ssh_keys_owner_self": "Me (current user)",
"no_saved_keys": "No saved SSH keys",
"deploy": "Deploy",
"deploy_queued": "Deploy task for \"{{name}}\" has been queued",
"deploy_success": "Instance \"{{name}}\" deployed successfully",
@@ -234,6 +240,10 @@
"search": "Search templates...",
"all_groups": "All groups",
"all_servers": "All servers",
"all_types": "All types",
"type_kvm": "KVM",
"type_lxc": "LXC",
"file_template": "File template",
"auto_import": "Auto Import",
"importing": "Importing...",
"no_templates": "No templates found",
@@ -268,10 +278,57 @@
"select_server": "Select server",
"select_storage": "Select storage",
"node": "Node",
"all_nodes": "All nodes",
"select_server_first": "Select a server to view backups",
"disable": "Disable",
"enable": "Enable",
"job_started": "Backup job started"
"job_started": "Backup job started",
"type": "Type",
"restore_title": "Restore from backup",
"restore_warning": "Restoring will overwrite the existing VM/container with this VMID. Provide a new VMID to restore as a new guest.",
"new_vmid_optional": "New VMID (optional)",
"new_vmid_hint": "Leave empty to restore with the original VMID (existing guest will be overwritten).",
"start_after_restore": "Start after restore",
"unique_mac": "Generate unique MAC addresses",
"restore_started": "Restore started",
"restore_failed": "Failed to start restore",
"node_required": "Could not determine node for the operation",
"invalid_vmid": "Invalid VMID",
"delete_title": "Delete backup",
"delete_confirm": "The backup file will be permanently deleted. Continue?",
"delete_success": "Backup deleted",
"delete_failed": "Failed to delete backup",
"create_job": "Create schedule",
"edit_job": "Edit schedule",
"job_created": "Schedule created",
"job_updated": "Schedule updated",
"job_deleted": "Schedule deleted",
"job_save_failed": "Failed to save schedule",
"job_delete_failed": "Failed to delete schedule",
"delete_job_title": "Delete schedule?",
"delete_job_confirm": "The schedule will be removed from the planner. Existing backup files are kept.",
"vmids_required": "Specify at least one VMID",
"vmids_hint": "Comma-separated VMIDs (e.g. 100, 101) or 'all' for every VM/CT.",
"all_vms": "All VMs/CTs",
"all_vms_selected": "All VMs/CTs on the node selected",
"selected_count": "Selected: {{count}}",
"select_all_visible": "Select all visible",
"clear_selection": "Clear",
"pick_server_node_first": "Select a server and node first",
"no_vms_found": "No VMs/CTs found",
"cron_required": "Specify a cron expression",
"cron_hint": "Format: minute hour day month weekday. Example: '0 2 * * *' — daily at 02:00.",
"mode": "Mode",
"compress": "Compression",
"none": "none",
"preset": "Preset",
"cron": {
"daily_2am": "Daily at 02:00",
"daily_midnight": "Daily at midnight",
"every_6h": "Every 6 hours",
"weekly_sunday": "Weekly (Sunday 03:00)",
"monthly_1st": "Monthly (1st at 04:00)"
}
},
"ipam": {
"dashboard": "IPAM Dashboard",
@@ -333,24 +390,95 @@
"update_available": "Update available",
"up_to_date": "You are up to date"
},
"ssh_keys": {
"title": "SSH Keys",
"add": "Add SSH key",
"edit": "Edit SSH key",
"name": "Name",
"public_key": "Public key",
"private_key": "Private key",
"private_key_hint": "The private key is stored encrypted. Used only on explicit download.",
"private_key_replace_hint": "Leave empty to keep the current value",
"comment": "Comment",
"fingerprint": "Fingerprint",
"has_private": "Has private",
"no_private": "Public only",
"download_private": "Download private key",
"copy_public": "Copy public key",
"copied": "Copied",
"copy_failed": "Copy failed",
"created": "SSH key added",
"deleted": "SSH key deleted",
"confirm_delete": "Delete this SSH key?",
"empty": "No saved SSH keys",
"manage_for_user": "User SSH keys"
},
"users": {
"users": "Users",
"roles": "Roles",
"sessions": "Sessions",
"security": "Security",
"create": "Create User",
"edit": "Edit User",
"create_role": "Create Role",
"edit_role": "Edit Role",
"role": "Role",
"no_role": "No role",
"last_login": "Last Login",
"active": "Active",
"inactive": "Inactive",
"locked": "Locked",
"is_active": "Active",
"is_admin": "Administrator",
"require_password_change": "Require password change",
"reset_password": "Reset Password",
"new_password": "New password",
"unlock": "Unlock",
"manage_servers": "Assigned Servers",
"manage_ssh_keys": "Manage SSH Keys",
"assigned_servers": "Assigned Servers",
"compatible_servers": "Compatible Servers",
"incompatible": "Incompatible",
"failed_attempts": "Failed Attempts",
"terminate_sessions": "Terminate Sessions",
"terminate_all_sessions": "Terminate All Sessions",
"permissions_count": "Permissions",
"permissions": "Permissions",
"select_all": "Select all",
"clear_all": "Clear all",
"system_role": "System",
"users_count": "Users",
"active_sessions": "Active Sessions",
"user_agent": "User Agent",
"device": "Device",
"last_activity": "Last Activity",
"created": "Created",
"expires": "Expires"
"expires": "Expires",
"security_settings": "Security Settings",
"max_login_attempts": "Max Login Attempts",
"lockout_duration": "Lockout Duration (min)",
"session_timeout": "Session Timeout (min)",
"ip_block_threshold": "IP Block Threshold",
"ip_block_duration": "IP Block Duration (min)",
"password_min_length": "Min Password Length",
"password_require_uppercase": "Uppercase letter",
"password_require_lowercase": "Lowercase letter",
"password_require_numbers": "Number",
"password_require_special": "Special character",
"blocked_ips": "Blocked IPs",
"block_ip": "Block IP",
"ip_address": "IP address",
"reason": "Reason",
"duration_minutes": "Duration (min, empty = permanent)",
"permanent": "Permanent",
"blocked_at": "Blocked at",
"expires_at": "Expires",
"unblock": "Unblock",
"security_events": "Security Events",
"event_type": "Event Type",
"workspaces": "Workspaces",
"no_workspaces": "No workspaces",
"workspace_required": "User must belong to the server's workspace"
},
"workspaces": {
"create": "Create Workspace",

View File

@@ -40,6 +40,8 @@
"common": {
"loading": "Загрузка...",
"save": "Сохранить",
"saved": "Сохранено",
"optional": "необязательно",
"cancel": "Отмена",
"delete": "Удалить",
"edit": "Редактировать",
@@ -172,6 +174,10 @@
"network": "Сеть",
"password": "Пароль",
"ssh_keys": "SSH ключи",
"ssh_keys_select": "Выбрать сохранённые SSH ключи",
"ssh_keys_owner": "Владелец инстанса",
"ssh_keys_owner_self": "Я (текущий пользователь)",
"no_saved_keys": "Нет сохранённых SSH ключей",
"deploy": "Развернуть",
"deploy_queued": "Задача создания \"{{name}}\" добавлена в очередь",
"deploy_success": "Инстанс \"{{name}}\" успешно развёрнут",
@@ -234,6 +240,10 @@
"search": "Поиск шаблонов...",
"all_groups": "Все группы",
"all_servers": "Все серверы",
"all_types": "Все типы",
"type_kvm": "KVM",
"type_lxc": "LXC",
"file_template": "Файл-шаблон",
"auto_import": "Авто-импорт",
"importing": "Импорт...",
"no_templates": "Шаблоны не найдены",
@@ -268,10 +278,57 @@
"select_server": "Выберите сервер",
"select_storage": "Выберите хранилище",
"node": "Узел",
"all_nodes": "Все узлы",
"select_server_first": "Выберите сервер для просмотра бэкапов",
"disable": "Отключить",
"enable": "Включить",
"job_started": "Задание бэкапа запущено"
"job_started": "Задание бэкапа запущено",
"type": "Тип",
"restore_title": "Восстановить из бэкапа",
"restore_warning": "Восстановление перезапишет существующую VM/контейнер с этим VMID. Укажите новый VMID, чтобы восстановить как новый.",
"new_vmid_optional": "Новый VMID (необязательно)",
"new_vmid_hint": "Оставьте пустым, чтобы восстановить с исходным VMID (текущая VM будет перезаписана).",
"start_after_restore": "Запустить после восстановления",
"unique_mac": "Сгенерировать уникальные MAC-адреса",
"restore_started": "Восстановление запущено",
"restore_failed": "Не удалось запустить восстановление",
"node_required": "Не удалось определить узел для операции",
"invalid_vmid": "Некорректный VMID",
"delete_title": "Удалить бэкап",
"delete_confirm": "Файл бэкапа будет удалён без возможности восстановления. Продолжить?",
"delete_success": "Бэкап удалён",
"delete_failed": "Не удалось удалить бэкап",
"create_job": "Создать расписание",
"edit_job": "Редактировать расписание",
"job_created": "Расписание создано",
"job_updated": "Расписание обновлено",
"job_deleted": "Расписание удалено",
"job_save_failed": "Не удалось сохранить расписание",
"job_delete_failed": "Не удалось удалить расписание",
"delete_job_title": "Удалить расписание?",
"delete_job_confirm": "Расписание будет удалено и снято с планировщика. Файлы бэкапов останутся.",
"vmids_required": "Укажите хотя бы один VMID",
"vmids_hint": "Список VMID через запятую (напр. 100, 101) или слово all для всех VM/CT.",
"all_vms": "Все VM/CT",
"all_vms_selected": "Выбраны все VM/CT на узле",
"selected_count": "Выбрано: {{count}}",
"select_all_visible": "Выбрать все видимые",
"clear_selection": "Очистить",
"pick_server_node_first": "Сначала выберите сервер и узел",
"no_vms_found": "VM/CT не найдены",
"cron_required": "Укажите cron-выражение",
"cron_hint": "Формат: минута час день месяц день-недели. Пример: '0 2 * * *' ежедневно в 02:00.",
"mode": "Режим",
"compress": "Сжатие",
"none": "без сжатия",
"preset": "Пресет",
"cron": {
"daily_2am": "Ежедневно в 02:00",
"daily_midnight": "Ежедневно в полночь",
"every_6h": "Каждые 6 часов",
"weekly_sunday": "Еженедельно (воскресенье 03:00)",
"monthly_1st": "Ежемесячно (1-е число 04:00)"
}
},
"ipam": {
"dashboard": "IPAM Дашборд",
@@ -333,24 +390,95 @@
"update_available": "Доступно обновление",
"up_to_date": "Установлена последняя версия"
},
"ssh_keys": {
"title": "SSH ключи",
"add": "Добавить SSH ключ",
"edit": "Редактировать SSH ключ",
"name": "Название",
"public_key": "Публичный ключ",
"private_key": "Приватный ключ",
"private_key_hint": "Приватный ключ хранится зашифрованно. Используется только при явной загрузке.",
"private_key_replace_hint": "Оставьте пустым, чтобы не менять",
"comment": "Комментарий",
"fingerprint": "Отпечаток",
"has_private": "С приватным",
"no_private": "Только публичный",
"download_private": "Скачать приватный ключ",
"copy_public": "Копировать публичный ключ",
"copied": "Скопировано",
"copy_failed": "Не удалось скопировать",
"created": "SSH ключ добавлен",
"deleted": "SSH ключ удалён",
"confirm_delete": "Удалить этот SSH ключ?",
"empty": "Нет сохранённых SSH ключей",
"manage_for_user": "SSH ключи пользователя"
},
"users": {
"users": "Пользователи",
"roles": "Роли",
"sessions": "Сессии",
"security": "Безопасность",
"create": "Создать пользователя",
"edit": "Редактировать пользователя",
"create_role": "Создать роль",
"edit_role": "Редактировать роль",
"role": "Роль",
"no_role": "Без роли",
"last_login": "Последний вход",
"active": "Активен",
"inactive": "Неактивен",
"locked": "Заблокирован",
"is_active": "Активен",
"is_admin": "Администратор",
"require_password_change": "Требовать смену пароля",
"reset_password": "Сбросить пароль",
"new_password": "Новый пароль",
"unlock": "Разблокировать",
"manage_servers": "Назначенные серверы",
"manage_ssh_keys": "Управление SSH ключами",
"assigned_servers": "Назначенные серверы",
"compatible_servers": "Совместимые серверы",
"incompatible": "Несовместим",
"failed_attempts": "Неуд. попыток",
"terminate_sessions": "Завершить сессии",
"terminate_all_sessions": "Завершить все сессии",
"permissions_count": "Разрешения",
"permissions": "Разрешения",
"select_all": "Выбрать все",
"clear_all": "Снять все",
"system_role": "Системная",
"users_count": "Пользователей",
"active_sessions": "Активные сессии",
"user_agent": "User Agent",
"device": "Устройство",
"last_activity": "Активность",
"created": "Создано",
"expires": "Истекает"
"expires": "Истекает",
"security_settings": "Настройки безопасности",
"max_login_attempts": "Макс. попыток входа",
"lockout_duration": "Блокировка (мин)",
"session_timeout": "Таймаут сессии (мин)",
"ip_block_threshold": "Порог блокировки IP",
"ip_block_duration": "Блокировка IP (мин)",
"password_min_length": "Мин. длина пароля",
"password_require_uppercase": "Заглавная буква",
"password_require_lowercase": "Строчная буква",
"password_require_numbers": "Цифра",
"password_require_special": "Спецсимвол",
"blocked_ips": "Заблокированные IP",
"block_ip": "Заблокировать IP",
"ip_address": "IP-адрес",
"reason": "Причина",
"duration_minutes": "Длительность (мин, пусто = навсегда)",
"permanent": "Навсегда",
"blocked_at": "Заблокирован",
"expires_at": "Истекает",
"unblock": "Разблокировать",
"security_events": "События безопасности",
"event_type": "Тип события",
"workspaces": "Рабочие области",
"no_workspaces": "Нет рабочих областей",
"workspace_required": "Пользователь должен принадлежать рабочей области сервера"
},
"workspaces": {
"create": "Создать рабочую область",

View File

@@ -9,9 +9,12 @@ export interface User {
is_admin: boolean;
created_at: string;
last_login?: string;
role_id?: number;
role_id?: number | null;
role_name?: string;
is_locked?: boolean;
failed_login_attempts?: number;
locked_until?: string | null;
require_password_change?: boolean;
permissions?: string[];
}
@@ -120,6 +123,8 @@ export interface VMDeployRequest {
cloud_init_user?: string;
cloud_init_password?: string;
ssh_keys?: string;
ssh_key_ids?: number[];
owner_id?: number;
}
// ==================== LXC CT Template Types ====================
@@ -150,6 +155,8 @@ export interface LXCDeployRequest {
ipam_pool_id?: number;
password?: string;
ssh_keys?: string;
ssh_key_ids?: number[];
owner_id?: number;
nameserver?: string;
searchdomain?: string;
unprivileged: boolean;
@@ -254,7 +261,9 @@ export interface OSTemplate {
group_id: number;
server_id: number;
name: string;
vmid: number;
vmid: number | null;
vm_type: 'qemu' | 'lxc';
volid?: string | null;
node: string;
default_cores: number;
default_memory: number;