feat: Add view toggle between table and card views on pipeline page

- Add ViewToggle component with table/cards icons
- Default to table view with TanStack table
- Card view shows execution cards in grid layout
- Toggle persists view preference during session

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-01-24 21:19:30 +00:00
parent 4d48437b21
commit 194e6e0fbf

View File

@@ -1,85 +1,268 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useParams } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import { AlertCircle, ArrowLeft, History, Settings } from 'lucide-react'; import {
import type { DashboardConfig, PipelineDetail } from '@/lib/pipeline-types'; ArrowLeft,
import { getPipeline, getDashboardConfig } from '@/lib/pipeline-api'; CheckCircle,
import { DynamicDashboard } from '@/components/dashboard'; XCircle,
Clock,
PlayCircle,
Loader,
RefreshCw,
AlertCircle,
Play,
ChevronRight,
Layers,
LayoutList,
LayoutGrid,
} from 'lucide-react';
import type { ExecutionStatus, PipelineDetail } from '@/lib/pipeline-types';
import { getPipeline, listExecutions } from '@/lib/pipeline-api';
import ExecutionsView from '@/components/ExecutionsView';
// Status badge component
function StatusBadge({ status }: { status: ExecutionStatus['status'] }) {
const config = {
pending: {
icon: Clock,
color: 'bg-gray-100 text-gray-600',
},
running: {
icon: Loader,
color: 'bg-blue-100 text-blue-600',
},
completed: {
icon: CheckCircle,
color: 'bg-green-100 text-green-600',
},
failed: {
icon: XCircle,
color: 'bg-red-100 text-red-600',
},
cancelled: {
icon: AlertCircle,
color: 'bg-yellow-100 text-yellow-600',
},
};
const { icon: Icon, color } = config[status] || config.pending;
return (
<span className={`inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold ${color}`}>
<Icon className={`w-3.5 h-3.5 mr-1.5 ${status === 'running' ? 'animate-spin' : ''}`} />
{status.charAt(0).toUpperCase() + status.slice(1)}
</span>
);
}
// Format date string
function formatDate(dateStr: string | undefined): string {
if (!dateStr) return '-';
const date = new Date(dateStr);
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
// Calculate duration
function formatDuration(start?: string, end?: string): string {
if (!start) return '-';
const startDate = new Date(start);
const endDate = end ? new Date(end) : new Date();
const ms = endDate.getTime() - startDate.getTime();
if (ms < 1000) return `${ms}ms`;
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
return `${(ms / 60000).toFixed(1)}m`;
}
// Execution card component
function ExecutionCard({
execution,
pipelineId,
onClick
}: {
execution: ExecutionStatus;
pipelineId: string;
onClick: () => void;
}) {
return (
<div
onClick={onClick}
className="bg-white rounded-xl border-2 border-gray-200 p-5 hover:shadow-lg hover:border-blue-400 transition-all cursor-pointer"
>
<div className="flex items-start justify-between mb-3">
<StatusBadge status={execution.status} />
<span className="text-xs text-gray-500">
{formatDate(execution.started_at)}
</span>
</div>
<div className="mb-3">
{execution.job_id ? (
<div className="flex items-center text-sm">
<span className="text-gray-500 mr-2">Job:</span>
<Link
href={`/jobs/${execution.job_id}`}
onClick={(e) => e.stopPropagation()}
className="text-blue-600 hover:underline font-medium"
>
{execution.job_id.slice(0, 8)}...
</Link>
</div>
) : execution.business_id ? (
<div className="flex items-center text-sm">
<span className="text-gray-500 mr-2">Business:</span>
<span className="text-gray-900 font-medium truncate max-w-[200px]">
{execution.business_id}
</span>
</div>
) : (
<div className="text-sm text-gray-400">Manual execution</div>
)}
</div>
<div className="flex items-center justify-between text-sm">
<div className="flex items-center text-gray-600">
<Layers className="w-4 h-4 mr-1.5" />
<span>
{execution.stages_completed.length} / {execution.stages_requested.length} stages
</span>
</div>
<div className="flex items-center text-gray-500">
<Clock className="w-4 h-4 mr-1" />
{formatDuration(execution.started_at, execution.completed_at)}
</div>
</div>
{execution.error_message && (
<div className="mt-3 p-2 bg-red-50 rounded-md">
<p className="text-xs text-red-600 line-clamp-2">{execution.error_message}</p>
</div>
)}
<div className="mt-3 pt-3 border-t border-gray-100 flex items-center justify-between">
<code className="text-xs text-gray-400">
{execution.id.slice(0, 12)}...
</code>
<ChevronRight className="w-4 h-4 text-gray-400" />
</div>
</div>
);
}
// View mode toggle component
function ViewToggle({
mode,
onChange
}: {
mode: 'table' | 'cards';
onChange: (mode: 'table' | 'cards') => void;
}) {
return (
<div className="flex rounded-lg border border-gray-200 p-1 bg-gray-50">
<button
onClick={() => onChange('table')}
className={`inline-flex items-center justify-center p-2 rounded-md transition-all ${
mode === 'table'
? 'bg-white text-gray-900 shadow-sm'
: 'text-gray-500 hover:text-gray-700 hover:bg-gray-100'
}`}
title="Table view"
>
<LayoutList className="w-4 h-4" />
</button>
<button
onClick={() => onChange('cards')}
className={`inline-flex items-center justify-center p-2 rounded-md transition-all ${
mode === 'cards'
? 'bg-white text-gray-900 shadow-sm'
: 'text-gray-500 hover:text-gray-700 hover:bg-gray-100'
}`}
title="Card view"
>
<LayoutGrid className="w-4 h-4" />
</button>
</div>
);
}
/** /**
* Pipeline dashboard page. * Pipeline detail page - shows executions like Jobs page.
*
* Displays the dynamic dashboard for a specific pipeline.
*/ */
export default function PipelineDashboardPage() { export default function PipelinePage() {
const params = useParams(); const params = useParams();
const router = useRouter();
const pipelineId = params.pipelineId as string; const pipelineId = params.pipelineId as string;
const [pipeline, setPipeline] = useState<PipelineDetail | null>(null); const [pipeline, setPipeline] = useState<PipelineDetail | null>(null);
const [dashboardConfig, setDashboardConfig] = useState<DashboardConfig | null>(null); const [executions, setExecutions] = useState<ExecutionStatus[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [viewMode, setViewMode] = useState<'table' | 'cards'>('table');
const fetchData = async () => {
setLoading(true);
setError(null);
try {
const [pipelineData, executionsData] = await Promise.all([
getPipeline(pipelineId),
listExecutions(pipelineId, { limit: 100 }),
]);
setPipeline(pipelineData);
setExecutions(executionsData);
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to load data');
} finally {
setLoading(false);
}
};
useEffect(() => { useEffect(() => {
const fetchData = async () => {
setLoading(true);
setError(null);
try {
const [pipelineData, configData] = await Promise.all([
getPipeline(pipelineId),
getDashboardConfig(pipelineId),
]);
setPipeline(pipelineData);
setDashboardConfig(configData);
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to load pipeline');
} finally {
setLoading(false);
}
};
if (pipelineId) { if (pipelineId) {
fetchData(); fetchData();
} }
}, [pipelineId]); }, [pipelineId]);
if (loading) { const handleExecutionClick = (execution: ExecutionStatus) => {
return ( router.push(`/pipelines/${pipelineId}/executions/${execution.id}`);
<div className="p-6"> };
{/* Header skeleton */}
<div className="flex items-center mb-6 animate-pulse">
<div className="w-8 h-8 bg-gray-200 dark:bg-gray-700 rounded" />
<div className="ml-4 flex-1">
<div className="h-6 w-48 bg-gray-200 dark:bg-gray-700 rounded" />
<div className="h-4 w-64 bg-gray-200 dark:bg-gray-700 rounded mt-2" />
</div>
</div>
{/* Dashboard skeleton */} const handleRunPipeline = () => {
<div className="space-y-6"> router.push(`/pipelines/${pipelineId}/run`);
<div className="grid grid-cols-4 gap-4"> };
{[1, 2, 3, 4].map((i) => (
<div // Count executions by status
key={i} const statusCounts = {
className="h-24 bg-gray-200 dark:bg-gray-700 rounded-lg animate-pulse" total: executions.length,
/> completed: executions.filter(e => e.status === 'completed').length,
failed: executions.filter(e => e.status === 'failed').length,
running: executions.filter(e => e.status === 'running').length,
};
if (loading && !pipeline) {
return (
<div className="h-full overflow-y-auto p-6">
<div className="animate-pulse">
<div className="h-8 w-48 bg-gray-200 rounded mb-2" />
<div className="h-4 w-96 bg-gray-200 rounded mb-6" />
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{[1, 2, 3].map((i) => (
<div key={i} className="h-40 bg-gray-200 rounded-xl" />
))} ))}
</div> </div>
<div className="h-64 bg-gray-200 dark:bg-gray-700 rounded-lg animate-pulse" />
</div> </div>
</div> </div>
); );
} }
if (error || !pipeline || !dashboardConfig) { if (error && !pipeline) {
return ( return (
<div className="p-6"> <div className="h-full overflow-y-auto p-6">
<Link <Link
href="/pipelines" href="/pipelines"
className="inline-flex items-center text-sm text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 mb-6" className="inline-flex items-center text-sm text-gray-500 hover:text-gray-700 mb-6"
> >
<ArrowLeft className="w-4 h-4 mr-1" /> <ArrowLeft className="w-4 h-4 mr-1" />
Back to Pipelines Back to Pipelines
@@ -87,82 +270,158 @@ export default function PipelineDashboardPage() {
<div className="text-center py-12"> <div className="text-center py-12">
<AlertCircle className="w-12 h-12 text-red-500 mx-auto mb-4" /> <AlertCircle className="w-12 h-12 text-red-500 mx-auto mb-4" />
<p className="text-red-600 dark:text-red-400"> <p className="text-red-600 mb-4">{error}</p>
{error || 'Pipeline not found'} <button
</p> onClick={fetchData}
<Link className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
href="/pipelines"
className="mt-4 inline-block px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
> >
Back to Pipelines Retry
</Link> </button>
</div> </div>
</div> </div>
); );
} }
return ( return (
<div className="p-6"> <div className="h-full overflow-y-auto p-6">
{/* Navigation */} {/* Navigation */}
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<Link <Link
href="/pipelines" href="/pipelines"
className="inline-flex items-center text-sm text-gray-500 hover:text-gray-700 dark:hover:text-gray-300" className="inline-flex items-center text-sm text-gray-500 hover:text-gray-700"
> >
<ArrowLeft className="w-4 h-4 mr-1" /> <ArrowLeft className="w-4 h-4 mr-1" />
Back to Pipelines Back to Pipelines
</Link> </Link>
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<Link <button
href={`/pipelines/${pipelineId}/executions`} onClick={fetchData}
className="inline-flex items-center px-3 py-2 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md" disabled={loading}
className="inline-flex items-center px-3 py-2 text-sm text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-md disabled:opacity-50"
> >
<History className="w-4 h-4 mr-2" /> <RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
Execution History Refresh
</Link> </button>
<button
onClick={handleRunPipeline}
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-md hover:bg-blue-700"
>
<Play className="w-4 h-4 mr-2" />
Run Pipeline
</button>
</div> </div>
</div> </div>
{/* Pipeline Info */} {/* Pipeline Header */}
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4 mb-6"> {pipeline && (
<div className="flex items-center justify-between"> <div className="bg-white rounded-xl border-2 border-gray-200 p-6 mb-6">
<div> <div className="flex items-start justify-between">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100"> <div>
{pipeline.name} <h1 className="text-2xl font-bold text-gray-900">
</h2> {pipeline.name}
<p className="text-sm text-gray-500 mt-1">{pipeline.description}</p> </h1>
</div> <p className="text-gray-600 mt-1">{pipeline.description}</p>
<div className="text-right"> </div>
<p className="text-sm text-gray-500"> <div className="text-right">
Version: <span className="font-medium">{pipeline.version}</span> <span className={`px-3 py-1 rounded-full text-sm font-medium ${
</p> pipeline.is_enabled
<p className="text-sm text-gray-500"> ? 'bg-green-100 text-green-700'
Input: <code className="text-xs bg-gray-100 dark:bg-gray-700 px-1 py-0.5 rounded">{pipeline.input_type}</code> : 'bg-gray-100 text-gray-600'
</p> }`}>
</div> {pipeline.is_enabled ? 'Enabled' : 'Disabled'}
</div>
<div className="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700">
<p className="text-sm text-gray-500 mb-2">Stages:</p>
<div className="flex flex-wrap gap-2">
{pipeline.stages.map((stage, index) => (
<span
key={stage}
className="inline-flex items-center px-2 py-1 bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 text-sm rounded"
>
<span className="w-5 h-5 flex items-center justify-center bg-blue-100 dark:bg-blue-800 rounded-full text-xs font-medium mr-2">
{index + 1}
</span>
{stage}
</span> </span>
))} <p className="text-sm text-gray-500 mt-2">v{pipeline.version}</p>
</div>
</div> </div>
<div className="mt-4 pt-4 border-t border-gray-200">
<div className="flex items-center space-x-6">
<div className="flex items-center space-x-2">
<span className="text-sm text-gray-500">Stages:</span>
<div className="flex flex-wrap gap-1">
{pipeline.stages.map((stage, index) => (
<span
key={stage}
className="inline-flex items-center px-2 py-0.5 bg-blue-50 text-blue-700 text-xs rounded"
>
{index + 1}. {stage}
</span>
))}
</div>
</div>
</div>
</div>
</div>
)}
{/* Stats Row */}
<div className="grid grid-cols-4 gap-4 mb-6">
<div className="bg-white rounded-lg border border-gray-200 p-4">
<div className="text-2xl font-bold text-gray-900">{statusCounts.total}</div>
<div className="text-sm text-gray-500">Total Runs</div>
</div>
<div className="bg-white rounded-lg border border-gray-200 p-4">
<div className="text-2xl font-bold text-green-600">{statusCounts.completed}</div>
<div className="text-sm text-gray-500">Completed</div>
</div>
<div className="bg-white rounded-lg border border-gray-200 p-4">
<div className="text-2xl font-bold text-red-600">{statusCounts.failed}</div>
<div className="text-sm text-gray-500">Failed</div>
</div>
<div className="bg-white rounded-lg border border-gray-200 p-4">
<div className="text-2xl font-bold text-blue-600">{statusCounts.running}</div>
<div className="text-sm text-gray-500">Running</div>
</div> </div>
</div> </div>
{/* Dynamic Dashboard */} {/* Executions Header with View Toggle */}
<DynamicDashboard pipelineId={pipelineId} config={dashboardConfig} /> <div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-gray-900">Executions</h2>
<ViewToggle mode={viewMode} onChange={setViewMode} />
</div>
{/* Executions Content */}
{loading ? (
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-8">
<div className="animate-pulse space-y-4">
<div className="h-10 bg-gray-200 rounded w-full" />
<div className="h-12 bg-gray-100 rounded w-full" />
<div className="h-12 bg-gray-100 rounded w-full" />
<div className="h-12 bg-gray-100 rounded w-full" />
</div>
</div>
) : executions.length === 0 ? (
<div className="text-center py-16">
<PlayCircle className="w-16 h-16 text-gray-300 mx-auto mb-4" />
<h3 className="text-xl font-semibold text-gray-700 mb-2">No Executions Yet</h3>
<p className="text-gray-500 mb-6">Run your first pipeline execution to see results here</p>
<button
onClick={handleRunPipeline}
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white font-medium rounded-md hover:bg-blue-700"
>
<Play className="w-4 h-4 mr-2" />
Run Pipeline
</button>
</div>
) : viewMode === 'table' ? (
<ExecutionsView
executions={executions}
pipelineId={pipelineId}
onRefresh={fetchData}
/>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{executions.map((execution) => (
<ExecutionCard
key={execution.id}
execution={execution}
pipelineId={pipelineId}
onClick={() => handleExecutionClick(execution)}
/>
))}
</div>
)}
</div> </div>
); );
} }