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>
);
}

View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Execution History</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,8 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
const root = document.getElementById('root');
if (root) {
createRoot(root).render(<App />);
}

View File

@@ -0,0 +1,141 @@
import React from 'react';
import '@shared/styles/theme.css';
import { Badge, Card } from '@shared/components';
import { useToolData } from '@shared/hooks/useToolData';
import type { HealthDashboardData } from '@shared/types';
export default function App() {
const { data, error, isConnected } = useToolData<HealthDashboardData>();
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' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '12px' }}>
<Badge variant="error">Disconnected</Badge>
</div>
<div style={{ fontSize: '13px', color: 'var(--n8n-error)' }}>{data.error}</div>
</div>
);
}
const d = data.data;
const isConnectedStatus = d?.status === 'connected' || d?.status === 'ok' || data.success;
const vc = d?.versionCheck;
const perf = d?.performance;
const nextSteps = d?.nextSteps ?? [];
return (
<div style={{ maxWidth: '480px' }}>
{/* Connection status */}
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '16px' }}>
<Badge variant={isConnectedStatus ? 'success' : 'error'}>
{isConnectedStatus ? 'Connected' : 'Disconnected'}
</Badge>
{d?.apiUrl && (
<span style={{
fontSize: '12px',
fontFamily: 'var(--font-mono, monospace)',
color: 'var(--color-text-secondary, var(--n8n-text-muted))',
}}>
{d.apiUrl}
</span>
)}
</div>
{/* Version info */}
{(d?.n8nVersion || d?.mcpVersion) && (
<Card>
<div style={{ fontSize: '13px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '4px' }}>
<span style={{ color: 'var(--color-text-secondary, var(--n8n-text-muted))' }}>n8n</span>
<span style={{ fontFamily: 'var(--font-mono, monospace)', fontWeight: 500 }}>
{d?.n8nVersion ?? ''}
</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '4px' }}>
<span style={{ color: 'var(--color-text-secondary, var(--n8n-text-muted))' }}>MCP Server</span>
<span style={{ fontFamily: 'var(--font-mono, monospace)', fontWeight: 500 }}>
{d?.mcpVersion ?? ''}
</span>
</div>
{vc && !vc.upToDate && (
<div style={{
marginTop: '8px',
padding: '6px 10px',
background: 'var(--n8n-warning-light)',
borderRadius: '4px',
fontSize: '12px',
color: 'var(--n8n-warning)',
}}>
Update available: {vc.current} {vc.latest}
{vc.updateCommand && (
<div style={{
fontFamily: 'var(--font-mono, monospace)',
fontSize: '11px',
marginTop: '4px',
opacity: 0.9,
}}>
{vc.updateCommand}
</div>
)}
</div>
)}
</div>
</Card>
)}
{/* Performance */}
{perf && (
<Card>
<div style={{ fontSize: '13px' }}>
{perf.responseTimeMs !== undefined && (
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '4px' }}>
<span style={{ color: 'var(--color-text-secondary, var(--n8n-text-muted))' }}>Response time</span>
<span style={{
fontFamily: 'var(--font-mono, monospace)',
fontWeight: 500,
color: perf.responseTimeMs < 500 ? 'var(--n8n-success)' : perf.responseTimeMs < 2000 ? 'var(--n8n-warning)' : 'var(--n8n-error)',
}}>
{perf.responseTimeMs}ms
</span>
</div>
)}
{perf.cacheHitRate !== undefined && (
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ color: 'var(--color-text-secondary, var(--n8n-text-muted))' }}>Cache hit rate</span>
<span style={{ fontFamily: 'var(--font-mono, monospace)', fontWeight: 500 }}>
{typeof perf.cacheHitRate === 'number' && perf.cacheHitRate <= 1
? `${(perf.cacheHitRate * 100).toFixed(0)}%`
: `${perf.cacheHitRate}%`}
</span>
</div>
)}
</div>
</Card>
)}
{/* Next steps */}
{nextSteps.length > 0 && (
<Card title="Next Steps">
<ul style={{ paddingLeft: '16px', fontSize: '12px' }}>
{nextSteps.map((step, i) => (
<li key={i} style={{ padding: '2px 0' }}>{step}</li>
))}
</ul>
</Card>
)}
</div>
);
}

View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Health Dashboard</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,8 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
const root = document.getElementById('root');
if (root) {
createRoot(root).render(<App />);
}

View File

@@ -1,65 +1,336 @@
import React from 'react';
import '@shared/styles/theme.css';
import { Card, Badge, Expandable } from '@shared/components';
import { Badge, Expandable } from '@shared/components';
import { useToolData } from '@shared/hooks/useToolData';
import type { OperationResultData } from '@shared/types';
import type { OperationResultData, OperationType } from '@shared/types';
const TOOL_TO_OP: Record<string, OperationType> = {
n8n_create_workflow: 'create',
n8n_update_full_workflow: 'update',
n8n_update_partial_workflow: 'partial_update',
n8n_delete_workflow: 'delete',
n8n_test_workflow: 'test',
n8n_autofix_workflow: 'autofix',
n8n_deploy_template: 'deploy',
};
const OP_CONFIG: Record<OperationType, { icon: string; label: string; color: string }> = {
create: { icon: '+', label: 'WORKFLOW CREATED', color: 'var(--n8n-success)' },
update: { icon: '⟳', label: 'WORKFLOW UPDATED', color: 'var(--n8n-info)' },
partial_update: { icon: '⟳', label: 'WORKFLOW UPDATED', color: 'var(--n8n-info)' },
delete: { icon: '', label: 'WORKFLOW DELETED', color: 'var(--n8n-error)' },
test: { icon: '▶', label: 'WORKFLOW TESTED', color: 'var(--n8n-info)' },
autofix: { icon: '⚡', label: 'WORKFLOW AUTO-FIXED', color: 'var(--n8n-warning)' },
deploy: { icon: '↓', label: 'TEMPLATE DEPLOYED', color: 'var(--n8n-success)' },
};
function detectOperation(toolName: string | null, data: OperationResultData): OperationType {
if (toolName && TOOL_TO_OP[toolName]) return TOOL_TO_OP[toolName];
const d = data.data;
if (d?.deleted) return 'delete';
if (d?.templateId) return 'deploy';
if (d?.fixesApplied !== undefined || d?.fixes) return 'autofix';
if (d?.executionId) return 'test';
if (d?.operationsApplied !== undefined) return 'partial_update';
return 'create';
}
function PartialUpdatePanel({ details }: { details?: Record<string, unknown> }) {
if (!details) return null;
const applied = Array.isArray(details.applied) ? details.applied as string[] : [];
const failed = Array.isArray(details.failed) ? details.failed as string[] : [];
const warnings = Array.isArray(details.warnings) ? details.warnings as string[] : [];
if (applied.length === 0 && failed.length === 0) return null;
const items = [
...applied.map((m) => ({ icon: '✓', color: 'var(--n8n-success)', text: String(m) })),
...failed.map((m) => ({ icon: '✗', color: 'var(--n8n-error)', text: String(m) })),
...warnings.map((m) => ({ icon: '!', color: 'var(--n8n-warning)', text: String(m) })),
];
const summary = (
<div style={{ fontSize: '12px', color: 'var(--color-text-secondary, var(--n8n-text-muted))', marginBottom: '6px' }}>
<span style={{ color: 'var(--n8n-success)' }}>{applied.length} applied</span>
{failed.length > 0 && <>, <span style={{ color: 'var(--n8n-error)' }}>{failed.length} failed</span></>}
</div>
);
const list = items.map((item, i) => (
<div key={i} style={{ fontSize: '12px', padding: '2px 0', display: 'flex', gap: '6px' }}>
<span style={{ color: item.color, flexShrink: 0 }}>{item.icon}</span>
<span>{item.text}</span>
</div>
));
if (items.length > 5) {
return <>{summary}<Expandable title="Operation Log" count={items.length}>{list}</Expandable></>;
}
return <>{summary}<div style={{ marginBottom: '8px' }}>{list}</div></>;
}
function AutofixPanel({ data }: { data: OperationResultData }) {
const fixes = Array.isArray(data.data?.fixes) ? data.data!.fixes as Record<string, unknown>[] : [];
const isPreview = data.data?.preview === true;
const fixCount = data.data?.fixesApplied ?? fixes.length;
return (
<>
{isPreview && (
<div style={{
fontSize: '11px',
fontWeight: 600,
color: 'var(--n8n-warning)',
background: 'var(--n8n-warning-light)',
padding: '4px 10px',
borderRadius: 'var(--n8n-radius)',
marginBottom: '8px',
textAlign: 'center',
}}>
PREVIEW MODE
</div>
)}
{fixes.length > 0 && (
<Expandable title="Fixes" count={fixCount} defaultOpen>
{fixes.map((fix, i) => {
const confidence = String(fix.confidence ?? '').toUpperCase();
return (
<div key={i} style={{
fontSize: '12px',
padding: '6px 8px',
marginBottom: '4px',
borderLeft: `3px solid ${confidence === 'HIGH' ? 'var(--n8n-success)' : 'var(--n8n-warning)'}`,
paddingLeft: '10px',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<span>{String(fix.description ?? fix.message ?? JSON.stringify(fix))}</span>
{confidence && (
<Badge variant={confidence === 'HIGH' ? 'success' : 'warning'}>
{confidence}
</Badge>
)}
</div>
</div>
);
})}
</Expandable>
)}
</>
);
}
function DeployPanel({ data }: { data: OperationResultData }) {
const d = data.data;
const creds = Array.isArray(d?.requiredCredentials) ? d!.requiredCredentials as string[] : [];
const triggerType = d?.triggerType;
const autoFixStatus = d?.autoFixStatus;
return (
<div style={{ fontSize: '12px', marginBottom: '8px' }}>
<div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap', marginBottom: creds.length > 0 ? '8px' : 0 }}>
{triggerType && <Badge variant="info">{String(triggerType)}</Badge>}
{autoFixStatus && <Badge variant={autoFixStatus === 'success' ? 'success' : 'warning'}>{String(autoFixStatus)}</Badge>}
</div>
{creds.length > 0 && (
<div>
<div style={{ fontWeight: 500, marginBottom: '4px', color: 'var(--color-text-secondary, var(--n8n-text-muted))' }}>Required credentials:</div>
{creds.map((c, i) => (
<div key={i} style={{ padding: '1px 0' }}> {c}</div>
))}
</div>
)}
</div>
);
}
function TestPanel({ data }: { data: OperationResultData }) {
const execId = data.data?.executionId;
const triggerType = data.data?.triggerType;
if (!execId && !triggerType) return null;
return (
<div style={{ fontSize: '12px', marginBottom: '8px' }}>
{execId && (
<div style={{ fontFamily: 'var(--font-mono, monospace)', fontSize: '13px', fontWeight: 600, marginBottom: '4px' }}>
Execution: {execId}
</div>
)}
{triggerType && <Badge variant="info">{String(triggerType)}</Badge>}
</div>
);
}
function ErrorDetails({ details }: { details?: Record<string, unknown> }) {
if (!details) return null;
if (Array.isArray(details.errors)) {
const errs = details.errors as string[];
return (
<Expandable title="Errors" count={errs.length}>
<ul style={{ paddingLeft: '16px', fontSize: '12px' }}>
{errs.map((e, i) => <li key={i} style={{ padding: '1px 0' }}>{String(e)}</li>)}
</ul>
</Expandable>
);
}
const entries = Object.entries(details).filter(([, v]) => v !== undefined && v !== null);
if (entries.length === 0) return null;
const hasComplexValues = entries.some(([, v]) => typeof v === 'object');
if (hasComplexValues) {
return (
<Expandable title="Details">
<pre style={{ fontSize: '11px', whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
{JSON.stringify(details, null, 2)}
</pre>
</Expandable>
);
}
return (
<Expandable title="Details">
<div style={{ fontSize: '12px' }}>
{entries.map(([key, val]) => (
<div key={key} style={{ padding: '2px 0' }}>
<span style={{ color: 'var(--color-text-secondary, var(--n8n-text-muted))' }}>{key}: </span>
<span>{String(val)}</span>
</div>
))}
</div>
</Expandable>
);
}
export default function App() {
const { data, error, isConnected } = useToolData<OperationResultData>();
const { data, error, isConnected, toolName } = useToolData<OperationResultData>();
if (error) {
return <div style={{ padding: '16px', color: '#ef4444' }}>Error: {error}</div>;
}
if (!isConnected) {
return <div style={{ padding: '16px', color: '#9ca3af' }}>Connecting...</div>;
return <div style={{ padding: '16px', color: 'var(--n8n-text-muted)' }}>Connecting...</div>;
}
if (!data) {
return <div style={{ padding: '16px', color: '#9ca3af' }}>Waiting for data...</div>;
return <div style={{ padding: '16px', color: 'var(--n8n-text-muted)' }}>Waiting for data...</div>;
}
const isSuccess = data.success === true;
const op = detectOperation(toolName, data);
const config = OP_CONFIG[op];
const workflowName = data.data?.name || data.data?.workflowName;
const workflowId = data.data?.id || data.data?.workflowId;
const nodeCount = data.data?.nodeCount;
const isDeleted = data.data?.deleted === true;
const isActive = data.data?.active;
const operationsApplied = data.data?.operationsApplied;
const executionId = data.data?.executionId;
const fixesApplied = data.data?.fixesApplied;
const templateId = data.data?.templateId;
const label = isSuccess ? config.label : config.label + ' FAILED';
const metaParts: string[] = [];
if (workflowId) metaParts.push(`ID: ${workflowId}`);
if (nodeCount !== undefined) metaParts.push(`${nodeCount} nodes`);
if (isActive !== undefined) metaParts.push(isActive ? 'active' : 'inactive');
if (operationsApplied !== undefined) metaParts.push(`${operationsApplied} ops applied`);
if (executionId) metaParts.push(`exec: ${executionId}`);
if (fixesApplied !== undefined) metaParts.push(`${fixesApplied} fixes`);
if (templateId) metaParts.push(`template: ${templateId}`);
const containerStyle = op === 'delete' ? {
maxWidth: '480px',
borderLeft: '3px solid var(--n8n-error)',
paddingLeft: '12px',
} : { maxWidth: '480px' };
return (
<div style={{ maxWidth: '480px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '16px' }}>
<div style={containerStyle}>
{/* Header */}
<div style={{
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'space-between',
marginBottom: '16px',
}}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '10px', flex: 1, minWidth: 0 }}>
<span style={{
fontSize: '18px',
lineHeight: '24px',
color: config.color,
flexShrink: 0,
}}>
{config.icon}
</span>
<div style={{ minWidth: 0 }}>
<div style={{
fontSize: '11px',
fontWeight: 600,
letterSpacing: '0.05em',
textTransform: 'uppercase' as const,
color: config.color,
lineHeight: '16px',
}}>
{label}
</div>
{workflowName && (
<div style={{
fontSize: '14px',
fontWeight: 600,
color: 'var(--color-text-primary, var(--n8n-text))',
marginTop: '2px',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap' as const,
}}>
{workflowName}
</div>
)}
{metaParts.length > 0 && (
<div style={{
fontSize: '12px',
fontFamily: 'var(--font-mono, monospace)',
color: 'var(--color-text-secondary, var(--n8n-text-muted))',
marginTop: '2px',
}}>
{metaParts.join(' · ')}
</div>
)}
</div>
</div>
<Badge variant={isSuccess ? 'success' : 'error'}>
{isSuccess ? 'Success' : 'Error'}
</Badge>
</div>
{(workflowName || workflowId) && (
<Card title="Workflow">
<div style={{ fontSize: '14px' }}>
{workflowName && <div><strong>Name:</strong> {workflowName}</div>}
{workflowId && <div><strong>ID:</strong> {workflowId}</div>}
{nodeCount !== undefined && <div><strong>Nodes:</strong> {nodeCount}</div>}
{isDeleted && <div style={{ color: 'var(--n8n-warning)', marginTop: '4px' }}>Deleted</div>}
{operationsApplied !== undefined && (
<div><strong>Operations applied:</strong> {operationsApplied}</div>
)}
</div>
</Card>
{/* Error info */}
{!isSuccess && data.error && (
<div style={{
fontSize: '12px',
color: 'var(--n8n-error)',
padding: '8px 12px',
background: 'var(--n8n-error-light)',
borderRadius: 'var(--n8n-radius)',
marginBottom: '8px',
}}>
{data.error}
</div>
)}
{(data.message || data.error) && (
<Card>
<div style={{ fontSize: '13px' }}>{data.message || data.error}</div>
</Card>
)}
{/* Operation-specific panels */}
{isSuccess && op === 'partial_update' && <PartialUpdatePanel details={data.details} />}
{isSuccess && op === 'autofix' && <AutofixPanel data={data} />}
{isSuccess && op === 'deploy' && <DeployPanel data={data} />}
{isSuccess && op === 'test' && <TestPanel data={data} />}
{data.details && (
<Expandable title="Details">
<pre style={{ fontSize: '11px', whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
{JSON.stringify(data.details, null, 2)}
</pre>
</Expandable>
{/* Error details */}
{!isSuccess && <ErrorDetails details={data.details} />}
{/* Fallback details for success states without specific panels */}
{isSuccess && !['partial_update', 'autofix', 'deploy', 'test'].includes(op) && data.details && (
<ErrorDetails details={data.details} />
)}
</div>
);

View File

@@ -1,107 +1,207 @@
import React from 'react';
import React, { useMemo } from 'react';
import '@shared/styles/theme.css';
import { Card, Badge, Expandable } from '@shared/components';
import { Badge, Expandable } from '@shared/components';
import { useToolData } from '@shared/hooks/useToolData';
import type { ValidationSummaryData, ValidationError, ValidationWarning } from '@shared/types';
interface NodeGroup {
node: string;
errors: ValidationError[];
warnings: ValidationWarning[];
}
function SeverityBar({ errorCount, warningCount }: { errorCount: number; warningCount: number }) {
const total = errorCount + warningCount;
if (total === 0) {
return (
<div style={{ marginBottom: '12px' }}>
<div style={{
height: '6px',
borderRadius: '3px',
background: 'var(--n8n-success)',
marginBottom: '6px',
}} />
<div style={{ fontSize: '12px', color: 'var(--n8n-success)', fontWeight: 500 }}>
All checks passed
</div>
</div>
);
}
const errorPct = (errorCount / total) * 100;
const warningPct = (warningCount / total) * 100;
return (
<div style={{ marginBottom: '12px' }}>
<div style={{
height: '6px',
borderRadius: '3px',
background: 'var(--n8n-border)',
overflow: 'hidden',
display: 'flex',
}}>
{errorCount > 0 && (
<div style={{ width: `${errorPct}%`, background: 'var(--n8n-error)', minWidth: '4px' }} />
)}
{warningCount > 0 && (
<div style={{ width: `${warningPct}%`, background: 'var(--n8n-warning)', minWidth: '4px' }} />
)}
</div>
<div style={{ fontSize: '12px', color: 'var(--color-text-secondary, var(--n8n-text-muted))', marginTop: '6px' }}>
<span style={{ color: 'var(--n8n-error)', fontWeight: 500 }}>{errorCount}</span> error{errorCount !== 1 ? 's' : ''}
{' · '}
<span style={{ color: 'var(--n8n-warning)', fontWeight: 500 }}>{warningCount}</span> warning{warningCount !== 1 ? 's' : ''}
</div>
</div>
);
}
function IssueItem({ issue, variant }: { issue: ValidationError | ValidationWarning; variant: 'error' | 'warning' }) {
const color = variant === 'error' ? 'var(--n8n-error)' : 'var(--n8n-warning)';
const fix = 'fix' in issue ? issue.fix : undefined;
return (
<div style={{
padding: '6px 10px',
marginBottom: '4px',
borderLeft: `3px solid ${color}`,
fontSize: '12px',
}}>
<div style={{ color: 'var(--color-text-primary, var(--n8n-text))' }}>{issue.message}</div>
{issue.property && (
<div style={{ color: 'var(--color-text-secondary, var(--n8n-text-muted))', fontSize: '11px', marginTop: '2px' }}>
{issue.property}
</div>
)}
{fix && (
<div style={{ color, fontSize: '11px', marginTop: '2px' }}>
{fix}
</div>
)}
</div>
);
}
function NodeGroupSection({ group }: { group: NodeGroup }) {
const errCount = group.errors.length;
const warnCount = group.warnings.length;
return (
<Expandable
title={group.node}
count={errCount + warnCount}
defaultOpen={errCount > 0}
>
<div style={{ display: 'flex', gap: '8px', marginBottom: '6px', flexWrap: 'wrap' }}>
{errCount > 0 && <Badge variant="error">{errCount} error{errCount !== 1 ? 's' : ''}</Badge>}
{warnCount > 0 && <Badge variant="warning">{warnCount} warning{warnCount !== 1 ? 's' : ''}</Badge>}
</div>
{group.errors.map((err, i) => (
<IssueItem key={`e-${i}`} issue={err} variant="error" />
))}
{group.warnings.map((warn, i) => (
<IssueItem key={`w-${i}`} issue={warn} variant="warning" />
))}
</Expandable>
);
}
export default function App() {
const { data: raw, error, isConnected } = useToolData<ValidationSummaryData>();
const inner = raw?.data || raw;
const errors: ValidationError[] = inner?.errors || raw?.errors || [];
const warnings: ValidationWarning[] = inner?.warnings || raw?.warnings || [];
const nodeGroups = useMemo(() => {
if (errors.length === 0 && warnings.length === 0) return null;
const hasNodes = errors.some((e) => e.node) || warnings.some((w) => w.node);
const uniqueNodes = new Set([
...errors.filter((e) => e.node).map((e) => e.node!),
...warnings.filter((w) => w.node).map((w) => w.node!),
]);
if (!hasNodes || uniqueNodes.size <= 1) return null;
const groups: NodeGroup[] = [];
for (const node of uniqueNodes) {
groups.push({
node,
errors: errors.filter((e) => e.node === node),
warnings: warnings.filter((w) => w.node === node),
});
}
// Ungrouped items
const ungroupedErrors = errors.filter((e) => !e.node);
const ungroupedWarnings = warnings.filter((w) => !w.node);
if (ungroupedErrors.length > 0 || ungroupedWarnings.length > 0) {
groups.push({ node: 'General', errors: ungroupedErrors, warnings: ungroupedWarnings });
}
// Sort: most issues first
groups.sort((a, b) => (b.errors.length + b.warnings.length) - (a.errors.length + a.warnings.length));
return groups;
}, [errors, warnings]);
if (error) {
return <div style={{ padding: '16px', color: '#ef4444' }}>Error: {error}</div>;
}
if (!isConnected) {
return <div style={{ padding: '16px', color: '#9ca3af' }}>Connecting...</div>;
return <div style={{ padding: '16px', color: 'var(--n8n-text-muted)' }}>Connecting...</div>;
}
if (!raw) {
return <div style={{ padding: '16px', color: '#9ca3af' }}>Waiting for data...</div>;
return <div style={{ padding: '16px', color: 'var(--n8n-text-muted)' }}>Waiting for data...</div>;
}
// n8n_validate_workflow wraps result in { success, data: {...} }
// validate_node and validate_workflow return data directly
const inner = raw.data || raw;
const valid = inner.valid ?? raw.valid ?? false;
const displayName = raw.displayName || raw.data?.workflowName;
const errors: ValidationError[] = inner.errors || raw.errors || [];
const warnings: ValidationWarning[] = inner.warnings || raw.warnings || [];
const suggestions: string[] = inner.suggestions || raw.suggestions || [];
const errorCount = raw.summary?.errorCount ?? inner.summary?.errorCount ?? errors.length;
const warningCount = raw.summary?.warningCount ?? inner.summary?.warningCount ?? warnings.length;
const suggestions: string[] = inner?.suggestions || raw?.suggestions || [];
const errorCount = raw.summary?.errorCount ?? inner?.summary?.errorCount ?? errors.length;
const warningCount = raw.summary?.warningCount ?? inner?.summary?.warningCount ?? warnings.length;
return (
<div style={{ maxWidth: '480px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '16px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '12px' }}>
<Badge variant={valid ? 'success' : 'error'}>
{valid ? 'Valid' : 'Invalid'}
</Badge>
{displayName && (
<span style={{ fontSize: '14px', color: 'var(--n8n-text-muted)' }}>{displayName}</span>
<span style={{ fontSize: '14px', color: 'var(--color-text-secondary, var(--n8n-text-muted))' }}>{displayName}</span>
)}
</div>
<Card>
<div style={{ display: 'flex', gap: '16px', fontSize: '13px' }}>
<div>
<span style={{ color: 'var(--n8n-error)' }}>{errorCount}</span> errors
</div>
<div>
<span style={{ color: 'var(--n8n-warning)' }}>{warningCount}</span> warnings
</div>
</div>
</Card>
<SeverityBar errorCount={errorCount} warningCount={warningCount} />
{errors.length > 0 && (
<Expandable title="Errors" count={errors.length} defaultOpen>
{errors.map((err, i) => (
<div key={i} style={{
padding: '8px',
marginBottom: '6px',
background: 'var(--n8n-error-light)',
borderRadius: '4px',
fontSize: '12px',
color: 'var(--n8n-error)',
}}>
{(err.type || err.node) && (
<div style={{ fontWeight: 600 }}>{err.type || err.node}</div>
)}
{err.property && <div style={{ opacity: 0.8 }}>Property: {err.property}</div>}
<div>{err.message}</div>
{err.fix && (
<div style={{ marginTop: '4px', fontStyle: 'italic', opacity: 0.9 }}>Fix: {err.fix}</div>
)}
</div>
))}
</Expandable>
)}
{nodeGroups ? (
nodeGroups.map((group) => (
<NodeGroupSection key={group.node} group={group} />
))
) : (
<>
{errors.length > 0 && (
<Expandable title="Errors" count={errors.length} defaultOpen>
{errors.map((err, i) => (
<IssueItem key={i} issue={err} variant="error" />
))}
</Expandable>
)}
{warnings.length > 0 && (
<Expandable title="Warnings" count={warnings.length}>
{warnings.map((warn, i) => (
<div key={i} style={{
padding: '8px',
marginBottom: '6px',
background: 'var(--n8n-warning-light)',
borderRadius: '4px',
fontSize: '12px',
color: 'var(--n8n-warning)',
}}>
{(warn.type || warn.node) && (
<div style={{ fontWeight: 600 }}>{warn.type || warn.node}</div>
)}
{warn.property && <div style={{ opacity: 0.8 }}>Property: {warn.property}</div>}
<div>{warn.message}</div>
</div>
))}
</Expandable>
{warnings.length > 0 && (
<Expandable title="Warnings" count={warnings.length}>
{warnings.map((warn, i) => (
<IssueItem key={i} issue={warn} variant="warning" />
))}
</Expandable>
)}
</>
)}
{suggestions.length > 0 && (
<Expandable title="Suggestions" count={suggestions.length}>
<ul style={{ paddingLeft: '16px', fontSize: '12px' }}>
{suggestions.map((suggestion, i) => (
<li key={i} style={{ padding: '2px 0', color: 'var(--n8n-info)' }}>{suggestion}</li>
<li key={i} style={{ padding: '2px 0', color: 'var(--n8n-info)' }}> {suggestion}</li>
))}
</ul>
</Expandable>

View File

@@ -0,0 +1,145 @@
import React from 'react';
import '@shared/styles/theme.css';
import { Badge } from '@shared/components';
import { useToolData } from '@shared/hooks/useToolData';
import type { WorkflowListData } from '@shared/types';
function formatDate(dateStr?: string): string {
if (!dateStr) return '';
try {
const d = new Date(dateStr);
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
} catch {
return dateStr;
}
}
export default function App() {
const { data, error, isConnected } = useToolData<WorkflowListData>();
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 workflows = data.data?.workflows ?? [];
const returned = data.data?.returned ?? workflows.length;
const hasMore = data.data?.hasMore;
return (
<div style={{ maxWidth: '480px' }}>
<div style={{
fontSize: '12px',
color: 'var(--color-text-secondary, var(--n8n-text-muted))',
marginBottom: '10px',
}}>
Showing {returned} workflow{returned !== 1 ? 's' : ''}
{hasMore && ' (more available)'}
</div>
<div style={{
border: '1px solid var(--n8n-border)',
borderRadius: 'var(--n8n-radius)',
overflow: 'hidden',
}}>
{/* Header row */}
<div style={{
display: 'grid',
gridTemplateColumns: '1fr 60px 50px auto',
gap: '8px',
padding: '8px 12px',
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>Name</span>
<span>Status</span>
<span>Nodes</span>
<span>Updated</span>
</div>
{workflows.length === 0 && (
<div style={{ padding: '16px', textAlign: 'center', color: 'var(--n8n-text-muted)', fontSize: '13px' }}>
No workflows found
</div>
)}
{workflows.map((wf) => (
<div
key={wf.id}
style={{
display: 'grid',
gridTemplateColumns: '1fr 60px 50px auto',
gap: '8px',
padding: '8px 12px',
fontSize: '12px',
borderBottom: '1px solid var(--n8n-border)',
opacity: wf.isArchived ? 0.5 : 1,
}}
>
<div style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' as const }}>
<span style={{ fontWeight: 500 }}>{wf.name}</span>
{wf.tags && wf.tags.length > 0 && (
<div style={{ display: 'flex', gap: '4px', marginTop: '2px', flexWrap: 'wrap' }}>
{wf.tags.slice(0, 3).map((tag, i) => (
<span key={i} style={{
fontSize: '10px',
padding: '1px 6px',
borderRadius: '8px',
background: 'var(--n8n-info-light)',
color: 'var(--n8n-info)',
}}>
{tag}
</span>
))}
{wf.tags.length > 3 && (
<span style={{ fontSize: '10px', color: 'var(--n8n-text-muted)' }}>+{wf.tags.length - 3}</span>
)}
</div>
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<span style={{
display: 'inline-block',
width: '8px',
height: '8px',
borderRadius: '50%',
background: wf.active ? 'var(--n8n-success)' : 'var(--n8n-border)',
flexShrink: 0,
}} />
<span style={{ fontSize: '11px', color: 'var(--color-text-secondary, var(--n8n-text-muted))' }}>
{wf.isArchived ? 'Archived' : wf.active ? 'Active' : 'Off'}
</span>
</div>
<span style={{ color: 'var(--color-text-secondary, var(--n8n-text-muted))' }}>
{wf.nodeCount ?? ''}
</span>
<span style={{ fontSize: '11px', color: 'var(--color-text-secondary, var(--n8n-text-muted))', whiteSpace: 'nowrap' as const }}>
{formatDate(wf.updatedAt)}
</span>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Workflow List</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,8 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
const root = document.getElementById('root');
if (root) {
createRoot(root).render(<App />);
}

View File

@@ -0,0 +1,54 @@
import React, { useState, useCallback } from 'react';
interface CopyButtonProps {
text: string;
}
export function CopyButton({ text }: CopyButtonProps) {
const [copied, setCopied] = useState(false);
const handleCopy = useCallback(async () => {
try {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// Fallback for sandboxed environments
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
}, [text]);
return (
<button
onClick={handleCopy}
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: '20px',
height: '20px',
padding: 0,
border: '1px solid var(--n8n-border)',
borderRadius: '4px',
background: 'transparent',
color: copied ? 'var(--n8n-success)' : 'var(--n8n-text-muted)',
cursor: 'pointer',
fontSize: '11px',
lineHeight: 1,
flexShrink: 0,
}}
title="Copy"
>
{copied ? '✓' : '⎘'}
</button>
);
}

View File

@@ -1,3 +1,4 @@
export { Card } from './Card';
export { Badge } from './Badge';
export { Expandable } from './Expandable';
export { CopyButton } from './CopyButton';

View File

@@ -1,22 +1,25 @@
import { useState, useCallback } from 'react';
import { useApp } from '@modelcontextprotocol/ext-apps/react';
import { useApp, useHostStyles } from '@modelcontextprotocol/ext-apps/react';
import type { App } from '@modelcontextprotocol/ext-apps/react';
interface UseToolDataResult<T> {
data: T | null;
error: string | null;
isConnected: boolean;
app: App | null;
toolName: string | null;
}
export function useToolData<T>(): UseToolDataResult<T> {
const [data, setData] = useState<T | null>(null);
const onAppCreated = useCallback((app: any) => {
app.ontoolresult = (result: any) => {
const onAppCreated = useCallback((app: App) => {
app.ontoolresult = (result) => {
if (result?.content) {
const textItem = Array.isArray(result.content)
? result.content.find((c: any) => c.type === 'text')
? result.content.find((c) => c.type === 'text')
: null;
if (textItem?.text) {
if (textItem && 'text' in textItem) {
try {
setData(JSON.parse(textItem.text) as T);
} catch {
@@ -27,15 +30,21 @@ export function useToolData<T>(): UseToolDataResult<T> {
};
}, []);
const { isConnected, error } = useApp({
const { app, isConnected, error } = useApp({
appInfo: { name: 'n8n-mcp-ui', version: '1.0.0' },
capabilities: {},
onAppCreated,
});
useHostStyles(app, app?.getHostContext());
const toolName = app?.getHostContext()?.toolInfo?.tool.name ?? null;
return {
data,
error: error?.message ?? null,
isConnected,
app,
toolName,
};
}

View File

@@ -1,21 +1,39 @@
:root {
/* n8n brand colors */
--n8n-primary: #ff6d5a;
--n8n-primary-light: #ff8a7a;
/* Semantic colors */
--n8n-success: #17bf79;
--n8n-success-light: #e8f9f0;
--n8n-warning: #f59e0b;
--n8n-warning-light: #fef3cd;
--n8n-error: #ef4444;
--n8n-error-light: #fee2e2;
--n8n-info: #3b82f6;
--n8n-info-light: #dbeafe;
/* Dark mode defaults (fallback when host vars unavailable) */
--n8n-bg: #1a1a2e;
--n8n-bg-card: #252540;
--n8n-text: #e0e0e0;
--n8n-text-muted: #9ca3af;
--n8n-border: #374151;
--n8n-error-light: #fee2e2;
--n8n-warning-light: #fef3cd;
--n8n-success-light: #e8f9f0;
--n8n-info-light: #dbeafe;
--n8n-radius: 8px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-family: var(--font-sans, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif);
}
[data-theme="light"] {
--n8n-bg: #ffffff;
--n8n-bg-card: #f9fafb;
--n8n-text: #1f2937;
--n8n-text-muted: #6b7280;
--n8n-border: #e5e7eb;
--n8n-error-light: #fef2f2;
--n8n-warning-light: #fffbeb;
--n8n-success-light: #f0fdf4;
--n8n-info-light: #eff6ff;
}
* {
@@ -25,8 +43,8 @@
}
body {
background: var(--n8n-bg);
color: var(--n8n-text);
background: var(--color-background-primary, var(--n8n-bg));
color: var(--color-text-primary, var(--n8n-text));
line-height: 1.5;
padding: 16px;
}

View File

@@ -10,6 +10,15 @@ export interface OperationResultData {
workflowName?: string;
deleted?: boolean;
operationsApplied?: number;
executionId?: string;
templateId?: string | number;
fixes?: unknown[];
fixesApplied?: number;
preview?: unknown;
triggerType?: string;
requiredCredentials?: string[];
autoFixStatus?: string;
url?: string;
[key: string]: unknown;
};
message?: string;
@@ -17,6 +26,8 @@ export interface OperationResultData {
details?: Record<string, unknown>;
}
export type OperationType = 'create' | 'update' | 'partial_update' | 'delete' | 'test' | 'autofix' | 'deploy';
export interface ValidationError {
type?: string;
property?: string;
@@ -34,6 +45,74 @@ export interface ValidationWarning {
details?: unknown;
}
// Workflow list response from n8n_list_workflows
export interface WorkflowListData {
success: boolean;
data?: {
workflows: {
id: string;
name: string;
active?: boolean;
isArchived?: boolean;
createdAt?: string;
updatedAt?: string;
tags?: string[];
nodeCount?: number;
}[];
returned?: number;
hasMore?: boolean;
nextCursor?: string;
};
message?: string;
error?: string;
}
// Execution history response from n8n_executions
export interface ExecutionHistoryData {
success: boolean;
data?: {
executions: {
id: string;
finished?: boolean;
mode?: string;
status?: string;
startedAt?: string;
stoppedAt?: string;
workflowId?: string;
workflowName?: string;
}[];
returned?: number;
hasMore?: boolean;
};
message?: string;
error?: string;
}
// Health check response from n8n_health_check
export interface HealthDashboardData {
success: boolean;
data?: {
status?: string;
instanceId?: string;
n8nVersion?: string;
mcpVersion?: string;
apiUrl?: string;
versionCheck?: {
current?: string;
latest?: string;
upToDate?: boolean;
updateCommand?: string;
};
performance?: {
responseTimeMs?: number;
cacheHitRate?: number;
};
nextSteps?: string[];
};
message?: string;
error?: string;
}
// Matches the validate_node / validate_workflow response format from server.ts
export interface ValidationSummaryData {
valid: boolean;