From 041a5e454cf97cbb001802af3ca56182e4957dca Mon Sep 17 00:00:00 2001 From: Kenichiro NOGI Date: Sat, 19 Sep 2026 13:58:10 +0900 Subject: [PATCH] =?UTF-8?q?refactor(app-portal):=20n8n=E3=83=AF=E3=83=BC?= =?UTF-8?q?=E3=82=AF=E3=83=95=E3=83=AD=E3=83=BC=E5=88=86=E9=A1=9E=E3=82=92?= =?UTF-8?q?=E3=83=95=E3=82=A9=E3=83=AB=E3=83=80=E3=81=8B=E3=82=89=E3=82=BF?= =?UTF-8?q?=E3=82=B0=E3=83=99=E3=83=BC=E3=82=B9=E3=81=AB=E5=A4=89=E6=9B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit n8n Public APIはworkflow.parentFolderIdがwriteOnlyでフォルダ→ワークフロー 対応を読み取れない仕様上の制約が判明したため、フォルダ階層表示を断念し タグ単位のフラットなグルーピング(未分類は最後)に切り替えた。n8n側は 既存6フォルダ相当のタグを作成、44ワークフローへ割当済み。 --- NodeSrv/apps/app-portal/.env.example | 3 +- NodeSrv/apps/app-portal/src/index.js | 9 +-- NodeSrv/apps/app-portal/src/n8nClient.js | 7 +- NodeSrv/apps/app-portal/src/n8nTree.js | 53 ++++++------- .../apps/app-portal/test/n8nClient.test.js | 17 ---- NodeSrv/apps/app-portal/test/n8nTree.test.js | 78 +++++++++---------- 6 files changed, 63 insertions(+), 104 deletions(-) diff --git a/NodeSrv/apps/app-portal/.env.example b/NodeSrv/apps/app-portal/.env.example index fd7c464f..35378ff0 100644 --- a/NodeSrv/apps/app-portal/.env.example +++ b/NodeSrv/apps/app-portal/.env.example @@ -6,7 +6,6 @@ DOKPLOY_ENVIRONMENT_ID=Cm0HjMIFyl11UdIcIGRy8 PORTAL_ALLOWED_EMAILS=kenichiro.nogi@next-hd.co.jp PORTAL_SECRET= PORTAL_MASTER_KEY= -# n8nワークフロー実行ログ閲覧(/n8n)用 +# n8nワークフロー実行ログ閲覧(/n8n)用。フォルダはPublic APIで取得不可なためタグで分類 N8N_BASE_URL=https://n8n32.next-hd.net N8N_API_KEY= -N8N_PROJECT_ID= diff --git a/NodeSrv/apps/app-portal/src/index.js b/NodeSrv/apps/app-portal/src/index.js index 2acfc26d..267063b9 100644 --- a/NodeSrv/apps/app-portal/src/index.js +++ b/NodeSrv/apps/app-portal/src/index.js @@ -15,8 +15,8 @@ const { SESSION_COOKIE_NAME, } = require('./adminAuth'); const { renderLoginPage, renderAdminPage } = require('./adminView'); -const { listFolders, listWorkflows, listExecutions, getExecution } = require('./n8nClient'); -const { buildFolderTree } = require('./n8nTree'); +const { listWorkflows, listExecutions, getExecution } = require('./n8nClient'); +const { buildTagTree } = require('./n8nTree'); const { renderN8nPage, renderExecutionList, renderExecutionDetail } = require('./n8nView'); const app = express(); @@ -24,7 +24,6 @@ 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); @@ -171,8 +170,8 @@ 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); + const workflows = await listWorkflows(); + const tree = buildTagTree(workflows); res.set('Content-Type', 'text/html; charset=utf-8').send(renderN8nPage(tree)); } catch (err) { console.error('n8n page render failed', err.message); diff --git a/NodeSrv/apps/app-portal/src/n8nClient.js b/NodeSrv/apps/app-portal/src/n8nClient.js index d15f4c50..03f96ad1 100644 --- a/NodeSrv/apps/app-portal/src/n8nClient.js +++ b/NodeSrv/apps/app-portal/src/n8nClient.js @@ -20,11 +20,6 @@ async function n8nGet(path, params) { 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; @@ -39,4 +34,4 @@ async function getExecution(id, includeData) { return n8nGet(`/executions/${id}`, { includeData: includeData ? 'true' : undefined }); } -module.exports = { listFolders, listWorkflows, listExecutions, getExecution }; +module.exports = { listWorkflows, listExecutions, getExecution }; diff --git a/NodeSrv/apps/app-portal/src/n8nTree.js b/NodeSrv/apps/app-portal/src/n8nTree.js index 9f2aa9ec..0a1ac65e 100644 --- a/NodeSrv/apps/app-portal/src/n8nTree.js +++ b/NodeSrv/apps/app-portal/src/n8nTree.js @@ -1,42 +1,33 @@ -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 UNTAGGED_ID = '__untagged__'; +const UNTAGGED_NAME = '未分類'; - 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); - } - } +function buildTagTree(workflows) { + const groups = new Map(); + + const getGroup = (id, name) => { + if (!groups.has(id)) groups.set(id, { type: 'folder', id, name, children: [] }); + return groups.get(id); + }; 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); + const tags = workflow.tags || []; + if (tags.length === 0) { + getGroup(UNTAGGED_ID, UNTAGGED_NAME).children.push(node); } else { - roots.push(node); + for (const tag of tags) { + getGroup(tag.name, tag.name).children.push(node); + } } } - const byTypeThenName = (a, b) => { - if (a.type !== b.type) return a.type === 'folder' ? -1 : 1; + const result = [...groups.values()]; + result.sort((a, b) => { + if (a.id === UNTAGGED_ID) return 1; + if (b.id === UNTAGGED_ID) return -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); + }); + return result; } -module.exports = { buildFolderTree }; +module.exports = { buildTagTree }; diff --git a/NodeSrv/apps/app-portal/test/n8nClient.test.js b/NodeSrv/apps/app-portal/test/n8nClient.test.js index 8777bba3..d209032b 100644 --- a/NodeSrv/apps/app-portal/test/n8nClient.test.js +++ b/NodeSrv/apps/app-portal/test/n8nClient.test.js @@ -9,23 +9,6 @@ function withMockFetch(mockFetch, fn) { }); } -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'; diff --git a/NodeSrv/apps/app-portal/test/n8nTree.test.js b/NodeSrv/apps/app-portal/test/n8nTree.test.js index 118e9154..256d4219 100644 --- a/NodeSrv/apps/app-portal/test/n8nTree.test.js +++ b/NodeSrv/apps/app-portal/test/n8nTree.test.js @@ -1,65 +1,57 @@ const { test } = require('node:test'); const assert = require('node:assert'); -const { buildFolderTree } = require('../src/n8nTree'); +const { buildTagTree } = require('../src/n8nTree'); -test('buildFolderTree returns an empty array when there are no folders or workflows', () => { - assert.deepStrictEqual(buildFolderTree([], []), []); +test('buildTagTree returns an empty array when there are no workflows', () => { + assert.deepStrictEqual(buildTagTree([]), []); }); -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); +test('buildTagTree groups a workflow under its tag name', () => { + const workflows = [{ id: 'w1', name: 'ワークフローA', active: true, tags: [{ name: 'タグA' }] }]; + const tree = buildTagTree(workflows); assert.deepStrictEqual(tree, [ { type: 'folder', - id: 'f1', - name: 'フォルダA', - children: [{ type: 'workflow', id: 'w1', name: 'ワークフローA', active: false }], + id: 'タグA', + name: 'タグA', + children: [{ type: 'workflow', id: 'w1', name: 'ワークフローA', active: true }], }, ]); }); -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, []); +test('buildTagTree puts untagged workflows into a "未分類" group', () => { + const workflows = [{ id: 'w1', name: 'ワークフローA', active: true, tags: [] }]; + const tree = buildTagTree(workflows); assert.deepStrictEqual(tree, [ { type: 'folder', - id: 'f1', - name: '親フォルダ', - children: [{ type: 'folder', id: 'f2', name: '子フォルダ', children: [] }], + id: '__untagged__', + name: '未分類', + children: [{ type: 'workflow', id: 'w1', name: 'ワークフローA', active: true }], }, ]); }); -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); +test('buildTagTree puts a multi-tagged workflow into every one of its tag groups', () => { + const workflows = [{ id: 'w1', name: 'ワークフローA', active: true, tags: [{ name: 'タグA' }, { name: 'タグB' }] }]; + const tree = buildTagTree(workflows); assert.deepStrictEqual( - tree.map((n) => n.name), - ['Aフォルダ', 'Zフォルダ', 'Aワークフロー', 'Zワークフロー'] + tree.map((g) => g.name), + ['タグA', 'タグB'] + ); + assert.strictEqual(tree[0].children.length, 1); + assert.strictEqual(tree[1].children.length, 1); +}); + +test('buildTagTree sorts tag groups alphabetically and puts "未分類" last', () => { + const workflows = [ + { id: 'w1', name: 'A', active: true, tags: [{ name: 'Zタグ' }] }, + { id: 'w2', name: 'B', active: true, tags: [] }, + { id: 'w3', name: 'C', active: true, tags: [{ name: 'Aタグ' }] }, + ]; + const tree = buildTagTree(workflows); + assert.deepStrictEqual( + tree.map((g) => g.name), + ['Aタグ', 'Zタグ', '未分類'] ); });