feat(appstore): глобальный трей свёрнутых операций, переживающий навигацию и перезагрузку
All checks were successful
Deploy to Production / Trigger production update (push) Successful in 9s
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:
@@ -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(); }}
|
||||
>
|
||||
|
||||
95
frontend/src/components/shared/MinimizedOpsTray.tsx
Normal file
95
frontend/src/components/shared/MinimizedOpsTray.tsx
Normal 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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
42
frontend/src/stores/minimized-ops-store.ts
Normal file
42
frontend/src/stores/minimized-ops-store.ts
Normal 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}`;
|
||||
Reference in New Issue
Block a user