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)}
    + +
  • `; +} + +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 ` + + ${rows} +
    開始日時状態
    `; +} + +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 ` + + ${rows} +
    ノード結果エラー内容
    `; +} + +function renderN8nPage(tree) { + const treeHtml = renderFolderTree(tree); + return ` + + + + +n8nワークフロー + + + + +
    +
    +
    +

    ワークフロー一覧

    +
      ${treeHtml}
    +
    +
    +

    実行履歴

    +

    ワークフローを選択してください

    +
    +
    +

    実行詳細

    +

    実行を選択してください

    +
    +
    +
    + + + +`; +} + +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')); +});