feat(app-portal): n8nワークフローのフォルダ構造・実行ログ閲覧ページを追加
n8nのフォルダ/ワークフロー構成をそのままapp-portal(/n8n)に反映し、 Webhookトリガーに限らず任意のワークフローの実行履歴・ノード単位の 成功/失敗・エラー内容を確認できるようにした(get-voice-mail専用にせず n8n Public API経由で動的取得する汎用設計)。TDDで実装、テスト23件追加。
This commit is contained in:
parent
4b14d992af
commit
eca5c84c39
@ -6,3 +6,7 @@ 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_BASE_URL=https://n8n32.next-hd.net
|
||||||
|
N8N_API_KEY=
|
||||||
|
N8N_PROJECT_ID=
|
||||||
|
|||||||
16
NodeSrv/apps/app-portal/public/n8n.js
Normal file
16
NodeSrv/apps/app-portal/public/n8n.js
Normal file
@ -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 = '<p class="text-muted">実行を選択してください</p>';
|
||||||
|
});
|
||||||
|
|
||||||
|
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() : '実行詳細取得に失敗しました';
|
||||||
|
});
|
||||||
@ -106,6 +106,7 @@ async function renderDashboard(environmentId) {
|
|||||||
<nav class="navbar navbar-dark bg-dark mb-4">
|
<nav class="navbar navbar-dark bg-dark mb-4">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<span class="navbar-brand mb-0 h1">App Portal</span>
|
<span class="navbar-brand mb-0 h1">App Portal</span>
|
||||||
|
<a class="btn btn-outline-light btn-sm me-2" href="/n8n">n8nワークフロー</a>
|
||||||
<a class="btn btn-outline-light btn-sm" href="/admin">管理</a>
|
<a class="btn btn-outline-light btn-sm" href="/admin">管理</a>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@ -15,12 +15,16 @@ 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 { buildFolderTree } = require('./n8nTree');
|
||||||
|
const { renderN8nPage, renderExecutionList, renderExecutionDetail } = require('./n8nView');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3000;
|
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);
|
||||||
|
|
||||||
@ -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, () => {
|
app.listen(PORT, () => {
|
||||||
console.log(`app-portal listening on port ${PORT}`);
|
console.log(`app-portal listening on port ${PORT}`);
|
||||||
});
|
});
|
||||||
|
|||||||
42
NodeSrv/apps/app-portal/src/n8nClient.js
Normal file
42
NodeSrv/apps/app-portal/src/n8nClient.js
Normal file
@ -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 };
|
||||||
42
NodeSrv/apps/app-portal/src/n8nTree.js
Normal file
42
NodeSrv/apps/app-portal/src/n8nTree.js
Normal file
@ -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 };
|
||||||
120
NodeSrv/apps/app-portal/src/n8nView.js
Normal file
120
NodeSrv/apps/app-portal/src/n8nView.js
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.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 `<li class="list-group-item d-flex justify-content-between align-items-center" data-workflow-id="${escapeHtml(node.id)}" role="button">
|
||||||
|
<span>${escapeHtml(node.name)}</span>
|
||||||
|
<span class="badge ${badgeClass}">${badgeText}</span>
|
||||||
|
</li>`;
|
||||||
|
}
|
||||||
|
return `<li class="list-group-item">
|
||||||
|
<div class="fw-bold mb-1">📁 ${escapeHtml(node.name)}</div>
|
||||||
|
<ul class="list-group ms-3">${renderFolderTree(node.children)}</ul>
|
||||||
|
</li>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 '<p class="text-muted">実行履歴なし</p>';
|
||||||
|
}
|
||||||
|
const rows = executions
|
||||||
|
.map((e) => {
|
||||||
|
const badgeClass = EXECUTION_BADGE[e.status] || 'bg-secondary';
|
||||||
|
return `<tr data-execution-id="${escapeHtml(e.id)}" role="button">
|
||||||
|
<td>${escapeHtml(e.startedAt || '')}</td>
|
||||||
|
<td><span class="badge ${badgeClass}">${escapeHtml(e.status)}</span></td>
|
||||||
|
</tr>`;
|
||||||
|
})
|
||||||
|
.join('\n');
|
||||||
|
return `<table class="table table-sm table-hover">
|
||||||
|
<thead><tr><th>開始日時</th><th>状態</th></tr></thead>
|
||||||
|
<tbody>${rows}</tbody>
|
||||||
|
</table>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 `<tr>
|
||||||
|
<td>${escapeHtml(nodeName)}</td>
|
||||||
|
<td><span class="badge ${badgeClass}">${badgeText}</span></td>
|
||||||
|
<td>${errorMessage}</td>
|
||||||
|
</tr>`;
|
||||||
|
})
|
||||||
|
.join('\n');
|
||||||
|
return `<table class="table table-sm">
|
||||||
|
<thead><tr><th>ノード</th><th>結果</th><th>エラー内容</th></tr></thead>
|
||||||
|
<tbody>${rows}</tbody>
|
||||||
|
</table>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderN8nPage(tree) {
|
||||||
|
const treeHtml = renderFolderTree(tree);
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="ja">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>n8nワークフロー</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body class="bg-body-tertiary">
|
||||||
|
<nav class="navbar navbar-dark bg-dark mb-4">
|
||||||
|
<div class="container-fluid">
|
||||||
|
<span class="navbar-brand mb-0 h1">n8nワークフロー</span>
|
||||||
|
<a class="btn btn-outline-light btn-sm" href="/">ダッシュボード</a>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
<main class="container-fluid pb-5">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12 col-md-4">
|
||||||
|
<h2 class="h5">ワークフロー一覧</h2>
|
||||||
|
<ul class="list-group" id="folderTree">${treeHtml}</ul>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-4">
|
||||||
|
<h2 class="h5">実行履歴</h2>
|
||||||
|
<div id="executionList"><p class="text-muted">ワークフローを選択してください</p></div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-4">
|
||||||
|
<h2 class="h5">実行詳細</h2>
|
||||||
|
<div id="executionDetail"><p class="text-muted">実行を選択してください</p></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
<script src="/n8n.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { renderFolderTree, renderExecutionList, renderExecutionDetail, renderN8nPage };
|
||||||
108
NodeSrv/apps/app-portal/test/n8nClient.test.js
Normal file
108
NodeSrv/apps/app-portal/test/n8nClient.test.js
Normal file
@ -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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
65
NodeSrv/apps/app-portal/test/n8nTree.test.js
Normal file
65
NodeSrv/apps/app-portal/test/n8nTree.test.js
Normal file
@ -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ワークフロー']
|
||||||
|
);
|
||||||
|
});
|
||||||
83
NodeSrv/apps/app-portal/test/n8nView.test.js
Normal file
83
NodeSrv/apps/app-portal/test/n8nView.test.js
Normal file
@ -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: '<script>bad</script>', active: true }]);
|
||||||
|
assert.ok(!html.includes('<script>bad</script>'));
|
||||||
|
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'));
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user