mirror of
https://github.com/czlonkowski/n8n-mcp.git
synced 2026-02-12 16:23:08 +00:00
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:
committed by
GitHub
parent
c601581714
commit
89146186d8
201
ui-apps/src/apps/execution-history/App.tsx
Normal file
201
ui-apps/src/apps/execution-history/App.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
12
ui-apps/src/apps/execution-history/index.html
Normal file
12
ui-apps/src/apps/execution-history/index.html
Normal 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>
|
||||
8
ui-apps/src/apps/execution-history/main.tsx
Normal file
8
ui-apps/src/apps/execution-history/main.tsx
Normal 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 />);
|
||||
}
|
||||
141
ui-apps/src/apps/health-dashboard/App.tsx
Normal file
141
ui-apps/src/apps/health-dashboard/App.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
12
ui-apps/src/apps/health-dashboard/index.html
Normal file
12
ui-apps/src/apps/health-dashboard/index.html
Normal 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>
|
||||
8
ui-apps/src/apps/health-dashboard/main.tsx
Normal file
8
ui-apps/src/apps/health-dashboard/main.tsx
Normal 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 />);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
145
ui-apps/src/apps/workflow-list/App.tsx
Normal file
145
ui-apps/src/apps/workflow-list/App.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
12
ui-apps/src/apps/workflow-list/index.html
Normal file
12
ui-apps/src/apps/workflow-list/index.html
Normal 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>
|
||||
8
ui-apps/src/apps/workflow-list/main.tsx
Normal file
8
ui-apps/src/apps/workflow-list/main.tsx
Normal 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 />);
|
||||
}
|
||||
54
ui-apps/src/shared/components/CopyButton.tsx
Normal file
54
ui-apps/src/shared/components/CopyButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export { Card } from './Card';
|
||||
export { Badge } from './Badge';
|
||||
export { Expandable } from './Expandable';
|
||||
export { CopyButton } from './CopyButton';
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user