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,
|
||||
Network,
|
||||
HardDrive,
|
||||
HardDriveDownload,
|
||||
Archive,
|
||||
Globe,
|
||||
Settings,
|
||||
@@ -61,6 +62,7 @@ const navGroups: NavGroup[] = [
|
||||
{ label: 'Nodes', icon: Server, path: '/nodes' },
|
||||
{ label: 'Cluster', icon: Network, path: '/cluster' },
|
||||
{ label: 'Templates', icon: HardDrive, path: '/templates' },
|
||||
{ label: 'Images', icon: HardDriveDownload, path: '/images' },
|
||||
{ label: 'Backups', icon: Archive, path: '/backups' },
|
||||
{ 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 ClusterPage = lazy(() => import('./features/cluster/ClusterPage.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 TasksPage = lazy(() => import('./features/tasks/TasksPage.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: '/cluster', element: <SuspenseWrap><ClusterPage /></SuspenseWrap> },
|
||||
{ path: '/templates', element: <SuspenseWrap><TemplatesPage /></SuspenseWrap> },
|
||||
{ path: '/images', element: <SuspenseWrap><ImagesPage /></SuspenseWrap> },
|
||||
{ path: '/backups', element: <SuspenseWrap><BackupsPage /></SuspenseWrap> },
|
||||
{ path: '/tasks', element: <SuspenseWrap><TasksPage /></SuspenseWrap> },
|
||||
{ path: '/ipam', element: <SuspenseWrap><IPAMDashboardPage /></SuspenseWrap> },
|
||||
|
||||
@@ -11,7 +11,7 @@ export interface DeployTask {
|
||||
node: string | null;
|
||||
error_message: string | null;
|
||||
created_at: number;
|
||||
kind?: 'deploy' | 'reinstall' | 'clone' | 'change_password';
|
||||
kind?: 'deploy' | 'reinstall' | 'clone' | 'change_password' | 'image_download' | 'image_template';
|
||||
server_id?: number | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -414,6 +414,42 @@ export interface OSTemplate {
|
||||
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 ====================
|
||||
|
||||
export interface Workspace {
|
||||
|
||||
Reference in New Issue
Block a user