feat: UI/UX redesign for MCP Apps - 3 new apps + enhanced existing (#583)

Add workflow-list, execution-history, and health-dashboard apps.
Redesign operation-result with operation-aware headers, detail panels,
and copy-to-clipboard. Fix React hooks violations in validation-summary
and execution-history (useMemo after early returns). Add local preview
harness for development. Update tests for 5-app config.

Conceived by Romuald Członkowski - www.aiadvisors.pl/en

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Romuald Członkowski
2026-02-09 03:36:27 +01:00
committed by GitHub
parent c601581714
commit 89146186d8
25 changed files with 1614 additions and 122 deletions

View File

@@ -0,0 +1,201 @@
import React, { useMemo } from 'react';
import '@shared/styles/theme.css';
import { Badge } from '@shared/components';
import { useToolData } from '@shared/hooks/useToolData';
import type { ExecutionHistoryData } from '@shared/types';
type ExecStatus = 'success' | 'error' | 'waiting' | 'running' | 'unknown';
function getStatusInfo(status?: string): { variant: 'success' | 'error' | 'warning' | 'info'; label: string } {
switch (status) {
case 'success': return { variant: 'success', label: 'Success' };
case 'error': case 'failed': case 'crashed': return { variant: 'error', label: 'Error' };
case 'waiting': return { variant: 'warning', label: 'Waiting' };
case 'running': return { variant: 'info', label: 'Running' };
default: return { variant: 'info', label: status ?? 'Unknown' };
}
}
function formatDuration(startedAt?: string, stoppedAt?: string): string {
if (!startedAt || !stoppedAt) return '';
try {
const ms = new Date(stoppedAt).getTime() - new Date(startedAt).getTime();
if (ms < 1000) return `${ms}ms`;
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
return `${Math.floor(ms / 60000)}m ${Math.floor((ms % 60000) / 1000)}s`;
} catch {
return '';
}
}
function formatTime(dateStr?: string): string {
if (!dateStr) return '';
try {
const d = new Date(dateStr);
return d.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
} catch {
return dateStr;
}
}
function classifyStatus(status?: string): ExecStatus {
switch (status) {
case 'success': return 'success';
case 'error': case 'failed': case 'crashed': return 'error';
case 'waiting': return 'waiting';
case 'running': return 'running';
default: return 'unknown';
}
}
export default function App() {
const { data, error, isConnected } = useToolData<ExecutionHistoryData>();
const executions = data?.data?.executions ?? [];
const summary = useMemo(() => {
const counts: Record<ExecStatus, number> = { success: 0, error: 0, waiting: 0, running: 0, unknown: 0 };
for (const ex of executions) {
counts[classifyStatus(ex.status)]++;
}
return counts;
}, [executions]);
if (error) {
return <div style={{ padding: '16px', color: '#ef4444' }}>Error: {error}</div>;
}
if (!isConnected) {
return <div style={{ padding: '16px', color: 'var(--n8n-text-muted)' }}>Connecting...</div>;
}
if (!data) {
return <div style={{ padding: '16px', color: 'var(--n8n-text-muted)' }}>Waiting for data...</div>;
}
if (!data.success && data.error) {
return (
<div style={{ maxWidth: '480px' }}>
<Badge variant="error">Error</Badge>
<div style={{ marginTop: '8px', fontSize: '13px', color: 'var(--n8n-error)' }}>{data.error}</div>
</div>
);
}
const total = executions.length;
const barSegments: { color: string; pct: number }[] = [];
if (total > 0) {
if (summary.success > 0) barSegments.push({ color: 'var(--n8n-success)', pct: (summary.success / total) * 100 });
if (summary.error > 0) barSegments.push({ color: 'var(--n8n-error)', pct: (summary.error / total) * 100 });
if (summary.waiting > 0) barSegments.push({ color: 'var(--n8n-warning)', pct: (summary.waiting / total) * 100 });
if (summary.running > 0) barSegments.push({ color: 'var(--n8n-info)', pct: (summary.running / total) * 100 });
if (summary.unknown > 0) barSegments.push({ color: 'var(--n8n-border)', pct: (summary.unknown / total) * 100 });
}
return (
<div style={{ maxWidth: '480px' }}>
{/* Summary bar */}
{total > 0 && (
<div style={{ marginBottom: '12px' }}>
<div style={{
height: '6px',
borderRadius: '3px',
background: 'var(--n8n-border)',
overflow: 'hidden',
display: 'flex',
}}>
{barSegments.map((seg, i) => (
<div key={i} style={{ width: `${seg.pct}%`, background: seg.color, minWidth: '3px' }} />
))}
</div>
<div style={{ fontSize: '12px', color: 'var(--color-text-secondary, var(--n8n-text-muted))', marginTop: '6px' }}>
{summary.success > 0 && <><span style={{ color: 'var(--n8n-success)', fontWeight: 500 }}>{summary.success}</span> succeeded</>}
{summary.error > 0 && <>{summary.success > 0 && ', '}<span style={{ color: 'var(--n8n-error)', fontWeight: 500 }}>{summary.error}</span> failed</>}
{summary.waiting > 0 && <>{(summary.success > 0 || summary.error > 0) && ', '}<span style={{ color: 'var(--n8n-warning)', fontWeight: 500 }}>{summary.waiting}</span> waiting</>}
{summary.running > 0 && <>{(summary.success > 0 || summary.error > 0 || summary.waiting > 0) && ', '}<span style={{ color: 'var(--n8n-info)', fontWeight: 500 }}>{summary.running}</span> running</>}
</div>
</div>
)}
{/* Table */}
<div style={{
border: '1px solid var(--n8n-border)',
borderRadius: 'var(--n8n-radius)',
overflow: 'hidden',
}}>
<div style={{
display: 'grid',
gridTemplateColumns: '70px 1fr 70px 90px 60px',
gap: '6px',
padding: '8px 10px',
fontSize: '11px',
fontWeight: 600,
textTransform: 'uppercase' as const,
letterSpacing: '0.03em',
color: 'var(--color-text-secondary, var(--n8n-text-muted))',
background: 'var(--n8n-bg-card)',
borderBottom: '1px solid var(--n8n-border)',
}}>
<span>ID</span>
<span>Workflow</span>
<span>Status</span>
<span>Started</span>
<span>Duration</span>
</div>
{executions.length === 0 && (
<div style={{ padding: '16px', textAlign: 'center', color: 'var(--n8n-text-muted)', fontSize: '13px' }}>
No executions found
</div>
)}
{executions.map((ex) => {
const statusInfo = getStatusInfo(ex.status);
return (
<div
key={ex.id}
style={{
display: 'grid',
gridTemplateColumns: '70px 1fr 70px 90px 60px',
gap: '6px',
padding: '6px 10px',
fontSize: '12px',
borderBottom: '1px solid var(--n8n-border)',
alignItems: 'center',
}}
>
<span style={{ fontFamily: 'var(--font-mono, monospace)', fontSize: '11px' }}>
{ex.id.length > 8 ? ex.id.slice(0, 8) + '…' : ex.id}
</span>
<span style={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap' as const,
}}>
{ex.workflowName || ex.workflowId || ''}
</span>
<Badge variant={statusInfo.variant}>{statusInfo.label}</Badge>
<span style={{ fontSize: '11px', color: 'var(--color-text-secondary, var(--n8n-text-muted))', whiteSpace: 'nowrap' as const }}>
{formatTime(ex.startedAt)}
</span>
<span style={{ fontSize: '11px', color: 'var(--color-text-secondary, var(--n8n-text-muted))' }}>
{formatDuration(ex.startedAt, ex.stoppedAt)}
</span>
</div>
);
})}
</div>
{data.data?.hasMore && (
<div style={{
fontSize: '11px',
color: 'var(--color-text-secondary, var(--n8n-text-muted))',
marginTop: '6px',
textAlign: 'center',
}}>
More executions available
</div>
)}
</div>
);
}