feat: enhance plugin installation process and improve task management

This commit is contained in:
Junyan Qin
2026-03-29 23:55:36 +08:00
parent f41d69324c
commit 0a69875c09
17 changed files with 429 additions and 186 deletions
@@ -265,6 +265,8 @@ class PluginsRouterGroup(group.RouterGroup):
return self.http_status(400, -1, 'Missing asset_url parameter') return self.http_status(400, -1, 'Missing asset_url parameter')
ctx = taskmgr.TaskContext.new() ctx = taskmgr.TaskContext.new()
ctx.metadata['plugin_name'] = f'{owner}/{repo}'
ctx.metadata['install_source'] = 'github'
install_info = { install_info = {
'asset_url': asset_url, 'asset_url': asset_url,
'owner': owner, 'owner': owner,
@@ -295,12 +297,17 @@ class PluginsRouterGroup(group.RouterGroup):
data = await quart.request.json data = await quart.request.json
plugin_author = data.get('plugin_author', '')
plugin_name = data.get('plugin_name', '')
ctx = taskmgr.TaskContext.new() ctx = taskmgr.TaskContext.new()
ctx.metadata['plugin_name'] = f'{plugin_author}/{plugin_name}'
ctx.metadata['install_source'] = 'marketplace'
wrapper = self.ap.task_mgr.create_user_task( wrapper = self.ap.task_mgr.create_user_task(
self.ap.plugin_connector.install_plugin(PluginInstallSource.MARKETPLACE, data, task_context=ctx), self.ap.plugin_connector.install_plugin(PluginInstallSource.MARKETPLACE, data, task_context=ctx),
kind='plugin-operation', kind='plugin-operation',
name='plugin-install-marketplace', name='plugin-install-marketplace',
label=f'Installing plugin from marketplace ...{data}', label=f'Installing plugin from marketplace {plugin_author}/{plugin_name}',
context=ctx, context=ctx,
) )
@@ -323,11 +330,13 @@ class PluginsRouterGroup(group.RouterGroup):
} }
ctx = taskmgr.TaskContext.new() ctx = taskmgr.TaskContext.new()
ctx.metadata['plugin_name'] = file.filename or 'local plugin'
ctx.metadata['install_source'] = 'local'
wrapper = self.ap.task_mgr.create_user_task( wrapper = self.ap.task_mgr.create_user_task(
self.ap.plugin_connector.install_plugin(PluginInstallSource.LOCAL, data, task_context=ctx), self.ap.plugin_connector.install_plugin(PluginInstallSource.LOCAL, data, task_context=ctx),
kind='plugin-operation', kind='plugin-operation',
name='plugin-install-local', name='plugin-install-local',
label=f'Installing plugin from local ...{file.filename}', label=f'Installing plugin from local {file.filename}',
context=ctx, context=ctx,
) )
@@ -118,11 +118,14 @@ class SystemRouterGroup(group.RouterGroup):
@self.route('/tasks', methods=['GET'], auth_type=group.AuthType.USER_TOKEN) @self.route('/tasks', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def _() -> str: async def _() -> str:
task_type = quart.request.args.get('type') task_type = quart.request.args.get('type')
task_kind = quart.request.args.get('kind')
if task_type == '': if task_type == '':
task_type = None task_type = None
if task_kind == '':
task_kind = None
return self.success(data=self.ap.task_mgr.get_tasks_dict(task_type)) return self.success(data=self.ap.task_mgr.get_tasks_dict(task_type, task_kind))
@self.route('/tasks/<task_id>', methods=['GET'], auth_type=group.AuthType.USER_TOKEN) @self.route('/tasks/<task_id>', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def _(task_id: str) -> str: async def _(task_id: str) -> str:
+6 -1
View File
@@ -215,9 +215,14 @@ class AsyncTaskManager:
def get_tasks_dict( def get_tasks_dict(
self, self,
type: str = None, type: str = None,
kind: str = None,
) -> dict: ) -> dict:
return { return {
'tasks': [t.to_dict() for t in self.tasks if type is None or t.task_type == type], 'tasks': [
t.to_dict()
for t in self.tasks
if (type is None or t.task_type == type) and (kind is None or t.kind == kind)
],
'id_index': TaskWrapper._id_index, 'id_index': TaskWrapper._id_index,
} }
+20 -23
View File
@@ -10,7 +10,7 @@ import {
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Download } from 'lucide-react'; import { Download } from 'lucide-react';
import React, { useState, useCallback } from 'react'; import React, { useState, useCallback, useEffect } from 'react';
import { httpClient } from '@/app/infra/http/HttpClient'; import { httpClient } from '@/app/infra/http/HttpClient';
import { systemInfo } from '@/app/infra/http/HttpClient'; import { systemInfo } from '@/app/infra/http/HttpClient';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -42,7 +42,12 @@ export default function MarketplacePage() {
function MarketplaceContent() { function MarketplaceContent() {
const { t } = useTranslation(); const { t } = useTranslation();
const { refreshPlugins } = useSidebarData(); const { refreshPlugins } = useSidebarData();
const { addTask, setSelectedTaskId } = usePluginInstallTasks(); const {
addTask,
setSelectedTaskId,
registerOnTaskComplete,
unregisterOnTaskComplete,
} = usePluginInstallTasks();
const [modalOpen, setModalOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false);
const [installInfo, setInstallInfo] = useState<Record<string, string>>({}); const [installInfo, setInstallInfo] = useState<Record<string, string>>({});
const [pluginInstallStatus, setPluginInstallStatus] = const [pluginInstallStatus, setPluginInstallStatus] =
@@ -71,26 +76,19 @@ function MarketplaceContent() {
return true; return true;
} }
function watchTask(taskId: number) { // Register task completion callback for toast and plugin list refresh
let alreadySuccess = false; useEffect(() => {
const onComplete = (_taskId: number, success: boolean) => {
const interval = setInterval(() => { if (success) {
httpClient.getAsyncTask(taskId).then((resp) => { toast.success(t('plugins.installSuccess'));
if (resp.runtime.done) { refreshPlugins();
clearInterval(interval); }
if (resp.runtime.exception) { };
// Error is shown via task queue progress dialog registerOnTaskComplete(onComplete);
} else { return () => {
if (!alreadySuccess) { unregisterOnTaskComplete(onComplete);
toast.success(t('plugins.installSuccess')); };
alreadySuccess = true; }, [registerOnTaskComplete, unregisterOnTaskComplete, refreshPlugins, t]);
}
refreshPlugins();
}
}
});
}, 1000);
}
const handleInstallPlugin = useCallback( const handleInstallPlugin = useCallback(
async (plugin: PluginV4) => { async (plugin: PluginV4) => {
@@ -126,7 +124,6 @@ function MarketplaceContent() {
}); });
setSelectedTaskId(taskKey); setSelectedTaskId(taskKey);
setModalOpen(false); setModalOpen(false);
watchTask(taskId);
}) })
.catch((err) => { .catch((err) => {
setInstallError(err.msg); setInstallError(err.msg);
@@ -11,9 +11,9 @@ import { Progress } from '@/components/ui/progress';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
Download, Download,
FolderOpen,
Package, Package,
FlaskConical, Settings,
Rocket,
CheckCircle2, CheckCircle2,
XCircle, XCircle,
Loader2, Loader2,
@@ -36,20 +36,20 @@ const STAGES: {
icon: Download, icon: Download,
i18nKey: 'plugins.installProgress.downloading', i18nKey: 'plugins.installProgress.downloading',
}, },
{
key: InstallStage.EXTRACTING,
icon: FolderOpen,
i18nKey: 'plugins.installProgress.extracting',
},
{ {
key: InstallStage.INSTALLING_DEPS, key: InstallStage.INSTALLING_DEPS,
icon: Package, icon: Package,
i18nKey: 'plugins.installProgress.installingDeps', i18nKey: 'plugins.installProgress.installingDeps',
}, },
{ {
key: InstallStage.TESTING, key: InstallStage.INITIALIZING,
icon: FlaskConical, icon: Settings,
i18nKey: 'plugins.installProgress.testing', i18nKey: 'plugins.installProgress.initializing',
},
{
key: InstallStage.LAUNCHING,
icon: Rocket,
i18nKey: 'plugins.installProgress.launching',
}, },
]; ];
@@ -88,18 +88,29 @@ function StageRow({
<div <div
className={cn( className={cn(
'flex items-center gap-3 px-3 py-2.5 rounded-lg transition-all duration-300', 'flex items-center gap-3 px-3 py-2.5 rounded-lg transition-all duration-300',
isActive && !isError && 'bg-blue-50 dark:bg-blue-950/30 border border-blue-200 dark:border-blue-800', isActive &&
isCompleted && 'bg-green-50/50 dark:bg-green-950/15 border border-green-100 dark:border-green-900/50', !isError &&
isError && isActive && 'bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-900', 'bg-blue-50 dark:bg-blue-950/30 border border-blue-200 dark:border-blue-800',
isCompleted &&
'bg-green-50/50 dark:bg-green-950/15 border border-green-100 dark:border-green-900/50',
isError &&
isActive &&
'bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-900',
)} )}
> >
{/* Left: status indicator */} {/* Left: status indicator */}
<div <div
className={cn( className={cn(
'flex items-center justify-center w-7 h-7 rounded-full shrink-0', 'flex items-center justify-center w-7 h-7 rounded-full shrink-0',
isCompleted && 'bg-green-100 dark:bg-green-900/40 text-green-600 dark:text-green-400', isCompleted &&
isActive && !isError && !isCompleted && 'bg-blue-100 dark:bg-blue-900/40 text-blue-600 dark:text-blue-400', 'bg-green-100 dark:bg-green-900/40 text-green-600 dark:text-green-400',
isError && isActive && 'bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400', isActive &&
!isError &&
!isCompleted &&
'bg-blue-100 dark:bg-blue-900/40 text-blue-600 dark:text-blue-400',
isError &&
isActive &&
'bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400',
)} )}
> >
{isCompleted ? ( {isCompleted ? (
@@ -119,7 +130,10 @@ function StageRow({
<span <span
className={cn( className={cn(
'text-sm font-medium', 'text-sm font-medium',
isActive && !isError && !isCompleted && 'text-blue-700 dark:text-blue-300', isActive &&
!isError &&
!isCompleted &&
'text-blue-700 dark:text-blue-300',
isCompleted && 'text-green-600 dark:text-green-400', isCompleted && 'text-green-600 dark:text-green-400',
isError && isActive && 'text-red-600 dark:text-red-400', isError && isActive && 'text-red-600 dark:text-red-400',
)} )}
@@ -130,17 +144,24 @@ function StageRow({
<Icon <Icon
className={cn( className={cn(
'w-3.5 h-3.5 shrink-0', 'w-3.5 h-3.5 shrink-0',
isActive && !isError && !isCompleted && 'text-blue-400 dark:text-blue-500', isActive &&
!isError &&
!isCompleted &&
'text-blue-400 dark:text-blue-500',
isCompleted && 'text-green-400 dark:text-green-500', isCompleted && 'text-green-400 dark:text-green-500',
isError && isActive && 'text-red-400 dark:text-red-500', isError && isActive && 'text-red-400 dark:text-red-500',
)} )}
/> />
</div> </div>
{detail && ( {detail && (
<div className={cn( <div
'text-xs mt-0.5', className={cn(
isCompleted ? 'text-green-600/70 dark:text-green-400/70' : 'text-blue-600/70 dark:text-blue-400/70', 'text-xs mt-0.5',
)}> isCompleted
? 'text-green-600/70 dark:text-green-400/70'
: 'text-blue-600/70 dark:text-blue-400/70',
)}
>
{detail} {detail}
</div> </div>
)} )}
@@ -259,20 +280,28 @@ function TaskProgressContent({ task }: { task: PluginInstallTask }) {
{/* Overall progress bar — always blue */} {/* Overall progress bar — always blue */}
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className={cn( <span
'text-sm font-medium', className={cn(
isDone ? 'text-green-700 dark:text-green-300' : 'text-blue-700 dark:text-blue-300', 'text-sm font-medium',
)}> isDone
? 'text-green-700 dark:text-green-300'
: 'text-blue-700 dark:text-blue-300',
)}
>
{isDone {isDone
? t('plugins.installProgress.completed') ? t('plugins.installProgress.completed')
: isError : isError
? t('plugins.installProgress.failed') ? t('plugins.installProgress.failed')
: t('plugins.installProgress.overallProgress')} : t('plugins.installProgress.overallProgress')}
</span> </span>
<span className={cn( <span
'text-sm font-medium', className={cn(
isDone ? 'text-green-600 dark:text-green-400' : 'text-blue-600 dark:text-blue-400', 'text-sm font-medium',
)}> isDone
? 'text-green-600 dark:text-green-400'
: 'text-blue-600 dark:text-blue-400',
)}
>
{isDone ? '100%' : `${task.overallProgress}%`} {isDone ? '100%' : `${task.overallProgress}%`}
</span> </span>
</div> </div>
@@ -282,52 +311,52 @@ function TaskProgressContent({ task }: { task: PluginInstallTask }) {
'h-2.5', 'h-2.5',
'[&>div]:bg-blue-500 dark:[&>div]:bg-blue-400', '[&>div]:bg-blue-500 dark:[&>div]:bg-blue-400',
'bg-blue-100 dark:bg-blue-900/30', 'bg-blue-100 dark:bg-blue-900/30',
isDone && '[&>div]:bg-green-500 dark:[&>div]:bg-green-400 bg-green-100 dark:bg-green-900/30', isDone &&
isError && '[&>div]:bg-red-500 dark:[&>div]:bg-red-400 bg-red-100 dark:bg-red-900/30', '[&>div]:bg-green-500 dark:[&>div]:bg-green-400 bg-green-100 dark:bg-green-900/30',
isError &&
'[&>div]:bg-red-500 dark:[&>div]:bg-red-400 bg-red-100 dark:bg-red-900/30',
)} )}
/> />
</div> </div>
{/* Stage display */} {/* Stage display */}
<div className="space-y-1.5"> <div className="space-y-1.5">
{isDone ? ( {isDone
/* When done: show all stages with completed style */ ? /* When done: show all stages with completed style */
STAGES.map((stageConfig) => ( STAGES.map((stageConfig) => (
<StageRow <StageRow
key={stageConfig.key} key={stageConfig.key}
icon={stageConfig.icon} icon={stageConfig.icon}
label={t(stageConfig.i18nKey)} label={t(stageConfig.i18nKey)}
isActive={false} isActive={false}
isCompleted={true} isCompleted={true}
isError={false} isError={false}
detail={getStageDetail(stageConfig.key, true)} detail={getStageDetail(stageConfig.key, true)}
/> />
)) ))
) : isError ? ( : isError
/* Error: show the failed stage */ ? /* Error: show the failed stage */
currentStageIndex >= 0 && ( currentStageIndex >= 0 && (
<StageRow <StageRow
icon={STAGES[currentStageIndex].icon} icon={STAGES[currentStageIndex].icon}
label={t(STAGES[currentStageIndex].i18nKey)} label={t(STAGES[currentStageIndex].i18nKey)}
isActive={true} isActive={true}
isCompleted={false} isCompleted={false}
isError={true} isError={true}
detail={task.error} detail={task.error}
/> />
) )
) : ( : /* In progress: only show the current active stage */
/* In progress: only show the current active stage */ currentStageIndex >= 0 && (
currentStageIndex >= 0 && ( <StageRow
<StageRow icon={STAGES[currentStageIndex].icon}
icon={STAGES[currentStageIndex].icon} label={t(STAGES[currentStageIndex].i18nKey)}
label={t(STAGES[currentStageIndex].i18nKey)} isActive={true}
isActive={true} isCompleted={false}
isCompleted={false} isError={false}
isError={false} detail={getStageDetail(STAGES[currentStageIndex].key, false)}
detail={getStageDetail(STAGES[currentStageIndex].key, false)} />
/> )}
)
)}
</div> </div>
{/* Done banner */} {/* Done banner */}
@@ -16,9 +16,9 @@ import { AsyncTask } from '@/app/infra/entities/api';
*/ */
export enum InstallStage { export enum InstallStage {
DOWNLOADING = 'downloading', DOWNLOADING = 'downloading',
EXTRACTING = 'extracting',
INSTALLING_DEPS = 'installing_deps', INSTALLING_DEPS = 'installing_deps',
TESTING = 'testing', INITIALIZING = 'initializing',
LAUNCHING = 'launching',
DONE = 'done', DONE = 'done',
ERROR = 'error', ERROR = 'error',
} }
@@ -47,6 +47,8 @@ export interface PluginInstallTask {
currentAction: string; // raw backend action string currentAction: string; // raw backend action string
} }
type OnTaskCompleteCallback = (taskId: number, success: boolean) => void;
interface PluginInstallTaskContextValue { interface PluginInstallTaskContextValue {
tasks: PluginInstallTask[]; tasks: PluginInstallTask[];
addTask: (params: { addTask: (params: {
@@ -59,6 +61,9 @@ interface PluginInstallTaskContextValue {
clearCompletedTasks: () => void; clearCompletedTasks: () => void;
selectedTaskId: string | null; selectedTaskId: string | null;
setSelectedTaskId: (id: string | null) => void; setSelectedTaskId: (id: string | null) => void;
/** Register a callback for when a task completes (for toast/refresh). Cleared on unmount. */
registerOnTaskComplete: (cb: OnTaskCompleteCallback) => void;
unregisterOnTaskComplete: (cb: OnTaskCompleteCallback) => void;
} }
const PluginInstallTaskContext = const PluginInstallTaskContext =
@@ -81,16 +86,11 @@ function mapActionToStage(action: string): InstallStage {
if (!action) return InstallStage.DOWNLOADING; if (!action) return InstallStage.DOWNLOADING;
const lower = action.toLowerCase(); const lower = action.toLowerCase();
if (lower.includes('download')) return InstallStage.DOWNLOADING; if (lower.includes('download')) return InstallStage.DOWNLOADING;
if (lower.includes('extract') || lower.includes('unzip'))
return InstallStage.EXTRACTING;
if (lower.includes('dependencies') || lower.includes('requirements')) if (lower.includes('dependencies') || lower.includes('requirements'))
return InstallStage.INSTALLING_DEPS; return InstallStage.INSTALLING_DEPS;
if ( if (lower.includes('initializ') || lower.includes('setting'))
lower.includes('initializ') || return InstallStage.INITIALIZING;
lower.includes('launch') || if (lower.includes('launch')) return InstallStage.LAUNCHING;
lower.includes('test')
)
return InstallStage.TESTING;
if (lower.includes('installed') || lower.includes('complete')) if (lower.includes('installed') || lower.includes('complete'))
return InstallStage.DONE; return InstallStage.DONE;
return InstallStage.DOWNLOADING; return InstallStage.DOWNLOADING;
@@ -103,12 +103,12 @@ function stageToProgress(stage: InstallStage): number {
switch (stage) { switch (stage) {
case InstallStage.DOWNLOADING: case InstallStage.DOWNLOADING:
return 10; return 10;
case InstallStage.EXTRACTING:
return 35;
case InstallStage.INSTALLING_DEPS: case InstallStage.INSTALLING_DEPS:
return 55; return 40;
case InstallStage.TESTING: case InstallStage.INITIALIZING:
return 80; return 70;
case InstallStage.LAUNCHING:
return 85;
case InstallStage.DONE: case InstallStage.DONE:
return 100; return 100;
case InstallStage.ERROR: case InstallStage.ERROR:
@@ -118,6 +118,79 @@ function stageToProgress(stage: InstallStage): number {
} }
} }
/**
* Extract install source from backend task name.
*/
function extractSourceFromName(
name: string,
): 'github' | 'marketplace' | 'local' {
if (name.includes('github')) return 'github';
if (name.includes('marketplace')) return 'marketplace';
return 'local';
}
/**
* Check if a backend task name is a plugin install task.
*/
function isPluginInstallTask(name: string): boolean {
return name.startsWith('plugin-install-');
}
/**
* Convert a backend AsyncTask to our PluginInstallTask.
*/
function asyncTaskToPluginInstallTask(task: AsyncTask): PluginInstallTask {
const source = extractSourceFromName(task.name);
const md = (task.task_context?.metadata ?? {}) as Record<string, unknown>;
const action = task.task_context?.current_action || '';
const done = task.runtime.done;
const exception = task.runtime.exception;
const num = (v: unknown) => (typeof v === 'number' ? v : undefined);
const str = (v: unknown) => (typeof v === 'string' ? v : undefined);
let stage: InstallStage;
let overallProgress: number;
let error: string | undefined;
if (done) {
if (exception) {
stage = InstallStage.ERROR;
overallProgress = 0;
error = exception;
} else {
stage = InstallStage.DONE;
overallProgress = 100;
}
} else {
stage = mapActionToStage(action);
overallProgress = Math.min(95, stageToProgress(stage));
}
const pluginName = str(md.plugin_name) || task.label || `${source} plugin`;
return {
id: `${source}-${task.id}`,
taskId: task.id,
pluginName,
source,
stage,
overallProgress,
downloadCurrent: num(md.download_current),
downloadTotal: num(md.download_total),
downloadSpeed: num(md.download_speed),
depsTotal: num(md.deps_total),
depsInstalled: num(md.deps_installed),
depsRemaining: num(md.deps_remaining),
currentDep: str(md.current_dep),
depsDownloadedSize: num(md.deps_downloaded_size),
depsSpeed: num(md.deps_speed),
error,
startedAt: Date.now(),
currentAction: action,
};
}
export function PluginInstallTaskProvider({ export function PluginInstallTaskProvider({
children, children,
}: { }: {
@@ -126,16 +199,46 @@ export function PluginInstallTaskProvider({
const [tasks, setTasks] = useState<PluginInstallTask[]>([]); const [tasks, setTasks] = useState<PluginInstallTask[]>([]);
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null); const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
const intervalRefs = useRef<Map<string, NodeJS.Timeout>>(new Map()); const intervalRefs = useRef<Map<string, NodeJS.Timeout>>(new Map());
const syncIntervalRef = useRef<NodeJS.Timeout | null>(null);
const onTaskCompleteCallbacks = useRef<Set<OnTaskCompleteCallback>>(
new Set(),
);
// Track tasks that have already been marked as completed/failed (to avoid duplicate callbacks)
const notifiedTaskIds = useRef<Set<number>>(new Set());
// Track task IDs that the user has explicitly dismissed
const dismissedTaskIds = useRef<Set<number>>(new Set());
// Cleanup all intervals on unmount // Cleanup all intervals on unmount
useEffect(() => { useEffect(() => {
return () => { return () => {
intervalRefs.current.forEach((interval) => clearInterval(interval)); intervalRefs.current.forEach((interval) => {
clearInterval(interval);
});
if (syncIntervalRef.current) clearInterval(syncIntervalRef.current);
}; };
}, []); }, []);
const registerOnTaskComplete = useCallback((cb: OnTaskCompleteCallback) => {
onTaskCompleteCallbacks.current.add(cb);
}, []);
const unregisterOnTaskComplete = useCallback((cb: OnTaskCompleteCallback) => {
onTaskCompleteCallbacks.current.delete(cb);
}, []);
const notifyTaskComplete = useCallback((taskId: number, success: boolean) => {
if (notifiedTaskIds.current.has(taskId)) return;
notifiedTaskIds.current.add(taskId);
onTaskCompleteCallbacks.current.forEach((cb) => {
cb(taskId, success);
});
}, []);
const pollTask = useCallback( const pollTask = useCallback(
(taskKey: string, taskId: number) => { (taskKey: string, taskId: number) => {
// Don't start duplicate polling for the same task
if (intervalRefs.current.has(taskKey)) return;
const interval = setInterval(() => { const interval = setInterval(() => {
httpClient httpClient
.getAsyncTask(taskId) .getAsyncTask(taskId)
@@ -143,7 +246,10 @@ export function PluginInstallTaskProvider({
const action = res.task_context?.current_action || ''; const action = res.task_context?.current_action || '';
const done = res.runtime.done; const done = res.runtime.done;
const exception = res.runtime.exception; const exception = res.runtime.exception;
const md = (res.task_context?.metadata ?? {}) as Record<string, unknown>; const md = (res.task_context?.metadata ?? {}) as Record<
string,
unknown
>;
// Extract progress fields from metadata // Extract progress fields from metadata
const num = (v: unknown) => (typeof v === 'number' ? v : undefined); const num = (v: unknown) => (typeof v === 'number' ? v : undefined);
@@ -171,7 +277,8 @@ export function PluginInstallTaskProvider({
depsInstalled: depsInstalled ?? t.depsInstalled, depsInstalled: depsInstalled ?? t.depsInstalled,
depsRemaining: depsRemaining ?? t.depsRemaining, depsRemaining: depsRemaining ?? t.depsRemaining,
currentDep: currentDep ?? t.currentDep, currentDep: currentDep ?? t.currentDep,
depsDownloadedSize: depsDownloadedSize ?? t.depsDownloadedSize, depsDownloadedSize:
depsDownloadedSize ?? t.depsDownloadedSize,
depsSpeed: depsSpeed ?? t.depsSpeed, depsSpeed: depsSpeed ?? t.depsSpeed,
}; };
@@ -184,6 +291,7 @@ export function PluginInstallTaskProvider({
} }
if (exception) { if (exception) {
notifyTaskComplete(taskId, false);
return { return {
...t, ...t,
stage: InstallStage.ERROR, stage: InstallStage.ERROR,
@@ -194,6 +302,7 @@ export function PluginInstallTaskProvider({
}; };
} }
notifyTaskComplete(taskId, true);
return { return {
...t, ...t,
stage: InstallStage.DONE, stage: InstallStage.DONE,
@@ -233,9 +342,76 @@ export function PluginInstallTaskProvider({
intervalRefs.current.set(taskKey, interval); intervalRefs.current.set(taskKey, interval);
}, },
[], [notifyTaskComplete],
); );
/**
* Fetch all plugin-operation tasks from backend and sync state.
* This is called on mount and periodically to recover tasks after refresh.
*/
const syncTasksFromBackend = useCallback(async () => {
try {
const resp = await httpClient.getAsyncTasks({ kind: 'plugin-operation' });
const backendTasks = (resp.tasks || []).filter((t: AsyncTask) =>
isPluginInstallTask(t.name),
);
setTasks((prevTasks) => {
const existingTaskIds = new Set(prevTasks.map((t) => t.taskId));
const updatedTasks = [...prevTasks];
for (const bt of backendTasks) {
// Skip tasks that the user has dismissed
if (dismissedTaskIds.current.has(bt.id)) continue;
if (!existingTaskIds.has(bt.id)) {
// New task from backend (e.g. after page refresh) — add it
const newTask = asyncTaskToPluginInstallTask(bt);
updatedTasks.push(newTask);
// If not done, start polling for progress
if (!bt.runtime.done) {
pollTask(newTask.id, bt.id);
} else {
// Mark as already notified so we don't re-trigger toasts for old completed tasks
notifiedTaskIds.current.add(bt.id);
}
} else {
// Already tracking — if it's done in backend but still active locally, update it
const idx = updatedTasks.findIndex((t) => t.taskId === bt.id);
if (idx !== -1) {
const existing = updatedTasks[idx];
if (
bt.runtime.done &&
existing.stage !== InstallStage.DONE &&
existing.stage !== InstallStage.ERROR
) {
const converted = asyncTaskToPluginInstallTask(bt);
converted.startedAt = existing.startedAt;
converted.pluginName = existing.pluginName;
converted.fileSize = existing.fileSize;
updatedTasks[idx] = converted;
}
}
}
}
return updatedTasks;
});
} catch {
// Silently ignore sync errors
}
}, [pollTask]);
// Initial sync on mount + periodic sync every 3s
useEffect(() => {
syncTasksFromBackend();
syncIntervalRef.current = setInterval(syncTasksFromBackend, 3000);
return () => {
if (syncIntervalRef.current) clearInterval(syncIntervalRef.current);
};
}, [syncTasksFromBackend]);
const addTask = useCallback( const addTask = useCallback(
(params: { (params: {
taskId: number; taskId: number;
@@ -244,6 +420,10 @@ export function PluginInstallTaskProvider({
fileSize?: number; fileSize?: number;
}) => { }) => {
const taskKey = `${params.source}-${params.taskId}`; const taskKey = `${params.source}-${params.taskId}`;
// Remove from dismissed set if re-added
dismissedTaskIds.current.delete(params.taskId);
const newTask: PluginInstallTask = { const newTask: PluginInstallTask = {
id: taskKey, id: taskKey,
taskId: params.taskId, taskId: params.taskId,
@@ -256,7 +436,11 @@ export function PluginInstallTaskProvider({
currentAction: '', currentAction: '',
}; };
setTasks((prev) => [...prev, newTask]); setTasks((prev) => {
// Avoid duplicate
if (prev.some((t) => t.taskId === params.taskId)) return prev;
return [...prev, newTask];
});
pollTask(taskKey, params.taskId); pollTask(taskKey, params.taskId);
}, },
[pollTask], [pollTask],
@@ -268,15 +452,28 @@ export function PluginInstallTaskProvider({
clearInterval(iv); clearInterval(iv);
intervalRefs.current.delete(id); intervalRefs.current.delete(id);
} }
setTasks((prev) => prev.filter((t) => t.id !== id));
setTasks((prev) => {
const task = prev.find((t) => t.id === id);
if (task) {
dismissedTaskIds.current.add(task.taskId);
}
return prev.filter((t) => t.id !== id);
});
}, []); }, []);
const clearCompletedTasks = useCallback(() => { const clearCompletedTasks = useCallback(() => {
setTasks((prev) => setTasks((prev) => {
prev.filter( const completed = prev.filter(
(t) => t.stage === InstallStage.DONE || t.stage === InstallStage.ERROR,
);
completed.forEach((t) => {
dismissedTaskIds.current.add(t.taskId);
});
return prev.filter(
(t) => t.stage !== InstallStage.DONE && t.stage !== InstallStage.ERROR, (t) => t.stage !== InstallStage.DONE && t.stage !== InstallStage.ERROR,
), );
); });
}, []); }, []);
return ( return (
@@ -288,6 +485,8 @@ export function PluginInstallTaskProvider({
clearCompletedTasks, clearCompletedTasks,
selectedTaskId, selectedTaskId,
setSelectedTaskId, setSelectedTaskId,
registerOnTaskComplete,
unregisterOnTaskComplete,
}} }}
> >
{children} {children}
@@ -5,9 +5,9 @@ import { useTranslation } from 'react-i18next';
import { Progress } from '@/components/ui/progress'; import { Progress } from '@/components/ui/progress';
import { import {
Download, Download,
FolderOpen,
Package, Package,
FlaskConical, Settings,
Rocket,
CheckCircle2, CheckCircle2,
XCircle, XCircle,
Loader2, Loader2,
@@ -30,9 +30,9 @@ import { cn } from '@/lib/utils';
const STAGE_ICONS: Record<string, React.ElementType> = { const STAGE_ICONS: Record<string, React.ElementType> = {
[InstallStage.DOWNLOADING]: Download, [InstallStage.DOWNLOADING]: Download,
[InstallStage.EXTRACTING]: FolderOpen,
[InstallStage.INSTALLING_DEPS]: Package, [InstallStage.INSTALLING_DEPS]: Package,
[InstallStage.TESTING]: FlaskConical, [InstallStage.INITIALIZING]: Settings,
[InstallStage.LAUNCHING]: Rocket,
[InstallStage.DONE]: CheckCircle2, [InstallStage.DONE]: CheckCircle2,
[InstallStage.ERROR]: XCircle, [InstallStage.ERROR]: XCircle,
}; };
@@ -56,12 +56,12 @@ function TaskQueueItem({
switch (task.stage) { switch (task.stage) {
case InstallStage.DOWNLOADING: case InstallStage.DOWNLOADING:
return t('plugins.installProgress.downloading'); return t('plugins.installProgress.downloading');
case InstallStage.EXTRACTING:
return t('plugins.installProgress.extracting');
case InstallStage.INSTALLING_DEPS: case InstallStage.INSTALLING_DEPS:
return t('plugins.installProgress.installingDeps'); return t('plugins.installProgress.installingDeps');
case InstallStage.TESTING: case InstallStage.INITIALIZING:
return t('plugins.installProgress.testing'); return t('plugins.installProgress.initializing');
case InstallStage.LAUNCHING:
return t('plugins.installProgress.launching');
case InstallStage.DONE: case InstallStage.DONE:
return t('plugins.installProgress.completed'); return t('plugins.installProgress.completed');
case InstallStage.ERROR: case InstallStage.ERROR:
@@ -79,9 +79,12 @@ function TaskQueueItem({
<div <div
className={cn( className={cn(
'flex items-center justify-center w-7 h-7 rounded-full shrink-0', 'flex items-center justify-center w-7 h-7 rounded-full shrink-0',
isDone && 'bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400', isDone &&
isError && 'bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400', 'bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400',
isRunning && 'bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400', isError &&
'bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400',
isRunning &&
'bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400',
)} )}
> >
{isRunning ? ( {isRunning ? (
@@ -138,10 +141,7 @@ export default function PluginInstallTaskQueue() {
return ( return (
<Popover> <Popover>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <Button variant="outline" className="relative px-4 py-5 cursor-pointer">
variant="outline"
className="relative px-4 py-5 cursor-pointer"
>
<ListTodo className="w-4 h-4 mr-2" /> <ListTodo className="w-4 h-4 mr-2" />
{t('plugins.installProgress.taskQueue')} {t('plugins.installProgress.taskQueue')}
{runningCount > 0 && ( {runningCount > 0 && (
@@ -152,12 +152,6 @@ export default function PluginInstallTaskQueue() {
{runningCount} {runningCount}
</Badge> </Badge>
)} )}
{runningCount > 0 && (
<span className="absolute top-1 right-1 flex h-2.5 w-2.5">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary opacity-75" />
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-primary" />
</span>
)}
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-[340px] p-2" align="end"> <PopoverContent className="w-[340px] p-2" align="end">
+20 -24
View File
@@ -103,7 +103,12 @@ function PluginListView() {
pendingPluginInstallAction, pendingPluginInstallAction,
setPendingPluginInstallAction, setPendingPluginInstallAction,
} = useSidebarData(); } = useSidebarData();
const { addTask, setSelectedTaskId } = usePluginInstallTasks(); const {
addTask,
setSelectedTaskId,
registerOnTaskComplete,
unregisterOnTaskComplete,
} = usePluginInstallTasks();
const [modalOpen, setModalOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false);
const [installSource, setInstallSource] = useState<string>('local'); const [installSource, setInstallSource] = useState<string>('local');
const [installInfo] = useState<Record<string, any>>({}); // eslint-disable-line @typescript-eslint/no-explicit-any const [installInfo] = useState<Record<string, any>>({}); // eslint-disable-line @typescript-eslint/no-explicit-any
@@ -160,27 +165,20 @@ function PluginListView() {
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i]; return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
} }
function watchTask(taskId: number) { // Register task completion callback for toast and plugin list refresh
let alreadySuccess = false; useEffect(() => {
const onComplete = (_taskId: number, success: boolean) => {
const interval = setInterval(() => { if (success) {
httpClient.getAsyncTask(taskId).then((resp) => { toast.success(t('plugins.installSuccess'));
if (resp.runtime.done) { pluginInstalledRef.current?.refreshPluginList();
clearInterval(interval); refreshPlugins();
if (resp.runtime.exception) { }
// Error is shown via task queue progress dialog };
} else { registerOnTaskComplete(onComplete);
if (!alreadySuccess) { return () => {
toast.success(t('plugins.installSuccess')); unregisterOnTaskComplete(onComplete);
alreadySuccess = true; };
} }, [registerOnTaskComplete, unregisterOnTaskComplete, refreshPlugins, t]);
pluginInstalledRef.current?.refreshPluginList();
refreshPlugins();
}
}
});
}, 1000);
}
const pluginInstalledRef = useRef<PluginInstalledComponentRef>(null); const pluginInstalledRef = useRef<PluginInstalledComponentRef>(null);
@@ -323,7 +321,6 @@ function PluginListView() {
setSelectedTaskId(taskKey); setSelectedTaskId(taskKey);
resetGithubState(); resetGithubState();
setModalOpen(false); setModalOpen(false);
watchTask(taskId);
}) })
.catch((err) => { .catch((err) => {
setInstallError(err.msg); setInstallError(err.msg);
@@ -345,7 +342,6 @@ function PluginListView() {
}); });
setSelectedTaskId(taskKey); setSelectedTaskId(taskKey);
setModalOpen(false); setModalOpen(false);
watchTask(taskId);
}) })
.catch((err) => { .catch((err) => {
setInstallError(err.msg); setInstallError(err.msg);
+1
View File
@@ -306,6 +306,7 @@ export interface AsyncTask {
id: number; id: number;
kind: string; kind: string;
name: string; name: string;
label: string;
task_type: string; // system or user task_type: string; // system or user
runtime: AsyncTaskRuntimeInfo; runtime: AsyncTaskRuntimeInfo;
task_context: AsyncTaskTaskContext; task_context: AsyncTaskTaskContext;
+9 -2
View File
@@ -715,8 +715,15 @@ export class BackendClient extends BaseHttpClient {
return this.put('/api/v1/system/wizard/progress', progress); return this.put('/api/v1/system/wizard/progress', progress);
} }
public getAsyncTasks(): Promise<ApiRespAsyncTasks> { public getAsyncTasks(params?: {
return this.get('/api/v1/system/tasks'); type?: string;
kind?: string;
}): Promise<ApiRespAsyncTasks> {
const query = new URLSearchParams();
if (params?.type) query.set('type', params.type);
if (params?.kind) query.set('kind', params.kind);
const qs = query.toString();
return this.get(`/api/v1/system/tasks${qs ? `?${qs}` : ''}`);
} }
public getAsyncTask(id: number): Promise<AsyncTask> { public getAsyncTask(id: number): Promise<AsyncTask> {
+4 -3
View File
@@ -498,14 +498,15 @@ const enUS = {
titleGeneric: 'Plugin Installation', titleGeneric: 'Plugin Installation',
overallProgress: 'Overall Progress', overallProgress: 'Overall Progress',
downloading: 'Downloading Plugin', downloading: 'Downloading Plugin',
extracting: 'Extracting Plugin',
installingDeps: 'Installing Dependencies', installingDeps: 'Installing Dependencies',
testing: 'Testing Plugin', initializing: 'Initializing Settings',
launching: 'Launching Plugin',
completed: 'Completed', completed: 'Completed',
failed: 'Failed', failed: 'Failed',
downloadSize: 'Package size: {{size}}', downloadSize: 'Package size: {{size}}',
depsInfo: '{{count}} dependencies to install', depsInfo: '{{count}} dependencies to install',
depsProgress: '{{installed}}/{{total}} installed · {{remaining}} remaining', depsProgress:
'{{installed}}/{{total}} installed · {{remaining}} remaining',
installComplete: 'Plugin installed successfully', installComplete: 'Plugin installed successfully',
dismiss: 'Dismiss', dismiss: 'Dismiss',
background: 'Run in Background', background: 'Run in Background',
+4 -3
View File
@@ -508,14 +508,15 @@ const esES = {
titleGeneric: 'Instalación de Plugin', titleGeneric: 'Instalación de Plugin',
overallProgress: 'Progreso general', overallProgress: 'Progreso general',
downloading: 'Descargando Plugin', downloading: 'Descargando Plugin',
extracting: 'Extrayendo Plugin',
installingDeps: 'Instalando dependencias', installingDeps: 'Instalando dependencias',
testing: 'Probando Plugin', initializing: 'Inicializando configuración',
launching: 'Iniciando Plugin',
completed: 'Completado', completed: 'Completado',
failed: 'Fallido', failed: 'Fallido',
downloadSize: 'Tamaño del paquete: {{size}}', downloadSize: 'Tamaño del paquete: {{size}}',
depsInfo: '{{count}} dependencias por instalar', depsInfo: '{{count}} dependencias por instalar',
depsProgress: '{{installed}}/{{total}} instaladas · {{remaining}} restantes', depsProgress:
'{{installed}}/{{total}} instaladas · {{remaining}} restantes',
installComplete: 'Plugin instalado correctamente', installComplete: 'Plugin instalado correctamente',
dismiss: 'Descartar', dismiss: 'Descartar',
background: 'Ejecutar en segundo plano', background: 'Ejecutar en segundo plano',
+4 -3
View File
@@ -498,14 +498,15 @@
titleGeneric: 'プラグインのインストール', titleGeneric: 'プラグインのインストール',
overallProgress: '全体の進捗', overallProgress: '全体の進捗',
downloading: 'プラグインをダウンロード中', downloading: 'プラグインをダウンロード中',
extracting: 'プラグインを展開中',
installingDeps: '依存関係をインストール中', installingDeps: '依存関係をインストール中',
testing: 'プラグインをテスト中', initializing: '設定を初期化中',
launching: 'プラグインを起動中',
completed: '完了', completed: '完了',
failed: '失敗', failed: '失敗',
downloadSize: 'パッケージサイズ: {{size}}', downloadSize: 'パッケージサイズ: {{size}}',
depsInfo: '{{count}} 個の依存関係をインストール', depsInfo: '{{count}} 個の依存関係をインストール',
depsProgress: '{{installed}}/{{total}} インストール済み · 残り {{remaining}} 個', depsProgress:
'{{installed}}/{{total}} インストール済み · 残り {{remaining}} 個',
installComplete: 'プラグインのインストール完了', installComplete: 'プラグインのインストール完了',
dismiss: '閉じる', dismiss: '閉じる',
background: 'バックグラウンドで実行', background: 'バックグラウンドで実行',
+2 -2
View File
@@ -490,9 +490,9 @@ const thTH = {
titleGeneric: 'การติดตั้งปลั๊กอิน', titleGeneric: 'การติดตั้งปลั๊กอิน',
overallProgress: 'ความคืบหน้าโดยรวม', overallProgress: 'ความคืบหน้าโดยรวม',
downloading: 'กำลังดาวน์โหลดปลั๊กอิน', downloading: 'กำลังดาวน์โหลดปลั๊กอิน',
extracting: 'กำลังแตกไฟล์ปลั๊กอิน',
installingDeps: 'กำลังติดตั้งแพ็กเกจ', installingDeps: 'กำลังติดตั้งแพ็กเกจ',
testing: 'กำลังทดสอบปลั๊กอิน', initializing: 'กำลังเริ่มต้นการตั้งค่า',
launching: 'กำลังเปิดใช้งานปลั๊กอิน',
completed: 'เสร็จสมบูรณ์', completed: 'เสร็จสมบูรณ์',
failed: 'ล้มเหลว', failed: 'ล้มเหลว',
downloadSize: 'ขนาดแพ็กเกจ: {{size}}', downloadSize: 'ขนาดแพ็กเกจ: {{size}}',
+2 -2
View File
@@ -502,9 +502,9 @@ const viVN = {
titleGeneric: 'Cài đặt Plugin', titleGeneric: 'Cài đặt Plugin',
overallProgress: 'Tiến độ tổng thể', overallProgress: 'Tiến độ tổng thể',
downloading: 'Đang tải Plugin', downloading: 'Đang tải Plugin',
extracting: 'Đang giải nén Plugin',
installingDeps: 'Đang cài đặt phụ thuộc', installingDeps: 'Đang cài đặt phụ thuộc',
testing: 'Đang kiểm tra Plugin', initializing: 'Đang khởi tạo cài đặt',
launching: 'Đang khởi chạy Plugin',
completed: 'Hoàn thành', completed: 'Hoàn thành',
failed: 'Thất bại', failed: 'Thất bại',
downloadSize: 'Kích thước gói: {{size}}', downloadSize: 'Kích thước gói: {{size}}',
+2 -2
View File
@@ -474,9 +474,9 @@ const zhHans = {
titleGeneric: '插件安装', titleGeneric: '插件安装',
overallProgress: '总体进度', overallProgress: '总体进度',
downloading: '下载插件', downloading: '下载插件',
extracting: '解压插件',
installingDeps: '安装依赖', installingDeps: '安装依赖',
testing: '测试插件', initializing: '初始化配置',
launching: '启动插件',
completed: '已完成', completed: '已完成',
failed: '安装失败', failed: '安装失败',
downloadSize: '包大小: {{size}}', downloadSize: '包大小: {{size}}',
+2 -2
View File
@@ -467,9 +467,9 @@ const zhHant = {
titleGeneric: '外掛安裝', titleGeneric: '外掛安裝',
overallProgress: '整體進度', overallProgress: '整體進度',
downloading: '下載外掛', downloading: '下載外掛',
extracting: '解壓外掛',
installingDeps: '安裝依賴', installingDeps: '安裝依賴',
testing: '測試外掛', initializing: '初始化設定',
launching: '啟動外掛',
completed: '已完成', completed: '已完成',
failed: '安裝失敗', failed: '安裝失敗',
downloadSize: '檔案大小: {{size}}', downloadSize: '檔案大小: {{size}}',