import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useNavigate } from 'react-router'; import { useTranslation } from 'react-i18next'; import { toast } from 'sonner'; import { Loader2, CheckCircle2, XCircle, Copy, ExternalLink } from 'lucide-react'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, } from '@/components/ui/dialog'; import { DialogMinimizeButton } from '@/components/shared/MinimizableDialog'; import { useMinimizedOpsStore, installOpKey } from '@/stores/minimized-ops-store'; import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Badge } from '@/components/ui/badge'; import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem, } from '@/components/ui/select'; import { useServers, useNodes } from '@/hooks/use-nodes'; import { useLXCStorages } from '@/hooks/use-lxc-templates'; import { useIPAMNetworks, useIPAMPools } from '@/hooks/use-ipam'; import { scopedNetworks, autoPickNetworkId } from '@/features/ipam/network-scope'; import { useWorkspaceStore } from '@/stores/workspace-store'; import { useInstallApp, useInstalledApp, useOperationProgress, useAppOperations } from '@/hooks/use-appstore'; import type { AppOperation, CatalogApp } from '@/types/appstore'; interface Props { app: CatalogApp; open: boolean; onOpenChange: (open: boolean) => void; // Возобновление наблюдения за уже запущенной установкой (клик по глобальной // плашке после навигации): мастер открывается сразу в фазе прогресса. resumeInstalledAppId?: number | null; } type Phase = 'form' | 'progress' | 'done' | 'failed'; export default function InstallWizard({ app, open, onOpenChange, resumeInstalledAppId }: Props) { const { t } = useTranslation(); const navigate = useNavigate(); const { data: servers = [] } = useServers(); const install = useInstallApp(); const [name, setName] = useState(app.app_id); const [serverId, setServerId] = useState(''); const [node, setNode] = useState(''); const [answers, setAnswers] = useState>({}); const [advanced, setAdvanced] = useState(false); const [cores, setCores] = useState(2); const [memory, setMemory] = useState(2048); const [disk, setDisk] = useState(8); const [storage, setStorage] = useState('local-lvm'); const [bridge, setBridge] = useState('vmbr0'); // Кастомный рабочий порт: по умолчанию берём из каталога (app.port). const [port, setPort] = useState(app.port ? String(app.port) : ''); const [ipamNetworkId, setIpamNetworkId] = useState(''); const [ipamPoolId, setIpamPoolId] = useState(''); const { data: ipamNetworks = [] } = useIPAMNetworks(); const { data: ipamPools = [] } = useIPAMPools(ipamNetworkId ? Number(ipamNetworkId) : undefined); const serverIdNum = serverId ? Number(serverId) : undefined; const { data: nodesData } = useNodes(serverIdNum ?? 0); const nodes = nodesData?.nodes ?? []; const activeWorkspaceId = useWorkspaceStore((s) => s.activeWorkspaceId); // Сети активной рабочей области (для выпадающего списка). const availableNetworks = useMemo( () => scopedNetworks(ipamNetworks, activeWorkspaceId), [ipamNetworks, activeWorkspaceId], ); // Смена сервера: сбрасываем ноду и подставляем сеть по умолчанию активной области. const handleServerChange = (v: string) => { setServerId(v ?? ''); setNode(''); const nid = autoPickNetworkId(ipamNetworks, activeWorkspaceId); setIpamNetworkId(nid ? String(nid) : ''); setIpamPoolId(''); }; // Нода нужна только для размещения контейнера; на выбор сети больше не влияет. const handleNodeChange = (v: string) => setNode(v); // Хранилища ноды под rootfs LXC (для выбора вместо ручного ввода). const { data: storages = [] } = useLXCStorages( serverId ? Number(serverId) : undefined, node || undefined, 'rootdir', ); // Автовыбор валидного хранилища, когда список загрузился (предпочитаем текущее/local-lvm). useEffect(() => { if (storages.length && !storages.some((s) => s.storage === storage)) { const preferred = storages.find((s) => s.storage === 'local-lvm') ?? storages[0]; setStorage(preferred.storage); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [storages]); const [phase, setPhase] = useState('form'); const [installedAppId, setInstalledAppId] = useState(null); // Сворачивание — через глобальный стор: «Свернуть» добавляет операцию в // MinimizedOpsTray (AppLayout) и закрывает диалог; плашка переживает // навигацию, разворачивание — клик по плашке (navigation state → resume). const addMinimized = useMinimizedOpsStore((s) => s.add); const removeMinimized = useMinimizedOpsStore((s) => s.remove); // true во время закрытия по «Свернуть» — чтобы handleClose не снял плашку. const minimizingRef = useRef(false); const [op, setOp] = useState(null); const [secrets, setSecrets] = useState>({}); const { data: installedApp } = useInstalledApp(phase === 'done' ? (installedAppId ?? 0) : 0); const onOp = useCallback((o: AppOperation) => { setOp(o); if (o.status === 'completed') setPhase('done'); else if (o.status === 'failed') setPhase('failed'); }, []); useOperationProgress(phase === 'progress' ? installedAppId : null, onOp); // Резервный опрос статуса через REST: если WebSocket-событие потеряно // (или установка идёт долго, напр. docker compose тянет тяжёлые образы), // прогресс всё равно обновляется и мастер не «зависает» на старом шаге. const { data: polledOps } = useAppOperations( phase === 'progress' ? (installedAppId ?? 0) : 0, 3000, ); useEffect(() => { const latest = polledOps?.[0]; // операции отсортированы по времени (свежая первой) if (latest && latest.installed_app_id === installedAppId) onOp(latest); }, [polledOps, installedAppId, onOp]); // видимые поля формы (random генерируются на сервере, пользователю не показываем) const visibleFields = useMemo( () => (app.form_fields ?? []).filter((f) => (f.type || '').toLowerCase() !== 'random'), [app.form_fields], ); // Возобновление после навигации (клик по глобальной плашке). useEffect(() => { if (open && resumeInstalledAppId && phase === 'form' && installedAppId == null) { setInstalledAppId(resumeInstalledAppId); setPhase('progress'); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [open, resumeInstalledAppId]); const reset = () => { setPhase('form'); setOp(null); setInstalledAppId(null); setSecrets({}); setName(app.app_id); setServerId(''); setNode(''); setAnswers({}); setAdvanced(false); setCores(2); setMemory(2048); setDisk(8); setStorage('local-lvm'); setBridge('vmbr0'); setPort(app.port ? String(app.port) : ''); setIpamNetworkId(''); setIpamPoolId(''); }; const handleClose = (o: boolean) => { if (!o) { // Полное закрытие (не сворачивание) снимает плашку этой установки, если была. if (!minimizingRef.current && installedAppId != null) { removeMinimized(installOpKey(installedAppId)); } minimizingRef.current = false; reset(); } onOpenChange(o); }; const handleInstall = () => { if (!serverId) { toast.error(t('appstore.select_server')); return; } install.mutate( { app_id: app.app_id, name, server_id: Number(serverId), node: node || undefined, form_answers: answers, cores, memory, disk, storage, bridge, port: port ? Number(port) : undefined, ipam_network_id: ipamNetworkId ? Number(ipamNetworkId) : undefined, ipam_pool_id: ipamPoolId ? Number(ipamPoolId) : undefined, }, { onSuccess: (r) => { setInstalledAppId(r.installed_app_id); setSecrets(r.secrets || {}); setPhase('progress'); toast.success(t('appstore.install_started')); }, onError: (e) => { const msg = (e as Error).message || ''; toast.error( msg.includes('APPSTORE_GOLDEN_TEMPLATE') ? t('appstore.golden_missing') : (msg || t('appstore.install_failed')), ); }, }, ); }; const copy = (text: string) => { navigator.clipboard?.writeText(text); toast.success(t('appstore.copied')); }; const progress = op?.progress ?? 0; const minimize = () => { if (installedAppId == null) return; // до старта установки сворачивать нечего addMinimized({ key: installOpKey(installedAppId), kind: 'appstore-install', title: t('appstore.install_title', { name: app.name }), installedAppId, appId: app.app_id, }); minimizingRef.current = true; handleClose(false); }; return ( {installedAppId != null && ( )} {t('appstore.install_title', { name: app.name })} {phase === 'form' && (
setName(e.target.value)} />
{/* Динамические поля form_fields */} {visibleFields.map((f) => { const type = (f.type || 'text').toLowerCase(); const val = answers[f.env_variable] ?? ''; const set = (v: string) => setAnswers((a) => ({ ...a, [f.env_variable]: v })); return (
{type === 'boolean' ? ( ) : ( set(e.target.value)} /> )}
); })} {/* Advanced */}
{advanced && (
setBridge(e.target.value)} />
setPort(e.target.value)} />
{ipamNetworkId && ipamPools.length > 0 && (
)}
)}
)} {(phase === 'progress' || phase === 'failed') && (
{(op?.steps_log ?? []).map((s, i) => (
{s.step} {s.progress}%
))} {!op &&
{t('appstore.installing')}
}
{phase === 'failed' && (
{op?.error_text || t('appstore.install_failed')}
)}
)} {phase === 'done' && (
{t('appstore.done')}
{installedApp?.url && ( )} {Object.keys(secrets).length > 0 && (

{t('appstore.credentials')}

{Object.entries(secrets).map(([k, v]) => (
{k} {v}
))}
)}
)} {phase === 'form' && ( )} {phase === 'done' && ( <> )} {(phase === 'progress' || phase === 'failed') && ( )}
); } function NumField({ label, value, onChange }: { label: string; value: number; onChange: (n: number) => void }) { return (
onChange(Number(e.target.value) || 0)} />
); }