refactor(app-portal): n8nワークフロー分類をフォルダからタグベースに変更
n8n Public APIはworkflow.parentFolderIdがwriteOnlyでフォルダ→ワークフロー 対応を読み取れない仕様上の制約が判明したため、フォルダ階層表示を断念し タグ単位のフラットなグルーピング(未分類は最後)に切り替えた。n8n側は 既存6フォルダ相当のタグを作成、44ワークフローへ割当済み。
This commit is contained in:
parent
eca5c84c39
commit
041a5e454c
@ -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=
|
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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 };
|
||||||
|
|||||||
@ -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 };
|
||||||
|
|||||||
@ -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';
|
||||||
|
|||||||
@ -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タグ', '未分類']
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user