Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
重构所有代码和文档中的项目名称,将OpenFang统一更新为ZCLAW。包括: - 配置文件中的项目名称 - 代码注释和文档引用 - 环境变量和路径 - 类型定义和接口名称 - 测试用例和模拟数据 同时优化部分代码结构,移除未使用的模块,并更新相关依赖项。
282 lines
11 KiB
TypeScript
282 lines
11 KiB
TypeScript
/**
|
||
* WorkflowHistory - ZCLAW Workflow Execution History Component
|
||
*
|
||
* Displays the execution history of a specific workflow,
|
||
* showing run details, status, and results.
|
||
*
|
||
* Design based on ZCLAW Dashboard v0.4.0
|
||
*/
|
||
|
||
import { useState, useEffect, useCallback } from 'react';
|
||
import { useWorkflowStore, type Workflow, type WorkflowRun } from '../store/workflowStore';
|
||
import {
|
||
ArrowLeft,
|
||
Clock,
|
||
CheckCircle,
|
||
XCircle,
|
||
AlertCircle,
|
||
Loader2,
|
||
ChevronRight,
|
||
RefreshCw,
|
||
History,
|
||
} from 'lucide-react';
|
||
|
||
interface WorkflowHistoryProps {
|
||
workflow: Workflow;
|
||
isOpen?: boolean;
|
||
onClose?: () => void;
|
||
onBack?: () => void;
|
||
}
|
||
|
||
// Status configuration
|
||
const STATUS_CONFIG: Record<string, { label: string; className: string; icon: React.ComponentType<{ className?: string }> }> = {
|
||
pending: { label: '等待中', className: 'text-gray-500 bg-gray-100', icon: Clock },
|
||
running: { label: '运行中', className: 'text-blue-600 bg-blue-100', icon: Loader2 },
|
||
completed: { label: '已完成', className: 'text-green-600 bg-green-100', icon: CheckCircle },
|
||
success: { label: '成功', className: 'text-green-600 bg-green-100', icon: CheckCircle },
|
||
failed: { label: '失败', className: 'text-red-600 bg-red-100', icon: XCircle },
|
||
error: { label: '错误', className: 'text-red-600 bg-red-100', icon: XCircle },
|
||
cancelled: { label: '已取消', className: 'text-gray-500 bg-gray-100', icon: XCircle },
|
||
paused: { label: '已暂停', className: 'text-yellow-600 bg-yellow-100', icon: AlertCircle },
|
||
};
|
||
|
||
// Run Card Component
|
||
interface RunCardProps {
|
||
run: WorkflowRun;
|
||
index: number;
|
||
}
|
||
|
||
function RunCard({ run, index }: RunCardProps) {
|
||
const [isExpanded, setIsExpanded] = useState(false);
|
||
const config = STATUS_CONFIG[run.status] || STATUS_CONFIG.pending;
|
||
const StatusIcon = config.icon;
|
||
|
||
// Format result for display
|
||
const resultText = run.result
|
||
? (typeof run.result === 'string' ? run.result : JSON.stringify(run.result, null, 2))
|
||
: undefined;
|
||
|
||
return (
|
||
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg border border-gray-100 dark:border-gray-700">
|
||
<div
|
||
className="flex items-center justify-between p-3 cursor-pointer"
|
||
onClick={() => setIsExpanded(!isExpanded)}
|
||
>
|
||
<div className="flex items-center gap-3 min-w-0">
|
||
<span className="flex-shrink-0 w-6 h-6 bg-gray-200 dark:bg-gray-700 rounded-full flex items-center justify-center text-xs font-medium text-gray-600 dark:text-gray-400">
|
||
{index + 1}
|
||
</span>
|
||
<StatusIcon className={`w-4 h-4 flex-shrink-0 ${run.status === 'running' ? 'animate-spin' : ''}`} />
|
||
<span className="text-sm font-medium text-gray-800 dark:text-gray-200 truncate">
|
||
运行 #{run.runId.slice(0, 8)}
|
||
</span>
|
||
{run.step && (
|
||
<span className="text-xs text-gray-500 dark:text-gray-400 truncate">
|
||
步骤: {run.step}
|
||
</span>
|
||
)}
|
||
</div>
|
||
<div className="flex items-center gap-2 flex-shrink-0">
|
||
<span className={`text-xs px-2 py-0.5 rounded ${config.className}`}>
|
||
{config.label}
|
||
</span>
|
||
<ChevronRight className={`w-4 h-4 text-gray-400 transition-transform ${isExpanded ? 'rotate-90' : ''}`} />
|
||
</div>
|
||
</div>
|
||
|
||
{/* Expanded Details */}
|
||
{isExpanded && (
|
||
<div className="px-3 pb-3 pt-0 border-t border-gray-200 dark:border-gray-700 space-y-2">
|
||
<div className="mt-3 text-xs text-gray-500 dark:text-gray-400 space-y-1">
|
||
<div className="flex justify-between">
|
||
<span>运行 ID</span>
|
||
<span className="font-mono">{run.runId}</span>
|
||
</div>
|
||
</div>
|
||
|
||
{resultText && (
|
||
<div className="p-2 bg-green-50 dark:bg-green-900/20 rounded border border-green-200 dark:border-green-800 text-green-700 dark:text-green-400 text-xs whitespace-pre-wrap max-h-60 overflow-auto">
|
||
<div className="font-medium mb-1">结果:</div>
|
||
{resultText}
|
||
</div>
|
||
)}
|
||
|
||
{run.status === 'failed' && !resultText && (
|
||
<div className="p-2 bg-red-50 dark:bg-red-900/20 rounded border border-red-200 dark:border-red-800 text-red-700 dark:text-red-400 text-xs">
|
||
执行失败,请检查日志获取详细信息。
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function WorkflowHistory({ workflow, onBack }: WorkflowHistoryProps) {
|
||
const loadWorkflowRuns = useWorkflowStore((s) => s.loadWorkflowRuns);
|
||
const cancelWorkflow = useWorkflowStore((s) => s.cancelWorkflow);
|
||
const isLoading = useWorkflowStore((s) => s.isLoading);
|
||
const [runs, setRuns] = useState<WorkflowRun[]>([]);
|
||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||
const [cancellingRunId, setCancellingRunId] = useState<string | null>(null);
|
||
|
||
// Load workflow runs
|
||
const loadRuns = useCallback(async () => {
|
||
try {
|
||
const result = await loadWorkflowRuns(workflow.id, { limit: 50 });
|
||
setRuns(result || []);
|
||
} catch {
|
||
setRuns([]);
|
||
}
|
||
}, [workflow.id, loadWorkflowRuns]);
|
||
|
||
useEffect(() => {
|
||
loadRuns();
|
||
}, [loadRuns]);
|
||
|
||
// Refresh runs
|
||
const handleRefresh = useCallback(async () => {
|
||
setIsRefreshing(true);
|
||
try {
|
||
await loadRuns();
|
||
} finally {
|
||
setIsRefreshing(false);
|
||
}
|
||
}, [loadRuns]);
|
||
|
||
// Cancel running workflow
|
||
const handleCancel = useCallback(async (runId: string) => {
|
||
if (!confirm('确定要取消这个正在运行的工作流吗?')) return;
|
||
|
||
setCancellingRunId(runId);
|
||
try {
|
||
await cancelWorkflow(workflow.id, runId);
|
||
await loadRuns();
|
||
} catch (error) {
|
||
console.error('Failed to cancel workflow:', error);
|
||
alert(`取消失败: ${error instanceof Error ? error.message : '未知错误'}`);
|
||
} finally {
|
||
setCancellingRunId(null);
|
||
}
|
||
}, [workflow.id, cancelWorkflow, loadRuns]);
|
||
|
||
// Categorize runs
|
||
const runningRuns = runs.filter(r => r.status === 'running');
|
||
const completedRuns = runs.filter(r => ['completed', 'success', 'failed', 'error', 'cancelled'].includes(r.status));
|
||
const pendingRuns = runs.filter(r => ['pending', 'paused'].includes(r.status));
|
||
|
||
return (
|
||
<div className="h-full flex flex-col">
|
||
{/* Header */}
|
||
<div className="p-4 border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 flex-shrink-0">
|
||
<div className="flex items-center gap-3">
|
||
<button
|
||
onClick={onBack}
|
||
className="p-1.5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||
>
|
||
<ArrowLeft className="w-4 h-4" />
|
||
</button>
|
||
<div className="w-8 h-8 bg-blue-100 dark:bg-blue-900/30 rounded-lg flex items-center justify-center flex-shrink-0">
|
||
<History className="w-4 h-4 text-blue-600 dark:text-blue-400" />
|
||
</div>
|
||
<div className="flex-1 min-w-0">
|
||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white truncate">
|
||
{workflow.name}
|
||
</h2>
|
||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||
执行历史 ({runs.length} 次运行)
|
||
</p>
|
||
</div>
|
||
<button
|
||
onClick={handleRefresh}
|
||
disabled={isRefreshing}
|
||
className="p-1.5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors disabled:opacity-50"
|
||
title="刷新"
|
||
>
|
||
<RefreshCw className={`w-4 h-4 ${isRefreshing ? 'animate-spin' : ''}`} />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Content */}
|
||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||
{/* Loading State */}
|
||
{isLoading && runs.length === 0 && (
|
||
<div className="text-center py-8">
|
||
<Loader2 className="w-8 h-8 mx-auto text-gray-400 animate-spin mb-3" />
|
||
<p className="text-sm text-gray-500 dark:text-gray-400">加载执行历史中...</p>
|
||
</div>
|
||
)}
|
||
|
||
{/* Running Runs */}
|
||
{runningRuns.length > 0 && (
|
||
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4 border border-blue-200 dark:border-blue-800">
|
||
<h3 className="text-sm font-semibold text-blue-700 dark:text-blue-400 mb-3 flex items-center gap-2">
|
||
<Loader2 className="w-4 h-4 animate-spin" />
|
||
运行中 ({runningRuns.length})
|
||
</h3>
|
||
<div className="space-y-2">
|
||
{runningRuns.map((run, index) => (
|
||
<div key={run.runId} className="flex items-center justify-between">
|
||
<RunCard run={run} index={index} />
|
||
<button
|
||
onClick={() => handleCancel(run.runId)}
|
||
disabled={cancellingRunId === run.runId}
|
||
className="ml-2 px-2 py-1 text-xs text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded disabled:opacity-50"
|
||
>
|
||
{cancellingRunId === run.runId ? '取消中...' : '取消'}
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Pending Runs */}
|
||
{pendingRuns.length > 0 && (
|
||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3 flex items-center gap-2">
|
||
<Clock className="w-4 h-4" />
|
||
等待中 ({pendingRuns.length})
|
||
</h3>
|
||
<div className="space-y-2">
|
||
{pendingRuns.map((run, index) => (
|
||
<RunCard key={run.runId} run={run} index={index} />
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Completed Runs */}
|
||
{completedRuns.length > 0 && (
|
||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">
|
||
历史记录 ({completedRuns.length})
|
||
</h3>
|
||
<div className="space-y-2">
|
||
{completedRuns.map((run, index) => (
|
||
<RunCard key={run.runId} run={run} index={index} />
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Empty State */}
|
||
{!isLoading && runs.length === 0 && (
|
||
<div className="text-center py-12">
|
||
<div className="w-16 h-16 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center mx-auto mb-4">
|
||
<History className="w-8 h-8 text-gray-400" />
|
||
</div>
|
||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">暂无执行记录</p>
|
||
<p className="text-xs text-gray-400 dark:text-gray-500">
|
||
运行此工作流后将在此显示历史记录
|
||
</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default WorkflowHistory;
|