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:
iven
2026-03-15 01:38:34 +08:00
parent 1f9b6553fc
commit 5599c1a4db
15 changed files with 3216 additions and 296 deletions

View 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;