refactor(app-portal): n8nワークフロー分類をフォルダからタグベースに変更

n8n Public APIはworkflow.parentFolderIdがwriteOnlyでフォルダ→ワークフロー
対応を読み取れない仕様上の制約が判明したため、フォルダ階層表示を断念し
タグ単位のフラットなグルーピング(未分類は最後)に切り替えた。n8n側は
既存6フォルダ相当のタグを作成、44ワークフローへ割当済み。
This commit is contained in:
Kenichiro NOGI 2026-09-19 13:58:10 +09:00
parent eca5c84c39
commit 041a5e454c
6 changed files with 63 additions and 104 deletions

View File

@ -6,7 +6,6 @@ DOKPLOY_ENVIRONMENT_ID=Cm0HjMIFyl11UdIcIGRy8
PORTAL_ALLOWED_EMAILS=kenichiro.nogi@next-hd.co.jp PORTAL_ALLOWED_EMAILS=kenichiro.nogi@next-hd.co.jp
PORTAL_SECRET= PORTAL_SECRET=
PORTAL_MASTER_KEY= PORTAL_MASTER_KEY=
# n8nワークフロー実行ログ閲覧(/n8n)用 # n8nワークフロー実行ログ閲覧(/n8n)用。フォルダはPublic APIで取得不可なためタグで分類
N8N_BASE_URL=https://n8n32.next-hd.net N8N_BASE_URL=https://n8n32.next-hd.net
N8N_API_KEY= N8N_API_KEY=
N8N_PROJECT_ID=

View File

@ -15,8 +15,8 @@ const {
SESSION_COOKIE_NAME, SESSION_COOKIE_NAME,
} = require('./adminAuth'); } = require('./adminAuth');
const { renderLoginPage, renderAdminPage } = require('./adminView'); const { renderLoginPage, renderAdminPage } = require('./adminView');
const { listFolders, listWorkflows, listExecutions, getExecution } = require('./n8nClient'); const { listWorkflows, listExecutions, getExecution } = require('./n8nClient');
const { buildFolderTree } = require('./n8nTree'); const { buildTagTree } = require('./n8nTree');
const { renderN8nPage, renderExecutionList, renderExecutionDetail } = require('./n8nView'); const { renderN8nPage, renderExecutionList, renderExecutionDetail } = require('./n8nView');
const app = express(); const app = express();
@ -24,7 +24,6 @@ const PORT = process.env.PORT || 3000;
const ENVIRONMENT_ID = process.env.DOKPLOY_ENVIRONMENT_ID; const ENVIRONMENT_ID = process.env.DOKPLOY_ENVIRONMENT_ID;
const PORTAL_SECRET = process.env.PORTAL_SECRET; const PORTAL_SECRET = process.env.PORTAL_SECRET;
const MASTER_KEY = process.env.PORTAL_MASTER_KEY; const MASTER_KEY = process.env.PORTAL_MASTER_KEY;
const N8N_PROJECT_ID = process.env.N8N_PROJECT_ID;
const ALLOWLIST_FILE = getAllowlistFilePath(); const ALLOWLIST_FILE = getAllowlistFilePath();
ensureFile(ALLOWLIST_FILE, process.env.PORTAL_ALLOWED_EMAILS); 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) => { app.get('/n8n', async (req, res) => {
try { try {
const [folders, workflows] = await Promise.all([listFolders(N8N_PROJECT_ID), listWorkflows()]); const workflows = await listWorkflows();
const tree = buildFolderTree(folders, workflows); const tree = buildTagTree(workflows);
res.set('Content-Type', 'text/html; charset=utf-8').send(renderN8nPage(tree)); res.set('Content-Type', 'text/html; charset=utf-8').send(renderN8nPage(tree));
} catch (err) { } catch (err) {
console.error('n8n page render failed', err.message); console.error('n8n page render failed', err.message);

View File

@ -20,11 +20,6 @@ async function n8nGet(path, params) {
return res.json(); return res.json();
} }
async function listFolders(projectId) {
const body = await n8nGet(`/projects/${projectId}/folders`);
return body.data;
}
async function listWorkflows() { async function listWorkflows() {
const body = await n8nGet('/workflows'); const body = await n8nGet('/workflows');
return body.data; return body.data;
@ -39,4 +34,4 @@ async function getExecution(id, includeData) {
return n8nGet(`/executions/${id}`, { includeData: includeData ? 'true' : undefined }); return n8nGet(`/executions/${id}`, { includeData: includeData ? 'true' : undefined });
} }
module.exports = { listFolders, listWorkflows, listExecutions, getExecution }; module.exports = { listWorkflows, listExecutions, getExecution };

View File

@ -1,42 +1,33 @@
function buildFolderTree(folders, workflows) { const UNTAGGED_ID = '__untagged__';
const folderNodes = new Map(); const UNTAGGED_NAME = '未分類';
for (const folder of folders) {
folderNodes.set(folder.id, { type: 'folder', id: folder.id, name: folder.name, children: [] });
}
const roots = []; function buildTagTree(workflows) {
for (const folder of folders) { const groups = new Map();
const node = folderNodes.get(folder.id);
if (folder.parentFolderId && folderNodes.has(folder.parentFolderId)) { const getGroup = (id, name) => {
folderNodes.get(folder.parentFolderId).children.push(node); if (!groups.has(id)) groups.set(id, { type: 'folder', id, name, children: [] });
} else { return groups.get(id);
roots.push(node); };
}
}
for (const workflow of workflows) { for (const workflow of workflows) {
const node = { type: 'workflow', id: workflow.id, name: workflow.name, active: workflow.active }; const node = { type: 'workflow', id: workflow.id, name: workflow.name, active: workflow.active };
const parentId = workflow.parentFolder && workflow.parentFolder.id; const tags = workflow.tags || [];
if (parentId && folderNodes.has(parentId)) { if (tags.length === 0) {
folderNodes.get(parentId).children.push(node); getGroup(UNTAGGED_ID, UNTAGGED_NAME).children.push(node);
} else { } else {
roots.push(node); for (const tag of tags) {
getGroup(tag.name, tag.name).children.push(node);
}
} }
} }
const byTypeThenName = (a, b) => { const result = [...groups.values()];
if (a.type !== b.type) return a.type === 'folder' ? -1 : 1; result.sort((a, b) => {
if (a.id === UNTAGGED_ID) return 1;
if (b.id === UNTAGGED_ID) return -1;
return a.name.localeCompare(b.name); return a.name.localeCompare(b.name);
}; });
const sortTree = (nodes) => { return result;
nodes.sort(byTypeThenName);
for (const node of nodes) {
if (node.type === 'folder') sortTree(node.children);
}
return nodes;
};
return sortTree(roots);
} }
module.exports = { buildFolderTree }; module.exports = { buildTagTree };

View File

@ -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 () => { test('listWorkflows calls /workflows and returns the data array', async () => {
process.env.N8N_BASE_URL = 'https://n8n.test'; process.env.N8N_BASE_URL = 'https://n8n.test';
process.env.N8N_API_KEY = 'test-key'; process.env.N8N_API_KEY = 'test-key';

View File

@ -1,65 +1,57 @@
const { test } = require('node:test'); const { test } = require('node:test');
const assert = require('node:assert'); 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', () => { test('buildTagTree returns an empty array when there are no workflows', () => {
assert.deepStrictEqual(buildFolderTree([], []), []); assert.deepStrictEqual(buildTagTree([]), []);
}); });
test('buildFolderTree places root-level workflows (no parentFolder) directly at the top', () => { test('buildTagTree groups a workflow under its tag name', () => {
const workflows = [{ id: 'w1', name: 'ワークフローA', active: true, parentFolder: null }]; const workflows = [{ id: 'w1', name: 'ワークフローA', active: true, tags: [{ name: 'タグA' }] }];
const tree = buildFolderTree([], workflows); const tree = buildTagTree(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, [ assert.deepStrictEqual(tree, [
{ {
type: 'folder', type: 'folder',
id: 'f1', id: 'タグA',
name: 'フォルダA', name: 'タグA',
children: [{ type: 'workflow', id: 'w1', name: 'ワークフローA', active: false }], children: [{ type: 'workflow', id: 'w1', name: 'ワークフローA', active: true }],
}, },
]); ]);
}); });
test('buildFolderTree nests a sub-folder inside its parent folder', () => { test('buildTagTree puts untagged workflows into a "未分類" group', () => {
const folders = [ const workflows = [{ id: 'w1', name: 'ワークフローA', active: true, tags: [] }];
{ id: 'f1', name: '親フォルダ', parentFolderId: null }, const tree = buildTagTree(workflows);
{ id: 'f2', name: '子フォルダ', parentFolderId: 'f1' },
];
const tree = buildFolderTree(folders, []);
assert.deepStrictEqual(tree, [ assert.deepStrictEqual(tree, [
{ {
type: 'folder', type: 'folder',
id: 'f1', id: '__untagged__',
name: '親フォルダ', name: '未分類',
children: [{ type: 'folder', id: 'f2', name: '子フォルダ', children: [] }], children: [{ type: 'workflow', id: 'w1', name: 'ワークフローA', active: true }],
}, },
]); ]);
}); });
test('buildFolderTree sorts folders before workflows, each alphabetically by name', () => { test('buildTagTree puts a multi-tagged workflow into every one of its tag groups', () => {
const folders = [ const workflows = [{ id: 'w1', name: 'ワークフローA', active: true, tags: [{ name: 'タグA' }, { name: 'タグB' }] }];
{ id: 'f2', name: 'Zフォルダ', parentFolderId: null }, const tree = buildTagTree(workflows);
{ 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( assert.deepStrictEqual(
tree.map((n) => n.name), tree.map((g) => g.name),
['Aフォルダ', 'Zフォルダ', 'Aワークフロー', 'Zワークフロー'] ['タグ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タグ', '未分類']
); );
}); });