mirror of
https://github.com/czlonkowski/n8n-mcp.git
synced 2026-02-09 06:43:08 +00:00
Compare commits
8 Commits
v2.34.0
...
feature/ui
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dfac65b291 | ||
|
|
cfb20a1cc6 | ||
|
|
c601581714 | ||
|
|
020bc3d43d | ||
|
|
a57b400bd0 | ||
|
|
38aa70261a | ||
|
|
1b328d8168 | ||
|
|
23b90d01a6 |
12
.github/workflows/release.yml
vendored
12
.github/workflows/release.yml
vendored
@@ -283,8 +283,8 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build project
|
||||
run: npm run build
|
||||
- name: Build project (server + UI apps)
|
||||
run: npm run build:all
|
||||
|
||||
# Database is already built and committed during development
|
||||
# Rebuilding here causes segfault due to memory pressure (exit code 139)
|
||||
@@ -322,8 +322,8 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build project
|
||||
run: npm run build
|
||||
- name: Build project (server + UI apps)
|
||||
run: npm run build:all
|
||||
|
||||
# Database is already built and committed during development
|
||||
- name: Verify database exists
|
||||
@@ -347,6 +347,8 @@ jobs:
|
||||
# Copy necessary files
|
||||
cp -r dist $PUBLISH_DIR/
|
||||
cp -r data $PUBLISH_DIR/
|
||||
mkdir -p $PUBLISH_DIR/ui-apps
|
||||
cp -r ui-apps/dist $PUBLISH_DIR/ui-apps/
|
||||
cp README.md $PUBLISH_DIR/
|
||||
cp LICENSE $PUBLISH_DIR/
|
||||
cp .env.example $PUBLISH_DIR/
|
||||
@@ -377,7 +379,7 @@ jobs:
|
||||
pkg.license = 'MIT';
|
||||
pkg.bugs = { url: 'https://github.com/czlonkowski/n8n-mcp/issues' };
|
||||
pkg.homepage = 'https://github.com/czlonkowski/n8n-mcp#readme';
|
||||
pkg.files = ['dist/**/*', 'data/nodes.db', '.env.example', 'README.md', 'LICENSE'];
|
||||
pkg.files = ['dist/**/*', 'ui-apps/dist/**/*', 'data/nodes.db', '.env.example', 'README.md', 'LICENSE'];
|
||||
delete pkg.private;
|
||||
require('fs').writeFileSync('./package.json', JSON.stringify(pkg, null, 2));
|
||||
"
|
||||
|
||||
59
CHANGELOG.md
59
CHANGELOG.md
@@ -7,6 +7,65 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [2.35.0] - 2026-02-09
|
||||
|
||||
### Added
|
||||
|
||||
- **3 new MCP Apps**: workflow-list (compact table with status/tags), execution-history (status summary bar + execution table), health-dashboard (connection status, versions, performance metrics)
|
||||
- **Enhanced operation-result**: operation-aware headers (create/update/delete/test/deploy), detail panels with workflow metadata, copy-to-clipboard for IDs/URLs, autofix diff viewer
|
||||
- **CopyButton shared component**: reusable clipboard button with visual feedback
|
||||
- **Local preview harness** (`ui-apps/preview.html`): test all 5 apps with mock data, dark/light theme toggle, JSON-RPC protocol simulation
|
||||
- **Expanded shared types**: TypeScript types for workflow-list, execution-history, and health-dashboard data
|
||||
|
||||
### Fixed
|
||||
|
||||
- **React hooks violation**: Fixed `useMemo` called after early returns in `execution-history/App.tsx` and `validation-summary/App.tsx`, causing React error #310 ("Rendered more hooks than during the previous render") and blank iframes
|
||||
- **JSON-RPC catch-all handler**: Preview harness responds to unknown SDK requests to prevent hangs
|
||||
|
||||
Conceived by Romuald Czlonkowski - https://www.aiadvisors.pl/en
|
||||
|
||||
## [2.34.5] - 2026-02-08
|
||||
|
||||
### Fixed
|
||||
|
||||
- **MCP Apps: Fix blank UI and wrong status badge in Claude**: Rewrote `useToolData` hook to use the official `useApp` hook from `@modelcontextprotocol/ext-apps/react` for proper lifecycle management. Updated UI types and components to match actual server response format (`success: boolean` instead of `status: string`, nested `data` object for workflow details). Validation summary now handles both direct and wrapped (`n8n_validate_workflow`) response shapes.
|
||||
|
||||
Conceived by Romuald Czlonkowski - https://www.aiadvisors.pl/en
|
||||
|
||||
## [2.34.3] - 2026-02-07
|
||||
|
||||
### Fixed
|
||||
|
||||
- **MCP Apps: Use correct MIME type for ext-apps spec**: Changed resource MIME type from `text/html` to `text/html;profile=mcp-app` (the `RESOURCE_MIME_TYPE` constant from `@modelcontextprotocol/ext-apps`). Without this profile parameter, Claude Desktop/web fails to recognize resources as MCP Apps and shows "Failed to load MCP App: the resource may exceed the 5 MB size limit."
|
||||
|
||||
Conceived by Romuald Czlonkowski - https://www.aiadvisors.pl/en
|
||||
|
||||
## [2.34.2] - 2026-02-07
|
||||
|
||||
### Fixed
|
||||
|
||||
- **CI: UI apps missing from npm package**: Release pipeline only ran `npm run build` (TypeScript), so `ui-apps/dist/` was never built and excluded from published packages
|
||||
- Changed build step to `npm run build:all` in `build-and-verify` and `publish-npm` jobs
|
||||
- Added `ui-apps/dist/` to npm publish staging directory
|
||||
- Added `ui-apps/dist/**/*` to published package files list
|
||||
|
||||
Conceived by Romuald Czlonkowski - https://www.aiadvisors.pl/en
|
||||
|
||||
## [2.34.1] - 2026-02-07
|
||||
|
||||
### Changed
|
||||
|
||||
- **MCP Apps: Align with official ext-apps spec** for Claude Desktop/web compatibility
|
||||
- URI scheme changed from `n8n-mcp://ui/{id}` to `ui://n8n-mcp/{id}` per MCP ext-apps spec
|
||||
- `_meta.ui.resourceUri` now set on tool definitions (`tools/list`) instead of tool call responses
|
||||
- `UIMetadata.ui.app` renamed to `UIMetadata.ui.resourceUri`
|
||||
- Added `_meta` field to `ToolDefinition` type
|
||||
- Added `UIAppRegistry.injectToolMeta()` method for enriching tool definitions
|
||||
- UI apps now use `@modelcontextprotocol/ext-apps` `App` class instead of `window.__MCP_DATA__`
|
||||
- Updated `ReadResource` URI parser to match new `ui://` scheme
|
||||
|
||||
Conceived by Romuald Czlonkowski - https://www.aiadvisors.pl/en
|
||||
|
||||
## [2.34.0] - 2026-02-07
|
||||
|
||||
### Added
|
||||
|
||||
2
dist/mcp/server.d.ts.map
vendored
2
dist/mcp/server.d.ts.map
vendored
@@ -1 +1 @@
|
||||
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../src/mcp/server.ts"],"names":[],"mappings":"AA0CA,OAAO,EAAE,eAAe,EAAE,MAAM,2BAA2B,CAAC;AAE5D,OAAO,EAAE,gBAAgB,EAAE,MAAM,iCAAiC,CAAC;AAmGnE,qBAAa,yBAAyB;IACpC,OAAO,CAAC,MAAM,CAAS;IACvB,OAAO,CAAC,EAAE,CAAgC;IAC1C,OAAO,CAAC,UAAU,CAA+B;IACjD,OAAO,CAAC,eAAe,CAAgC;IACvD,OAAO,CAAC,WAAW,CAAgB;IACnC,OAAO,CAAC,KAAK,CAAqB;IAClC,OAAO,CAAC,UAAU,CAAa;IAC/B,OAAO,CAAC,eAAe,CAAC,CAAkB;IAC1C,OAAO,CAAC,YAAY,CAAuB;IAC3C,OAAO,CAAC,qBAAqB,CAAsB;IACnD,OAAO,CAAC,WAAW,CAAiC;IACpD,OAAO,CAAC,kBAAkB,CAA4B;IACtD,OAAO,CAAC,iBAAiB,CAAkB;IAC3C,OAAO,CAAC,aAAa,CAAoC;IACzD,OAAO,CAAC,UAAU,CAAkB;gBAExB,eAAe,CAAC,EAAE,eAAe,EAAE,WAAW,CAAC,EAAE,gBAAgB;IAuGvE,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;YA+Cd,kBAAkB;YAiDlB,wBAAwB;IA0BtC,OAAO,CAAC,kBAAkB;YA6CZ,iBAAiB;IAa/B,OAAO,CAAC,eAAe,CAAkB;YAE3B,sBAAsB;IAgDpC,OAAO,CAAC,gBAAgB;IAqCxB,OAAO,CAAC,aAAa;IAmWrB,OAAO,CAAC,wBAAwB;IAoFhC,OAAO,CAAC,kBAAkB;IAqE1B,OAAO,CAAC,uBAAuB;IAwB/B,OAAO,CAAC,qBAAqB;YAoTf,SAAS;YA2DT,WAAW;YAkFX,WAAW;YA0CX,cAAc;YA8Md,gBAAgB;IAqD9B,OAAO,CAAC,mBAAmB;IAwE3B,OAAO,CAAC,eAAe;YAsBT,eAAe;IA2L7B,OAAO,CAAC,kBAAkB;IAQ1B,OAAO,CAAC,uBAAuB;IA0D/B,OAAO,CAAC,iBAAiB;YAqFX,WAAW;YAgCX,oBAAoB;IAuFlC,OAAO,CAAC,aAAa;YAQP,qBAAqB;YAwDrB,iBAAiB;YAiKjB,OAAO;YAgDP,cAAc;YAwFd,iBAAiB;IAqC/B,OAAO,CAAC,iBAAiB;IA0BzB,OAAO,CAAC,iBAAiB;IA0BzB,OAAO,CAAC,eAAe;IAwCvB,OAAO,CAAC,kBAAkB;IAiC1B,OAAO,CAAC,aAAa;IAoCrB,OAAO,CAAC,0BAA0B;IAgClC,OAAO,CAAC,4BAA4B;YAKtB,oBAAoB;IAsDlC,OAAO,CAAC,gBAAgB;YAiBV,SAAS;YA6CT,kBAAkB;YAqElB,uBAAuB;YAsDvB,iBAAiB;IAqE/B,OAAO,CAAC,qBAAqB;IA8C7B,OAAO,CAAC,uBAAuB;IA4D/B,OAAO,CAAC,wBAAwB;IAkChC,OAAO,CAAC,iBAAiB;YAoDX,mBAAmB;YAoEnB,qBAAqB;IAS7B,OAAO,CAAC,SAAS,EAAE,GAAG,GAAG,OAAO,CAAC,IAAI,CAAC;YAS9B,aAAa;YAcb,iBAAiB;YAoBjB,WAAW;YAwBX,eAAe;YAqBf,mBAAmB;YAwBnB,yBAAyB;IA4CvC,OAAO,CAAC,kBAAkB;YAiBZ,gBAAgB;YA6HhB,2BAA2B;YAiE3B,2BAA2B;IAyEnC,GAAG,IAAI,OAAO,CAAC,IAAI,CAAC;IA0BpB,QAAQ,IAAI,OAAO,CAAC,IAAI,CAAC;CAgEhC"}
|
||||
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../src/mcp/server.ts"],"names":[],"mappings":"AA0CA,OAAO,EAAE,eAAe,EAAE,MAAM,2BAA2B,CAAC;AAE5D,OAAO,EAAE,gBAAgB,EAAE,MAAM,iCAAiC,CAAC;AAmGnE,qBAAa,yBAAyB;IACpC,OAAO,CAAC,MAAM,CAAS;IACvB,OAAO,CAAC,EAAE,CAAgC;IAC1C,OAAO,CAAC,UAAU,CAA+B;IACjD,OAAO,CAAC,eAAe,CAAgC;IACvD,OAAO,CAAC,WAAW,CAAgB;IACnC,OAAO,CAAC,KAAK,CAAqB;IAClC,OAAO,CAAC,UAAU,CAAa;IAC/B,OAAO,CAAC,eAAe,CAAC,CAAkB;IAC1C,OAAO,CAAC,YAAY,CAAuB;IAC3C,OAAO,CAAC,qBAAqB,CAAsB;IACnD,OAAO,CAAC,WAAW,CAAiC;IACpD,OAAO,CAAC,kBAAkB,CAA4B;IACtD,OAAO,CAAC,iBAAiB,CAAkB;IAC3C,OAAO,CAAC,aAAa,CAAoC;IACzD,OAAO,CAAC,UAAU,CAAkB;gBAExB,eAAe,CAAC,EAAE,eAAe,EAAE,WAAW,CAAC,EAAE,gBAAgB;IAuGvE,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;YA+Cd,kBAAkB;YAiDlB,wBAAwB;IA0BtC,OAAO,CAAC,kBAAkB;YA6CZ,iBAAiB;IAa/B,OAAO,CAAC,eAAe,CAAkB;YAE3B,sBAAsB;IAgDpC,OAAO,CAAC,gBAAgB;IAqCxB,OAAO,CAAC,aAAa;IA8VrB,OAAO,CAAC,wBAAwB;IAoFhC,OAAO,CAAC,kBAAkB;IAqE1B,OAAO,CAAC,uBAAuB;IAwB/B,OAAO,CAAC,qBAAqB;YAoTf,SAAS;YA2DT,WAAW;YAkFX,WAAW;YA0CX,cAAc;YA8Md,gBAAgB;IAqD9B,OAAO,CAAC,mBAAmB;IAwE3B,OAAO,CAAC,eAAe;YAsBT,eAAe;IA2L7B,OAAO,CAAC,kBAAkB;IAQ1B,OAAO,CAAC,uBAAuB;IA0D/B,OAAO,CAAC,iBAAiB;YAqFX,WAAW;YAgCX,oBAAoB;IAuFlC,OAAO,CAAC,aAAa;YAQP,qBAAqB;YAwDrB,iBAAiB;YAiKjB,OAAO;YAgDP,cAAc;YAwFd,iBAAiB;IAqC/B,OAAO,CAAC,iBAAiB;IA0BzB,OAAO,CAAC,iBAAiB;IA0BzB,OAAO,CAAC,eAAe;IAwCvB,OAAO,CAAC,kBAAkB;IAiC1B,OAAO,CAAC,aAAa;IAoCrB,OAAO,CAAC,0BAA0B;IAgClC,OAAO,CAAC,4BAA4B;YAKtB,oBAAoB;IAsDlC,OAAO,CAAC,gBAAgB;YAiBV,SAAS;YA6CT,kBAAkB;YAqElB,uBAAuB;YAsDvB,iBAAiB;IAqE/B,OAAO,CAAC,qBAAqB;IA8C7B,OAAO,CAAC,uBAAuB;IA4D/B,OAAO,CAAC,wBAAwB;IAkChC,OAAO,CAAC,iBAAiB;YAoDX,mBAAmB;YAoEnB,qBAAqB;IAS7B,OAAO,CAAC,SAAS,EAAE,GAAG,GAAG,OAAO,CAAC,IAAI,CAAC;YAS9B,aAAa;YAcb,iBAAiB;YAoBjB,WAAW;YAwBX,eAAe;YAqBf,mBAAmB;YAwBnB,yBAAyB;IA4CvC,OAAO,CAAC,kBAAkB;YAiBZ,gBAAgB;YA6HhB,2BAA2B;YAiE3B,2BAA2B;IAyEnC,GAAG,IAAI,OAAO,CAAC,IAAI,CAAC;IA0BpB,QAAQ,IAAI,OAAO,CAAC,IAAI,CAAC;CAgEhC"}
|
||||
7
dist/mcp/server.js
vendored
7
dist/mcp/server.js
vendored
@@ -427,6 +427,7 @@ class N8NDocumentationMCPServer {
|
||||
description: tool.description
|
||||
});
|
||||
});
|
||||
ui_1.UIAppRegistry.injectToolMeta(tools);
|
||||
return { tools };
|
||||
});
|
||||
this.server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
||||
@@ -529,10 +530,6 @@ class N8NDocumentationMCPServer {
|
||||
if (name.startsWith('validate_') && structuredContent !== null) {
|
||||
mcpResponse.structuredContent = structuredContent;
|
||||
}
|
||||
const uiApp = ui_1.UIAppRegistry.getAppForTool(name);
|
||||
if (uiApp && uiApp.html) {
|
||||
mcpResponse._meta = { ui: { app: uiApp.config.uri } };
|
||||
}
|
||||
return mcpResponse;
|
||||
}
|
||||
catch (error) {
|
||||
@@ -585,7 +582,7 @@ class N8NDocumentationMCPServer {
|
||||
});
|
||||
this.server.setRequestHandler(types_js_1.ReadResourceRequestSchema, async (request) => {
|
||||
const uri = request.params.uri;
|
||||
const match = uri.match(/^n8n-mcp:\/\/ui\/(.+)$/);
|
||||
const match = uri.match(/^ui:\/\/n8n-mcp\/(.+)$/);
|
||||
if (!match) {
|
||||
throw new Error(`Unknown resource URI: ${uri}`);
|
||||
}
|
||||
|
||||
2
dist/mcp/server.js.map
vendored
2
dist/mcp/server.js.map
vendored
File diff suppressed because one or more lines are too long
5
dist/types/index.d.ts
vendored
5
dist/types/index.d.ts
vendored
@@ -30,6 +30,11 @@ export interface ToolDefinition {
|
||||
additionalProperties?: boolean | Record<string, any>;
|
||||
};
|
||||
annotations?: ToolAnnotations;
|
||||
_meta?: {
|
||||
ui?: {
|
||||
resourceUri?: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
export interface ResourceDefinition {
|
||||
uri: string;
|
||||
|
||||
2
dist/types/index.d.ts.map
vendored
2
dist/types/index.d.ts.map
vendored
@@ -1 +1 @@
|
||||
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/types/index.ts"],"names":[],"mappings":"AACA,cAAc,cAAc,CAAC;AAC7B,cAAc,mBAAmB,CAAC;AAClC,cAAc,oBAAoB,CAAC;AACnC,cAAc,iBAAiB,CAAC;AAEhC,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAMD,MAAM,WAAW,eAAe;IAE9B,KAAK,CAAC,EAAE,MAAM,CAAC;IAEf,YAAY,CAAC,EAAE,OAAO,CAAC;IAEvB,eAAe,CAAC,EAAE,OAAO,CAAC;IAE1B,cAAc,CAAC,EAAE,OAAO,CAAC;IAEzB,aAAa,CAAC,EAAE,OAAO,CAAC;CACzB;AAED,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE;QACX,IAAI,EAAE,MAAM,CAAC;QACb,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;QAChC,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;QACpB,oBAAoB,CAAC,EAAE,OAAO,GAAG,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;KACtD,CAAC;IACF,YAAY,CAAC,EAAE;QACb,IAAI,EAAE,MAAM,CAAC;QACb,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;QAChC,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;QACpB,oBAAoB,CAAC,EAAE,OAAO,GAAG,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;KACtD,CAAC;IAEF,WAAW,CAAC,EAAE,eAAe,CAAC;CAC/B;AAED,MAAM,WAAW,kBAAkB;IACjC,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,gBAAgB;IAC/B,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,SAAS,CAAC,EAAE,KAAK,CAAC;QAChB,IAAI,EAAE,MAAM,CAAC;QACb,WAAW,CAAC,EAAE,MAAM,CAAC;QACrB,QAAQ,CAAC,EAAE,OAAO,CAAC;KACpB,CAAC,CAAC;CACJ"}
|
||||
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/types/index.ts"],"names":[],"mappings":"AACA,cAAc,cAAc,CAAC;AAC7B,cAAc,mBAAmB,CAAC;AAClC,cAAc,oBAAoB,CAAC;AACnC,cAAc,iBAAiB,CAAC;AAEhC,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAMD,MAAM,WAAW,eAAe;IAE9B,KAAK,CAAC,EAAE,MAAM,CAAC;IAEf,YAAY,CAAC,EAAE,OAAO,CAAC;IAEvB,eAAe,CAAC,EAAE,OAAO,CAAC;IAE1B,cAAc,CAAC,EAAE,OAAO,CAAC;IAEzB,aAAa,CAAC,EAAE,OAAO,CAAC;CACzB;AAED,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE;QACX,IAAI,EAAE,MAAM,CAAC;QACb,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;QAChC,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;QACpB,oBAAoB,CAAC,EAAE,OAAO,GAAG,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;KACtD,CAAC;IACF,YAAY,CAAC,EAAE;QACb,IAAI,EAAE,MAAM,CAAC;QACb,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;QAChC,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;QACpB,oBAAoB,CAAC,EAAE,OAAO,GAAG,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;KACtD,CAAC;IAEF,WAAW,CAAC,EAAE,eAAe,CAAC;IAC9B,KAAK,CAAC,EAAE;QACN,EAAE,CAAC,EAAE;YACH,WAAW,CAAC,EAAE,MAAM,CAAC;SACtB,CAAC;KACH,CAAC;CACH;AAED,MAAM,WAAW,kBAAkB;IACjC,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,gBAAgB;IAC/B,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,SAAS,CAAC,EAAE,KAAK,CAAC;QAChB,IAAI,EAAE,MAAM,CAAC;QACb,WAAW,CAAC,EAAE,MAAM,CAAC;QACrB,QAAQ,CAAC,EAAE,OAAO,CAAC;KACpB,CAAC,CAAC;CACJ"}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "n8n-mcp",
|
||||
"version": "2.34.0",
|
||||
"version": "2.35.0",
|
||||
"description": "Integration between n8n workflow automation and Model Context Protocol (MCP)",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
|
||||
@@ -651,6 +651,7 @@ export class N8NDocumentationMCPServer {
|
||||
});
|
||||
});
|
||||
|
||||
UIAppRegistry.injectToolMeta(tools);
|
||||
return { tools };
|
||||
});
|
||||
|
||||
@@ -781,12 +782,6 @@ export class N8NDocumentationMCPServer {
|
||||
mcpResponse.structuredContent = structuredContent;
|
||||
}
|
||||
|
||||
// Inject UI app metadata if available
|
||||
const uiApp = UIAppRegistry.getAppForTool(name);
|
||||
if (uiApp && uiApp.html) {
|
||||
mcpResponse._meta = { ui: { app: uiApp.config.uri } };
|
||||
}
|
||||
|
||||
return mcpResponse;
|
||||
} catch (error) {
|
||||
logger.error(`Error executing tool ${name}`, error);
|
||||
@@ -857,8 +852,8 @@ export class N8NDocumentationMCPServer {
|
||||
// Handle ReadResource for UI apps
|
||||
this.server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
|
||||
const uri = request.params.uri;
|
||||
// Parse n8n-mcp://ui/{id} pattern
|
||||
const match = uri.match(/^n8n-mcp:\/\/ui\/(.+)$/);
|
||||
// Parse ui://n8n-mcp/{id} pattern
|
||||
const match = uri.match(/^ui:\/\/n8n-mcp\/(.+)$/);
|
||||
if (!match) {
|
||||
throw new Error(`Unknown resource URI: ${uri}`);
|
||||
}
|
||||
|
||||
@@ -5,8 +5,8 @@ export const UI_APP_CONFIGS: UIAppConfig[] = [
|
||||
id: 'operation-result',
|
||||
displayName: 'Operation Result',
|
||||
description: 'Visual summary of workflow operations (create, update, delete, test)',
|
||||
uri: 'n8n-mcp://ui/operation-result',
|
||||
mimeType: 'text/html',
|
||||
uri: 'ui://n8n-mcp/operation-result',
|
||||
mimeType: 'text/html;profile=mcp-app',
|
||||
toolPatterns: [
|
||||
'n8n_create_workflow',
|
||||
'n8n_update_full_workflow',
|
||||
@@ -21,12 +21,42 @@ export const UI_APP_CONFIGS: UIAppConfig[] = [
|
||||
id: 'validation-summary',
|
||||
displayName: 'Validation Summary',
|
||||
description: 'Visual summary of node and workflow validation results',
|
||||
uri: 'n8n-mcp://ui/validation-summary',
|
||||
mimeType: 'text/html',
|
||||
uri: 'ui://n8n-mcp/validation-summary',
|
||||
mimeType: 'text/html;profile=mcp-app',
|
||||
toolPatterns: [
|
||||
'validate_node',
|
||||
'validate_workflow',
|
||||
'n8n_validate_workflow',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'workflow-list',
|
||||
displayName: 'Workflow List',
|
||||
description: 'Compact table of workflows with status, tags, and metadata',
|
||||
uri: 'ui://n8n-mcp/workflow-list',
|
||||
mimeType: 'text/html;profile=mcp-app',
|
||||
toolPatterns: [
|
||||
'n8n_list_workflows',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'execution-history',
|
||||
displayName: 'Execution History',
|
||||
description: 'Execution history table with status summary bar',
|
||||
uri: 'ui://n8n-mcp/execution-history',
|
||||
mimeType: 'text/html;profile=mcp-app',
|
||||
toolPatterns: [
|
||||
'n8n_executions',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'health-dashboard',
|
||||
displayName: 'Health Dashboard',
|
||||
description: 'Connection status, versions, and performance metrics',
|
||||
uri: 'ui://n8n-mcp/health-dashboard',
|
||||
mimeType: 'text/html;profile=mcp-app',
|
||||
toolPatterns: [
|
||||
'n8n_health_check',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -60,6 +60,21 @@ export class UIAppRegistry {
|
||||
return Array.from(this.entries.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Enrich tool definitions with _meta.ui.resourceUri for tools that have
|
||||
* a matching UI app. Per MCP ext-apps spec, this goes on the tool
|
||||
* definition (tools/list), not the tool call response.
|
||||
*/
|
||||
static injectToolMeta(tools: Array<{ name: string; [key: string]: any }>): void {
|
||||
if (!this.loaded) return;
|
||||
for (const tool of tools) {
|
||||
const entry = this.toolIndex.get(tool.name);
|
||||
if (entry && entry.html) {
|
||||
tool._meta = { ui: { resourceUri: entry.config.uri } };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Reset registry state. Intended for testing only. */
|
||||
static reset(): void {
|
||||
this.entries.clear();
|
||||
|
||||
@@ -13,7 +13,7 @@ export interface UIAppConfig {
|
||||
|
||||
export interface UIMetadata {
|
||||
ui: {
|
||||
app: string;
|
||||
resourceUri: string;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -44,6 +44,11 @@ export interface ToolDefinition {
|
||||
};
|
||||
/** Tool behavior hints for AI assistants */
|
||||
annotations?: ToolAnnotations;
|
||||
_meta?: {
|
||||
ui?: {
|
||||
resourceUri?: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface ResourceDefinition {
|
||||
|
||||
@@ -27,9 +27,9 @@ describe('UI_APP_CONFIGS', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('should have URIs following n8n-mcp://ui/{id} pattern', () => {
|
||||
it('should have URIs following ui://n8n-mcp/{id} pattern', () => {
|
||||
for (const config of UI_APP_CONFIGS) {
|
||||
expect(config.uri).toBe(`n8n-mcp://ui/${config.id}`);
|
||||
expect(config.uri).toBe(`ui://n8n-mcp/${config.id}`);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -65,15 +65,15 @@ describe('UI_APP_CONFIGS', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('should have consistent mimeType of text/html', () => {
|
||||
it('should have consistent mimeType of text/html;profile=mcp-app', () => {
|
||||
for (const config of UI_APP_CONFIGS) {
|
||||
expect(config.mimeType).toBe('text/html');
|
||||
expect(config.mimeType).toBe('text/html;profile=mcp-app');
|
||||
}
|
||||
});
|
||||
|
||||
it('should have URIs that start with the n8n-mcp://ui/ scheme', () => {
|
||||
it('should have URIs that start with the ui://n8n-mcp/ scheme', () => {
|
||||
for (const config of UI_APP_CONFIGS) {
|
||||
expect(config.uri).toMatch(/^n8n-mcp:\/\/ui\//);
|
||||
expect(config.uri).toMatch(/^ui:\/\/n8n-mcp\//);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -98,8 +98,29 @@ describe('UI_APP_CONFIGS', () => {
|
||||
expect(config!.toolPatterns).toContain('n8n_validate_workflow');
|
||||
});
|
||||
|
||||
it('should have exactly 2 configs', () => {
|
||||
expect(UI_APP_CONFIGS.length).toBe(2);
|
||||
it('should have exactly 5 configs', () => {
|
||||
expect(UI_APP_CONFIGS.length).toBe(5);
|
||||
});
|
||||
|
||||
it('should contain the workflow-list config', () => {
|
||||
const config = UI_APP_CONFIGS.find(c => c.id === 'workflow-list');
|
||||
expect(config).toBeDefined();
|
||||
expect(config!.displayName).toBe('Workflow List');
|
||||
expect(config!.toolPatterns).toContain('n8n_list_workflows');
|
||||
});
|
||||
|
||||
it('should contain the execution-history config', () => {
|
||||
const config = UI_APP_CONFIGS.find(c => c.id === 'execution-history');
|
||||
expect(config).toBeDefined();
|
||||
expect(config!.displayName).toBe('Execution History');
|
||||
expect(config!.toolPatterns).toContain('n8n_executions');
|
||||
});
|
||||
|
||||
it('should contain the health-dashboard config', () => {
|
||||
const config = UI_APP_CONFIGS.find(c => c.id === 'health-dashboard');
|
||||
expect(config).toBeDefined();
|
||||
expect(config!.displayName).toBe('Health Dashboard');
|
||||
expect(config!.toolPatterns).toContain('n8n_health_check');
|
||||
});
|
||||
|
||||
it('should have IDs that are valid URI path segments (no spaces or special chars)', () => {
|
||||
|
||||
@@ -11,7 +11,7 @@ import { existsSync, readFileSync } from 'fs';
|
||||
const mockExistsSync = vi.mocked(existsSync);
|
||||
const mockReadFileSync = vi.mocked(readFileSync);
|
||||
|
||||
describe('UI Meta Injection Logic', () => {
|
||||
describe('UI Meta Injection on Tool Definitions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
UIAppRegistry.reset();
|
||||
@@ -24,74 +24,69 @@ describe('UI Meta Injection Logic', () => {
|
||||
UIAppRegistry.load();
|
||||
});
|
||||
|
||||
it('should add _meta.ui for matching tools', () => {
|
||||
const uiApp = UIAppRegistry.getAppForTool('n8n_create_workflow');
|
||||
expect(uiApp).not.toBeNull();
|
||||
expect(uiApp!.html).not.toBeNull();
|
||||
it('should add _meta.ui.resourceUri to matching tool definitions', () => {
|
||||
const tools: any[] = [
|
||||
{ name: 'n8n_create_workflow', description: 'Create workflow', inputSchema: { type: 'object', properties: {} } },
|
||||
];
|
||||
|
||||
// Simulate the injection logic from server.ts
|
||||
const mcpResponse: any = {
|
||||
content: [{ type: 'text', text: 'result' }],
|
||||
};
|
||||
UIAppRegistry.injectToolMeta(tools);
|
||||
|
||||
if (uiApp && uiApp.html) {
|
||||
mcpResponse._meta = { ui: { app: uiApp.config.uri } };
|
||||
}
|
||||
|
||||
expect(mcpResponse._meta).toBeDefined();
|
||||
expect(mcpResponse._meta.ui.app).toBe('n8n-mcp://ui/operation-result');
|
||||
expect(tools[0]._meta).toBeDefined();
|
||||
expect(tools[0]._meta.ui.resourceUri).toBe('ui://n8n-mcp/operation-result');
|
||||
});
|
||||
|
||||
it('should add _meta.ui for validation tools', () => {
|
||||
const uiApp = UIAppRegistry.getAppForTool('validate_workflow');
|
||||
expect(uiApp).not.toBeNull();
|
||||
it('should add _meta.ui.resourceUri to validation tool definitions', () => {
|
||||
const tools: any[] = [
|
||||
{ name: 'validate_workflow', description: 'Validate', inputSchema: { type: 'object', properties: {} } },
|
||||
];
|
||||
|
||||
const mcpResponse: any = {
|
||||
content: [{ type: 'text', text: 'validation result' }],
|
||||
};
|
||||
UIAppRegistry.injectToolMeta(tools);
|
||||
|
||||
if (uiApp && uiApp.html) {
|
||||
mcpResponse._meta = { ui: { app: uiApp.config.uri } };
|
||||
}
|
||||
|
||||
expect(mcpResponse._meta).toBeDefined();
|
||||
expect(mcpResponse._meta.ui.app).toBe('n8n-mcp://ui/validation-summary');
|
||||
expect(tools[0]._meta).toBeDefined();
|
||||
expect(tools[0]._meta.ui.resourceUri).toBe('ui://n8n-mcp/validation-summary');
|
||||
});
|
||||
|
||||
it('should NOT add _meta.ui for non-matching tools', () => {
|
||||
const uiApp = UIAppRegistry.getAppForTool('get_node_info');
|
||||
expect(uiApp).toBeNull();
|
||||
it('should NOT add _meta to non-matching tool definitions', () => {
|
||||
const tools: any[] = [
|
||||
{ name: 'get_node_info', description: 'Get info', inputSchema: { type: 'object', properties: {} } },
|
||||
];
|
||||
|
||||
const mcpResponse: any = {
|
||||
content: [{ type: 'text', text: 'node info' }],
|
||||
};
|
||||
UIAppRegistry.injectToolMeta(tools);
|
||||
|
||||
if (uiApp && uiApp.html) {
|
||||
mcpResponse._meta = { ui: { app: uiApp.config.uri } };
|
||||
}
|
||||
|
||||
expect(mcpResponse._meta).toBeUndefined();
|
||||
expect(tools[0]._meta).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should produce _meta with exact shape { ui: { app: string } }', () => {
|
||||
const uiApp = UIAppRegistry.getAppForTool('n8n_create_workflow')!;
|
||||
const meta = { ui: { app: uiApp.config.uri } };
|
||||
it('should inject _meta on matching tools and skip non-matching in a mixed list', () => {
|
||||
const tools: any[] = [
|
||||
{ name: 'n8n_create_workflow', description: 'Create', inputSchema: { type: 'object', properties: {} } },
|
||||
{ name: 'get_node_info', description: 'Info', inputSchema: { type: 'object', properties: {} } },
|
||||
{ name: 'validate_node', description: 'Validate', inputSchema: { type: 'object', properties: {} } },
|
||||
];
|
||||
|
||||
expect(meta).toEqual({
|
||||
UIAppRegistry.injectToolMeta(tools);
|
||||
|
||||
expect(tools[0]._meta).toBeDefined();
|
||||
expect(tools[0]._meta.ui.resourceUri).toBe('ui://n8n-mcp/operation-result');
|
||||
expect(tools[1]._meta).toBeUndefined();
|
||||
expect(tools[2]._meta).toBeDefined();
|
||||
expect(tools[2]._meta.ui.resourceUri).toBe('ui://n8n-mcp/validation-summary');
|
||||
});
|
||||
|
||||
it('should produce _meta with exact shape { ui: { resourceUri: string } }', () => {
|
||||
const tools: any[] = [
|
||||
{ name: 'n8n_create_workflow', description: 'Create', inputSchema: { type: 'object', properties: {} } },
|
||||
];
|
||||
|
||||
UIAppRegistry.injectToolMeta(tools);
|
||||
|
||||
expect(tools[0]._meta).toEqual({
|
||||
ui: {
|
||||
app: 'n8n-mcp://ui/operation-result',
|
||||
resourceUri: 'ui://n8n-mcp/operation-result',
|
||||
},
|
||||
});
|
||||
expect(Object.keys(meta)).toEqual(['ui']);
|
||||
expect(Object.keys(meta.ui)).toEqual(['app']);
|
||||
expect(typeof meta.ui.app).toBe('string');
|
||||
});
|
||||
|
||||
it('should produce _meta.ui.app that matches the config uri', () => {
|
||||
const uiApp = UIAppRegistry.getAppForTool('validate_node')!;
|
||||
const meta = { ui: { app: uiApp.config.uri } };
|
||||
expect(meta.ui.app).toBe(uiApp.config.uri);
|
||||
expect(meta.ui.app).toBe('n8n-mcp://ui/validation-summary');
|
||||
expect(Object.keys(tools[0]._meta)).toEqual(['ui']);
|
||||
expect(Object.keys(tools[0]._meta.ui)).toEqual(['resourceUri']);
|
||||
expect(typeof tools[0]._meta.ui.resourceUri).toBe('string');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -101,114 +96,50 @@ describe('UI Meta Injection Logic', () => {
|
||||
UIAppRegistry.load();
|
||||
});
|
||||
|
||||
it('should NOT add _meta.ui even for matching tools', () => {
|
||||
const uiApp = UIAppRegistry.getAppForTool('n8n_create_workflow');
|
||||
expect(uiApp).not.toBeNull();
|
||||
expect(uiApp!.html).toBeNull();
|
||||
it('should NOT add _meta even for matching tools', () => {
|
||||
const tools: any[] = [
|
||||
{ name: 'n8n_create_workflow', description: 'Create', inputSchema: { type: 'object', properties: {} } },
|
||||
];
|
||||
|
||||
const mcpResponse: any = {
|
||||
content: [{ type: 'text', text: 'result' }],
|
||||
};
|
||||
UIAppRegistry.injectToolMeta(tools);
|
||||
|
||||
if (uiApp && uiApp.html) {
|
||||
mcpResponse._meta = { ui: { app: uiApp.config.uri } };
|
||||
}
|
||||
|
||||
expect(mcpResponse._meta).toBeUndefined();
|
||||
expect(tools[0]._meta).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should NOT add _meta.ui for validation tools without HTML', () => {
|
||||
const uiApp = UIAppRegistry.getAppForTool('validate_node');
|
||||
expect(uiApp).not.toBeNull();
|
||||
expect(uiApp!.html).toBeNull();
|
||||
it('should NOT add _meta for validation tools without HTML', () => {
|
||||
const tools: any[] = [
|
||||
{ name: 'validate_node', description: 'Validate', inputSchema: { type: 'object', properties: {} } },
|
||||
];
|
||||
|
||||
const mcpResponse: any = {
|
||||
content: [{ type: 'text', text: 'result' }],
|
||||
};
|
||||
UIAppRegistry.injectToolMeta(tools);
|
||||
|
||||
if (uiApp && uiApp.html) {
|
||||
mcpResponse._meta = { ui: { app: uiApp.config.uri } };
|
||||
}
|
||||
|
||||
expect(mcpResponse._meta).toBeUndefined();
|
||||
expect(tools[0]._meta).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when registry has not been loaded at all', () => {
|
||||
it('should NOT add _meta because getAppForTool returns null', () => {
|
||||
// Registry never loaded - reset() was called in beforeEach
|
||||
const uiApp = UIAppRegistry.getAppForTool('n8n_create_workflow');
|
||||
expect(uiApp).toBeNull();
|
||||
it('should NOT add _meta because registry is not loaded', () => {
|
||||
const tools: any[] = [
|
||||
{ name: 'n8n_create_workflow', description: 'Create', inputSchema: { type: 'object', properties: {} } },
|
||||
];
|
||||
|
||||
const mcpResponse: any = {
|
||||
content: [{ type: 'text', text: 'result' }],
|
||||
};
|
||||
UIAppRegistry.injectToolMeta(tools);
|
||||
|
||||
if (uiApp && uiApp.html) {
|
||||
mcpResponse._meta = { ui: { app: uiApp.config.uri } };
|
||||
}
|
||||
|
||||
expect(mcpResponse._meta).toBeUndefined();
|
||||
expect(tools[0]._meta).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('coexistence with structuredContent', () => {
|
||||
describe('empty tool list', () => {
|
||||
beforeEach(() => {
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReadFileSync.mockReturnValue('<html>ui</html>');
|
||||
UIAppRegistry.load();
|
||||
});
|
||||
|
||||
it('should coexist with structuredContent on the response', () => {
|
||||
const uiApp = UIAppRegistry.getAppForTool('n8n_create_workflow');
|
||||
|
||||
const mcpResponse: any = {
|
||||
content: [{ type: 'text', text: 'result' }],
|
||||
structuredContent: { workflowId: '123', status: 'created' },
|
||||
};
|
||||
|
||||
if (uiApp && uiApp.html) {
|
||||
mcpResponse._meta = { ui: { app: uiApp.config.uri } };
|
||||
}
|
||||
|
||||
expect(mcpResponse.structuredContent).toBeDefined();
|
||||
expect(mcpResponse.structuredContent.workflowId).toBe('123');
|
||||
expect(mcpResponse._meta).toBeDefined();
|
||||
expect(mcpResponse._meta.ui.app).toBe('n8n-mcp://ui/operation-result');
|
||||
});
|
||||
|
||||
it('should not overwrite existing _meta properties when merging', () => {
|
||||
const uiApp = UIAppRegistry.getAppForTool('n8n_create_workflow');
|
||||
|
||||
const mcpResponse: any = {
|
||||
content: [{ type: 'text', text: 'result' }],
|
||||
_meta: { existingProp: 'value' },
|
||||
};
|
||||
|
||||
if (uiApp && uiApp.html) {
|
||||
mcpResponse._meta = { ...mcpResponse._meta, ui: { app: uiApp.config.uri } };
|
||||
}
|
||||
|
||||
expect(mcpResponse._meta.existingProp).toBe('value');
|
||||
expect(mcpResponse._meta.ui.app).toBe('n8n-mcp://ui/operation-result');
|
||||
});
|
||||
|
||||
it('should work with responses that have both structuredContent and existing _meta', () => {
|
||||
const uiApp = UIAppRegistry.getAppForTool('validate_workflow');
|
||||
|
||||
const mcpResponse: any = {
|
||||
content: [{ type: 'text', text: 'validation ok' }],
|
||||
structuredContent: { valid: true, errors: [] },
|
||||
_meta: { timing: 42 },
|
||||
};
|
||||
|
||||
if (uiApp && uiApp.html) {
|
||||
mcpResponse._meta = { ...mcpResponse._meta, ui: { app: uiApp.config.uri } };
|
||||
}
|
||||
|
||||
expect(mcpResponse.structuredContent.valid).toBe(true);
|
||||
expect(mcpResponse._meta.timing).toBe(42);
|
||||
expect(mcpResponse._meta.ui.app).toBe('n8n-mcp://ui/validation-summary');
|
||||
it('should handle an empty tools array without error', () => {
|
||||
const tools: any[] = [];
|
||||
UIAppRegistry.injectToolMeta(tools);
|
||||
expect(tools.length).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -287,6 +287,75 @@ describe('UIAppRegistry', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('injectToolMeta()', () => {
|
||||
it('should not modify tools before load() is called', () => {
|
||||
const tools: any[] = [
|
||||
{ name: 'n8n_create_workflow', description: 'Create', inputSchema: { type: 'object', properties: {} } },
|
||||
];
|
||||
UIAppRegistry.injectToolMeta(tools);
|
||||
expect(tools[0]._meta).toBeUndefined();
|
||||
});
|
||||
|
||||
describe('after loading with HTML', () => {
|
||||
beforeEach(() => {
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReadFileSync.mockReturnValue('<html>loaded</html>');
|
||||
UIAppRegistry.load();
|
||||
});
|
||||
|
||||
it('should set _meta.ui.resourceUri on matching operation tools', () => {
|
||||
const tools: any[] = [
|
||||
{ name: 'n8n_create_workflow', description: 'Create', inputSchema: { type: 'object', properties: {} } },
|
||||
];
|
||||
UIAppRegistry.injectToolMeta(tools);
|
||||
expect(tools[0]._meta).toEqual({ ui: { resourceUri: 'ui://n8n-mcp/operation-result' } });
|
||||
});
|
||||
|
||||
it('should set _meta.ui.resourceUri on matching validation tools', () => {
|
||||
const tools: any[] = [
|
||||
{ name: 'validate_node', description: 'Validate', inputSchema: { type: 'object', properties: {} } },
|
||||
];
|
||||
UIAppRegistry.injectToolMeta(tools);
|
||||
expect(tools[0]._meta).toEqual({ ui: { resourceUri: 'ui://n8n-mcp/validation-summary' } });
|
||||
});
|
||||
|
||||
it('should not set _meta on tools without a matching UI app', () => {
|
||||
const tools: any[] = [
|
||||
{ name: 'search_nodes', description: 'Search', inputSchema: { type: 'object', properties: {} } },
|
||||
];
|
||||
UIAppRegistry.injectToolMeta(tools);
|
||||
expect(tools[0]._meta).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle a mix of matching and non-matching tools', () => {
|
||||
const tools: any[] = [
|
||||
{ name: 'n8n_delete_workflow', description: 'Delete', inputSchema: { type: 'object', properties: {} } },
|
||||
{ name: 'get_node_essentials', description: 'Essentials', inputSchema: { type: 'object', properties: {} } },
|
||||
{ name: 'validate_workflow', description: 'Validate', inputSchema: { type: 'object', properties: {} } },
|
||||
];
|
||||
UIAppRegistry.injectToolMeta(tools);
|
||||
expect(tools[0]._meta?.ui?.resourceUri).toBe('ui://n8n-mcp/operation-result');
|
||||
expect(tools[1]._meta).toBeUndefined();
|
||||
expect(tools[2]._meta?.ui?.resourceUri).toBe('ui://n8n-mcp/validation-summary');
|
||||
});
|
||||
});
|
||||
|
||||
describe('after loading without HTML', () => {
|
||||
beforeEach(() => {
|
||||
mockExistsSync.mockReturnValue(false);
|
||||
UIAppRegistry.load();
|
||||
});
|
||||
|
||||
it('should not set _meta when HTML is not available', () => {
|
||||
const tools: any[] = [
|
||||
{ name: 'n8n_create_workflow', description: 'Create', inputSchema: { type: 'object', properties: {} } },
|
||||
];
|
||||
UIAppRegistry.injectToolMeta(tools);
|
||||
expect(tools[0]._meta).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('reset()', () => {
|
||||
it('should clear loaded state so getters return defaults', () => {
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
|
||||
@@ -5,12 +5,16 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "APP_NAME=operation-result vite build && APP_NAME=validation-summary vite build",
|
||||
"build": "APP_NAME=operation-result vite build && APP_NAME=validation-summary vite build && APP_NAME=workflow-list vite build && APP_NAME=execution-history vite build && APP_NAME=health-dashboard vite build",
|
||||
"build:operation-result": "APP_NAME=operation-result vite build",
|
||||
"build:validation-summary": "APP_NAME=validation-summary vite build",
|
||||
"build:workflow-list": "APP_NAME=workflow-list vite build",
|
||||
"build:execution-history": "APP_NAME=execution-history vite build",
|
||||
"build:health-dashboard": "APP_NAME=health-dashboard vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/ext-apps": "^1.0.1",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
|
||||
357
ui-apps/preview.html
Normal file
357
ui-apps/preview.html
Normal file
@@ -0,0 +1,357 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>MCP App Preview</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; padding: 24px; transition: background 0.3s, color 0.3s; }
|
||||
body.dark { background: #111; color: #e0e0e0; }
|
||||
body.light { background: #f5f5f5; color: #1f2937; }
|
||||
h1 { font-size: 18px; margin-bottom: 8px; }
|
||||
.theme-toggle { margin-bottom: 16px; }
|
||||
.theme-toggle button { padding: 6px 14px; border: 1px solid #666; border-radius: 6px; cursor: pointer; font-size: 12px; margin-right: 8px; }
|
||||
body.dark .theme-toggle button { background: #252540; color: #e0e0e0; border-color: #444; }
|
||||
body.light .theme-toggle button { background: #fff; color: #1f2937; border-color: #ccc; }
|
||||
.theme-toggle button.active { border-color: #ff6d5a; color: #ff6d5a; }
|
||||
.section-label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; margin: 16px 0 8px; opacity: 0.6; }
|
||||
.controls { display: flex; gap: 8px; margin-bottom: 12px; flex-wrap: wrap; }
|
||||
button { padding: 6px 14px; border-radius: 6px; cursor: pointer; font-size: 12px; }
|
||||
body.dark button { border: 1px solid #444; background: #252540; color: #e0e0e0; }
|
||||
body.light button { border: 1px solid #d1d5db; background: #fff; color: #1f2937; }
|
||||
button:hover { opacity: 0.85; }
|
||||
button.active { border-color: #ff6d5a; color: #ff6d5a; }
|
||||
.preview-frame { border-radius: 8px; overflow: hidden; max-width: 520px; transition: background 0.3s, border-color 0.3s; }
|
||||
body.dark .preview-frame { border: 1px solid #333; background: #1a1a2e; }
|
||||
body.light .preview-frame { border: 1px solid #e5e7eb; background: #ffffff; }
|
||||
iframe { border: none; width: 100%; height: 600px; }
|
||||
.info { font-size: 12px; opacity: 0.4; margin-top: 12px; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="dark">
|
||||
<h1>MCP App Local Preview</h1>
|
||||
<div class="theme-toggle">
|
||||
<button onclick="setTheme('dark')" class="active" id="btn-dark">Dark</button>
|
||||
<button onclick="setTheme('light')" id="btn-light">Light</button>
|
||||
</div>
|
||||
|
||||
<div class="section-label">Operation Result</div>
|
||||
<div class="controls">
|
||||
<button onclick="load('operation-result', mockCreateSuccess, 'n8n_create_workflow', this)" class="active">Create (success)</button>
|
||||
<button onclick="load('operation-result', mockCreateError, 'n8n_create_workflow', this)">Create (error)</button>
|
||||
<button onclick="load('operation-result', mockDelete, 'n8n_delete_workflow', this)">Delete</button>
|
||||
<button onclick="load('operation-result', mockPartialUpdate, 'n8n_update_partial_workflow', this)">Partial Update</button>
|
||||
<button onclick="load('operation-result', mockFullUpdate, 'n8n_update_full_workflow', this)">Full Update</button>
|
||||
<button onclick="load('operation-result', mockAutofix, 'n8n_autofix_workflow', this)">Autofix</button>
|
||||
<button onclick="load('operation-result', mockAutofixPreview, 'n8n_autofix_workflow', this)">Autofix (preview)</button>
|
||||
<button onclick="load('operation-result', mockDeploy, 'n8n_deploy_template', this)">Deploy Template</button>
|
||||
<button onclick="load('operation-result', mockTest, 'n8n_test_workflow', this)">Test Workflow</button>
|
||||
</div>
|
||||
|
||||
<div class="section-label">Validation Summary</div>
|
||||
<div class="controls">
|
||||
<button onclick="load('validation-summary', mockValidValid, 'validate_node', this)">Valid Node</button>
|
||||
<button onclick="load('validation-summary', mockValidInvalid, 'validate_node', this)">Invalid Node</button>
|
||||
<button onclick="load('validation-summary', mockN8nValidate, 'n8n_validate_workflow', this)">Workflow (multi-node)</button>
|
||||
</div>
|
||||
|
||||
<div class="section-label">Workflow List</div>
|
||||
<div class="controls">
|
||||
<button onclick="load('workflow-list', mockWorkflowList, 'n8n_list_workflows', this)">Workflow List</button>
|
||||
<button onclick="load('workflow-list', mockWorkflowListEmpty, 'n8n_list_workflows', this)">Empty List</button>
|
||||
</div>
|
||||
|
||||
<div class="section-label">Execution History</div>
|
||||
<div class="controls">
|
||||
<button onclick="load('execution-history', mockExecutions, 'n8n_executions', this)">Executions</button>
|
||||
<button onclick="load('execution-history', mockExecutionsEmpty, 'n8n_executions', this)">Empty</button>
|
||||
</div>
|
||||
|
||||
<div class="section-label">Health Dashboard</div>
|
||||
<div class="controls">
|
||||
<button onclick="load('health-dashboard', mockHealthOk, 'n8n_health_check', this)">Healthy</button>
|
||||
<button onclick="load('health-dashboard', mockHealthOutdated, 'n8n_health_check', this)">Outdated</button>
|
||||
<button onclick="load('health-dashboard', mockHealthError, 'n8n_health_check', this)">Error</button>
|
||||
</div>
|
||||
|
||||
<div class="preview-frame">
|
||||
<iframe id="app"></iframe>
|
||||
</div>
|
||||
<div class="info">This preview simulates the Claude host postMessage protocol to push mock tool result data into the MCP App iframe.</div>
|
||||
|
||||
<script>
|
||||
const iframe = document.getElementById('app');
|
||||
let pendingData = null;
|
||||
let pendingToolName = null;
|
||||
let currentTheme = 'dark';
|
||||
|
||||
// --- Theme ---
|
||||
function setTheme(theme) {
|
||||
currentTheme = theme;
|
||||
document.body.className = theme;
|
||||
document.getElementById('btn-dark').classList.toggle('active', theme === 'dark');
|
||||
document.getElementById('btn-light').classList.toggle('active', theme === 'light');
|
||||
// Reload current iframe to re-initialize with new theme
|
||||
if (iframe.src) iframe.src = iframe.src;
|
||||
}
|
||||
|
||||
// --- Mock Data: Operation Result ---
|
||||
const mockCreateSuccess = {
|
||||
success: true,
|
||||
data: { id: 'abc123XYZ', name: 'Webhook with Set Node', active: false, nodeCount: 2 },
|
||||
message: 'Workflow "Webhook with Set Node" created successfully.'
|
||||
};
|
||||
|
||||
const mockCreateError = {
|
||||
success: false,
|
||||
error: 'Node type format error: n8n API requires FULL form node types',
|
||||
details: { errors: ['Node 0 ("HTTP Request") uses SHORT form "nodes-base.httpRequest". Change to "n8n-nodes-base.httpRequest"'] }
|
||||
};
|
||||
|
||||
const mockDelete = {
|
||||
success: true,
|
||||
data: { id: 'wf_456', name: 'Old Workflow', deleted: true },
|
||||
message: 'Workflow "Old Workflow" deleted successfully.'
|
||||
};
|
||||
|
||||
const mockPartialUpdate = {
|
||||
success: true,
|
||||
data: { id: 'wf_789', name: 'My API Workflow', active: true, nodeCount: 5, operationsApplied: 3 },
|
||||
message: 'Workflow "My API Workflow" updated successfully.',
|
||||
details: {
|
||||
applied: ['add_node:Set', 'modify_node:HTTP Request', 'add_connection:Set->HTTP Request'],
|
||||
failed: [],
|
||||
warnings: ['Node "Set" has deprecated property "keepOnlySet"']
|
||||
}
|
||||
};
|
||||
|
||||
const mockFullUpdate = {
|
||||
success: true,
|
||||
data: { id: 'wf_101', name: 'Updated Workflow', active: true, nodeCount: 8 },
|
||||
message: 'Workflow updated.'
|
||||
};
|
||||
|
||||
const mockAutofix = {
|
||||
success: true,
|
||||
data: {
|
||||
id: 'wf_auto', name: 'Fixed Workflow', nodeCount: 4, fixesApplied: 3,
|
||||
fixes: [
|
||||
{ description: 'Changed node type from short to full form', confidence: 'HIGH' },
|
||||
{ description: 'Added missing authentication parameter', confidence: 'HIGH' },
|
||||
{ description: 'Replaced deprecated property channelId with channel', confidence: 'MEDIUM' }
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
const mockAutofixPreview = {
|
||||
success: true,
|
||||
data: {
|
||||
id: 'wf_preview', name: 'Preview Workflow', nodeCount: 3, fixesApplied: 2, preview: true,
|
||||
fixes: [
|
||||
{ description: 'Would change node type from short to full form', confidence: 'HIGH' },
|
||||
{ description: 'Would add missing required field "resource"', confidence: 'MEDIUM' }
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
const mockDeploy = {
|
||||
success: true,
|
||||
data: {
|
||||
id: 'wf_deployed', name: 'Email Notification Flow', active: false, nodeCount: 5,
|
||||
templateId: 1234, triggerType: 'webhook',
|
||||
requiredCredentials: ['gmailOAuth2Api (Gmail node)', 'slackOAuth2Api (Slack node)'],
|
||||
autoFixStatus: 'success'
|
||||
}
|
||||
};
|
||||
|
||||
const mockTest = {
|
||||
success: true,
|
||||
data: {
|
||||
id: 'wf_test', name: 'Test Workflow', executionId: 'exec_98765',
|
||||
triggerType: 'manual'
|
||||
}
|
||||
};
|
||||
|
||||
// --- Mock Data: Validation Summary ---
|
||||
const mockValidValid = {
|
||||
nodeType: 'n8n-nodes-base.httpRequest',
|
||||
displayName: 'HTTP Request',
|
||||
valid: true,
|
||||
errors: [],
|
||||
warnings: [],
|
||||
suggestions: ['Consider adding error handling with an Error Trigger node'],
|
||||
summary: { hasErrors: false, errorCount: 0, warningCount: 0, suggestionCount: 1 }
|
||||
};
|
||||
|
||||
const mockValidInvalid = {
|
||||
nodeType: 'n8n-nodes-base.slack',
|
||||
displayName: 'Slack',
|
||||
valid: false,
|
||||
errors: [
|
||||
{ type: 'missing_required', property: 'authentication', message: 'Required field "authentication" is missing', fix: 'Set authentication to "oAuth2" or "accessToken"' },
|
||||
{ type: 'missing_required', property: 'resource', message: 'Required field "resource" is missing', fix: 'Set resource to "channel", "message", "user", etc.' }
|
||||
],
|
||||
warnings: [
|
||||
{ type: 'deprecated_property', property: 'channelId', message: 'Property "channelId" is deprecated, use "channel" instead' }
|
||||
],
|
||||
suggestions: ['Use OAuth2 authentication for production workflows', 'Add error handling for rate limits'],
|
||||
summary: { hasErrors: true, errorCount: 2, warningCount: 1, suggestionCount: 2 }
|
||||
};
|
||||
|
||||
const mockN8nValidate = {
|
||||
success: true,
|
||||
data: {
|
||||
valid: false,
|
||||
workflowId: 'wf_abc',
|
||||
workflowName: 'My Production Workflow',
|
||||
errors: [
|
||||
{ node: 'HTTP Request', message: 'Missing URL parameter', property: 'url', fix: 'Set the url field' },
|
||||
{ node: 'HTTP Request', message: 'Invalid method "PATCH" for this endpoint', property: 'method' },
|
||||
{ node: 'Slack', message: 'Missing authentication configuration', property: 'authentication', fix: 'Set authentication to oAuth2' }
|
||||
],
|
||||
warnings: [
|
||||
{ node: 'Set', message: 'Unused output field "oldField"', property: 'oldField' },
|
||||
{ node: 'HTTP Request', message: 'Consider using retry on failure', property: 'options.retry' }
|
||||
],
|
||||
suggestions: ['Add error handling between HTTP Request and Set nodes'],
|
||||
summary: { errorCount: 3, warningCount: 2, totalNodes: 4, enabledNodes: 4 }
|
||||
}
|
||||
};
|
||||
|
||||
// --- Mock Data: Workflow List ---
|
||||
const mockWorkflowList = {
|
||||
success: true,
|
||||
data: {
|
||||
workflows: [
|
||||
{ id: 'wf_001', name: 'Customer Onboarding', active: true, nodeCount: 12, tags: ['production', 'crm'], updatedAt: '2026-02-07T14:30:00Z' },
|
||||
{ id: 'wf_002', name: 'Slack Notifications', active: true, nodeCount: 5, tags: ['notifications'], updatedAt: '2026-02-06T09:15:00Z' },
|
||||
{ id: 'wf_003', name: 'Data Backup (weekly)', active: false, nodeCount: 8, tags: ['maintenance', 'backup'], updatedAt: '2026-01-28T22:00:00Z' },
|
||||
{ id: 'wf_004', name: 'Invoice Processing', active: true, nodeCount: 15, tags: ['finance', 'production', 'critical'], updatedAt: '2026-02-08T01:00:00Z' },
|
||||
{ id: 'wf_005', name: 'Old Integration Test', active: false, isArchived: true, nodeCount: 3, tags: [], updatedAt: '2025-11-01T10:00:00Z' },
|
||||
{ id: 'wf_006', name: 'Email Campaign Drip', active: true, nodeCount: 9, tags: ['marketing'], updatedAt: '2026-02-05T16:45:00Z' },
|
||||
],
|
||||
returned: 6,
|
||||
hasMore: true,
|
||||
nextCursor: 'cursor_abc'
|
||||
}
|
||||
};
|
||||
|
||||
const mockWorkflowListEmpty = {
|
||||
success: true,
|
||||
data: { workflows: [], returned: 0, hasMore: false }
|
||||
};
|
||||
|
||||
// --- Mock Data: Execution History ---
|
||||
const mockExecutions = {
|
||||
success: true,
|
||||
data: {
|
||||
executions: [
|
||||
{ id: 'exec_001', workflowName: 'Customer Onboarding', status: 'success', startedAt: '2026-02-08T10:30:00Z', stoppedAt: '2026-02-08T10:30:02Z', mode: 'webhook' },
|
||||
{ id: 'exec_002', workflowName: 'Slack Notifications', status: 'success', startedAt: '2026-02-08T10:28:00Z', stoppedAt: '2026-02-08T10:28:01Z', mode: 'trigger' },
|
||||
{ id: 'exec_003', workflowName: 'Invoice Processing', status: 'error', startedAt: '2026-02-08T10:25:00Z', stoppedAt: '2026-02-08T10:25:15Z', mode: 'webhook' },
|
||||
{ id: 'exec_004', workflowName: 'Customer Onboarding', status: 'success', startedAt: '2026-02-08T10:20:00Z', stoppedAt: '2026-02-08T10:20:03Z', mode: 'webhook' },
|
||||
{ id: 'exec_005', workflowName: 'Email Campaign Drip', status: 'waiting', startedAt: '2026-02-08T10:15:00Z', mode: 'manual' },
|
||||
{ id: 'exec_006', workflowName: 'Data Backup', status: 'success', startedAt: '2026-02-08T09:00:00Z', stoppedAt: '2026-02-08T09:02:30Z', mode: 'cron' },
|
||||
{ id: 'exec_007', workflowName: 'Invoice Processing', status: 'error', startedAt: '2026-02-07T23:00:00Z', stoppedAt: '2026-02-07T23:00:08Z', mode: 'webhook' },
|
||||
],
|
||||
returned: 7,
|
||||
hasMore: true
|
||||
}
|
||||
};
|
||||
|
||||
const mockExecutionsEmpty = {
|
||||
success: true,
|
||||
data: { executions: [], returned: 0, hasMore: false }
|
||||
};
|
||||
|
||||
// --- Mock Data: Health Dashboard ---
|
||||
const mockHealthOk = {
|
||||
success: true,
|
||||
data: {
|
||||
status: 'connected',
|
||||
instanceId: 'inst_abc123',
|
||||
n8nVersion: '1.72.1',
|
||||
mcpVersion: '2.24.1',
|
||||
apiUrl: 'https://n8n.example.com/api/v1',
|
||||
versionCheck: { current: '1.72.1', latest: '1.72.1', upToDate: true },
|
||||
performance: { responseTimeMs: 142, cacheHitRate: 0.87 },
|
||||
nextSteps: ['Your n8n instance is up to date', 'Try creating a workflow with n8n_create_workflow']
|
||||
}
|
||||
};
|
||||
|
||||
const mockHealthOutdated = {
|
||||
success: true,
|
||||
data: {
|
||||
status: 'connected',
|
||||
instanceId: 'inst_xyz789',
|
||||
n8nVersion: '1.68.0',
|
||||
mcpVersion: '2.24.1',
|
||||
apiUrl: 'https://n8n.company.io/api/v1',
|
||||
versionCheck: { current: '1.68.0', latest: '1.72.1', upToDate: false, updateCommand: 'npm update n8n -g' },
|
||||
performance: { responseTimeMs: 1850, cacheHitRate: 0.45 },
|
||||
nextSteps: ['Update n8n to version 1.72.1 for latest features and fixes', 'Cache hit rate is low - consider warming the cache']
|
||||
}
|
||||
};
|
||||
|
||||
const mockHealthError = {
|
||||
success: false,
|
||||
error: 'Connection failed: Unable to reach n8n instance at https://n8n.offline.com/api/v1'
|
||||
};
|
||||
|
||||
// --- Host Protocol ---
|
||||
window.addEventListener('message', (event) => {
|
||||
if (!event.data || typeof event.data !== 'object') return;
|
||||
const msg = event.data;
|
||||
if (!msg.jsonrpc) return;
|
||||
|
||||
if (msg.jsonrpc === '2.0' && msg.method === 'ui/initialize' && msg.id != null) {
|
||||
iframe.contentWindow.postMessage({
|
||||
jsonrpc: '2.0',
|
||||
id: msg.id,
|
||||
result: {
|
||||
protocolVersion: '2026-01-26',
|
||||
hostCapabilities: {},
|
||||
hostInfo: { name: 'Local Preview', version: '1.0.0' },
|
||||
hostContext: {
|
||||
theme: currentTheme,
|
||||
toolInfo: pendingToolName ? { tool: { name: pendingToolName, inputSchema: { type: 'object', properties: {} } } } : undefined,
|
||||
}
|
||||
}
|
||||
}, '*');
|
||||
|
||||
setTimeout(() => {
|
||||
if (pendingData) {
|
||||
iframe.contentWindow.postMessage({
|
||||
jsonrpc: '2.0',
|
||||
method: 'ui/notifications/tool-result',
|
||||
params: {
|
||||
content: [{ type: 'text', text: JSON.stringify(pendingData) }]
|
||||
}
|
||||
}, '*');
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
// Respond to any other JSON-RPC request (with id) with empty result
|
||||
else if (msg.jsonrpc === '2.0' && msg.id != null && msg.method) {
|
||||
iframe.contentWindow.postMessage({
|
||||
jsonrpc: '2.0',
|
||||
id: msg.id,
|
||||
result: {}
|
||||
}, '*');
|
||||
}
|
||||
});
|
||||
|
||||
function load(appName, data, toolName, btn) {
|
||||
pendingData = data;
|
||||
pendingToolName = toolName || null;
|
||||
document.querySelectorAll('.controls button').forEach(b => b.classList.remove('active'));
|
||||
if (btn) btn.classList.add('active');
|
||||
iframe.src = `dist/${appName}/index.html`;
|
||||
}
|
||||
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
load('operation-result', mockCreateSuccess, 'n8n_create_workflow', document.querySelector('.controls button'));
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
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,83 +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';
|
||||
|
||||
export default function App() {
|
||||
const data = useToolData<OperationResultData>();
|
||||
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',
|
||||
};
|
||||
|
||||
if (!data) {
|
||||
return <div style={{ padding: '16px', color: 'var(--n8n-text-muted)' }}>Loading...</div>;
|
||||
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></>;
|
||||
}
|
||||
|
||||
const isSuccess = data.status === 'success';
|
||||
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 (
|
||||
<div style={{ maxWidth: '480px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '16px' }}>
|
||||
<Badge variant={isSuccess ? 'success' : 'error'}>
|
||||
{isSuccess ? 'Success' : 'Error'}
|
||||
</Badge>
|
||||
<h2 style={{ fontSize: '16px', fontWeight: 600 }}>{data.operation}</h2>
|
||||
</div>
|
||||
|
||||
<Card title="Workflow">
|
||||
<div style={{ fontSize: '14px' }}>
|
||||
{data.workflowName && <div><strong>Name:</strong> {data.workflowName}</div>}
|
||||
{data.workflowId && <div><strong>ID:</strong> {data.workflowId}</div>}
|
||||
{data.timestamp && (
|
||||
<div style={{ color: 'var(--n8n-text-muted)', fontSize: '12px', marginTop: '4px' }}>
|
||||
{data.timestamp}
|
||||
</div>
|
||||
)}
|
||||
<>
|
||||
{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>
|
||||
</Card>
|
||||
|
||||
{data.message && (
|
||||
<Card>
|
||||
<div style={{ fontSize: '13px' }}>{data.message}</div>
|
||||
</Card>
|
||||
)}
|
||||
{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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
{data.changes && (
|
||||
<>
|
||||
{data.changes.nodesAdded && data.changes.nodesAdded.length > 0 && (
|
||||
<Expandable title="Nodes Added" count={data.changes.nodesAdded.length} defaultOpen>
|
||||
<ul style={{ listStyle: 'none', fontSize: '13px' }}>
|
||||
{data.changes.nodesAdded.map((node, i) => (
|
||||
<li key={i} style={{ padding: '4px 0', borderBottom: '1px solid var(--n8n-border)' }}>
|
||||
<span style={{ color: 'var(--n8n-success)' }}>+</span> {node}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</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;
|
||||
|
||||
{data.changes.nodesModified && data.changes.nodesModified.length > 0 && (
|
||||
<Expandable title="Nodes Modified" count={data.changes.nodesModified.length}>
|
||||
<ul style={{ listStyle: 'none', fontSize: '13px' }}>
|
||||
{data.changes.nodesModified.map((node, i) => (
|
||||
<li key={i} style={{ padding: '4px 0', borderBottom: '1px solid var(--n8n-border)' }}>
|
||||
<span style={{ color: 'var(--n8n-warning)' }}>~</span> {node}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Expandable>
|
||||
)}
|
||||
|
||||
{data.changes.nodesRemoved && data.changes.nodesRemoved.length > 0 && (
|
||||
<Expandable title="Nodes Removed" count={data.changes.nodesRemoved.length}>
|
||||
<ul style={{ listStyle: 'none', fontSize: '13px' }}>
|
||||
{data.changes.nodesRemoved.map((node, i) => (
|
||||
<li key={i} style={{ padding: '4px 0', borderBottom: '1px solid var(--n8n-border)' }}>
|
||||
<span style={{ color: 'var(--n8n-error)' }}>-</span> {node}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Expandable>
|
||||
)}
|
||||
</>
|
||||
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, toolName } = useToolData<OperationResultData>();
|
||||
|
||||
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>;
|
||||
}
|
||||
|
||||
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 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={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>
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
{/* 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} />}
|
||||
|
||||
{/* 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,84 +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 } from '@shared/types';
|
||||
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 = useToolData<ValidationSummaryData>();
|
||||
const { data: raw, error, isConnected } = useToolData<ValidationSummaryData>();
|
||||
|
||||
if (!data) {
|
||||
return <div style={{ padding: '16px', color: 'var(--n8n-text-muted)' }}>Loading...</div>;
|
||||
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: 'var(--n8n-text-muted)' }}>Connecting...</div>;
|
||||
}
|
||||
|
||||
if (!raw) {
|
||||
return <div style={{ padding: '16px', color: 'var(--n8n-text-muted)' }}>Waiting for data...</div>;
|
||||
}
|
||||
|
||||
const valid = inner.valid ?? raw.valid ?? false;
|
||||
const displayName = raw.displayName || raw.data?.workflowName;
|
||||
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' }}>
|
||||
<Badge variant={data.valid ? 'success' : 'error'}>
|
||||
{data.valid ? 'Valid' : 'Invalid'}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '12px' }}>
|
||||
<Badge variant={valid ? 'success' : 'error'}>
|
||||
{valid ? 'Valid' : 'Invalid'}
|
||||
</Badge>
|
||||
{data.displayName && (
|
||||
<span style={{ fontSize: '14px', color: 'var(--n8n-text-muted)' }}>{data.displayName}</span>
|
||||
{displayName && (
|
||||
<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)' }}>{data.errorCount}</span> errors
|
||||
</div>
|
||||
<div>
|
||||
<span style={{ color: 'var(--n8n-warning)' }}>{data.warningCount}</span> warnings
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<SeverityBar errorCount={errorCount} warningCount={warningCount} />
|
||||
|
||||
{data.errors.length > 0 && (
|
||||
<Expandable title="Errors" count={data.errors.length} defaultOpen>
|
||||
{data.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)',
|
||||
}}>
|
||||
<div style={{ fontWeight: 600 }}>{err.type}</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) => (
|
||||
<IssueItem key={i} issue={warn} variant="warning" />
|
||||
))}
|
||||
</Expandable>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{data.warnings.length > 0 && (
|
||||
<Expandable title="Warnings" count={data.warnings.length}>
|
||||
{data.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)',
|
||||
}}>
|
||||
<div style={{ fontWeight: 600 }}>{warn.type}</div>
|
||||
{warn.property && <div style={{ opacity: 0.8 }}>Property: {warn.property}</div>}
|
||||
<div>{warn.message}</div>
|
||||
</div>
|
||||
))}
|
||||
</Expandable>
|
||||
)}
|
||||
|
||||
{data.suggestions && data.suggestions.length > 0 && (
|
||||
<Expandable title="Suggestions" count={data.suggestions.length}>
|
||||
{suggestions.length > 0 && (
|
||||
<Expandable title="Suggestions" count={suggestions.length}>
|
||||
<ul style={{ paddingLeft: '16px', fontSize: '12px' }}>
|
||||
{data.suggestions.map((suggestion, i) => (
|
||||
<li key={i} style={{ padding: '2px 0', color: 'var(--n8n-info)' }}>{suggestion}</li>
|
||||
{suggestions.map((suggestion, i) => (
|
||||
<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,31 +1,50 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useApp, useHostStyles } from '@modelcontextprotocol/ext-apps/react';
|
||||
import type { App } from '@modelcontextprotocol/ext-apps/react';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__MCP_DATA__?: unknown;
|
||||
}
|
||||
interface UseToolDataResult<T> {
|
||||
data: T | null;
|
||||
error: string | null;
|
||||
isConnected: boolean;
|
||||
app: App | null;
|
||||
toolName: string | null;
|
||||
}
|
||||
|
||||
export function useToolData<T>(): T | null {
|
||||
export function useToolData<T>(): UseToolDataResult<T> {
|
||||
const [data, setData] = useState<T | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Try window.__MCP_DATA__ first (injected by host)
|
||||
if (window.__MCP_DATA__) {
|
||||
setData(window.__MCP_DATA__ as T);
|
||||
return;
|
||||
}
|
||||
|
||||
// Try embedded script tag
|
||||
const scriptEl = document.getElementById('mcp-data');
|
||||
if (scriptEl?.textContent) {
|
||||
try {
|
||||
setData(JSON.parse(scriptEl.textContent) as T);
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
const onAppCreated = useCallback((app: App) => {
|
||||
app.ontoolresult = (result) => {
|
||||
if (result?.content) {
|
||||
const textItem = Array.isArray(result.content)
|
||||
? result.content.find((c) => c.type === 'text')
|
||||
: null;
|
||||
if (textItem && 'text' in textItem) {
|
||||
try {
|
||||
setData(JSON.parse(textItem.text) as T);
|
||||
} catch {
|
||||
setData(textItem.text as unknown as T);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return data;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,38 +1,146 @@
|
||||
// Matches the McpToolResponse format from handlers-n8n-manager.ts
|
||||
export interface OperationResultData {
|
||||
status: 'success' | 'error';
|
||||
operation: string;
|
||||
workflowName?: string;
|
||||
workflowId?: string;
|
||||
timestamp?: string;
|
||||
message?: string;
|
||||
changes?: {
|
||||
nodesAdded?: string[];
|
||||
nodesModified?: string[];
|
||||
nodesRemoved?: string[];
|
||||
success: boolean;
|
||||
data?: {
|
||||
id?: string;
|
||||
name?: string;
|
||||
active?: boolean;
|
||||
nodeCount?: number;
|
||||
workflowId?: string;
|
||||
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;
|
||||
error?: string;
|
||||
details?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type OperationType = 'create' | 'update' | 'partial_update' | 'delete' | 'test' | 'autofix' | 'deploy';
|
||||
|
||||
export interface ValidationError {
|
||||
type: string;
|
||||
type?: string;
|
||||
property?: string;
|
||||
message: string;
|
||||
fix?: string;
|
||||
node?: string;
|
||||
details?: unknown;
|
||||
}
|
||||
|
||||
export interface ValidationWarning {
|
||||
type: string;
|
||||
type?: string;
|
||||
property?: string;
|
||||
message: string;
|
||||
node?: string;
|
||||
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;
|
||||
errorCount: number;
|
||||
warningCount: number;
|
||||
nodeType?: string;
|
||||
displayName?: string;
|
||||
errors: ValidationError[];
|
||||
warnings: ValidationWarning[];
|
||||
suggestions?: string[];
|
||||
nodeType?: string;
|
||||
displayName?: string;
|
||||
summary?: {
|
||||
errorCount?: number;
|
||||
warningCount?: number;
|
||||
hasErrors?: boolean;
|
||||
suggestionCount?: number;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
// n8n_validate_workflow wraps result in success/data
|
||||
success?: boolean;
|
||||
data?: {
|
||||
valid?: boolean;
|
||||
workflowId?: string;
|
||||
workflowName?: string;
|
||||
errors?: ValidationError[];
|
||||
warnings?: ValidationWarning[];
|
||||
suggestions?: string[];
|
||||
summary?: {
|
||||
errorCount?: number;
|
||||
warningCount?: number;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user