feat(images): frontend Images page, catalog hooks, download dialog
Phase 1 UI for VM image mirror feature: - use-image-catalog hook (catalog, lxc repo templates, target storages, download) - ImagesPage: server/node context, arch/search filters, cloud + LXC sections - DownloadImageDialog: storage picker, background download via DeployTask - CatalogImage/ImageMirror types, image_download/image_template task kinds - sidebar nav 'Images' + /images route Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
149
frontend/src/features/images/DownloadImageDialog.tsx
Normal file
149
frontend/src/features/images/DownloadImageDialog.tsx
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Loader2, Download } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { useImageStorages, useDownloadImage } from '@/hooks/use-image-catalog';
|
||||||
|
import { useDeployTasksStore } from '@/stores/deploy-tasks-store';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
export interface SelectedImage {
|
||||||
|
source_id?: string;
|
||||||
|
kind: 'qcow2' | 'vztmpl';
|
||||||
|
name: string;
|
||||||
|
template?: string | null;
|
||||||
|
arch?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
serverId: number;
|
||||||
|
node: string;
|
||||||
|
image: SelectedImage | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtSize(bytes?: number): string {
|
||||||
|
if (!bytes || bytes <= 0) return '';
|
||||||
|
const gb = bytes / 1024 ** 3;
|
||||||
|
return `${gb.toFixed(0)} GB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DownloadImageDialog({ open, onClose, serverId, node, image }: Props) {
|
||||||
|
const [storage, setStorage] = useState('');
|
||||||
|
|
||||||
|
// qcow2 кладём как образ для импорта (PVE 8.2+ content=import), vztmpl — как шаблон CT
|
||||||
|
const content = image?.kind === 'vztmpl' ? 'vztmpl' : 'import';
|
||||||
|
const { data: storages = [], isLoading: storagesLoading } = useImageStorages(
|
||||||
|
open ? serverId : undefined,
|
||||||
|
open ? node : undefined,
|
||||||
|
open ? content : undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
const download = useDownloadImage(serverId);
|
||||||
|
const addDeployTask = useDeployTasksStore((s) => s.addTask);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
// Авто-выбор единственного хранилища
|
||||||
|
setStorage(storages.length === 1 ? storages[0].storage : '');
|
||||||
|
}
|
||||||
|
}, [open, storages]);
|
||||||
|
|
||||||
|
if (!image) return null;
|
||||||
|
|
||||||
|
const submit = () => {
|
||||||
|
if (!storage) return;
|
||||||
|
download.mutate(
|
||||||
|
{
|
||||||
|
node,
|
||||||
|
storage,
|
||||||
|
source_id: image.source_id,
|
||||||
|
kind: image.kind,
|
||||||
|
template: image.source_id ? undefined : image.template || undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: (data) => {
|
||||||
|
addDeployTask({
|
||||||
|
id: data.task_id,
|
||||||
|
name: data.name,
|
||||||
|
status: 'pending',
|
||||||
|
step: 'В очереди...',
|
||||||
|
progress: 0,
|
||||||
|
vmid: null,
|
||||||
|
node,
|
||||||
|
error_message: null,
|
||||||
|
kind: 'image_download',
|
||||||
|
server_id: serverId,
|
||||||
|
});
|
||||||
|
toast.success('Загрузка образа запущена в фоне');
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
onError: (e: Error) => toast.error(e.message),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Скачать образ</DialogTitle>
|
||||||
|
<DialogDescription>{image.name}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
Нода: <span className="font-medium text-foreground">{node}</span>
|
||||||
|
{image.arch ? <> · Архитектура: <span className="font-medium text-foreground">{image.arch}</span></> : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label>Хранилище ({content})</Label>
|
||||||
|
<Select value={storage || '__none__'} onValueChange={(v) => { if (v !== null) setStorage(v === '__none__' ? '' : v); }}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder={storagesLoading ? 'Загрузка...' : 'Выберите хранилище'} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{storages.map((s) => (
|
||||||
|
<SelectItem key={s.storage} value={s.storage}>
|
||||||
|
{s.storage} ({s.type}){s.avail ? ` · ${fmtSize(s.avail)} свободно` : ''}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{!storagesLoading && storages.length === 0 && (
|
||||||
|
<p className="mt-2 text-sm text-amber-600 dark:text-amber-500">
|
||||||
|
{content === 'import'
|
||||||
|
? 'Нет хранилища с поддержкой импорта (content=import). Нужен dir/NFS storage с типом контента «Import» (PVE 8.2+).'
|
||||||
|
: 'Нет хранилища с поддержкой шаблонов контейнеров (vztmpl).'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={onClose}>Отмена</Button>
|
||||||
|
<Button onClick={submit} disabled={!storage || download.isPending}>
|
||||||
|
{download.isPending ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Download className="mr-2 h-4 w-4" />}
|
||||||
|
Скачать
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
168
frontend/src/features/images/ImagesPage.tsx
Normal file
168
frontend/src/features/images/ImagesPage.tsx
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { Search, Download, HardDriveDownload, Package } from 'lucide-react';
|
||||||
|
import { OsLogo } from '@/features/templates/OsLogo';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { useServers, useNodes } from '@/hooks/use-nodes';
|
||||||
|
import { useImageCatalog, useImageLXCTemplates } from '@/hooks/use-image-catalog';
|
||||||
|
import DownloadImageDialog, { type SelectedImage } from './DownloadImageDialog';
|
||||||
|
import type { CatalogImage } from '@/types';
|
||||||
|
|
||||||
|
export default function ImagesPage() {
|
||||||
|
const [serverId, setServerId] = useState<number | null>(null);
|
||||||
|
const [node, setNode] = useState<string>('');
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [archFilter, setArchFilter] = useState<string>('');
|
||||||
|
const [selected, setSelected] = useState<SelectedImage | null>(null);
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
|
||||||
|
const { data: servers = [] } = useServers();
|
||||||
|
const { data: nodesResp } = useNodes(serverId ?? 0);
|
||||||
|
const nodes = nodesResp?.nodes ?? [];
|
||||||
|
|
||||||
|
const { data: catalog } = useImageCatalog();
|
||||||
|
const { data: lxcRepo = [] } = useImageLXCTemplates(serverId ?? undefined, node || undefined);
|
||||||
|
|
||||||
|
// Авто-выбор первого сервера и ноды
|
||||||
|
useEffect(() => {
|
||||||
|
if (serverId == null && servers.length > 0) setServerId(servers[0].id);
|
||||||
|
}, [servers, serverId]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!node && nodes.length > 0) setNode(nodes[0].node);
|
||||||
|
}, [nodes, node]);
|
||||||
|
|
||||||
|
const cloudImages = useMemo<CatalogImage[]>(() => {
|
||||||
|
const all = [...(catalog?.builtin ?? []), ...(catalog?.mirrors ?? [])];
|
||||||
|
return all.filter((img) => {
|
||||||
|
if (img.kind !== 'qcow2') return false;
|
||||||
|
if (archFilter && img.arch !== archFilter) return false;
|
||||||
|
const q = search.toLowerCase();
|
||||||
|
return !q || `${img.name} ${img.os ?? ''} ${img.version ?? ''}`.toLowerCase().includes(q);
|
||||||
|
});
|
||||||
|
}, [catalog, archFilter, search]);
|
||||||
|
|
||||||
|
const lxcTemplates = useMemo(() => {
|
||||||
|
const q = search.toLowerCase();
|
||||||
|
return lxcRepo.filter((t) => !q || `${t.template} ${t.os ?? ''} ${t.headline ?? ''}`.toLowerCase().includes(q));
|
||||||
|
}, [lxcRepo, search]);
|
||||||
|
|
||||||
|
const openDownload = (img: SelectedImage) => {
|
||||||
|
setSelected(img);
|
||||||
|
setDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const canDownload = serverId != null && !!node;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<HardDriveDownload className="h-6 w-6" />
|
||||||
|
<h1 className="text-2xl font-bold">Образы</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Контекст: сервер + нода */}
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<Select value={serverId != null ? String(serverId) : ''} onValueChange={(v) => { if (v !== null) { setServerId(Number(v)); setNode(''); } }}>
|
||||||
|
<SelectTrigger className="w-[200px]"><SelectValue placeholder="Сервер" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{servers.map((s) => <SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select value={node || '__none__'} onValueChange={(v) => { if (v !== null) setNode(v === '__none__' ? '' : v); }}>
|
||||||
|
<SelectTrigger className="w-[180px]"><SelectValue placeholder="Нода" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{nodes.map((n) => <SelectItem key={n.node} value={n.node}>{n.node}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<div className="relative flex-1 min-w-[200px]">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input className="pl-9" placeholder="Поиск образа..." value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<Select value={archFilter || '__all__'} onValueChange={(v) => { if (v !== null) setArchFilter(v === '__all__' ? '' : v); }}>
|
||||||
|
<SelectTrigger className="w-[150px]"><SelectValue placeholder="Архитектура" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__all__">Все архитектуры</SelectItem>
|
||||||
|
<SelectItem value="amd64">amd64</SelectItem>
|
||||||
|
<SelectItem value="arm64">arm64</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cloud-образы (qcow2 → шаблон ВМ) */}
|
||||||
|
<section className="space-y-3">
|
||||||
|
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||||
|
<Package className="h-5 w-5" /> Cloud-образы ВМ
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||||
|
{cloudImages.map((img) => (
|
||||||
|
<Card key={img.id}>
|
||||||
|
<CardContent className="flex items-center gap-3 p-4">
|
||||||
|
<OsLogo name={img.os || img.name} />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="font-medium truncate">{img.name}</div>
|
||||||
|
<div className="flex items-center gap-1.5 mt-1">
|
||||||
|
<Badge variant="secondary">{img.arch}</Badge>
|
||||||
|
{img.source === 'mirror' && <Badge variant="outline">зеркало</Badge>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" disabled={!canDownload}
|
||||||
|
onClick={() => openDownload({ source_id: img.id, kind: 'qcow2', name: img.name, arch: String(img.arch) })}>
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
{cloudImages.length === 0 && (
|
||||||
|
<div className="text-sm text-muted-foreground col-span-full">Нет образов по фильтру.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* LXC-шаблоны (репозиторий Proxmox) */}
|
||||||
|
<section className="space-y-3">
|
||||||
|
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||||
|
<Package className="h-5 w-5" /> LXC-шаблоны (репозиторий Proxmox)
|
||||||
|
</h2>
|
||||||
|
{!canDownload && (
|
||||||
|
<div className="text-sm text-muted-foreground">Выберите сервер и ноду, чтобы увидеть список.</div>
|
||||||
|
)}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||||
|
{lxcTemplates.map((t) => (
|
||||||
|
<Card key={t.template}>
|
||||||
|
<CardContent className="flex items-center gap-3 p-4">
|
||||||
|
<OsLogo name={t.os || t.template} />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="font-medium truncate" title={t.template}>{t.headline || t.template}</div>
|
||||||
|
<div className="text-xs text-muted-foreground truncate">{t.template}</div>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" disabled={!canDownload}
|
||||||
|
onClick={() => openDownload({ kind: 'vztmpl', name: t.template, template: t.template })}>
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<DownloadImageDialog
|
||||||
|
open={dialogOpen}
|
||||||
|
onClose={() => setDialogOpen(false)}
|
||||||
|
serverId={serverId ?? 0}
|
||||||
|
node={node}
|
||||||
|
image={selected}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
75
frontend/src/hooks/use-image-catalog.ts
Normal file
75
frontend/src/hooks/use-image-catalog.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { apiClient } from '@/lib/api-client';
|
||||||
|
import type {
|
||||||
|
ImageCatalogResponse,
|
||||||
|
ImageTargetStorage,
|
||||||
|
} from '@/types';
|
||||||
|
import type { AvailableLXCTemplate } from '@/hooks/use-lxc-templates';
|
||||||
|
|
||||||
|
export const imageKeys = {
|
||||||
|
catalog: ['image-catalog'] as const,
|
||||||
|
lxcRepo: (serverId?: number, node?: string) => ['image-lxc-repo', serverId, node] as const,
|
||||||
|
storages: (serverId?: number, node?: string, content?: string) =>
|
||||||
|
['image-storages', serverId, node, content] as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Встроенный каталог + кастомные зеркала. Меняется редко — кэш 10 минут.
|
||||||
|
export function useImageCatalog() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: imageKeys.catalog,
|
||||||
|
queryFn: () => apiClient.get<ImageCatalogResponse>('/proxmox/api/images/catalog'),
|
||||||
|
staleTime: 10 * 60 * 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Динамический список LXC-шаблонов из репозитория Proxmox (aplinfo).
|
||||||
|
export function useImageLXCTemplates(serverId?: number, node?: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: imageKeys.lxcRepo(serverId, node),
|
||||||
|
queryFn: () =>
|
||||||
|
apiClient.get<AvailableLXCTemplate[]>(
|
||||||
|
`/proxmox/api/${serverId}/images/lxc-templates?node=${encodeURIComponent(node!)}`,
|
||||||
|
),
|
||||||
|
enabled: !!serverId && !!node,
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Целевые хранилища ноды по типу контента (import|vztmpl|iso).
|
||||||
|
export function useImageStorages(serverId?: number, node?: string, content?: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: imageKeys.storages(serverId, node, content),
|
||||||
|
queryFn: () =>
|
||||||
|
apiClient.get<ImageTargetStorage[]>(
|
||||||
|
`/proxmox/api/${serverId}/images/storages?node=${encodeURIComponent(node!)}&content=${encodeURIComponent(content!)}`,
|
||||||
|
),
|
||||||
|
enabled: !!serverId && !!node && !!content,
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImageDownloadRequest {
|
||||||
|
node: string;
|
||||||
|
storage: string;
|
||||||
|
source_id?: string;
|
||||||
|
kind?: 'qcow2' | 'vztmpl';
|
||||||
|
url?: string;
|
||||||
|
template?: string;
|
||||||
|
filename?: string;
|
||||||
|
checksum?: string;
|
||||||
|
checksum_algorithm?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDownloadImage(serverId: number) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (data: ImageDownloadRequest) =>
|
||||||
|
apiClient.post<{ task_id: number; status: string; name: string }>(
|
||||||
|
`/proxmox/api/${serverId}/images/download`,
|
||||||
|
data,
|
||||||
|
),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['all-tasks'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
Monitor,
|
Monitor,
|
||||||
Network,
|
Network,
|
||||||
HardDrive,
|
HardDrive,
|
||||||
|
HardDriveDownload,
|
||||||
Archive,
|
Archive,
|
||||||
Globe,
|
Globe,
|
||||||
Settings,
|
Settings,
|
||||||
@@ -61,6 +62,7 @@ const navGroups: NavGroup[] = [
|
|||||||
{ label: 'Nodes', icon: Server, path: '/nodes' },
|
{ label: 'Nodes', icon: Server, path: '/nodes' },
|
||||||
{ label: 'Cluster', icon: Network, path: '/cluster' },
|
{ label: 'Cluster', icon: Network, path: '/cluster' },
|
||||||
{ label: 'Templates', icon: HardDrive, path: '/templates' },
|
{ label: 'Templates', icon: HardDrive, path: '/templates' },
|
||||||
|
{ label: 'Images', icon: HardDriveDownload, path: '/images' },
|
||||||
{ label: 'Backups', icon: Archive, path: '/backups' },
|
{ label: 'Backups', icon: Archive, path: '/backups' },
|
||||||
{ label: 'Tasks', icon: ClipboardList, path: '/tasks' },
|
{ label: 'Tasks', icon: ClipboardList, path: '/tasks' },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const NodesPage = lazy(() => import('./features/nodes/NodesPage.tsx'));
|
|||||||
const NodeDetailPage = lazy(() => import('./features/nodes/NodeDetailPage.tsx'));
|
const NodeDetailPage = lazy(() => import('./features/nodes/NodeDetailPage.tsx'));
|
||||||
const ClusterPage = lazy(() => import('./features/cluster/ClusterPage.tsx'));
|
const ClusterPage = lazy(() => import('./features/cluster/ClusterPage.tsx'));
|
||||||
const TemplatesPage = lazy(() => import('./features/templates/TemplatesPage.tsx'));
|
const TemplatesPage = lazy(() => import('./features/templates/TemplatesPage.tsx'));
|
||||||
|
const ImagesPage = lazy(() => import('./features/images/ImagesPage.tsx'));
|
||||||
const BackupsPage = lazy(() => import('./features/backups/BackupsPage.tsx'));
|
const BackupsPage = lazy(() => import('./features/backups/BackupsPage.tsx'));
|
||||||
const TasksPage = lazy(() => import('./features/tasks/TasksPage.tsx'));
|
const TasksPage = lazy(() => import('./features/tasks/TasksPage.tsx'));
|
||||||
const IPAMDashboardPage = lazy(() => import('./features/ipam/IPAMDashboardPage.tsx'));
|
const IPAMDashboardPage = lazy(() => import('./features/ipam/IPAMDashboardPage.tsx'));
|
||||||
@@ -74,6 +75,7 @@ export const router = createBrowserRouter([
|
|||||||
{ path: '/nodes/:serverId', element: <SuspenseWrap><NodeDetailPage /></SuspenseWrap> },
|
{ path: '/nodes/:serverId', element: <SuspenseWrap><NodeDetailPage /></SuspenseWrap> },
|
||||||
{ path: '/cluster', element: <SuspenseWrap><ClusterPage /></SuspenseWrap> },
|
{ path: '/cluster', element: <SuspenseWrap><ClusterPage /></SuspenseWrap> },
|
||||||
{ path: '/templates', element: <SuspenseWrap><TemplatesPage /></SuspenseWrap> },
|
{ path: '/templates', element: <SuspenseWrap><TemplatesPage /></SuspenseWrap> },
|
||||||
|
{ path: '/images', element: <SuspenseWrap><ImagesPage /></SuspenseWrap> },
|
||||||
{ path: '/backups', element: <SuspenseWrap><BackupsPage /></SuspenseWrap> },
|
{ path: '/backups', element: <SuspenseWrap><BackupsPage /></SuspenseWrap> },
|
||||||
{ path: '/tasks', element: <SuspenseWrap><TasksPage /></SuspenseWrap> },
|
{ path: '/tasks', element: <SuspenseWrap><TasksPage /></SuspenseWrap> },
|
||||||
{ path: '/ipam', element: <SuspenseWrap><IPAMDashboardPage /></SuspenseWrap> },
|
{ path: '/ipam', element: <SuspenseWrap><IPAMDashboardPage /></SuspenseWrap> },
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export interface DeployTask {
|
|||||||
node: string | null;
|
node: string | null;
|
||||||
error_message: string | null;
|
error_message: string | null;
|
||||||
created_at: number;
|
created_at: number;
|
||||||
kind?: 'deploy' | 'reinstall' | 'clone' | 'change_password';
|
kind?: 'deploy' | 'reinstall' | 'clone' | 'change_password' | 'image_download' | 'image_template';
|
||||||
server_id?: number | null;
|
server_id?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -414,6 +414,42 @@ export interface OSTemplate {
|
|||||||
server_name?: string;
|
server_name?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== Image Catalog Types ====================
|
||||||
|
|
||||||
|
export type ImageKind = 'qcow2' | 'vztmpl';
|
||||||
|
export type ImageArch = 'amd64' | 'arm64';
|
||||||
|
|
||||||
|
export interface CatalogImage {
|
||||||
|
id: string;
|
||||||
|
source: 'builtin' | 'mirror';
|
||||||
|
kind: ImageKind;
|
||||||
|
os?: string;
|
||||||
|
version?: string;
|
||||||
|
arch: ImageArch | string;
|
||||||
|
name: string;
|
||||||
|
url?: string | null;
|
||||||
|
template?: string | null;
|
||||||
|
filename?: string;
|
||||||
|
checksum?: string | null;
|
||||||
|
checksum_algorithm?: string | null;
|
||||||
|
icon?: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImageCatalogResponse {
|
||||||
|
builtin: CatalogImage[];
|
||||||
|
mirrors: CatalogImage[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImageTargetStorage {
|
||||||
|
storage: string;
|
||||||
|
type: string;
|
||||||
|
content: string;
|
||||||
|
avail?: number;
|
||||||
|
total?: number;
|
||||||
|
active?: number;
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== Workspace Types ====================
|
// ==================== Workspace Types ====================
|
||||||
|
|
||||||
export interface Workspace {
|
export interface Workspace {
|
||||||
|
|||||||
Reference in New Issue
Block a user