forked from 77media/video-flow
Merge branch 'dev' of https://git.qikongjian.com/77media/video-flow into dev
This commit is contained in:
commit
c401f1ff0a
@ -823,6 +823,116 @@ export const retryTask = async (request: {
|
||||
return post("/task/retry_task", request);
|
||||
};
|
||||
|
||||
/**
|
||||
* 任务统计数据接口定义
|
||||
*/
|
||||
export interface TaskStatistics {
|
||||
/** 项目ID */
|
||||
project_id: string;
|
||||
/** 项目名称 */
|
||||
project_name?: string;
|
||||
/** 总任务数 */
|
||||
total_tasks: number;
|
||||
/** 各状态任务统计 */
|
||||
status_stats: {
|
||||
/** 已完成任务数 */
|
||||
completed: number;
|
||||
/** 进行中任务数 */
|
||||
in_progress: number;
|
||||
/** 等待中任务数 */
|
||||
pending: number;
|
||||
/** 失败任务数 */
|
||||
failed: number;
|
||||
/** 阻塞任务数 */
|
||||
blocked: number;
|
||||
};
|
||||
/** 成功率 */
|
||||
success_rate: number;
|
||||
/** 平均执行时间(秒) */
|
||||
avg_execution_time: number;
|
||||
/** 最长执行时间(秒) */
|
||||
max_execution_time: number;
|
||||
/** 最短执行时间(秒) */
|
||||
min_execution_time: number;
|
||||
/** 最近更新时间 */
|
||||
last_updated: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 消息队列状态接口定义
|
||||
*/
|
||||
export interface MessageQueueStatus {
|
||||
/** 队列名称 */
|
||||
queue_name: string;
|
||||
/** 队列状态 */
|
||||
status: 'healthy' | 'warning' | 'error';
|
||||
/** 队列中消息数量 */
|
||||
message_count: number;
|
||||
/** 消费者数量 */
|
||||
consumer_count: number;
|
||||
/** 最后心跳时间 */
|
||||
last_heartbeat: string;
|
||||
/** 错误信息 */
|
||||
error_message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取项目任务统计数据
|
||||
* @param request - 请求参数
|
||||
* @returns Promise<ApiResponse<任务统计数据>>
|
||||
*/
|
||||
export const getProjectTaskStatistics = async (request: {
|
||||
/** 项目ID */
|
||||
project_id: string;
|
||||
/** 时间范围(小时),默认24小时 */
|
||||
time_range?: number;
|
||||
}): Promise<ApiResponse<TaskStatistics>> => {
|
||||
return post("/task/get_project_statistics", request);
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取多个项目的任务统计数据
|
||||
* @param request - 请求参数
|
||||
* @returns Promise<ApiResponse<任务统计数据列表>>
|
||||
*/
|
||||
export const getMultiProjectTaskStatistics = async (request: {
|
||||
/** 项目ID列表 */
|
||||
project_ids: string[];
|
||||
/** 时间范围(小时),默认24小时 */
|
||||
time_range?: number;
|
||||
}): Promise<ApiResponse<TaskStatistics[]>> => {
|
||||
return post("/task/get_multi_project_statistics", request);
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取消息队列状态
|
||||
* @returns Promise<ApiResponse<消息队列状态列表>>
|
||||
*/
|
||||
export const getMessageQueueStatus = async (): Promise<ApiResponse<MessageQueueStatus[]>> => {
|
||||
return post("/system/get_queue_status", {});
|
||||
};
|
||||
|
||||
/**
|
||||
* 重启消息队列
|
||||
* @param request - 请求参数
|
||||
* @returns Promise<ApiResponse<重启结果>>
|
||||
*/
|
||||
export const restartMessageQueue = async (request: {
|
||||
/** 队列名称 */
|
||||
queue_name: string;
|
||||
}): Promise<ApiResponse<{
|
||||
/** 队列名称 */
|
||||
queue_name: string;
|
||||
/** 重启状态 */
|
||||
status: string;
|
||||
/** 状态描述 */
|
||||
message: string;
|
||||
/** 是否成功重启 */
|
||||
success: boolean;
|
||||
}>> => {
|
||||
return post("/system/restart_queue", request);
|
||||
};
|
||||
|
||||
export const resumePlanFlow = async (request: {
|
||||
/** 项目ID */
|
||||
project_id: string;
|
||||
|
||||
289
app/task-monitor/page.tsx
Normal file
289
app/task-monitor/page.tsx
Normal file
@ -0,0 +1,289 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import {
|
||||
getProjectTaskStatistics,
|
||||
getMultiProjectTaskStatistics,
|
||||
getMessageQueueStatus,
|
||||
restartMessageQueue,
|
||||
TaskStatistics,
|
||||
MessageQueueStatus
|
||||
} from '@/api/video_flow';
|
||||
import { cn } from '@/public/lib/utils';
|
||||
import TaskStatisticsCards from '@/components/task-monitor/task-statistics-cards';
|
||||
import TaskStatusChart from '@/components/task-monitor/task-status-chart';
|
||||
import TaskTimelineChart from '@/components/task-monitor/task-timeline-chart';
|
||||
import MessageQueuePanel from '@/components/task-monitor/message-queue-panel';
|
||||
import TaskDetailTable from '@/components/task-monitor/task-detail-table';
|
||||
import ProjectSelector from '@/components/task-monitor/project-selector';
|
||||
|
||||
export default function TaskMonitorPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const initialProjectId = searchParams.get('project_id') || 'bc43bc81-c781-4caa-8256-9710fd5bee80';
|
||||
|
||||
// 状态管理
|
||||
const [selectedProjectIds, setSelectedProjectIds] = useState<string[]>([initialProjectId]);
|
||||
const [timeRange, setTimeRange] = useState<number>(24); // 24小时
|
||||
const [taskStatistics, setTaskStatistics] = useState<TaskStatistics[]>([]);
|
||||
const [queueStatus, setQueueStatus] = useState<MessageQueueStatus[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [lastUpdateTime, setLastUpdateTime] = useState<Date | null>(null);
|
||||
const [autoRefresh, setAutoRefresh] = useState(true);
|
||||
|
||||
// 刷新控制
|
||||
const refreshIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// 获取任务统计数据
|
||||
const fetchTaskStatistics = async () => {
|
||||
try {
|
||||
setError(null);
|
||||
|
||||
if (selectedProjectIds.length === 1) {
|
||||
const response = await getProjectTaskStatistics({
|
||||
project_id: selectedProjectIds[0],
|
||||
time_range: timeRange
|
||||
});
|
||||
|
||||
if (response.code === 0 && response.data) {
|
||||
setTaskStatistics([response.data]);
|
||||
} else {
|
||||
throw new Error(response.message || '获取任务统计失败');
|
||||
}
|
||||
} else if (selectedProjectIds.length > 1) {
|
||||
const response = await getMultiProjectTaskStatistics({
|
||||
project_ids: selectedProjectIds,
|
||||
time_range: timeRange
|
||||
});
|
||||
|
||||
if (response.code === 0 && response.data) {
|
||||
setTaskStatistics(response.data);
|
||||
} else {
|
||||
throw new Error(response.message || '获取多项目统计失败');
|
||||
}
|
||||
}
|
||||
|
||||
setLastUpdateTime(new Date());
|
||||
} catch (err: any) {
|
||||
console.error('获取任务统计失败:', err);
|
||||
setError(err.message || '获取数据失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 获取消息队列状态
|
||||
const fetchQueueStatus = async () => {
|
||||
try {
|
||||
const response = await getMessageQueueStatus();
|
||||
|
||||
if (response.code === 0 && response.data) {
|
||||
setQueueStatus(response.data);
|
||||
} else {
|
||||
console.warn('获取队列状态失败:', response.message);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('获取队列状态失败:', err);
|
||||
}
|
||||
};
|
||||
|
||||
// 初始化数据加载
|
||||
const initializeData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await Promise.all([
|
||||
fetchTaskStatistics(),
|
||||
fetchQueueStatus()
|
||||
]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 手动刷新
|
||||
const handleRefresh = async () => {
|
||||
await initializeData();
|
||||
};
|
||||
|
||||
// 重启消息队列
|
||||
const handleRestartQueue = async (queueName: string) => {
|
||||
try {
|
||||
const response = await restartMessageQueue({ queue_name: queueName });
|
||||
|
||||
if (response.code === 0 && response.data?.success) {
|
||||
console.log('队列重启成功:', response.data);
|
||||
// 延迟刷新队列状态
|
||||
setTimeout(() => {
|
||||
fetchQueueStatus();
|
||||
}, 2000);
|
||||
} else {
|
||||
throw new Error(response.message || '重启失败');
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('重启队列失败:', err);
|
||||
alert(`重启队列失败: ${err.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
// 项目选择变化
|
||||
const handleProjectChange = (projectIds: string[]) => {
|
||||
setSelectedProjectIds(projectIds);
|
||||
};
|
||||
|
||||
// 时间范围变化
|
||||
const handleTimeRangeChange = (range: number) => {
|
||||
setTimeRange(range);
|
||||
};
|
||||
|
||||
// 自动刷新控制
|
||||
useEffect(() => {
|
||||
if (autoRefresh) {
|
||||
refreshIntervalRef.current = setInterval(() => {
|
||||
fetchTaskStatistics();
|
||||
fetchQueueStatus();
|
||||
}, 30000); // 30秒刷新一次
|
||||
} else {
|
||||
if (refreshIntervalRef.current) {
|
||||
clearInterval(refreshIntervalRef.current);
|
||||
refreshIntervalRef.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (refreshIntervalRef.current) {
|
||||
clearInterval(refreshIntervalRef.current);
|
||||
}
|
||||
};
|
||||
}, [autoRefresh, selectedProjectIds, timeRange]);
|
||||
|
||||
// 初始化和依赖变化时重新加载数据
|
||||
useEffect(() => {
|
||||
initializeData();
|
||||
}, [selectedProjectIds, timeRange]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-blue-500 mx-auto"></div>
|
||||
<p className="mt-4 text-lg text-gray-600">加载任务监控数据...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-900 via-gray-800 to-gray-900">
|
||||
{/* 顶部导航栏 */}
|
||||
<div className="bg-gray-900 border-b border-gray-800 px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full bg-emerald-400 animate-pulse"></div>
|
||||
<h1 className="text-xl font-bold text-white">任务监控中心</h1>
|
||||
</div>
|
||||
<div className="text-sm text-gray-400">
|
||||
实时监控项目任务状态和系统健康度
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 控制面板 */}
|
||||
<div className="flex items-center gap-4">
|
||||
{/* 自动刷新开关 */}
|
||||
<label className="flex items-center gap-2 text-sm text-gray-400">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoRefresh}
|
||||
onChange={(e) => setAutoRefresh(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
自动刷新
|
||||
</label>
|
||||
|
||||
{/* 手动刷新按钮 */}
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={loading}
|
||||
className="px-4 py-2 bg-blue-500 hover:bg-blue-600 disabled:opacity-50 text-white rounded-lg transition-colors flex items-center gap-2"
|
||||
>
|
||||
<svg className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
刷新
|
||||
</button>
|
||||
|
||||
{/* 最后更新时间 */}
|
||||
{lastUpdateTime && (
|
||||
<div className="text-sm text-gray-500">
|
||||
更新于 {lastUpdateTime.toLocaleTimeString()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 错误提示 */}
|
||||
{error && (
|
||||
<div className="mx-6 mt-4 p-4 bg-red-500/10 border border-red-500/20 rounded-lg">
|
||||
<div className="flex items-center gap-2 text-red-400">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 主要内容区域 */}
|
||||
<div className="p-6 space-y-6">
|
||||
{/* 项目选择和时间范围控制 */}
|
||||
<div className="flex items-center gap-4">
|
||||
<ProjectSelector
|
||||
selectedProjectIds={selectedProjectIds}
|
||||
onProjectChange={handleProjectChange}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm text-gray-400">时间范围:</label>
|
||||
<select
|
||||
value={timeRange}
|
||||
onChange={(e) => handleTimeRangeChange(Number(e.target.value))}
|
||||
className="px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white text-sm"
|
||||
>
|
||||
<option value={1}>最近1小时</option>
|
||||
<option value={6}>最近6小时</option>
|
||||
<option value={24}>最近24小时</option>
|
||||
<option value={72}>最近3天</option>
|
||||
<option value={168}>最近7天</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 核心指标卡片 */}
|
||||
<TaskStatisticsCards statistics={taskStatistics} />
|
||||
|
||||
{/* 数据可视化和控制面板 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* 图表区域 */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<TaskStatusChart statistics={taskStatistics} />
|
||||
<TaskTimelineChart projectIds={selectedProjectIds} timeRange={timeRange} />
|
||||
</div>
|
||||
|
||||
{/* 控制面板 */}
|
||||
<div className="space-y-6">
|
||||
<MessageQueuePanel
|
||||
queueStatus={queueStatus}
|
||||
onRestartQueue={handleRestartQueue}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 任务详情表格 */}
|
||||
<TaskDetailTable
|
||||
projectIds={selectedProjectIds}
|
||||
timeRange={timeRange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -268,7 +268,7 @@ export function InputBar({ onSend, setVideoPreview, initialVideoUrl, initialVide
|
||||
<motion.div layout key="text-input" className="flex-1 flex">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
placeholder="输入文字…"
|
||||
placeholder="Describe your idea..."
|
||||
className="w-full pl-2 pr-2 py-4 rounded-2 leading-4 text-sm border-none bg-transparent text-white placeholder:text-white/[0.40] focus:outline-none resize-none min-h-[48px] max-h-[120px] overflow-y-auto"
|
||||
rows={1}
|
||||
value={text}
|
||||
|
||||
@ -175,13 +175,6 @@ export default function SmartChatBox({
|
||||
<span className="w-2 h-2 bg-gray-400 rounded-full animate-bounce"></span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error message */}
|
||||
{error && (
|
||||
<div className="text-red-500 text-center py-2 text-sm">
|
||||
{error.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Back to latest button */}
|
||||
{isViewingHistory && !isAtBottom && (
|
||||
|
||||
267
components/task-monitor/message-queue-panel.tsx
Normal file
267
components/task-monitor/message-queue-panel.tsx
Normal file
@ -0,0 +1,267 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { MessageQueueStatus } from '@/api/video_flow';
|
||||
import { cn } from '@/public/lib/utils';
|
||||
|
||||
interface MessageQueuePanelProps {
|
||||
queueStatus: MessageQueueStatus[];
|
||||
onRestartQueue: (queueName: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export default function MessageQueuePanel({ queueStatus, onRestartQueue }: MessageQueuePanelProps) {
|
||||
const [restartingQueues, setRestartingQueues] = useState<Set<string>>(new Set());
|
||||
|
||||
const handleRestart = async (queueName: string) => {
|
||||
if (restartingQueues.has(queueName)) return;
|
||||
|
||||
setRestartingQueues(prev => new Set(prev).add(queueName));
|
||||
|
||||
try {
|
||||
await onRestartQueue(queueName);
|
||||
} finally {
|
||||
setRestartingQueues(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(queueName);
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: MessageQueueStatus['status']) => {
|
||||
switch (status) {
|
||||
case 'healthy':
|
||||
return 'text-emerald-400 bg-emerald-500/10 border-emerald-500/20';
|
||||
case 'warning':
|
||||
return 'text-yellow-400 bg-yellow-500/10 border-yellow-500/20';
|
||||
case 'error':
|
||||
return 'text-red-400 bg-red-500/10 border-red-500/20';
|
||||
default:
|
||||
return 'text-gray-400 bg-gray-500/10 border-gray-500/20';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: MessageQueueStatus['status']) => {
|
||||
switch (status) {
|
||||
case 'healthy':
|
||||
return '✅';
|
||||
case 'warning':
|
||||
return '⚠️';
|
||||
case 'error':
|
||||
return '❌';
|
||||
default:
|
||||
return '❓';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusText = (status: MessageQueueStatus['status']) => {
|
||||
switch (status) {
|
||||
case 'healthy':
|
||||
return '健康';
|
||||
case 'warning':
|
||||
return '警告';
|
||||
case 'error':
|
||||
return '错误';
|
||||
default:
|
||||
return '未知';
|
||||
}
|
||||
};
|
||||
|
||||
// 计算整体健康度
|
||||
const overallHealth = React.useMemo(() => {
|
||||
if (queueStatus.length === 0) return { status: 'unknown', percentage: 0 };
|
||||
|
||||
const healthyCount = queueStatus.filter(q => q.status === 'healthy').length;
|
||||
const warningCount = queueStatus.filter(q => q.status === 'warning').length;
|
||||
const errorCount = queueStatus.filter(q => q.status === 'error').length;
|
||||
|
||||
const percentage = (healthyCount / queueStatus.length) * 100;
|
||||
|
||||
if (errorCount > 0) return { status: 'error', percentage };
|
||||
if (warningCount > 0) return { status: 'warning', percentage };
|
||||
return { status: 'healthy', percentage };
|
||||
}, [queueStatus]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 消息队列总览 */}
|
||||
<div className="bg-gray-800/50 border border-gray-700 rounded-lg p-6 backdrop-blur-sm">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-white">消息队列监控</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={cn(
|
||||
'w-2 h-2 rounded-full',
|
||||
overallHealth.status === 'healthy' ? 'bg-emerald-400' :
|
||||
overallHealth.status === 'warning' ? 'bg-yellow-400' :
|
||||
overallHealth.status === 'error' ? 'bg-red-400' : 'bg-gray-400'
|
||||
)}></div>
|
||||
<span className="text-sm text-gray-400">
|
||||
{queueStatus.length} 个队列
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 整体健康度指示器 */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm text-gray-400">系统健康度</span>
|
||||
<span className="text-sm font-medium text-white">
|
||||
{overallHealth.percentage.toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 bg-gray-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full transition-all duration-500',
|
||||
overallHealth.status === 'healthy' ? 'bg-emerald-400' :
|
||||
overallHealth.status === 'warning' ? 'bg-yellow-400' :
|
||||
'bg-red-400'
|
||||
)}
|
||||
style={{ width: `${overallHealth.percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 队列列表 */}
|
||||
{queueStatus.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-400">
|
||||
<div className="text-4xl mb-2">🔄</div>
|
||||
<div>暂无队列数据</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{queueStatus.map((queue, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
'p-4 rounded-lg border transition-all duration-200',
|
||||
getStatusColor(queue.status)
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-lg">{getStatusIcon(queue.status)}</span>
|
||||
<div>
|
||||
<div className="font-medium">{queue.queue_name}</div>
|
||||
<div className="text-xs opacity-80">
|
||||
{getStatusText(queue.status)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => handleRestart(queue.queue_name)}
|
||||
disabled={restartingQueues.has(queue.queue_name) || queue.status === 'healthy'}
|
||||
className={cn(
|
||||
'px-3 py-1 rounded text-xs font-medium transition-colors',
|
||||
queue.status === 'healthy'
|
||||
? 'bg-gray-600 text-gray-400 cursor-not-allowed'
|
||||
: 'bg-blue-500 hover:bg-blue-600 text-white disabled:opacity-50'
|
||||
)}
|
||||
>
|
||||
{restartingQueues.has(queue.queue_name) ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-3 h-3 animate-spin rounded-full border border-white border-t-transparent"></div>
|
||||
<span>重启中</span>
|
||||
</div>
|
||||
) : (
|
||||
'重启'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<div className="opacity-80">消息数量</div>
|
||||
<div className="font-medium">{queue.message_count.toLocaleString()}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="opacity-80">消费者</div>
|
||||
<div className="font-medium">{queue.consumer_count}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 text-xs opacity-60">
|
||||
最后心跳: {new Date(queue.last_heartbeat).toLocaleString()}
|
||||
</div>
|
||||
|
||||
{queue.error_message && (
|
||||
<div className="mt-2 p-2 bg-red-500/10 border border-red-500/20 rounded text-xs text-red-300">
|
||||
{queue.error_message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 快速操作面板 */}
|
||||
<div className="bg-gray-800/50 border border-gray-700 rounded-lg p-6 backdrop-blur-sm">
|
||||
<h4 className="text-md font-semibold text-white mb-4">快速操作</h4>
|
||||
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
queueStatus
|
||||
.filter(q => q.status === 'error')
|
||||
.forEach(q => handleRestart(q.queue_name));
|
||||
}}
|
||||
disabled={queueStatus.filter(q => q.status === 'error').length === 0}
|
||||
className="w-full px-4 py-2 bg-red-500 hover:bg-red-600 disabled:opacity-50 disabled:cursor-not-allowed text-white rounded-lg transition-colors text-sm"
|
||||
>
|
||||
重启所有错误队列
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
queueStatus
|
||||
.filter(q => q.status !== 'healthy')
|
||||
.forEach(q => handleRestart(q.queue_name));
|
||||
}}
|
||||
disabled={queueStatus.filter(q => q.status !== 'healthy').length === 0}
|
||||
className="w-full px-4 py-2 bg-yellow-500 hover:bg-yellow-600 disabled:opacity-50 disabled:cursor-not-allowed text-white rounded-lg transition-colors text-sm"
|
||||
>
|
||||
重启所有异常队列
|
||||
</button>
|
||||
|
||||
<div className="pt-3 border-t border-gray-700">
|
||||
<div className="text-xs text-gray-400 space-y-1">
|
||||
<div>• 健康队列无需重启</div>
|
||||
<div>• 重启操作会中断当前处理</div>
|
||||
<div>• 建议在低峰期执行批量重启</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 队列统计 */}
|
||||
<div className="bg-gray-800/50 border border-gray-700 rounded-lg p-6 backdrop-blur-sm">
|
||||
<h4 className="text-md font-semibold text-white mb-4">队列统计</h4>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div className="text-center p-3 bg-emerald-500/10 border border-emerald-500/20 rounded-lg">
|
||||
<div className="text-lg font-bold text-emerald-400">
|
||||
{queueStatus.filter(q => q.status === 'healthy').length}
|
||||
</div>
|
||||
<div className="text-emerald-300">健康队列</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center p-3 bg-red-500/10 border border-red-500/20 rounded-lg">
|
||||
<div className="text-lg font-bold text-red-400">
|
||||
{queueStatus.filter(q => q.status === 'error').length}
|
||||
</div>
|
||||
<div className="text-red-300">异常队列</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 text-center">
|
||||
<div className="text-2xl font-bold text-white">
|
||||
{queueStatus.reduce((sum, q) => sum + q.message_count, 0).toLocaleString()}
|
||||
</div>
|
||||
<div className="text-sm text-gray-400">总消息数</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
219
components/task-monitor/project-selector.tsx
Normal file
219
components/task-monitor/project-selector.tsx
Normal file
@ -0,0 +1,219 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { cn } from '@/public/lib/utils';
|
||||
|
||||
interface Project {
|
||||
id: string;
|
||||
name: string;
|
||||
status: 'active' | 'inactive';
|
||||
last_activity: string;
|
||||
}
|
||||
|
||||
interface ProjectSelectorProps {
|
||||
selectedProjectIds: string[];
|
||||
onProjectChange: (projectIds: string[]) => void;
|
||||
}
|
||||
|
||||
export default function ProjectSelector({ selectedProjectIds, onProjectChange }: ProjectSelectorProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// 模拟项目数据 - 在实际应用中应该从API获取
|
||||
const mockProjects: Project[] = [
|
||||
{
|
||||
id: 'bc43bc81-c781-4caa-8256-9710fd5bee80',
|
||||
name: '视频制作项目 A',
|
||||
status: 'active',
|
||||
last_activity: '2024-01-15T10:30:00Z'
|
||||
},
|
||||
{
|
||||
id: 'd203016d-6f7e-4d1c-b66b-1b7d33632800',
|
||||
name: '视频制作项目 B',
|
||||
status: 'active',
|
||||
last_activity: '2024-01-15T09:15:00Z'
|
||||
},
|
||||
{
|
||||
id: '029bbc09-6d83-440b-97fe-e2aa37b8042d',
|
||||
name: '视频制作项目 C',
|
||||
status: 'active',
|
||||
last_activity: '2024-01-14T16:45:00Z'
|
||||
},
|
||||
{
|
||||
id: 'f47ac10b-58cc-4372-a567-0e02b2c3d479',
|
||||
name: '视频制作项目 D',
|
||||
status: 'inactive',
|
||||
last_activity: '2024-01-10T14:20:00Z'
|
||||
},
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
// 模拟加载项目列表
|
||||
setLoading(true);
|
||||
setTimeout(() => {
|
||||
setProjects(mockProjects);
|
||||
setLoading(false);
|
||||
}, 500);
|
||||
}, []);
|
||||
|
||||
const filteredProjects = projects.filter(project =>
|
||||
project.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
project.id.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
const handleProjectToggle = (projectId: string) => {
|
||||
if (selectedProjectIds.includes(projectId)) {
|
||||
// 移除项目
|
||||
onProjectChange(selectedProjectIds.filter(id => id !== projectId));
|
||||
} else {
|
||||
// 添加项目
|
||||
onProjectChange([...selectedProjectIds, projectId]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectAll = () => {
|
||||
const activeProjects = filteredProjects.filter(p => p.status === 'active');
|
||||
onProjectChange(activeProjects.map(p => p.id));
|
||||
};
|
||||
|
||||
const handleClearAll = () => {
|
||||
onProjectChange([]);
|
||||
};
|
||||
|
||||
const getSelectedProjectNames = () => {
|
||||
return selectedProjectIds
|
||||
.map(id => projects.find(p => p.id === id)?.name || id.slice(0, 8))
|
||||
.join(', ');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{/* 选择器按钮 */}
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white hover:bg-gray-700 transition-colors min-w-[200px]"
|
||||
>
|
||||
<div className="flex-1 text-left truncate">
|
||||
{selectedProjectIds.length === 0 ? (
|
||||
<span className="text-gray-400">选择项目...</span>
|
||||
) : selectedProjectIds.length === 1 ? (
|
||||
<span>{getSelectedProjectNames()}</span>
|
||||
) : (
|
||||
<span>{selectedProjectIds.length} 个项目</span>
|
||||
)}
|
||||
</div>
|
||||
<svg
|
||||
className={cn('w-4 h-4 transition-transform', isOpen && 'rotate-180')}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* 下拉面板 */}
|
||||
{isOpen && (
|
||||
<div className="absolute top-full left-0 mt-2 w-80 bg-gray-800 border border-gray-700 rounded-lg shadow-xl z-50">
|
||||
{/* 搜索框 */}
|
||||
<div className="p-3 border-b border-gray-700">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索项目..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded text-white placeholder-gray-400 text-sm focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 批量操作 */}
|
||||
<div className="p-3 border-b border-gray-700 flex gap-2">
|
||||
<button
|
||||
onClick={handleSelectAll}
|
||||
className="px-3 py-1 bg-blue-500 hover:bg-blue-600 text-white rounded text-sm transition-colors"
|
||||
>
|
||||
全选活跃
|
||||
</button>
|
||||
<button
|
||||
onClick={handleClearAll}
|
||||
className="px-3 py-1 bg-gray-600 hover:bg-gray-500 text-white rounded text-sm transition-colors"
|
||||
>
|
||||
清空
|
||||
</button>
|
||||
<div className="flex-1 text-right text-sm text-gray-400 flex items-center justify-end">
|
||||
已选 {selectedProjectIds.length} 个
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 项目列表 */}
|
||||
<div className="max-h-64 overflow-y-auto">
|
||||
{loading ? (
|
||||
<div className="p-4 text-center text-gray-400">
|
||||
<div className="animate-spin w-6 h-6 border-2 border-blue-500 border-t-transparent rounded-full mx-auto mb-2"></div>
|
||||
加载中...
|
||||
</div>
|
||||
) : filteredProjects.length === 0 ? (
|
||||
<div className="p-4 text-center text-gray-400">
|
||||
{searchTerm ? '未找到匹配的项目' : '暂无项目'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-2">
|
||||
{filteredProjects.map((project) => (
|
||||
<div
|
||||
key={project.id}
|
||||
className={cn(
|
||||
'flex items-center gap-3 p-2 rounded hover:bg-gray-700 cursor-pointer transition-colors',
|
||||
selectedProjectIds.includes(project.id) && 'bg-blue-500/20'
|
||||
)}
|
||||
onClick={() => handleProjectToggle(project.id)}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedProjectIds.includes(project.id)}
|
||||
onChange={() => {}} // 由父级点击处理
|
||||
className="rounded"
|
||||
/>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-white font-medium truncate">{project.name}</span>
|
||||
<div className={cn(
|
||||
'w-2 h-2 rounded-full',
|
||||
project.status === 'active' ? 'bg-emerald-400' : 'bg-gray-500'
|
||||
)}></div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 truncate">
|
||||
ID: {project.id}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
最后活动: {new Date(project.last_activity).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 底部信息 */}
|
||||
<div className="p-3 border-t border-gray-700 text-xs text-gray-400">
|
||||
<div className="flex items-center justify-between">
|
||||
<span>共 {projects.length} 个项目</span>
|
||||
<span>支持多选对比</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 点击外部关闭 */}
|
||||
{isOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-40"
|
||||
onClick={() => setIsOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
394
components/task-monitor/task-detail-table.tsx
Normal file
394
components/task-monitor/task-detail-table.tsx
Normal file
@ -0,0 +1,394 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { getProjectTaskList, retryTask, TaskItem } from '@/api/video_flow';
|
||||
import { cn } from '@/public/lib/utils';
|
||||
|
||||
interface TaskDetailTableProps {
|
||||
projectIds: string[];
|
||||
timeRange: number;
|
||||
}
|
||||
|
||||
export default function TaskDetailTable({ projectIds, timeRange }: TaskDetailTableProps) {
|
||||
const [tasks, setTasks] = useState<TaskItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [sortBy, setSortBy] = useState<'created_at' | 'updated_at' | 'task_name' | 'task_status'>('updated_at');
|
||||
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
|
||||
const [filterStatus, setFilterStatus] = useState<string>('all');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [retryingTasks, setRetryingTasks] = useState<Set<string>>(new Set());
|
||||
|
||||
const itemsPerPage = 20;
|
||||
|
||||
// 获取任务列表
|
||||
const fetchTasks = async () => {
|
||||
if (projectIds.length === 0) {
|
||||
setTasks([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const allTasks: TaskItem[] = [];
|
||||
|
||||
// 并行获取所有项目的任务
|
||||
const promises = projectIds.map(async (projectId) => {
|
||||
try {
|
||||
const response = await getProjectTaskList({ project_id: projectId });
|
||||
if (response.code === 0 && response.data) {
|
||||
return response.data.map(task => ({ ...task, project_id: projectId }));
|
||||
}
|
||||
return [];
|
||||
} catch (err) {
|
||||
console.error(`获取项目 ${projectId} 任务失败:`, err);
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
results.forEach(projectTasks => {
|
||||
allTasks.push(...projectTasks);
|
||||
});
|
||||
|
||||
setTasks(allTasks);
|
||||
} catch (err: any) {
|
||||
console.error('获取任务列表失败:', err);
|
||||
setError(err.message || '获取任务列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 重试任务
|
||||
const handleRetryTask = async (taskId: string) => {
|
||||
if (retryingTasks.has(taskId)) return;
|
||||
|
||||
setRetryingTasks(prev => new Set(prev).add(taskId));
|
||||
|
||||
try {
|
||||
const response = await retryTask({ task_id: taskId });
|
||||
|
||||
if (response.code === 0 && response.data?.success) {
|
||||
// 乐观更新任务状态
|
||||
setTasks(prevTasks =>
|
||||
prevTasks.map(task =>
|
||||
task.task_id === taskId
|
||||
? { ...task, task_status: 'IN_PROGRESS' as const }
|
||||
: task
|
||||
)
|
||||
);
|
||||
|
||||
// 延迟刷新获取最新状态
|
||||
setTimeout(() => {
|
||||
fetchTasks();
|
||||
}, 2000);
|
||||
} else {
|
||||
throw new Error(response.message || '重试失败');
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('重试任务失败:', err);
|
||||
alert(`重试任务失败: ${err.message}`);
|
||||
} finally {
|
||||
setRetryingTasks(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(taskId);
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 过滤和排序任务
|
||||
const filteredAndSortedTasks = React.useMemo(() => {
|
||||
let filtered = tasks;
|
||||
|
||||
// 状态过滤
|
||||
if (filterStatus !== 'all') {
|
||||
filtered = filtered.filter(task => task.task_status === filterStatus);
|
||||
}
|
||||
|
||||
// 搜索过滤
|
||||
if (searchTerm) {
|
||||
filtered = filtered.filter(task =>
|
||||
task.task_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
task.task_id.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
task.task_message.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
// 排序
|
||||
filtered.sort((a, b) => {
|
||||
let aValue: any = a[sortBy];
|
||||
let bValue: any = b[sortBy];
|
||||
|
||||
if (sortBy === 'created_at' || sortBy === 'updated_at') {
|
||||
aValue = new Date(aValue).getTime();
|
||||
bValue = new Date(bValue).getTime();
|
||||
}
|
||||
|
||||
if (sortOrder === 'asc') {
|
||||
return aValue > bValue ? 1 : -1;
|
||||
} else {
|
||||
return aValue < bValue ? 1 : -1;
|
||||
}
|
||||
});
|
||||
|
||||
return filtered;
|
||||
}, [tasks, filterStatus, searchTerm, sortBy, sortOrder]);
|
||||
|
||||
// 分页
|
||||
const paginatedTasks = React.useMemo(() => {
|
||||
const startIndex = (currentPage - 1) * itemsPerPage;
|
||||
return filteredAndSortedTasks.slice(startIndex, startIndex + itemsPerPage);
|
||||
}, [filteredAndSortedTasks, currentPage]);
|
||||
|
||||
const totalPages = Math.ceil(filteredAndSortedTasks.length / itemsPerPage);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTasks();
|
||||
}, [projectIds, timeRange]);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [filterStatus, searchTerm]);
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'COMPLETED':
|
||||
return 'text-emerald-400 bg-emerald-500/10';
|
||||
case 'IN_PROGRESS':
|
||||
return 'text-blue-400 bg-blue-500/10';
|
||||
case 'PENDING':
|
||||
return 'text-yellow-400 bg-yellow-500/10';
|
||||
case 'FAILED':
|
||||
return 'text-red-400 bg-red-500/10';
|
||||
default:
|
||||
return 'text-gray-400 bg-gray-500/10';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusText = (status: string) => {
|
||||
switch (status) {
|
||||
case 'COMPLETED':
|
||||
return '已完成';
|
||||
case 'IN_PROGRESS':
|
||||
return '进行中';
|
||||
case 'PENDING':
|
||||
return '等待中';
|
||||
case 'FAILED':
|
||||
return '失败';
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
};
|
||||
|
||||
const formatDuration = (startTime: string, endTime?: string) => {
|
||||
const start = new Date(startTime);
|
||||
const end = endTime ? new Date(endTime) : new Date();
|
||||
const duration = end.getTime() - start.getTime();
|
||||
|
||||
const minutes = Math.floor(duration / 60000);
|
||||
const seconds = Math.floor((duration % 60000) / 1000);
|
||||
|
||||
if (minutes > 0) {
|
||||
return `${minutes}分${seconds}秒`;
|
||||
}
|
||||
return `${seconds}秒`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-gray-800/50 border border-gray-700 rounded-lg backdrop-blur-sm">
|
||||
{/* 表格头部控制 */}
|
||||
<div className="p-6 border-b border-gray-700">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-white">任务详情列表</h3>
|
||||
<button
|
||||
onClick={fetchTasks}
|
||||
disabled={loading}
|
||||
className="px-4 py-2 bg-blue-500 hover:bg-blue-600 disabled:opacity-50 text-white rounded-lg transition-colors text-sm"
|
||||
>
|
||||
{loading ? '刷新中...' : '刷新'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
{/* 搜索框 */}
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索任务名称、ID或消息..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded text-white placeholder-gray-400 text-sm focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 状态过滤 */}
|
||||
<select
|
||||
value={filterStatus}
|
||||
onChange={(e) => setFilterStatus(e.target.value)}
|
||||
className="px-3 py-2 bg-gray-700 border border-gray-600 rounded text-white text-sm focus:outline-none focus:border-blue-500"
|
||||
>
|
||||
<option value="all">全部状态</option>
|
||||
<option value="COMPLETED">已完成</option>
|
||||
<option value="IN_PROGRESS">进行中</option>
|
||||
<option value="PENDING">等待中</option>
|
||||
<option value="FAILED">失败</option>
|
||||
</select>
|
||||
|
||||
{/* 排序 */}
|
||||
<select
|
||||
value={`${sortBy}-${sortOrder}`}
|
||||
onChange={(e) => {
|
||||
const [field, order] = e.target.value.split('-');
|
||||
setSortBy(field as any);
|
||||
setSortOrder(order as any);
|
||||
}}
|
||||
className="px-3 py-2 bg-gray-700 border border-gray-600 rounded text-white text-sm focus:outline-none focus:border-blue-500"
|
||||
>
|
||||
<option value="updated_at-desc">最新更新</option>
|
||||
<option value="created_at-desc">最新创建</option>
|
||||
<option value="task_name-asc">任务名称</option>
|
||||
<option value="task_status-asc">任务状态</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 统计信息 */}
|
||||
<div className="mt-4 text-sm text-gray-400">
|
||||
显示 {paginatedTasks.length} / {filteredAndSortedTasks.length} 个任务
|
||||
{projectIds.length > 1 && ` (来自 ${projectIds.length} 个项目)`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 错误提示 */}
|
||||
{error && (
|
||||
<div className="mx-6 mt-4 p-4 bg-red-500/10 border border-red-500/20 rounded-lg">
|
||||
<div className="flex items-center gap-2 text-red-400">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 表格内容 */}
|
||||
<div className="overflow-x-auto">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-48">
|
||||
<div className="text-center text-gray-400">
|
||||
<div className="animate-spin w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full mx-auto mb-2"></div>
|
||||
<div>加载任务数据...</div>
|
||||
</div>
|
||||
</div>
|
||||
) : paginatedTasks.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-48 text-gray-400">
|
||||
<div className="text-center">
|
||||
<div className="text-4xl mb-2">📋</div>
|
||||
<div>{filteredAndSortedTasks.length === 0 ? '暂无任务数据' : '没有匹配的任务'}</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-700/50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider">任务信息</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider">状态</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider">进度</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider">耗时</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-700">
|
||||
{paginatedTasks.map((task, index) => (
|
||||
<tr key={task.task_id} className="hover:bg-gray-700/30 transition-colors">
|
||||
<td className="px-6 py-4">
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-medium text-white">{task.task_name}</div>
|
||||
<div className="text-xs text-gray-400 font-mono">{task.task_id}</div>
|
||||
{task.task_message && (
|
||||
<div className="text-xs text-gray-500 max-w-xs truncate" title={task.task_message}>
|
||||
{task.task_message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className={cn(
|
||||
'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium',
|
||||
getStatusColor(task.task_status)
|
||||
)}>
|
||||
{getStatusText(task.task_status)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
{task.task_result?.progress_percentage !== undefined ? (
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm text-white">{task.task_result.progress_percentage.toFixed(1)}%</div>
|
||||
<div className="w-20 h-2 bg-gray-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-blue-500 transition-all duration-300"
|
||||
style={{ width: `${task.task_result.progress_percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-gray-400 text-sm">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="text-sm text-gray-300">
|
||||
{formatDuration(task.created_at, task.updated_at)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{new Date(task.updated_at).toLocaleString()}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
{task.task_status === 'FAILED' && (
|
||||
<button
|
||||
onClick={() => handleRetryTask(task.task_id)}
|
||||
disabled={retryingTasks.has(task.task_id)}
|
||||
className="px-3 py-1 bg-red-500 hover:bg-red-600 disabled:opacity-50 text-white rounded text-xs transition-colors"
|
||||
>
|
||||
{retryingTasks.has(task.task_id) ? '重试中...' : '重试'}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 分页控制 */}
|
||||
{totalPages > 1 && (
|
||||
<div className="px-6 py-4 border-t border-gray-700 flex items-center justify-between">
|
||||
<div className="text-sm text-gray-400">
|
||||
第 {currentPage} 页,共 {totalPages} 页
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="px-3 py-1 bg-gray-700 hover:bg-gray-600 disabled:opacity-50 text-white rounded text-sm transition-colors"
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCurrentPage(Math.min(totalPages, currentPage + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
className="px-3 py-1 bg-gray-700 hover:bg-gray-600 disabled:opacity-50 text-white rounded text-sm transition-colors"
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
212
components/task-monitor/task-statistics-cards.tsx
Normal file
212
components/task-monitor/task-statistics-cards.tsx
Normal file
@ -0,0 +1,212 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { TaskStatistics } from '@/api/video_flow';
|
||||
import { cn } from '@/public/lib/utils';
|
||||
|
||||
interface TaskStatisticsCardsProps {
|
||||
statistics: TaskStatistics[];
|
||||
}
|
||||
|
||||
export default function TaskStatisticsCards({ statistics }: TaskStatisticsCardsProps) {
|
||||
// 聚合多个项目的统计数据
|
||||
const aggregatedStats = React.useMemo(() => {
|
||||
if (statistics.length === 0) {
|
||||
return {
|
||||
total_tasks: 0,
|
||||
status_stats: {
|
||||
completed: 0,
|
||||
in_progress: 0,
|
||||
pending: 0,
|
||||
failed: 0,
|
||||
blocked: 0,
|
||||
},
|
||||
success_rate: 0,
|
||||
avg_execution_time: 0,
|
||||
project_count: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const totals = statistics.reduce(
|
||||
(acc, stat) => ({
|
||||
total_tasks: acc.total_tasks + stat.total_tasks,
|
||||
completed: acc.completed + stat.status_stats.completed,
|
||||
in_progress: acc.in_progress + stat.status_stats.in_progress,
|
||||
pending: acc.pending + stat.status_stats.pending,
|
||||
failed: acc.failed + stat.status_stats.failed,
|
||||
blocked: acc.blocked + stat.status_stats.blocked,
|
||||
total_execution_time: acc.total_execution_time + (stat.avg_execution_time * stat.total_tasks),
|
||||
}),
|
||||
{
|
||||
total_tasks: 0,
|
||||
completed: 0,
|
||||
in_progress: 0,
|
||||
pending: 0,
|
||||
failed: 0,
|
||||
blocked: 0,
|
||||
total_execution_time: 0,
|
||||
}
|
||||
);
|
||||
|
||||
const success_rate = totals.total_tasks > 0
|
||||
? (totals.completed / totals.total_tasks) * 100
|
||||
: 0;
|
||||
|
||||
const avg_execution_time = totals.total_tasks > 0
|
||||
? totals.total_execution_time / totals.total_tasks
|
||||
: 0;
|
||||
|
||||
return {
|
||||
total_tasks: totals.total_tasks,
|
||||
status_stats: {
|
||||
completed: totals.completed,
|
||||
in_progress: totals.in_progress,
|
||||
pending: totals.pending,
|
||||
failed: totals.failed,
|
||||
blocked: totals.blocked,
|
||||
},
|
||||
success_rate,
|
||||
avg_execution_time,
|
||||
project_count: statistics.length,
|
||||
};
|
||||
}, [statistics]);
|
||||
|
||||
const cards = [
|
||||
{
|
||||
title: '总任务数',
|
||||
value: aggregatedStats.total_tasks.toLocaleString(),
|
||||
icon: '📊',
|
||||
color: 'blue',
|
||||
subtitle: `${aggregatedStats.project_count} 个项目`,
|
||||
},
|
||||
{
|
||||
title: '成功率',
|
||||
value: `${aggregatedStats.success_rate.toFixed(1)}%`,
|
||||
icon: '✅',
|
||||
color: aggregatedStats.success_rate >= 90 ? 'green' : aggregatedStats.success_rate >= 70 ? 'yellow' : 'red',
|
||||
subtitle: `${aggregatedStats.status_stats.completed} 个成功`,
|
||||
},
|
||||
{
|
||||
title: '进行中',
|
||||
value: aggregatedStats.status_stats.in_progress.toLocaleString(),
|
||||
icon: '⚡',
|
||||
color: 'blue',
|
||||
subtitle: '正在执行',
|
||||
},
|
||||
{
|
||||
title: '失败任务',
|
||||
value: aggregatedStats.status_stats.failed.toLocaleString(),
|
||||
icon: '❌',
|
||||
color: 'red',
|
||||
subtitle: '需要处理',
|
||||
},
|
||||
{
|
||||
title: '平均耗时',
|
||||
value: `${(aggregatedStats.avg_execution_time / 60).toFixed(1)}分`,
|
||||
icon: '⏱️',
|
||||
color: 'purple',
|
||||
subtitle: '执行时间',
|
||||
},
|
||||
{
|
||||
title: '阻塞任务',
|
||||
value: aggregatedStats.status_stats.blocked.toLocaleString(),
|
||||
icon: '🚫',
|
||||
color: 'orange',
|
||||
subtitle: '等待处理',
|
||||
},
|
||||
];
|
||||
|
||||
const getColorClasses = (color: string) => {
|
||||
switch (color) {
|
||||
case 'green':
|
||||
return 'bg-emerald-500/10 border-emerald-500/20 text-emerald-400';
|
||||
case 'red':
|
||||
return 'bg-red-500/10 border-red-500/20 text-red-400';
|
||||
case 'yellow':
|
||||
return 'bg-yellow-500/10 border-yellow-500/20 text-yellow-400';
|
||||
case 'blue':
|
||||
return 'bg-blue-500/10 border-blue-500/20 text-blue-400';
|
||||
case 'purple':
|
||||
return 'bg-purple-500/10 border-purple-500/20 text-purple-400';
|
||||
case 'orange':
|
||||
return 'bg-orange-500/10 border-orange-500/20 text-orange-400';
|
||||
default:
|
||||
return 'bg-gray-500/10 border-gray-500/20 text-gray-400';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-4">
|
||||
{cards.map((card, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
'p-4 rounded-lg border backdrop-blur-sm transition-all duration-200 hover:scale-105',
|
||||
getColorClasses(card.color)
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="text-2xl">{card.icon}</div>
|
||||
<div className="text-xs opacity-60">{card.title}</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="text-2xl font-bold">{card.value}</div>
|
||||
<div className="text-xs opacity-80">{card.subtitle}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 单个项目的详细统计卡片
|
||||
export function ProjectStatisticsCard({ statistics }: { statistics: TaskStatistics }) {
|
||||
const statusItems = [
|
||||
{ label: '已完成', value: statistics.status_stats.completed, color: 'text-emerald-400' },
|
||||
{ label: '进行中', value: statistics.status_stats.in_progress, color: 'text-blue-400' },
|
||||
{ label: '等待中', value: statistics.status_stats.pending, color: 'text-yellow-400' },
|
||||
{ label: '失败', value: statistics.status_stats.failed, color: 'text-red-400' },
|
||||
{ label: '阻塞', value: statistics.status_stats.blocked, color: 'text-orange-400' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="bg-gray-800/50 border border-gray-700 rounded-lg p-6 backdrop-blur-sm">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white">
|
||||
{statistics.project_name || statistics.project_id}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-400">项目ID: {statistics.project_id}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-2xl font-bold text-white">{statistics.total_tasks}</div>
|
||||
<div className="text-sm text-gray-400">总任务数</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-5 gap-3 mb-4">
|
||||
{statusItems.map((item, index) => (
|
||||
<div key={index} className="text-center">
|
||||
<div className={cn('text-lg font-semibold', item.color)}>{item.value}</div>
|
||||
<div className="text-xs text-gray-500">{item.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-gray-400">
|
||||
成功率: <span className="text-emerald-400 font-semibold">{statistics.success_rate.toFixed(1)}%</span>
|
||||
</span>
|
||||
<span className="text-gray-400">
|
||||
平均耗时: <span className="text-blue-400 font-semibold">{(statistics.avg_execution_time / 60).toFixed(1)}分</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
更新于 {new Date(statistics.last_updated).toLocaleTimeString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
205
components/task-monitor/task-status-chart.tsx
Normal file
205
components/task-monitor/task-status-chart.tsx
Normal file
@ -0,0 +1,205 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { TaskStatistics } from '@/api/video_flow';
|
||||
|
||||
interface TaskStatusChartProps {
|
||||
statistics: TaskStatistics[];
|
||||
}
|
||||
|
||||
export default function TaskStatusChart({ statistics }: TaskStatusChartProps) {
|
||||
// 聚合所有项目的状态数据
|
||||
const aggregatedData = React.useMemo(() => {
|
||||
if (statistics.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const totals = statistics.reduce(
|
||||
(acc, stat) => ({
|
||||
completed: acc.completed + stat.status_stats.completed,
|
||||
in_progress: acc.in_progress + stat.status_stats.in_progress,
|
||||
pending: acc.pending + stat.status_stats.pending,
|
||||
failed: acc.failed + stat.status_stats.failed,
|
||||
blocked: acc.blocked + stat.status_stats.blocked,
|
||||
}),
|
||||
{
|
||||
completed: 0,
|
||||
in_progress: 0,
|
||||
pending: 0,
|
||||
failed: 0,
|
||||
blocked: 0,
|
||||
}
|
||||
);
|
||||
|
||||
const total = Object.values(totals).reduce((sum, val) => sum + val, 0);
|
||||
|
||||
return [
|
||||
{ name: '已完成', value: totals.completed, color: '#10b981', percentage: total > 0 ? (totals.completed / total) * 100 : 0 },
|
||||
{ name: '进行中', value: totals.in_progress, color: '#3b82f6', percentage: total > 0 ? (totals.in_progress / total) * 100 : 0 },
|
||||
{ name: '等待中', value: totals.pending, color: '#f59e0b', percentage: total > 0 ? (totals.pending / total) * 100 : 0 },
|
||||
{ name: '失败', value: totals.failed, color: '#ef4444', percentage: total > 0 ? (totals.failed / total) * 100 : 0 },
|
||||
{ name: '阻塞', value: totals.blocked, color: '#f97316', percentage: total > 0 ? (totals.blocked / total) * 100 : 0 },
|
||||
].filter(item => item.value > 0);
|
||||
}, [statistics]);
|
||||
|
||||
// 简单的饼图实现
|
||||
const PieChart = ({ data }: { data: typeof aggregatedData }) => {
|
||||
const total = data.reduce((sum, item) => sum + item.value, 0);
|
||||
let cumulativePercentage = 0;
|
||||
|
||||
return (
|
||||
<div className="relative w-48 h-48 mx-auto">
|
||||
<svg width="192" height="192" viewBox="0 0 192 192" className="transform -rotate-90">
|
||||
<circle
|
||||
cx="96"
|
||||
cy="96"
|
||||
r="80"
|
||||
fill="none"
|
||||
stroke="#374151"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
{data.map((item, index) => {
|
||||
const percentage = (item.value / total) * 100;
|
||||
const strokeDasharray = `${percentage * 5.03} 502`;
|
||||
const strokeDashoffset = -cumulativePercentage * 5.03;
|
||||
cumulativePercentage += percentage;
|
||||
|
||||
return (
|
||||
<circle
|
||||
key={index}
|
||||
cx="96"
|
||||
cy="96"
|
||||
r="80"
|
||||
fill="none"
|
||||
stroke={item.color}
|
||||
strokeWidth="16"
|
||||
strokeDasharray={strokeDasharray}
|
||||
strokeDashoffset={strokeDashoffset}
|
||||
className="transition-all duration-300"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
|
||||
{/* 中心文字 */}
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<div className="text-2xl font-bold text-white">{total.toLocaleString()}</div>
|
||||
<div className="text-sm text-gray-400">总任务数</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 柱状图实现
|
||||
const BarChart = ({ data }: { data: typeof aggregatedData }) => {
|
||||
const maxValue = Math.max(...data.map(item => item.value));
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{data.map((item, index) => (
|
||||
<div key={index} className="flex items-center gap-3">
|
||||
<div className="w-16 text-sm text-gray-400 text-right">{item.name}</div>
|
||||
<div className="flex-1 relative">
|
||||
<div className="h-6 bg-gray-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-500"
|
||||
style={{
|
||||
backgroundColor: item.color,
|
||||
width: `${maxValue > 0 ? (item.value / maxValue) * 100 : 0}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute inset-0 flex items-center justify-between px-2">
|
||||
<span className="text-xs text-white font-medium">{item.value}</span>
|
||||
<span className="text-xs text-gray-300">{item.percentage.toFixed(1)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (statistics.length === 0) {
|
||||
return (
|
||||
<div className="bg-gray-800/50 border border-gray-700 rounded-lg p-6 backdrop-blur-sm">
|
||||
<h3 className="text-lg font-semibold text-white mb-4">任务状态分布</h3>
|
||||
<div className="flex items-center justify-center h-48 text-gray-400">
|
||||
<div className="text-center">
|
||||
<div className="text-4xl mb-2">📊</div>
|
||||
<div>暂无数据</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-gray-800/50 border border-gray-700 rounded-lg p-6 backdrop-blur-sm">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-lg font-semibold text-white">任务状态分布</h3>
|
||||
<div className="text-sm text-gray-400">
|
||||
{statistics.length > 1 ? `${statistics.length} 个项目` : '单项目视图'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
{/* 饼图 */}
|
||||
<div className="text-center">
|
||||
<h4 className="text-sm font-medium text-gray-300 mb-4">状态占比</h4>
|
||||
<PieChart data={aggregatedData} />
|
||||
</div>
|
||||
|
||||
{/* 柱状图 */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-300 mb-4">详细数据</h4>
|
||||
<BarChart data={aggregatedData} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 图例 */}
|
||||
<div className="mt-6 pt-4 border-t border-gray-700">
|
||||
<div className="flex flex-wrap gap-4 justify-center">
|
||||
{aggregatedData.map((item, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: item.color }}
|
||||
/>
|
||||
<span className="text-sm text-gray-300">{item.name}</span>
|
||||
<span className="text-sm text-gray-500">({item.value})</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 多项目详情 */}
|
||||
{statistics.length > 1 && (
|
||||
<div className="mt-6 pt-4 border-t border-gray-700">
|
||||
<h4 className="text-sm font-medium text-gray-300 mb-3">项目详情</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{statistics.map((stat, index) => (
|
||||
<div key={index} className="bg-gray-700/30 rounded-lg p-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="text-sm font-medium text-white truncate">
|
||||
{stat.project_name || stat.project_id}
|
||||
</div>
|
||||
<div className="text-sm text-gray-400">{stat.total_tasks}</div>
|
||||
</div>
|
||||
<div className="flex gap-2 text-xs">
|
||||
<span className="text-emerald-400">✓{stat.status_stats.completed}</span>
|
||||
<span className="text-blue-400">⚡{stat.status_stats.in_progress}</span>
|
||||
<span className="text-yellow-400">⏳{stat.status_stats.pending}</span>
|
||||
<span className="text-red-400">✗{stat.status_stats.failed}</span>
|
||||
{stat.status_stats.blocked > 0 && (
|
||||
<span className="text-orange-400">🚫{stat.status_stats.blocked}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
285
components/task-monitor/task-timeline-chart.tsx
Normal file
285
components/task-monitor/task-timeline-chart.tsx
Normal file
@ -0,0 +1,285 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
interface TaskTimelineData {
|
||||
timestamp: string;
|
||||
completed: number;
|
||||
failed: number;
|
||||
in_progress: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
interface TaskTimelineChartProps {
|
||||
projectIds: string[];
|
||||
timeRange: number; // 小时
|
||||
}
|
||||
|
||||
export default function TaskTimelineChart({ projectIds, timeRange }: TaskTimelineChartProps) {
|
||||
const [timelineData, setTimelineData] = useState<TaskTimelineData[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedMetric, setSelectedMetric] = useState<'total' | 'completed' | 'failed' | 'success_rate'>('total');
|
||||
|
||||
// 模拟生成时间线数据
|
||||
const generateMockData = () => {
|
||||
const data: TaskTimelineData[] = [];
|
||||
const now = new Date();
|
||||
const intervalHours = timeRange <= 24 ? 1 : timeRange <= 72 ? 3 : 6;
|
||||
const points = Math.floor(timeRange / intervalHours);
|
||||
|
||||
for (let i = points; i >= 0; i--) {
|
||||
const timestamp = new Date(now.getTime() - i * intervalHours * 60 * 60 * 1000);
|
||||
|
||||
// 模拟数据波动
|
||||
const baseCompleted = 50 + Math.random() * 30;
|
||||
const baseFailed = 5 + Math.random() * 10;
|
||||
const baseInProgress = 10 + Math.random() * 15;
|
||||
|
||||
// 根据项目数量调整
|
||||
const multiplier = projectIds.length;
|
||||
|
||||
data.push({
|
||||
timestamp: timestamp.toISOString(),
|
||||
completed: Math.floor(baseCompleted * multiplier),
|
||||
failed: Math.floor(baseFailed * multiplier),
|
||||
in_progress: Math.floor(baseInProgress * multiplier),
|
||||
total: Math.floor((baseCompleted + baseFailed + baseInProgress) * multiplier),
|
||||
});
|
||||
}
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
// 模拟API调用
|
||||
setTimeout(() => {
|
||||
setTimelineData(generateMockData());
|
||||
setLoading(false);
|
||||
}, 500);
|
||||
}, [projectIds, timeRange]);
|
||||
|
||||
const getMetricValue = (data: TaskTimelineData, metric: string) => {
|
||||
switch (metric) {
|
||||
case 'total':
|
||||
return data.total;
|
||||
case 'completed':
|
||||
return data.completed;
|
||||
case 'failed':
|
||||
return data.failed;
|
||||
case 'success_rate':
|
||||
return data.total > 0 ? (data.completed / data.total) * 100 : 0;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
const getMetricColor = (metric: string) => {
|
||||
switch (metric) {
|
||||
case 'total':
|
||||
return '#3b82f6';
|
||||
case 'completed':
|
||||
return '#10b981';
|
||||
case 'failed':
|
||||
return '#ef4444';
|
||||
case 'success_rate':
|
||||
return '#8b5cf6';
|
||||
default:
|
||||
return '#6b7280';
|
||||
}
|
||||
};
|
||||
|
||||
const formatTimestamp = (timestamp: string) => {
|
||||
const date = new Date(timestamp);
|
||||
if (timeRange <= 24) {
|
||||
return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
|
||||
} else {
|
||||
return date.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' });
|
||||
}
|
||||
};
|
||||
|
||||
const LineChart = () => {
|
||||
if (timelineData.length === 0) return null;
|
||||
|
||||
const values = timelineData.map(d => getMetricValue(d, selectedMetric));
|
||||
const maxValue = Math.max(...values);
|
||||
const minValue = Math.min(...values);
|
||||
const range = maxValue - minValue || 1;
|
||||
|
||||
const width = 600;
|
||||
const height = 200;
|
||||
const padding = 40;
|
||||
|
||||
const points = timelineData.map((data, index) => {
|
||||
const x = padding + (index / (timelineData.length - 1)) * (width - 2 * padding);
|
||||
const value = getMetricValue(data, selectedMetric);
|
||||
const y = height - padding - ((value - minValue) / range) * (height - 2 * padding);
|
||||
return { x, y, value, data };
|
||||
});
|
||||
|
||||
const pathData = points.map((point, index) =>
|
||||
`${index === 0 ? 'M' : 'L'} ${point.x} ${point.y}`
|
||||
).join(' ');
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<svg width={width} height={height} className="overflow-visible">
|
||||
{/* 网格线 */}
|
||||
<defs>
|
||||
<pattern id="grid" width="50" height="40" patternUnits="userSpaceOnUse">
|
||||
<path d="M 50 0 L 0 0 0 40" fill="none" stroke="#374151" strokeWidth="0.5" opacity="0.3"/>
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width={width} height={height} fill="url(#grid)" />
|
||||
|
||||
{/* Y轴标签 */}
|
||||
{[0, 0.25, 0.5, 0.75, 1].map((ratio, index) => {
|
||||
const y = height - padding - ratio * (height - 2 * padding);
|
||||
const value = minValue + ratio * range;
|
||||
return (
|
||||
<g key={index}>
|
||||
<line x1={padding} y1={y} x2={width - padding} y2={y} stroke="#374151" strokeWidth="0.5" opacity="0.5" />
|
||||
<text x={padding - 10} y={y + 4} fill="#9ca3af" fontSize="12" textAnchor="end">
|
||||
{selectedMetric === 'success_rate' ? `${value.toFixed(0)}%` : Math.round(value)}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 主线条 */}
|
||||
<path
|
||||
d={pathData}
|
||||
fill="none"
|
||||
stroke={getMetricColor(selectedMetric)}
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
|
||||
{/* 填充区域 */}
|
||||
<path
|
||||
d={`${pathData} L ${points[points.length - 1].x} ${height - padding} L ${padding} ${height - padding} Z`}
|
||||
fill={getMetricColor(selectedMetric)}
|
||||
fillOpacity="0.1"
|
||||
/>
|
||||
|
||||
{/* 数据点 */}
|
||||
{points.map((point, index) => (
|
||||
<circle
|
||||
key={index}
|
||||
cx={point.x}
|
||||
cy={point.y}
|
||||
r="4"
|
||||
fill={getMetricColor(selectedMetric)}
|
||||
stroke="white"
|
||||
strokeWidth="2"
|
||||
className="hover:r-6 transition-all cursor-pointer"
|
||||
>
|
||||
<title>
|
||||
{formatTimestamp(point.data.timestamp)}: {
|
||||
selectedMetric === 'success_rate'
|
||||
? `${point.value.toFixed(1)}%`
|
||||
: Math.round(point.value)
|
||||
}
|
||||
</title>
|
||||
</circle>
|
||||
))}
|
||||
|
||||
{/* X轴标签 */}
|
||||
{points.filter((_, index) => index % Math.ceil(points.length / 6) === 0).map((point, index) => (
|
||||
<text
|
||||
key={index}
|
||||
x={point.x}
|
||||
y={height - 10}
|
||||
fill="#9ca3af"
|
||||
fontSize="12"
|
||||
textAnchor="middle"
|
||||
>
|
||||
{formatTimestamp(point.data.timestamp)}
|
||||
</text>
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const metrics = [
|
||||
{ key: 'total', label: '总任务数', color: '#3b82f6' },
|
||||
{ key: 'completed', label: '完成数', color: '#10b981' },
|
||||
{ key: 'failed', label: '失败数', color: '#ef4444' },
|
||||
{ key: 'success_rate', label: '成功率', color: '#8b5cf6' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="bg-gray-800/50 border border-gray-700 rounded-lg p-6 backdrop-blur-sm">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-lg font-semibold text-white">任务趋势分析</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
{metrics.map((metric) => (
|
||||
<button
|
||||
key={metric.key}
|
||||
onClick={() => setSelectedMetric(metric.key as any)}
|
||||
className={`px-3 py-1 rounded text-sm transition-colors ${
|
||||
selectedMetric === metric.key
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
{metric.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-48">
|
||||
<div className="text-center text-gray-400">
|
||||
<div className="animate-spin w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full mx-auto mb-2"></div>
|
||||
<div>加载趋势数据...</div>
|
||||
</div>
|
||||
</div>
|
||||
) : timelineData.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-48 text-gray-400">
|
||||
<div className="text-center">
|
||||
<div className="text-4xl mb-2">📈</div>
|
||||
<div>暂无趋势数据</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<LineChart />
|
||||
|
||||
{/* 统计信息 */}
|
||||
<div className="mt-6 pt-4 border-t border-gray-700">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||
{metrics.map((metric) => {
|
||||
const values = timelineData.map(d => getMetricValue(d, metric.key));
|
||||
const latest = values[values.length - 1] || 0;
|
||||
const previous = values[values.length - 2] || 0;
|
||||
const change = previous !== 0 ? ((latest - previous) / previous) * 100 : 0;
|
||||
|
||||
return (
|
||||
<div key={metric.key} className="text-center">
|
||||
<div className="flex items-center justify-center gap-1 mb-1">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: metric.color }}
|
||||
/>
|
||||
<span className="text-gray-300">{metric.label}</span>
|
||||
</div>
|
||||
<div className="text-lg font-bold text-white">
|
||||
{metric.key === 'success_rate' ? `${latest.toFixed(1)}%` : Math.round(latest)}
|
||||
</div>
|
||||
<div className={`text-xs ${change >= 0 ? 'text-emerald-400' : 'text-red-400'}`}>
|
||||
{change >= 0 ? '↗' : '↘'} {Math.abs(change).toFixed(1)}%
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -63,15 +63,16 @@ export const ShotTabContent = forwardRef<
|
||||
const [updateData, setUpdateData] = useState<VideoSegmentEntity[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
console.log('shotTabContent-----shotData', shotData);
|
||||
}, [shotData]);
|
||||
console.log('shotTabContent-----selectedSegment', selectedSegment);
|
||||
}, [selectedSegment]);
|
||||
|
||||
useEffect(() => {
|
||||
console.log('-==========shotData===========-', shotData);
|
||||
console.log('-==========shotTabContent===========-', shotData, currentSketchIndex, originalVideos);
|
||||
// 只在初始化且有角色数据时执行
|
||||
if (isInitialized && shotData.length > 0) {
|
||||
setIsInitialized(false);
|
||||
setSelectedSegment(shotData[0]);
|
||||
const defaultSelectIndex = currentSketchIndex >= shotData.length ? 0 : currentSketchIndex;
|
||||
setSelectedSegment(shotData[defaultSelectIndex]);
|
||||
}
|
||||
}, [shotData, isInitialized]);
|
||||
|
||||
@ -118,7 +119,6 @@ export const ShotTabContent = forwardRef<
|
||||
// 清空检测状态 和 检测结果
|
||||
setScanState('idle');
|
||||
setDetections([]);
|
||||
setSelectedSegment(shotData[0]);
|
||||
}
|
||||
}, [shotData, selectedSegment]);
|
||||
|
||||
@ -314,7 +314,7 @@ export const ShotTabContent = forwardRef<
|
||||
<HorizontalScroller
|
||||
itemWidth={128}
|
||||
gap={0}
|
||||
selectedIndex={originalVideos.findIndex(shot => selectedSegment?.videoUrl.some(url => url.video_id === shot.video_id))}
|
||||
selectedIndex={originalVideos.findIndex(shot => selectedSegment?.id === shot.video_id)}
|
||||
onItemClick={(i: number) => handleSelectShot(i)}
|
||||
>
|
||||
{originalVideos.map((shot, index) => (
|
||||
@ -322,7 +322,7 @@ export const ShotTabContent = forwardRef<
|
||||
key={shot.video_id || index}
|
||||
className={cn(
|
||||
'relative flex-shrink-0 w-32 aspect-video rounded-lg overflow-hidden cursor-pointer group',
|
||||
selectedSegment?.videoUrl.some(url => url.video_id === shot.video_id) ? 'ring-2 ring-blue-500' : 'hover:ring-2 hover:ring-blue-500/50'
|
||||
selectedSegment?.id === shot.video_id ? 'ring-2 ring-blue-500' : 'hover:ring-2 hover:ring-blue-500/50'
|
||||
)}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
@ -361,11 +361,11 @@ export const ShotTabContent = forwardRef<
|
||||
<HorizontalScroller
|
||||
itemWidth={'auto'}
|
||||
gap={0}
|
||||
selectedIndex={originalVideos.findIndex(shot => selectedSegment?.videoUrl.some(url => url.video_id === shot.video_id))}
|
||||
selectedIndex={originalVideos.findIndex(shot => selectedSegment?.id === shot.video_id)}
|
||||
onItemClick={(i: number) => handleSelectShot(i)}
|
||||
>
|
||||
{originalVideos.map((shot, index) => {
|
||||
const isActive = selectedSegment?.videoUrl.some(url => url.video_id === shot.video_id);
|
||||
const isActive = selectedSegment?.id === shot.video_id;
|
||||
return (
|
||||
<motion.div
|
||||
key={shot.video_id || index}
|
||||
@ -611,3 +611,5 @@ export const ShotTabContent = forwardRef<
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
ShotTabContent.displayName = 'ShotTabContent';
|
||||
Loading…
x
Reference in New Issue
Block a user