feat(images): Phase 3 UI — custom mirror manager (admin)
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:
@@ -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)}
|
||||
|
||||
247
frontend/src/features/images/MirrorsManager.tsx
Normal file
247
frontend/src/features/images/MirrorsManager.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user