feat(appstore): глобальный трей свёрнутых операций, переживающий навигацию и перезагрузку
All checks were successful
Deploy to Production / Trigger production update (push) Successful in 9s

Свёрнутые долгие операции (установка приложения, сборка золотого шаблона)
теперь живут в глобальном zustand-сторе (persist в localStorage) и
рендерятся треем MinimizedOpsTray в AppLayout, а не самим диалогом:

- плашка переживает навигацию между страницами и перезагрузку вкладки;
  прогресс опрашивает сам трей (REST, 3с) независимо от диалога
- клик по плашке возвращает на страницу операции и разворачивает диалог:
  установка — resume-режим InstallWizard (resumeInstall в navigation
  state), шаблон — openGolden с предвыбранным сервером
- «Свернуть» добавляет операцию в трей и закрывает диалог; крестик на
  плашке только убирает её (операция продолжается на сервере)
- плашки несуществующих операций (404 после перезагрузки) трей убирает сам

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
markmorado
2026-07-17 14:13:42 +05:00
parent 4621ffe88a
commit 25899866bc
8 changed files with 278 additions and 72 deletions

View File

@@ -4,10 +4,11 @@ import { cn } from '@/lib/utils';
/**
* Сворачивание модалок с долгими операциями (установка приложения, сборка
* золотого шаблона и т.п.): кнопка «Свернуть» в шапке диалога + плавающая
* плашка прогресса внизу справа. Родительский компонент диалога остаётся
* смонтированным (сворачивание меняет только open у <Dialog>), поэтому
* WS-подписки и опросы прогресса продолжают работать.
* золотого шаблона и т.п.): кнопка «Свернуть» в шапке диалога + плашка
* прогресса. Свёрнутые операции хранятся в глобальном сторе
* (stores/minimized-ops-store), а плашки рендерит MinimizedOpsTray в
* AppLayout — поэтому они переживают навигацию между страницами: прогресс
* опрашивает сам трей, независимо от смонтированности диалога.
*/
export function DialogMinimizeButton({ onClick, title }: { onClick: () => void; title: string }) {
@@ -41,7 +42,7 @@ export function MinimizedTaskPill({ title, subtitle, progress, status, onRestore
<div
role="button"
tabIndex={0}
className="fixed bottom-4 right-4 z-50 w-72 cursor-pointer rounded-lg border bg-popover p-3 text-popover-foreground shadow-lg ring-1 ring-foreground/10 transition-colors hover:bg-accent/40"
className="w-72 cursor-pointer rounded-lg border bg-popover p-3 text-popover-foreground shadow-lg ring-1 ring-foreground/10 transition-colors hover:bg-accent/40"
onClick={onRestore}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') onRestore(); }}
>

View File

@@ -0,0 +1,95 @@
import { useEffect } from 'react';
import { useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next';
import { MinimizedTaskPill, type MinimizedStatus } from '@/components/shared/MinimizableDialog';
import { useMinimizedOpsStore, type MinimizedOp } from '@/stores/minimized-ops-store';
import { useAppOperations, useGoldenTemplateStatus } from '@/hooks/use-appstore';
/**
* Глобальный трей свёрнутых операций (внизу справа). Монтируется в AppLayout,
* поэтому плашки переживают навигацию: каждая сама опрашивает прогресс своей
* операции по REST. Клик по плашке возвращает на страницу операции и
* разворачивает диалог (через navigation state — resumeInstall / openGolden).
*/
export default function MinimizedOpsTray() {
const items = useMinimizedOpsStore((s) => s.items);
if (!items.length) return null;
return (
<div className="fixed bottom-4 right-4 z-50 flex flex-col items-end gap-2">
{items.map((i) => (
i.kind === 'appstore-install'
? <InstallPill key={i.key} item={i} />
: <GoldenPill key={i.key} item={i} />
))}
</div>
);
}
function statusOf(opStatus: string | undefined): MinimizedStatus {
if (opStatus === 'running') return 'running';
if (opStatus === 'completed') return 'done';
if (opStatus === 'failed') return 'failed';
return 'idle';
}
function InstallPill({ item }: { item: MinimizedOp }) {
const { t } = useTranslation();
const navigate = useNavigate();
const remove = useMinimizedOpsStore((s) => s.remove);
const { data: ops, isError } = useAppOperations(item.installedAppId ?? 0, 3000);
const op = ops?.[0] ?? null;
// Стор персистентный (localStorage): после перезагрузки страницы плашка
// может ссылаться на уже удалённую установку — 404 от API убирает её сам.
useEffect(() => {
if (isError) remove(item.key);
}, [isError, remove, item.key]);
const lastStep = op?.steps_log?.length ? op.steps_log[op.steps_log.length - 1].step : null;
const status = statusOf(op?.status);
return (
<MinimizedTaskPill
title={item.title}
subtitle={status === 'failed'
? (op?.error_text || t('appstore.install_failed'))
: (lastStep || t('appstore.installing'))}
progress={op ? op.progress : undefined}
status={status === 'idle' ? 'running' : status}
onRestore={() => {
remove(item.key);
navigate(`/appstore/${item.appId}`, { state: { resumeInstall: item.installedAppId } });
}}
onClose={() => remove(item.key)}
/>
);
}
function GoldenPill({ item }: { item: MinimizedOp }) {
const { t } = useTranslation();
const navigate = useNavigate();
const remove = useMinimizedOpsStore((s) => s.remove);
const { data: gstatus, isError } = useGoldenTemplateStatus(item.serverId, 3000);
const op = gstatus?.last_operation ?? null;
useEffect(() => {
if (isError) remove(item.key);
}, [isError, remove, item.key]);
const lastStep = op?.steps_log?.length ? op.steps_log[op.steps_log.length - 1].step : null;
const status = statusOf(op?.status);
return (
<MinimizedTaskPill
title={item.title}
subtitle={status === 'failed'
? (op?.error_text || t('appstore.golden.failed', 'Ошибка сборки'))
: (lastStep || t('appstore.golden.working', 'Выполняется...'))}
progress={op ? op.progress : undefined}
status={status === 'idle' ? 'running' : status}
onRestore={() => {
remove(item.key);
navigate('/appstore', { state: { openGolden: item.serverId } });
}}
onClose={() => remove(item.key)}
/>
);
}

View File

@@ -1,5 +1,5 @@
import { useState } from 'react';
import { useParams, useNavigate } from 'react-router';
import { useEffect, useState } from 'react';
import { useParams, useNavigate, useLocation } from 'react-router';
import { useTranslation } from 'react-i18next';
import { ArrowLeft, Package, ExternalLink, Download, AlertTriangle, Code } from 'lucide-react';
import { Button } from '@/components/ui/button';
@@ -14,8 +14,22 @@ export default function AppDetailPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const { data: app, isLoading } = useCatalogApp(appId);
const location = useLocation();
const [wizardOpen, setWizardOpen] = useState(false);
const [showCompose, setShowCompose] = useState(false);
// Возврат из глобальной плашки установки: мастер открывается сразу в фазе
// прогресса запущенной установки (resumeInstall в navigation state).
const [resumeId, setResumeId] = useState<number | null>(null);
useEffect(() => {
const rid = (location.state as { resumeInstall?: number } | null)?.resumeInstall;
if (rid) {
setResumeId(rid);
setWizardOpen(true);
// Очищаем state, чтобы повторное открытие мастера не «возобновляло» старую установку.
navigate('.', { replace: true, state: null });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [location.state]);
if (isLoading) {
return <div className="py-20 text-center text-sm text-muted-foreground">{t('appstore.loading')}</div>;
@@ -88,7 +102,12 @@ export default function AppDetailPage() {
</Card>
)}
<InstallWizard app={app} open={wizardOpen} onOpenChange={setWizardOpen} />
<InstallWizard
app={app}
open={wizardOpen}
onOpenChange={(o) => { setWizardOpen(o); if (!o) setResumeId(null); }}
resumeInstalledAppId={resumeId}
/>
</div>
);
}

View File

@@ -1,5 +1,5 @@
import { useMemo, useState } from 'react';
import { useNavigate } from 'react-router';
import { useEffect, useMemo, useState } from 'react';
import { useNavigate, useLocation } from 'react-router';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { Store, Search, RefreshCw, Loader2, Package, HardDriveDownload } from 'lucide-react';
@@ -20,6 +20,18 @@ export default function AppStorePage() {
const [source, setSource] = useState<string>('');
const [goldenOpen, setGoldenOpen] = useState(false);
const isAdmin = useAuthStore((s) => s.user?.is_admin ?? false);
const location = useLocation();
// Возврат из глобальной плашки сборки шаблона: открываем диалог с нужным сервером.
const [goldenServerId, setGoldenServerId] = useState<number | null>(null);
useEffect(() => {
const sid = (location.state as { openGolden?: number } | null)?.openGolden;
if (sid) {
setGoldenServerId(sid);
setGoldenOpen(true);
navigate('.', { replace: true, state: null });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [location.state]);
const { data: catalog, isLoading } = useCatalog(search, category, source);
const { data: meta } = useCatalogMeta();
@@ -175,7 +187,13 @@ export default function AppStorePage() {
</a>
</div>
{isAdmin && <GoldenTemplateDialog open={goldenOpen} onOpenChange={setGoldenOpen} />}
{isAdmin && (
<GoldenTemplateDialog
open={goldenOpen}
onOpenChange={(o) => { setGoldenOpen(o); if (!o) setGoldenServerId(null); }}
initialServerId={goldenServerId}
/>
)}
</div>
);
}

View File

@@ -1,11 +1,10 @@
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { Loader2, HardDriveDownload, CheckCircle2, XCircle } from 'lucide-react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
import {
DialogMinimizeButton, MinimizedTaskPill, type MinimizedStatus,
} from '@/components/shared/MinimizableDialog';
import { DialogMinimizeButton } from '@/components/shared/MinimizableDialog';
import { useMinimizedOpsStore, goldenOpKey } from '@/stores/minimized-ops-store';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
@@ -17,17 +16,23 @@ import { useBuildGoldenTemplate, useGoldenTemplateStatus } from '@/hooks/use-app
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
// Предвыбор сервера при возврате из глобальной плашки (openGolden в
// navigation state) — чтобы диалог сразу показал прогресс нужной сборки.
initialServerId?: number | null;
}
export default function GoldenTemplateDialog({ open, onOpenChange }: Props) {
export default function GoldenTemplateDialog({ open, onOpenChange, initialServerId }: Props) {
const { t } = useTranslation();
const { data: servers = [] } = useServers();
const [serverId, setServerId] = useState('');
const [node, setNode] = useState('');
// Свёрнутое состояние: диалог скрыт, прогресс сборки — в плашке внизу справа.
// open остаётся true, поэтому опрос статуса (useGoldenTemplateStatus) продолжается.
const [minimized, setMinimized] = useState(false);
// Возврат из глобальной плашки: предвыбираем сервер сборки.
useEffect(() => {
if (open && initialServerId && !serverId) setServerId(String(initialServerId));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, initialServerId]);
const [templateStorage, setTemplateStorage] = useState('local');
const [rootfsStorage, setRootfsStorage] = useState('local-lvm');
const [force, setForce] = useState(false);
@@ -73,19 +78,40 @@ export default function GoldenTemplateDialog({ open, onOpenChange }: Props) {
);
};
// Сворачивание — через глобальный стор: «Свернуть» добавляет сборку в
// MinimizedOpsTray (AppLayout) и закрывает диалог; плашка переживает
// навигацию, разворачивание — клик по плашке (openGolden в navigation state).
const addMinimized = useMinimizedOpsStore((s) => s.add);
const removeMinimized = useMinimizedOpsStore((s) => s.remove);
// true во время закрытия по «Свернуть» — чтобы handleOpenChange не снял плашку.
const minimizingRef = useRef(false);
const minimize = () => {
if (!serverIdNum) return; // без сервера нечего отслеживать
addMinimized({
key: goldenOpKey(serverIdNum),
kind: 'golden-template',
title: t('appstore.golden.title', 'Золотой LXC-шаблон'),
serverId: serverIdNum,
});
minimizingRef.current = true;
handleOpenChange(false);
};
const handleOpenChange = (o: boolean) => {
if (!o) setMinimized(false);
if (!o) {
if (!minimizingRef.current && serverIdNum) removeMinimized(goldenOpKey(serverIdNum));
minimizingRef.current = false;
}
onOpenChange(o);
};
const pillStatus: MinimizedStatus =
running ? 'running' : op?.status === 'completed' ? 'done' : op?.status === 'failed' ? 'failed' : 'idle';
return (
<>
<Dialog open={open && !minimized} onOpenChange={handleOpenChange}>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-lg">
<DialogMinimizeButton onClick={() => setMinimized(true)} title={t('appstore.minimize')} />
{!!serverIdNum && (
<DialogMinimizeButton onClick={minimize} title={t('appstore.minimize')} />
)}
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<HardDriveDownload className="h-5 w-5 text-primary" />
@@ -193,18 +219,5 @@ export default function GoldenTemplateDialog({ open, onOpenChange }: Props) {
</DialogFooter>
</DialogContent>
</Dialog>
{open && minimized && (
<MinimizedTaskPill
title={t('appstore.golden.title', 'Золотой LXC-шаблон')}
subtitle={op?.status === 'failed'
? (op.error_text || t('appstore.golden.failed', 'Ошибка сборки'))
: (lastStep || t('appstore.golden.working', 'Выполняется...'))}
progress={op ? op.progress : undefined}
status={pillStatus}
onRestore={() => setMinimized(false)}
onClose={() => handleOpenChange(false)}
/>
)}
</>
);
}

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
@@ -6,9 +6,8 @@ import { Loader2, CheckCircle2, XCircle, Copy, ExternalLink } from 'lucide-react
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
} from '@/components/ui/dialog';
import {
DialogMinimizeButton, MinimizedTaskPill, type MinimizedStatus,
} from '@/components/shared/MinimizableDialog';
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';
@@ -29,11 +28,14 @@ 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 }: Props) {
export default function InstallWizard({ app, open, onOpenChange, resumeInstalledAppId }: Props) {
const { t } = useTranslation();
const navigate = useNavigate();
const { data: servers = [] } = useServers();
@@ -95,10 +97,15 @@ export default function InstallWizard({ app, open, onOpenChange }: Props) {
}, [storages]);
const [phase, setPhase] = useState<Phase>('form');
// Свёрнутое состояние: диалог скрыт, внизу справа — плашка прогресса.
// Компонент остаётся смонтированным, поэтому WS/опрос прогресса живут.
const [minimized, setMinimized] = useState(false);
const [installedAppId, setInstalledAppId] = useState<number | null>(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<AppOperation | null>(null);
const [secrets, setSecrets] = useState<Record<string, string>>({});
@@ -129,8 +136,17 @@ export default function InstallWizard({ app, open, onOpenChange }: Props) {
[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'); setMinimized(false); setOp(null); setInstalledAppId(null); setSecrets({});
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');
@@ -139,7 +155,14 @@ export default function InstallWizard({ app, open, onOpenChange }: Props) {
};
const handleClose = (o: boolean) => {
if (!o) reset();
if (!o) {
// Полное закрытие (не сворачивание) снимает плашку этой установки, если была.
if (!minimizingRef.current && installedAppId != null) {
removeMinimized(installOpKey(installedAppId));
}
minimizingRef.current = false;
reset();
}
onOpenChange(o);
};
@@ -179,21 +202,25 @@ export default function InstallWizard({ app, open, onOpenChange }: Props) {
const progress = op?.progress ?? 0;
// Статус для свёрнутой плашки.
const pillStatus: MinimizedStatus =
phase === 'progress' ? 'running' : phase === 'done' ? 'done' : phase === 'failed' ? 'failed' : 'idle';
const lastStep = op?.steps_log?.length ? op.steps_log[op.steps_log.length - 1].step : null;
const pillSubtitle =
phase === 'progress' ? (lastStep || t('appstore.installing'))
: phase === 'done' ? t('appstore.done')
: phase === 'failed' ? t('appstore.install_failed')
: null;
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 (
<>
<Dialog open={open && !minimized} onOpenChange={handleClose}>
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-lg">
<DialogMinimizeButton onClick={() => setMinimized(true)} title={t('appstore.minimize')} />
{installedAppId != null && (
<DialogMinimizeButton onClick={minimize} title={t('appstore.minimize')} />
)}
<DialogHeader>
<DialogTitle>{t('appstore.install_title', { name: app.name })}</DialogTitle>
</DialogHeader>
@@ -421,17 +448,6 @@ export default function InstallWizard({ app, open, onOpenChange }: Props) {
</DialogFooter>
</DialogContent>
</Dialog>
{open && minimized && (
<MinimizedTaskPill
title={t('appstore.install_title', { name: app.name })}
subtitle={pillSubtitle}
progress={phase === 'progress' || phase === 'failed' ? progress : undefined}
status={pillStatus}
onRestore={() => setMinimized(false)}
onClose={() => handleClose(false)}
/>
)}
</>
);
}

View File

@@ -33,6 +33,7 @@ import { useTranslation } from 'react-i18next';
import { useQueryClient } from '@tanstack/react-query';
import NotificationsDropdown from '@/components/shared/NotificationsDropdown';
import CommandPalette from '@/components/shared/CommandPalette';
import MinimizedOpsTray from '@/components/shared/MinimizedOpsTray';
import { navGroups, canSeeNavItem } from '@/lib/nav-items';
function SidebarContent() {
@@ -253,6 +254,7 @@ export default function AppLayout() {
return (
<div className="flex h-dvh overflow-hidden">
<CommandPalette />
<MinimizedOpsTray />
{/* Desktop sidebar */}
<aside className="hidden w-[var(--sidebar-width)] shrink-0 border-r bg-card lg:block">

View File

@@ -0,0 +1,42 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
/**
* Глобальный список свёрнутых долгих операций (установка приложения, сборка
* золотого шаблона). Живёт вне страниц, поэтому плашки переживают навигацию:
* прогресс опрашивает глобальный трей (MinimizedOpsTray в AppLayout), а не
* свёрнутый диалог. Хранится в localStorage — переживает и перезагрузку
* страницы: операции идут на сервере, трей просто заново подпишется на их
* прогресс. Плашки несуществующих операций трей убирает сам (см. tray).
*/
export type MinimizedOpKind = 'appstore-install' | 'golden-template';
export interface MinimizedOp {
key: string;
kind: MinimizedOpKind;
title: string;
installedAppId?: number; // appstore-install
appId?: string; // appstore-install: app_id каталога для возврата на страницу
serverId?: number; // golden-template
}
interface MinimizedOpsState {
items: MinimizedOp[];
add: (op: MinimizedOp) => void;
remove: (key: string) => void;
}
export const useMinimizedOpsStore = create<MinimizedOpsState>()(
persist(
(set) => ({
items: [],
add: (op) => set((s) => (s.items.some((i) => i.key === op.key) ? s : { items: [...s.items, op] })),
remove: (key) => set((s) => ({ items: s.items.filter((i) => i.key !== key) })),
}),
{ name: 'pvemanager-minimized-ops' },
),
);
export const installOpKey = (installedAppId: number) => `appstore-install:${installedAppId}`;
export const goldenOpKey = (serverId: number) => `golden-template:${serverId}`;