feat(images): Phase 3 UI — custom mirror manager (admin)
Some checks failed
Backend Tests / pytest (Python 3.12) (push) Successful in 51s
Deploy to Production / Trigger production update (push) Failing after 10s

Admin-only MirrorsManager on ImagesPage: list/add/edit/delete custom
mirrors with kind/arch/url/template/checksum fields; mirror CRUD hooks.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 23:17:00 +05:00
parent ed84e00e7b
commit d2f57e13db
4 changed files with 328 additions and 0 deletions

View File

@@ -14,7 +14,9 @@ import {
} from '@/components/ui/select';
import { useServers, useNodes } from '@/hooks/use-nodes';
import { useImageCatalog, useImageLXCTemplates } from '@/hooks/use-image-catalog';
import { useProfile } from '@/hooks/use-settings';
import DownloadImageDialog, { type SelectedImage } from './DownloadImageDialog';
import MirrorsManager from './MirrorsManager';
import type { CatalogImage } from '@/types';
export default function ImagesPage() {
@@ -26,6 +28,8 @@ export default function ImagesPage() {
const [dialogOpen, setDialogOpen] = useState(false);
const { data: servers = [] } = useServers();
const { data: profile } = useProfile();
const isAdmin = !!profile?.is_admin;
const { data: nodesResp } = useNodes(serverId ?? 0);
const nodes = nodesResp?.nodes ?? [];
@@ -156,6 +160,8 @@ export default function ImagesPage() {
</div>
</section>
{isAdmin && <MirrorsManager />}
<DownloadImageDialog
open={dialogOpen}
onClose={() => setDialogOpen(false)}

View File

@@ -0,0 +1,247 @@
import { useEffect, useState } from 'react';
import { Plus, Pencil, Trash2, Loader2 } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
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,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
useImageMirrors,
useCreateMirror,
useUpdateMirror,
useDeleteMirror,
} from '@/hooks/use-image-catalog';
import { useConfirm } from '@/components/shared/ConfirmDialog';
import { toast } from 'sonner';
import type { ImageMirrorItem, ImageMirrorInput } from '@/types';
const EMPTY: ImageMirrorInput = {
name: '', kind: 'qcow2', os: '', version: '', arch: 'amd64',
url: '', template: '', checksum: '', checksum_algorithm: '', description: '', enabled: true,
};
function MirrorFormDialog({
open, onClose, editing,
}: {
open: boolean;
onClose: () => void;
editing: ImageMirrorItem | null;
}) {
const [form, setForm] = useState<ImageMirrorInput>(EMPTY);
const create = useCreateMirror();
const update = useUpdateMirror();
const pending = create.isPending || update.isPending;
useEffect(() => {
if (!open) return;
if (editing) {
setForm({
name: editing.name, kind: editing.kind, os: editing.os || '', version: editing.version || '',
arch: editing.arch, url: editing.url || '', template: editing.template || '',
checksum: editing.checksum || '', checksum_algorithm: editing.checksum_algorithm || '',
description: editing.description || '', enabled: editing.enabled,
});
} else {
setForm(EMPTY);
}
}, [open, editing]);
const set = (k: keyof ImageMirrorInput, v: string | boolean) => setForm((f) => ({ ...f, [k]: v }));
const submit = () => {
if (!form.name.trim()) return;
if (form.kind === 'qcow2' && !form.url?.trim()) {
toast.error('Для qcow2 нужен URL');
return;
}
if (!form.url?.trim() && !form.template?.trim()) {
toast.error('Нужен URL или имя шаблона');
return;
}
const payload: ImageMirrorInput = {
...form,
os: form.os?.trim() || undefined,
version: form.version?.trim() || undefined,
url: form.url?.trim() || undefined,
template: form.template?.trim() || undefined,
checksum: form.checksum?.trim() || undefined,
checksum_algorithm: form.checksum_algorithm?.trim() || undefined,
description: form.description?.trim() || undefined,
};
const opts = {
onSuccess: () => { toast.success('Сохранено'); onClose(); },
onError: (e: Error) => toast.error(e.message),
};
if (editing) update.mutate({ id: editing.mirror_id, data: payload }, opts);
else create.mutate(payload, opts);
};
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>{editing ? 'Редактировать зеркало' : 'Добавить зеркало'}</DialogTitle>
</DialogHeader>
<div className="space-y-3 max-h-[60vh] overflow-y-auto pr-1">
<div>
<Label htmlFor="m-name">Название</Label>
<Input id="m-name" value={form.name} onChange={(e) => set('name', e.target.value)} />
</div>
<div className="grid grid-cols-3 gap-3">
<div>
<Label>Тип</Label>
<Select value={form.kind} onValueChange={(v) => { if (v !== null) set('kind', v); }}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="qcow2">qcow2 (ВМ)</SelectItem>
<SelectItem value="vztmpl">vztmpl (LXC)</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>Архитектура</Label>
<Select value={form.arch} onValueChange={(v) => { if (v !== null) set('arch', v); }}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="amd64">amd64</SelectItem>
<SelectItem value="arm64">arm64</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="m-os">ОС</Label>
<Input id="m-os" placeholder="ubuntu" value={form.os} onChange={(e) => set('os', e.target.value)} />
</div>
</div>
<div>
<Label htmlFor="m-ver">Версия</Label>
<Input id="m-ver" placeholder="24.04" value={form.version} onChange={(e) => set('version', e.target.value)} />
</div>
<div>
<Label htmlFor="m-url">URL {form.kind === 'qcow2' ? '(обязательно)' : '(или имя шаблона ниже)'}</Label>
<Input id="m-url" placeholder="https://.../image.qcow2" value={form.url} onChange={(e) => set('url', e.target.value)} />
</div>
{form.kind === 'vztmpl' && (
<div>
<Label htmlFor="m-tpl">Имя шаблона (vztmpl)</Label>
<Input id="m-tpl" placeholder="debian-12-standard_..._amd64.tar.zst" value={form.template} onChange={(e) => set('template', e.target.value)} />
</div>
)}
<div className="grid grid-cols-2 gap-3">
<div>
<Label htmlFor="m-sum">Контрольная сумма</Label>
<Input id="m-sum" value={form.checksum} onChange={(e) => set('checksum', e.target.value)} />
</div>
<div>
<Label>Алгоритм</Label>
<Select value={form.checksum_algorithm || '__none__'} onValueChange={(v) => { if (v !== null) set('checksum_algorithm', v === '__none__' ? '' : v); }}>
<SelectTrigger><SelectValue placeholder="—" /></SelectTrigger>
<SelectContent>
<SelectItem value="__none__">—</SelectItem>
<SelectItem value="sha256">sha256</SelectItem>
<SelectItem value="sha512">sha512</SelectItem>
<SelectItem value="md5">md5</SelectItem>
<SelectItem value="sha1">sha1</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div>
<Label htmlFor="m-desc">Описание</Label>
<Input id="m-desc" value={form.description} onChange={(e) => set('description', e.target.value)} />
</div>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={form.enabled} onChange={(e) => set('enabled', e.target.checked)} />
<span>Включено (показывать в каталоге)</span>
</label>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>Отмена</Button>
<Button onClick={submit} disabled={!form.name.trim() || pending}>
{pending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Сохранить
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export default function MirrorsManager() {
const confirm = useConfirm();
const { data: mirrors = [], isLoading } = useImageMirrors();
const del = useDeleteMirror();
const [dialogOpen, setDialogOpen] = useState(false);
const [editing, setEditing] = useState<ImageMirrorItem | null>(null);
const openAdd = () => { setEditing(null); setDialogOpen(true); };
const openEdit = (m: ImageMirrorItem) => { setEditing(m); setDialogOpen(true); };
const handleDelete = async (m: ImageMirrorItem) => {
const ok = await confirm(m.name, {
title: 'Удалить зеркало?',
confirmLabel: 'Удалить',
variant: 'destructive',
});
if (!ok) return;
del.mutate(m.mirror_id, {
onSuccess: () => toast.success('Зеркало удалено'),
onError: (e: Error) => toast.error(e.message),
});
};
return (
<section className="space-y-3">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Кастомные зеркала</h2>
<Button size="sm" onClick={openAdd}>
<Plus className="mr-2 h-4 w-4" /> Добавить
</Button>
</div>
{isLoading ? (
<div className="text-sm text-muted-foreground">Загрузка...</div>
) : mirrors.length === 0 ? (
<div className="text-sm text-muted-foreground">Кастомных зеркал пока нет.</div>
) : (
<div className="space-y-2">
{mirrors.map((m) => (
<Card key={m.id}>
<CardContent className="flex items-center gap-3 p-3">
<div className="min-w-0 flex-1">
<div className="font-medium truncate">{m.name}</div>
<div className="text-xs text-muted-foreground truncate">{m.url || m.template}</div>
</div>
<Badge variant="secondary">{m.kind}</Badge>
<Badge variant="outline">{m.arch}</Badge>
{!m.enabled && <Badge variant="destructive">выкл</Badge>}
<Button variant="ghost" size="icon" onClick={() => openEdit(m)}>
<Pencil className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" onClick={() => handleDelete(m)}>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</CardContent>
</Card>
))}
</div>
)}
<MirrorFormDialog open={dialogOpen} onClose={() => setDialogOpen(false)} editing={editing} />
</section>
);
}

View File

@@ -3,6 +3,8 @@ import { apiClient } from '@/lib/api-client';
import type {
ImageCatalogResponse,
ImageTargetStorage,
ImageMirrorItem,
ImageMirrorInput,
} from '@/types';
import type { AvailableLXCTemplate } from '@/hooks/use-lxc-templates';
@@ -82,3 +84,45 @@ export function useDownloadImage(serverId: number) {
},
});
}
// ── Custom mirrors (admin) ───────────────────────────────────────────────────
export function useImageMirrors(enabled = true) {
return useQuery({
queryKey: ['image-mirrors'],
queryFn: () => apiClient.get<ImageMirrorItem[]>('/proxmox/api/images/mirrors'),
enabled,
});
}
function invalidateMirrors(qc: ReturnType<typeof useQueryClient>) {
qc.invalidateQueries({ queryKey: ['image-mirrors'] });
qc.invalidateQueries({ queryKey: imageKeys.catalog });
}
export function useCreateMirror() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: ImageMirrorInput) =>
apiClient.post<ImageMirrorItem>('/proxmox/api/images/mirrors', data),
onSuccess: () => invalidateMirrors(qc),
});
}
export function useUpdateMirror() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: number; data: Partial<ImageMirrorInput> }) =>
apiClient.put<ImageMirrorItem>(`/proxmox/api/images/mirrors/${id}`, data),
onSuccess: () => invalidateMirrors(qc),
});
}
export function useDeleteMirror() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number) =>
apiClient.delete<{ deleted: boolean }>(`/proxmox/api/images/mirrors/${id}`),
onSuccess: () => invalidateMirrors(qc),
});
}

View File

@@ -441,6 +441,37 @@ export interface ImageCatalogResponse {
mirrors: CatalogImage[];
}
export interface ImageMirrorItem {
id: string; // "mirror-<n>"
mirror_id: number;
source: 'mirror';
kind: ImageKind;
os?: string | null;
version?: string | null;
arch: string;
name: string;
url?: string | null;
template?: string | null;
checksum?: string | null;
checksum_algorithm?: string | null;
description?: string | null;
enabled: boolean;
}
export interface ImageMirrorInput {
name: string;
kind: ImageKind;
os?: string;
version?: string;
arch: string;
url?: string;
template?: string;
checksum?: string;
checksum_algorithm?: string;
description?: string;
enabled: boolean;
}
export interface ImageTargetStorage {
storage: string;
type: string;