feat(images): frontend Images page, catalog hooks, download dialog
Some checks failed
Backend Tests / pytest (Python 3.12) (push) Successful in 55s
Deploy to Production / Trigger production update (push) Failing after 11s

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:
2026-06-22 22:57:16 +05:00
parent 7aed5dd97e
commit 32e06fe922
7 changed files with 433 additions and 1 deletions

View 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>
);
}

View 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>
);
}

View 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'] });
},
});
}

View File

@@ -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' },
],

View File

@@ -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> },

View File

@@ -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;
}

View File

@@ -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 {