feat(phase2): complete P1 tasks - Channels, Triggers, Skills CRUD and UI enhancements
Phase 2 P1 Tasks Completed: API Layer (gateway-client.ts, gatewayStore.ts): - Add Channels CRUD: getChannel, createChannel, updateChannel, deleteChannel - Add Triggers CRUD: getTrigger, createTrigger, updateTrigger, deleteTrigger - Add Skills CRUD: getSkill, createSkill, updateSkill, deleteSkill - Add Scheduled Tasks API: createScheduledTask, deleteScheduledTask, toggleScheduledTask - Add loadModels action for dynamic model list UI Components: - ModelsAPI.tsx: Dynamic model loading from API with loading/error states - SchedulerPanel.tsx: Full CreateJobModal with cron/interval/once scheduling - SecurityStatus.tsx: Loading states, error handling, retry functionality - WorkflowEditor.tsx: New workflow creation/editing modal (new file) - WorkflowHistory.tsx: Workflow execution history viewer (new file) - WorkflowList.tsx: Integrated editor and history access Configuration: - Add 4 Hands TOML configs: clip, collector, predictor, twitter Documentation (SYSTEM_ANALYSIS.md): - Update API coverage: 65% → 89% (53/62 endpoints) - Update UI completion: 85% → 92% - Mark Phase 2 P1 tasks as completed - Update technical debt cleanup status Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
277
desktop/src/components/WorkflowHistory.tsx
Normal file
277
desktop/src/components/WorkflowHistory.tsx
Normal file
@@ -0,0 +1,277 @@
|
||||
/**
|
||||
* WorkflowHistory - OpenFang Workflow Execution History Component
|
||||
*
|
||||
* Displays the execution history of a specific workflow,
|
||||
* showing run details, status, and results.
|
||||
*
|
||||
* Design based on OpenFang Dashboard v0.4.0
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useGatewayStore, type Workflow, type WorkflowRun } from '../store/gatewayStore';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Clock,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
AlertCircle,
|
||||
Loader2,
|
||||
ChevronRight,
|
||||
RefreshCw,
|
||||
History,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface WorkflowHistoryProps {
|
||||
workflow: Workflow;
|
||||
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, cancelWorkflow, isLoading } = useGatewayStore();
|
||||
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;
|
||||
Reference in New Issue
Block a user