diff --git a/NodeSrv/apps/app-portal/.env.example b/NodeSrv/apps/app-portal/.env.example
index a4f22bfd..fd7c464f 100644
--- a/NodeSrv/apps/app-portal/.env.example
+++ b/NodeSrv/apps/app-portal/.env.example
@@ -6,3 +6,7 @@ DOKPLOY_ENVIRONMENT_ID=Cm0HjMIFyl11UdIcIGRy8
PORTAL_ALLOWED_EMAILS=kenichiro.nogi@next-hd.co.jp
PORTAL_SECRET=
PORTAL_MASTER_KEY=
+# n8nワークフロー実行ログ閲覧(/n8n)用
+N8N_BASE_URL=https://n8n32.next-hd.net
+N8N_API_KEY=
+N8N_PROJECT_ID=
diff --git a/NodeSrv/apps/app-portal/public/n8n.js b/NodeSrv/apps/app-portal/public/n8n.js
new file mode 100644
index 00000000..baf7eec5
--- /dev/null
+++ b/NodeSrv/apps/app-portal/public/n8n.js
@@ -0,0 +1,16 @@
+document.getElementById('folderTree').addEventListener('click', async (event) => {
+ const item = event.target.closest('[data-workflow-id]');
+ if (!item) return;
+ const workflowId = item.dataset.workflowId;
+ const res = await fetch(`/api/n8n/workflows/${workflowId}/executions`);
+ document.getElementById('executionList').innerHTML = res.ok ? await res.text() : '実行履歴取得に失敗しました';
+ document.getElementById('executionDetail').innerHTML = '
実行を選択してください
';
+});
+
+document.getElementById('executionList').addEventListener('click', async (event) => {
+ const row = event.target.closest('[data-execution-id]');
+ if (!row) return;
+ const executionId = row.dataset.executionId;
+ const res = await fetch(`/api/n8n/executions/${executionId}`);
+ document.getElementById('executionDetail').innerHTML = res.ok ? await res.text() : '実行詳細取得に失敗しました';
+});
diff --git a/NodeSrv/apps/app-portal/src/dashboard.js b/NodeSrv/apps/app-portal/src/dashboard.js
index b4baa604..7d3d6e6c 100644
--- a/NodeSrv/apps/app-portal/src/dashboard.js
+++ b/NodeSrv/apps/app-portal/src/dashboard.js
@@ -106,6 +106,7 @@ async function renderDashboard(environmentId) {
diff --git a/NodeSrv/apps/app-portal/src/index.js b/NodeSrv/apps/app-portal/src/index.js
index 8be379f9..2acfc26d 100644
--- a/NodeSrv/apps/app-portal/src/index.js
+++ b/NodeSrv/apps/app-portal/src/index.js
@@ -15,12 +15,16 @@ const {
SESSION_COOKIE_NAME,
} = require('./adminAuth');
const { renderLoginPage, renderAdminPage } = require('./adminView');
+const { listFolders, listWorkflows, listExecutions, getExecution } = require('./n8nClient');
+const { buildFolderTree } = require('./n8nTree');
+const { renderN8nPage, renderExecutionList, renderExecutionDetail } = require('./n8nView');
const app = express();
const PORT = process.env.PORT || 3000;
const ENVIRONMENT_ID = process.env.DOKPLOY_ENVIRONMENT_ID;
const PORTAL_SECRET = process.env.PORTAL_SECRET;
const MASTER_KEY = process.env.PORTAL_MASTER_KEY;
+const N8N_PROJECT_ID = process.env.N8N_PROJECT_ID;
const ALLOWLIST_FILE = getAllowlistFilePath();
ensureFile(ALLOWLIST_FILE, process.env.PORTAL_ALLOWED_EMAILS);
@@ -165,6 +169,37 @@ app.post('/api/compose/:composeId/trigger', async (req, res) => {
}
});
+app.get('/n8n', async (req, res) => {
+ try {
+ const [folders, workflows] = await Promise.all([listFolders(N8N_PROJECT_ID), listWorkflows()]);
+ const tree = buildFolderTree(folders, workflows);
+ res.set('Content-Type', 'text/html; charset=utf-8').send(renderN8nPage(tree));
+ } catch (err) {
+ console.error('n8n page render failed', err.message);
+ res.status(500).send('n8nワークフロー一覧の取得に失敗しました');
+ }
+});
+
+app.get('/api/n8n/workflows/:workflowId/executions', async (req, res) => {
+ try {
+ const executions = await listExecutions(req.params.workflowId, { limit: 20 });
+ res.set('Content-Type', 'text/html; charset=utf-8').send(renderExecutionList(executions));
+ } catch (err) {
+ console.error('n8n executions fetch failed', err.message);
+ res.sendStatus(502);
+ }
+});
+
+app.get('/api/n8n/executions/:executionId', async (req, res) => {
+ try {
+ const execution = await getExecution(req.params.executionId, true);
+ res.set('Content-Type', 'text/html; charset=utf-8').send(renderExecutionDetail(execution));
+ } catch (err) {
+ console.error('n8n execution detail fetch failed', err.message);
+ res.sendStatus(502);
+ }
+});
+
app.listen(PORT, () => {
console.log(`app-portal listening on port ${PORT}`);
});
diff --git a/NodeSrv/apps/app-portal/src/n8nClient.js b/NodeSrv/apps/app-portal/src/n8nClient.js
new file mode 100644
index 00000000..d15f4c50
--- /dev/null
+++ b/NodeSrv/apps/app-portal/src/n8nClient.js
@@ -0,0 +1,42 @@
+function getConfig() {
+ const baseUrl = process.env.N8N_BASE_URL;
+ const apiKey = process.env.N8N_API_KEY;
+ if (!baseUrl || !apiKey) {
+ throw new Error('N8N_BASE_URL / N8N_API_KEY が設定されていません');
+ }
+ return { baseUrl, apiKey };
+}
+
+async function n8nGet(path, params) {
+ const { baseUrl, apiKey } = getConfig();
+ const url = new URL(`${baseUrl}/api/v1${path}`);
+ for (const [key, value] of Object.entries(params || {})) {
+ if (value !== undefined && value !== null) url.searchParams.set(key, String(value));
+ }
+ const res = await fetch(url, { headers: { 'X-N8N-API-KEY': apiKey } });
+ if (!res.ok) {
+ throw new Error(`n8n API error: ${res.status} ${path}`);
+ }
+ return res.json();
+}
+
+async function listFolders(projectId) {
+ const body = await n8nGet(`/projects/${projectId}/folders`);
+ return body.data;
+}
+
+async function listWorkflows() {
+ const body = await n8nGet('/workflows');
+ return body.data;
+}
+
+async function listExecutions(workflowId, { status, limit } = {}) {
+ const body = await n8nGet('/executions', { workflowId, status, limit });
+ return body.data;
+}
+
+async function getExecution(id, includeData) {
+ return n8nGet(`/executions/${id}`, { includeData: includeData ? 'true' : undefined });
+}
+
+module.exports = { listFolders, listWorkflows, listExecutions, getExecution };
diff --git a/NodeSrv/apps/app-portal/src/n8nTree.js b/NodeSrv/apps/app-portal/src/n8nTree.js
new file mode 100644
index 00000000..9f2aa9ec
--- /dev/null
+++ b/NodeSrv/apps/app-portal/src/n8nTree.js
@@ -0,0 +1,42 @@
+function buildFolderTree(folders, workflows) {
+ const folderNodes = new Map();
+ for (const folder of folders) {
+ folderNodes.set(folder.id, { type: 'folder', id: folder.id, name: folder.name, children: [] });
+ }
+
+ const roots = [];
+ for (const folder of folders) {
+ const node = folderNodes.get(folder.id);
+ if (folder.parentFolderId && folderNodes.has(folder.parentFolderId)) {
+ folderNodes.get(folder.parentFolderId).children.push(node);
+ } else {
+ roots.push(node);
+ }
+ }
+
+ for (const workflow of workflows) {
+ const node = { type: 'workflow', id: workflow.id, name: workflow.name, active: workflow.active };
+ const parentId = workflow.parentFolder && workflow.parentFolder.id;
+ if (parentId && folderNodes.has(parentId)) {
+ folderNodes.get(parentId).children.push(node);
+ } else {
+ roots.push(node);
+ }
+ }
+
+ const byTypeThenName = (a, b) => {
+ if (a.type !== b.type) return a.type === 'folder' ? -1 : 1;
+ return a.name.localeCompare(b.name);
+ };
+ const sortTree = (nodes) => {
+ nodes.sort(byTypeThenName);
+ for (const node of nodes) {
+ if (node.type === 'folder') sortTree(node.children);
+ }
+ return nodes;
+ };
+
+ return sortTree(roots);
+}
+
+module.exports = { buildFolderTree };
diff --git a/NodeSrv/apps/app-portal/src/n8nView.js b/NodeSrv/apps/app-portal/src/n8nView.js
new file mode 100644
index 00000000..b6169825
--- /dev/null
+++ b/NodeSrv/apps/app-portal/src/n8nView.js
@@ -0,0 +1,120 @@
+function escapeHtml(value) {
+ return String(value)
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"');
+}
+
+function renderNode(node) {
+ if (node.type === 'workflow') {
+ const badgeClass = node.active ? 'bg-success' : 'bg-secondary';
+ const badgeText = node.active ? '有効' : '無効';
+ return `
+ ${escapeHtml(node.name)}
+ ${badgeText}
+ `;
+ }
+ return `
+ 📁 ${escapeHtml(node.name)}
+ ${renderFolderTree(node.children)}
+ `;
+}
+
+function renderFolderTree(tree) {
+ if (!tree || tree.length === 0) return '';
+ return tree.map(renderNode).join('\n');
+}
+
+const EXECUTION_BADGE = {
+ success: 'bg-success',
+ error: 'bg-danger',
+ crashed: 'bg-danger',
+ running: 'bg-primary',
+ waiting: 'bg-warning text-dark',
+ canceled: 'bg-secondary',
+ new: 'bg-secondary',
+ unknown: 'bg-secondary',
+};
+
+function renderExecutionList(executions) {
+ if (!executions || executions.length === 0) {
+ return '実行履歴なし
';
+ }
+ const rows = executions
+ .map((e) => {
+ const badgeClass = EXECUTION_BADGE[e.status] || 'bg-secondary';
+ return `
+ | ${escapeHtml(e.startedAt || '')} |
+ ${escapeHtml(e.status)} |
+
`;
+ })
+ .join('\n');
+ return ``;
+}
+
+function renderExecutionDetail(execution) {
+ const runData = (execution.data && execution.data.resultData && execution.data.resultData.runData) || {};
+ const rows = Object.entries(runData)
+ .map(([nodeName, runs]) => {
+ const lastRun = runs[runs.length - 1] || {};
+ const hasError = Boolean(lastRun.error);
+ const badgeClass = hasError ? 'bg-danger' : 'bg-success';
+ const badgeText = hasError ? 'エラー' : '成功';
+ const errorMessage = hasError ? escapeHtml(lastRun.error.message || '') : '';
+ return `
+ | ${escapeHtml(nodeName)} |
+ ${badgeText} |
+ ${errorMessage} |
+
`;
+ })
+ .join('\n');
+ return ``;
+}
+
+function renderN8nPage(tree) {
+ const treeHtml = renderFolderTree(tree);
+ return `
+
+
+
+
+n8nワークフロー
+
+
+
+
+
+
+
+
+
+
+`;
+}
+
+module.exports = { renderFolderTree, renderExecutionList, renderExecutionDetail, renderN8nPage };
diff --git a/NodeSrv/apps/app-portal/test/n8nClient.test.js b/NodeSrv/apps/app-portal/test/n8nClient.test.js
new file mode 100644
index 00000000..8777bba3
--- /dev/null
+++ b/NodeSrv/apps/app-portal/test/n8nClient.test.js
@@ -0,0 +1,108 @@
+const { test } = require('node:test');
+const assert = require('node:assert');
+
+function withMockFetch(mockFetch, fn) {
+ const original = global.fetch;
+ global.fetch = mockFetch;
+ return fn().finally(() => {
+ global.fetch = original;
+ });
+}
+
+test('listFolders calls /projects/{projectId}/folders with the API key header', async () => {
+ process.env.N8N_BASE_URL = 'https://n8n.test';
+ process.env.N8N_API_KEY = 'test-key';
+ delete require.cache[require.resolve('../src/n8nClient')];
+ const { listFolders } = require('../src/n8nClient');
+
+ await withMockFetch(async (url, options) => {
+ const parsed = new URL(url);
+ assert.strictEqual(parsed.pathname, '/api/v1/projects/proj-1/folders');
+ assert.strictEqual(options.headers['X-N8N-API-KEY'], 'test-key');
+ return { ok: true, json: async () => ({ data: [{ id: 'f1', name: 'フォルダA', parentFolderId: null }] }) };
+ }, async () => {
+ const folders = await listFolders('proj-1');
+ assert.deepStrictEqual(folders, [{ id: 'f1', name: 'フォルダA', parentFolderId: null }]);
+ });
+});
+
+test('listWorkflows calls /workflows and returns the data array', async () => {
+ process.env.N8N_BASE_URL = 'https://n8n.test';
+ process.env.N8N_API_KEY = 'test-key';
+ delete require.cache[require.resolve('../src/n8nClient')];
+ const { listWorkflows } = require('../src/n8nClient');
+
+ await withMockFetch(async (url) => {
+ const parsed = new URL(url);
+ assert.strictEqual(parsed.pathname, '/api/v1/workflows');
+ return {
+ ok: true,
+ json: async () => ({ data: [{ id: 'w1', name: 'ワークフローA', active: true, parentFolder: null }] }),
+ };
+ }, async () => {
+ const workflows = await listWorkflows();
+ assert.deepStrictEqual(workflows, [{ id: 'w1', name: 'ワークフローA', active: true, parentFolder: null }]);
+ });
+});
+
+test('listExecutions passes workflowId and status as query params', async () => {
+ process.env.N8N_BASE_URL = 'https://n8n.test';
+ process.env.N8N_API_KEY = 'test-key';
+ delete require.cache[require.resolve('../src/n8nClient')];
+ const { listExecutions } = require('../src/n8nClient');
+
+ await withMockFetch(async (url) => {
+ const parsed = new URL(url);
+ assert.strictEqual(parsed.pathname, '/api/v1/executions');
+ assert.strictEqual(parsed.searchParams.get('workflowId'), 'w1');
+ assert.strictEqual(parsed.searchParams.get('status'), 'error');
+ assert.strictEqual(parsed.searchParams.get('limit'), '20');
+ return { ok: true, json: async () => ({ data: [{ id: 'e1', status: 'error' }] }) };
+ }, async () => {
+ const executions = await listExecutions('w1', { status: 'error', limit: 20 });
+ assert.deepStrictEqual(executions, [{ id: 'e1', status: 'error' }]);
+ });
+});
+
+test('listExecutions omits status param when not given', async () => {
+ process.env.N8N_BASE_URL = 'https://n8n.test';
+ process.env.N8N_API_KEY = 'test-key';
+ delete require.cache[require.resolve('../src/n8nClient')];
+ const { listExecutions } = require('../src/n8nClient');
+
+ await withMockFetch(async (url) => {
+ const parsed = new URL(url);
+ assert.strictEqual(parsed.searchParams.has('status'), false);
+ return { ok: true, json: async () => ({ data: [] }) };
+ }, async () => {
+ await listExecutions('w1', { limit: 20 });
+ });
+});
+
+test('getExecution requests includeData=true when asked and returns the execution', async () => {
+ process.env.N8N_BASE_URL = 'https://n8n.test';
+ process.env.N8N_API_KEY = 'test-key';
+ delete require.cache[require.resolve('../src/n8nClient')];
+ const { getExecution } = require('../src/n8nClient');
+
+ await withMockFetch(async (url) => {
+ const parsed = new URL(url);
+ assert.strictEqual(parsed.pathname, '/api/v1/executions/e1');
+ assert.strictEqual(parsed.searchParams.get('includeData'), 'true');
+ return { ok: true, json: async () => ({ id: 'e1', status: 'success', data: {} }) };
+ }, async () => {
+ const execution = await getExecution('e1', true);
+ assert.deepStrictEqual(execution, { id: 'e1', status: 'success', data: {} });
+ });
+});
+
+test('n8n client throws when the response is not ok', async () => {
+ process.env.N8N_BASE_URL = 'https://n8n.test';
+ process.env.N8N_API_KEY = 'test-key';
+ delete require.cache[require.resolve('../src/n8nClient')];
+ const { listWorkflows } = require('../src/n8nClient');
+
+ await withMockFetch(async () => ({ ok: false, status: 401 }), async () => {
+ await assert.rejects(() => listWorkflows(), /401/);
+ });
+});
diff --git a/NodeSrv/apps/app-portal/test/n8nTree.test.js b/NodeSrv/apps/app-portal/test/n8nTree.test.js
new file mode 100644
index 00000000..118e9154
--- /dev/null
+++ b/NodeSrv/apps/app-portal/test/n8nTree.test.js
@@ -0,0 +1,65 @@
+const { test } = require('node:test');
+const assert = require('node:assert');
+const { buildFolderTree } = require('../src/n8nTree');
+
+test('buildFolderTree returns an empty array when there are no folders or workflows', () => {
+ assert.deepStrictEqual(buildFolderTree([], []), []);
+});
+
+test('buildFolderTree places root-level workflows (no parentFolder) directly at the top', () => {
+ const workflows = [{ id: 'w1', name: 'ワークフローA', active: true, parentFolder: null }];
+ const tree = buildFolderTree([], workflows);
+ assert.deepStrictEqual(tree, [{ type: 'workflow', id: 'w1', name: 'ワークフローA', active: true }]);
+});
+
+test('buildFolderTree places root-level folders (no parentFolderId) at the top with empty children', () => {
+ const folders = [{ id: 'f1', name: 'フォルダA', parentFolderId: null }];
+ const tree = buildFolderTree(folders, []);
+ assert.deepStrictEqual(tree, [{ type: 'folder', id: 'f1', name: 'フォルダA', children: [] }]);
+});
+
+test('buildFolderTree nests a workflow inside its parent folder', () => {
+ const folders = [{ id: 'f1', name: 'フォルダA', parentFolderId: null }];
+ const workflows = [{ id: 'w1', name: 'ワークフローA', active: false, parentFolder: { id: 'f1' } }];
+ const tree = buildFolderTree(folders, workflows);
+ assert.deepStrictEqual(tree, [
+ {
+ type: 'folder',
+ id: 'f1',
+ name: 'フォルダA',
+ children: [{ type: 'workflow', id: 'w1', name: 'ワークフローA', active: false }],
+ },
+ ]);
+});
+
+test('buildFolderTree nests a sub-folder inside its parent folder', () => {
+ const folders = [
+ { id: 'f1', name: '親フォルダ', parentFolderId: null },
+ { id: 'f2', name: '子フォルダ', parentFolderId: 'f1' },
+ ];
+ const tree = buildFolderTree(folders, []);
+ assert.deepStrictEqual(tree, [
+ {
+ type: 'folder',
+ id: 'f1',
+ name: '親フォルダ',
+ children: [{ type: 'folder', id: 'f2', name: '子フォルダ', children: [] }],
+ },
+ ]);
+});
+
+test('buildFolderTree sorts folders before workflows, each alphabetically by name', () => {
+ const folders = [
+ { id: 'f2', name: 'Zフォルダ', parentFolderId: null },
+ { id: 'f1', name: 'Aフォルダ', parentFolderId: null },
+ ];
+ const workflows = [
+ { id: 'w2', name: 'Zワークフロー', active: true, parentFolder: null },
+ { id: 'w1', name: 'Aワークフロー', active: true, parentFolder: null },
+ ];
+ const tree = buildFolderTree(folders, workflows);
+ assert.deepStrictEqual(
+ tree.map((n) => n.name),
+ ['Aフォルダ', 'Zフォルダ', 'Aワークフロー', 'Zワークフロー']
+ );
+});
diff --git a/NodeSrv/apps/app-portal/test/n8nView.test.js b/NodeSrv/apps/app-portal/test/n8nView.test.js
new file mode 100644
index 00000000..38eedb08
--- /dev/null
+++ b/NodeSrv/apps/app-portal/test/n8nView.test.js
@@ -0,0 +1,83 @@
+const { test } = require('node:test');
+const assert = require('node:assert');
+const { renderFolderTree, renderExecutionList, renderExecutionDetail, renderN8nPage } = require('../src/n8nView');
+
+test('renderFolderTree renders nothing for an empty tree', () => {
+ assert.strictEqual(renderFolderTree([]), '');
+});
+
+test('renderFolderTree renders a workflow item with its id in a data attribute', () => {
+ const html = renderFolderTree([{ type: 'workflow', id: 'w1', name: 'ワークフローA', active: true }]);
+ assert.ok(html.includes('data-workflow-id="w1"'));
+ assert.ok(html.includes('ワークフローA'));
+});
+
+test('renderFolderTree marks inactive workflows differently from active ones', () => {
+ const activeHtml = renderFolderTree([{ type: 'workflow', id: 'w1', name: 'A', active: true }]);
+ const inactiveHtml = renderFolderTree([{ type: 'workflow', id: 'w2', name: 'B', active: false }]);
+ assert.ok(activeHtml.includes('bg-success'));
+ assert.ok(inactiveHtml.includes('bg-secondary'));
+});
+
+test('renderFolderTree escapes HTML in workflow and folder names', () => {
+ const html = renderFolderTree([{ type: 'workflow', id: 'w1', name: '', active: true }]);
+ assert.ok(!html.includes(''));
+ assert.ok(html.includes('<script>'));
+});
+
+test('renderFolderTree nests folder children inside the folder element', () => {
+ const html = renderFolderTree([
+ {
+ type: 'folder',
+ id: 'f1',
+ name: 'フォルダA',
+ children: [{ type: 'workflow', id: 'w1', name: 'ワークフローA', active: true }],
+ },
+ ]);
+ assert.ok(html.includes('フォルダA'));
+ assert.ok(html.includes('data-workflow-id="w1"'));
+});
+
+test('renderExecutionList renders a row per execution with a status badge', () => {
+ const html = renderExecutionList([
+ { id: 'e1', status: 'success', startedAt: '2026-09-19T00:00:00.000Z' },
+ { id: 'e2', status: 'error', startedAt: '2026-09-18T00:00:00.000Z' },
+ ]);
+ assert.ok(html.includes('data-execution-id="e1"'));
+ assert.ok(html.includes('data-execution-id="e2"'));
+ assert.ok(html.includes('bg-success'));
+ assert.ok(html.includes('bg-danger'));
+});
+
+test('renderExecutionList shows a message when there are no executions', () => {
+ const html = renderExecutionList([]);
+ assert.ok(html.includes('実行履歴なし'));
+});
+
+test('renderExecutionDetail lists each node result with its status', () => {
+ const execution = {
+ id: 'e1',
+ status: 'error',
+ data: {
+ resultData: {
+ runData: {
+ 'SSH: 対象wav一覧取得': [{ error: undefined }],
+ 'IF: 未登録ファイルあり': [{ error: { message: 'timeout' } }],
+ },
+ },
+ },
+ };
+ const html = renderExecutionDetail(execution);
+ assert.ok(html.includes('SSH: 対象wav一覧取得'));
+ assert.ok(html.includes('IF: 未登録ファイルあり'));
+ assert.ok(html.includes('timeout'));
+});
+
+test('renderN8nPage embeds the folder tree and a container for execution details', () => {
+ const tree = [{ type: 'workflow', id: 'w1', name: 'ワークフローA', active: true }];
+ const html = renderN8nPage(tree);
+ assert.ok(html.includes('data-workflow-id="w1"'));
+ assert.ok(html.includes('id="executionList"'));
+ assert.ok(html.includes('id="executionDetail"'));
+ assert.ok(html.includes('/n8n.js'));
+});