feat: get-voice-mail app-portalキッカー追加、app-portal UIをBootstrap化
get-voice-mailはn8n Webhookへの中継専用アプリ(SCP取得・プリザンター書込はn8n側で実施)。 app-portalはBootstrap 5導入で見た目を刷新(カード/モーダル/トースト化)。 dokploy-webapp SKILLはGitea統合(NodeSrv→ken_nogi)後の設定変更を反映。
This commit is contained in:
parent
4b8498ba2d
commit
76700845fe
125
.claude/skills/dokploy-webapp/SKILL.md
Normal file
125
.claude/skills/dokploy-webapp/SKILL.md
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
---
|
||||||
|
name: dokploy-webapp
|
||||||
|
description: NodeSrv配下(apps/<app-name>)にNode.js WEBアプリを新規作成し、ローカル開発から既存Dokploy環境(AWS Lightsail上で稼働中のセルフホストPaaS)へのデプロイ・運用までを行う。「新しいアプリを作って」「Dokployにデプロイして」「アプリをNodeSrvに追加して」「動作検証して」等の依頼で使う。
|
||||||
|
---
|
||||||
|
|
||||||
|
# NodeSrv アプリ開発 & Dokployデプロイ
|
||||||
|
|
||||||
|
`NodeSrv/apps/<app-name>` を1アプリ1フォルダ1Dokploy Composeとして管理する。アプリ同士は完全に独立し、片方の開発・再デプロイがもう片方に影響しない設計。
|
||||||
|
|
||||||
|
## 新規アプリ作成
|
||||||
|
|
||||||
|
1. `NodeSrv/apps/_template` を `NodeSrv/apps/<app-name>` としてコピー
|
||||||
|
2. `package.json` の `name` を `<app-name>` に変更
|
||||||
|
3. 必要な依存関係を追加、`src/index.js` に実装を追加していく
|
||||||
|
4. `/health` エンドポイントは削除・変更しない(Dokployのヘルスチェック対象)
|
||||||
|
|
||||||
|
## ローカル開発
|
||||||
|
|
||||||
|
```
|
||||||
|
cd NodeSrv/apps/<app-name>
|
||||||
|
npm install
|
||||||
|
cp .env.example .env
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
`http://localhost:<PORT>/` と `/health` で動作確認。
|
||||||
|
|
||||||
|
## デプロイ前のDocker動作確認(必須)
|
||||||
|
|
||||||
|
Dokployは本番でDockerfileから直接ビルドするため、pushする前にローカルでも同じ手順でビルド・起動できることを確認する。
|
||||||
|
|
||||||
|
```
|
||||||
|
docker compose -f docker-compose.local.yml up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
エラー無く起動し `/health` が200を返すことを確認してから次に進む。
|
||||||
|
|
||||||
|
## Gitソース: Gitea
|
||||||
|
|
||||||
|
GitHub直接連携ではなく、Dokploy上に自前ホストしたGitea(`https://git73.next-hd.net/mygit-admin/ken_nogi.git`、リモート名`gitea`)を経由する。理由: モノレポ(`ken_nogi`)のプライベートリポジトリをDokployのGit Provider連携で直接使う設定がうまくいかず、Gitea自前ホストに切り替えた経緯がある(詳細はプロジェクトメモリ参照)。
|
||||||
|
|
||||||
|
**注意(2026-09-19判明)**: 当初はGitea上に`NodeSrv`という単体リポジトリを作って運用していたが、その後`dev`配下(NodeSrv/Pleasanter等)を`ken_nogi`という1つのGiteaリポジトリへ統合した。`NodeSrv`単体リポジトリは既に削除済み(`git73.next-hd.net/mygit-admin/NodeSrv`は404)。Dokploy側の既存Compose(app-portal/n8n等)は統合後もしばらく古い`giteaRepository: "NodeSrv"`のままになっていて、pushしても反映されない状態だった。2026-09-19に全Compose(app-portal/authgw-poc/auth-redirect/portal-sample-batch/n8n/keycloak-monitor/lineworks-user-auth)を`giteaRepository: "ken_nogi"`・`composePath`の先頭に`NodeSrv/`を付ける形へ`dokploy compose update`で修正済み。**新規Compose作成時は`giteaRepository`に`ken_nogi`、`composePath`は必ず`NodeSrv/apps/<app-name>/docker-compose.yml`(先頭に`NodeSrv/`が要る)を指定すること。**
|
||||||
|
|
||||||
|
```
|
||||||
|
git remote -v # "gitea" が登録済みか確認
|
||||||
|
git push gitea main
|
||||||
|
```
|
||||||
|
|
||||||
|
**注意**: Windows環境でBashツールからpushすると、Git Credential Manager (GCM) がGUIダイアログを出そうとして `tty` が無い環境では失敗する(`fatal: helper error (-1): User cancelled dialog.`)。その場合は一時的な `GIT_ASKPASS` スクリプトで認証情報(`AUTH_SECRETS.md` の Gitea項目)を渡し、push後は必ずスクリプトを削除する。認証情報を `git push` のURLやBashコマンド引数に直接書かない。
|
||||||
|
|
||||||
|
## Dokployへの接続
|
||||||
|
|
||||||
|
`@dokploy/cli`(`npm install -g @dokploy/cli`)をインストール済みで使う。認証は `dokploy auth -u https://dokploy45.next-hd.net -t <token>` 済みでセッションが保持されている前提(**トークンは絶対にチャットに貼らせない・貼られたら即Revoke指示**。認証はユーザー自身のターミナルで実行させる)。以後は `dokploy <command>` をそのまま実行してよい(本番操作なので実行前に毎回内容を提示し確認を取る)。
|
||||||
|
|
||||||
|
**既存環境の実態**(2026-07-25確認、CLI v0.29.4):
|
||||||
|
- NodeSrv用アプリは `webapps` Project配下に **Compose**(Docker Compose、Provider: Gitea、`sourceType: gitea`)として追加する。Dokploy標準の「Application」type・「Domains」タブは使わない(README.md「共通ルール」参照)
|
||||||
|
- `projectId: 3T-drY1Q377JcLeTiNTDK` / `environmentId: Cm0HjMIFyl11UdIcIGRy8`(`production`環境)
|
||||||
|
- **CLIのGET系コマンドはほぼ全滅**(`compose one`, `docker get-containers*` 等が軒並み `Request failed with status code 400`)。POST/PUT系(`compose deploy`等)は動く。状態確認はCLIに頼らずSSH直接調査(下記)を使う
|
||||||
|
- **原因特定済み(2026-07-25)**: `@dokploy/cli`(`dist/client.js`の`apiGet`関数)がtRPCのGETクエリを組み立てる際、`apiPost`と違い`{json: params}`でラップせず生の`params`を`?input=`にJSON化しているだけの実装バグ。DokployのtRPCサーバーは`{"json": {...}}`形式のラップを要求するため、これがない全てのGETリクエストが400になる
|
||||||
|
- Dokploy tRPC API自体は正常。`x-api-key`ヘッダー付きで`GET {DOKPLOY_URL}/api/trpc/<endpoint>?input=<encodeURIComponent('{"json":{...}}')>`の形式で直接叩けば200が返ることを確認済み(`compose.one`で実証)。ポータル等でDokploy API連携が必要な場合は、CLIを介さずこの正しい形式で自前クライアントを実装すること
|
||||||
|
- CLI自体へのパッチは行わない方針(グローバルnpmパッケージの一時修正は`npm update`等で消えるため、恒常的な対応にならない)
|
||||||
|
|
||||||
|
## Dokployへのデプロイ(CLI手順)
|
||||||
|
|
||||||
|
1アプリ = 1 Dokploy "Compose"。`webapps` Project配下に、`apps/<app-name>/docker-compose.yml` をCompose Pathとして指定する。
|
||||||
|
|
||||||
|
### 新規Compose作成(CLI手順、2026-07-25実地検証済み)
|
||||||
|
|
||||||
|
`dokploy compose create`はGitHub向けのデフォルト値で作成されるため、直後に`dokploy compose update`でGitea連携用の値へ切り替える2段階が必要。
|
||||||
|
|
||||||
|
```
|
||||||
|
# 1. Compose自体を作成(webapps projectのenvironmentIdを指定)
|
||||||
|
dokploy compose create \
|
||||||
|
--name "<app-name>" \
|
||||||
|
--environmentId "Cm0HjMIFyl11UdIcIGRy8" \
|
||||||
|
--composeType "docker-compose" \
|
||||||
|
--appName "<app-name>" \
|
||||||
|
--json
|
||||||
|
# → composeId が返る
|
||||||
|
|
||||||
|
# 2. sourceTypeをgiteaへ切替、リポジトリ・パスを指定
|
||||||
|
dokploy compose update \
|
||||||
|
--composeId "<上で得たcomposeId>" \
|
||||||
|
--sourceType "gitea" \
|
||||||
|
--giteaId "O5-CqLQwVdlzXw3KfmN-8" \
|
||||||
|
--giteaOwner "mygit-admin" \
|
||||||
|
--giteaRepository "ken_nogi" \
|
||||||
|
--giteaBranch "main" \
|
||||||
|
--composePath "NodeSrv/apps/<app-name>/docker-compose.yml" \
|
||||||
|
--json
|
||||||
|
|
||||||
|
# 3. デプロイ実行
|
||||||
|
dokploy compose deploy --composeId "<composeId>" --title "<変更内容>" --json
|
||||||
|
```
|
||||||
|
|
||||||
|
`serverId`は省略可(単一サーバー構成のため)。
|
||||||
|
|
||||||
|
## デプロイ後の検証
|
||||||
|
|
||||||
|
CLIの `compose one` 等GET系コマンドは400エラーで使えないため、以下のいずれかで代替する。
|
||||||
|
|
||||||
|
```
|
||||||
|
# SSH経由でコンテナ状態・ラベルを直接確認
|
||||||
|
ssh -i NodeSrv/Keys/LightsailDefaultKey-ap-northeast-1.pem ubuntu@dokploy45.next-hd.net \
|
||||||
|
"sudo docker ps --filter name=<app-name>"
|
||||||
|
ssh -i NodeSrv/Keys/LightsailDefaultKey-ap-northeast-1.pem ubuntu@dokploy45.next-hd.net \
|
||||||
|
"sudo docker inspect <container-name> --format '{{json .Config.Labels}}'"
|
||||||
|
|
||||||
|
# 外部疎通確認
|
||||||
|
curl -I https://<app-name>.next-hd.net/health
|
||||||
|
```
|
||||||
|
|
||||||
|
**SSHが自動モード分類器にブロックされる場合**(2026-07-26確認、`sudo`付きコマンド等でブロックされることがある)は、上記57行目の自前fetchクライアント方式でDokploy側の状態(デプロイ履歴の`status`、compose設定等)を確認する。認証情報はCLIの`config.json`(`C:/Users/k.nogi/AppData/Roaming/npm/node_modules/@dokploy/cli/config.json`)から読み取り、結果は必ずファイルへ書き出してから必要な値だけ抽出する(レスポンスに機密情報が含まれるため画面に出さない)。デプロイ完了確認は`deployment.allByCompose`エンドポイントの`status`(`done`か)を見る。
|
||||||
|
|
||||||
|
- 割り当てたドメインへ `curl` し `/health` が200
|
||||||
|
- 別アプリを同時にredeployし、対象アプリ以外が無停止であることを確認(独立性の担保)
|
||||||
|
|
||||||
|
## アプリポータルへの登録(任意)
|
||||||
|
|
||||||
|
新規アプリを`NodeSrv/apps/app-portal`のダッシュボードに登録したい場合、`.env.example`にPORTAL_*変数を追加する(詳細は`NodeSrv/apps/_template/README.md`「アプリポータルへの登録」章、実装は`NodeSrv/apps/app-portal/src/dokployClient.js`参照)。
|
||||||
|
|
||||||
|
## 参考
|
||||||
|
|
||||||
|
- 全体の運用方針・認証ゲートウェイの詳細: [../../../NodeSrv/README.md](../../../NodeSrv/README.md)
|
||||||
|
- Dokployインフラの経緯: プロジェクトメモリ `project_nodesrv-dokploy-lightsail`
|
||||||
@ -1,3 +1,22 @@
|
|||||||
|
const logModalEl = document.getElementById('logModal');
|
||||||
|
const logModalBody = document.getElementById('logModalBody');
|
||||||
|
const logModal = logModalEl ? new bootstrap.Modal(logModalEl) : null;
|
||||||
|
|
||||||
|
const toastEl = document.getElementById('actionToast');
|
||||||
|
const toastBody = document.getElementById('actionToastBody');
|
||||||
|
const toast = toastEl ? new bootstrap.Toast(toastEl) : null;
|
||||||
|
|
||||||
|
function showToast(message, isError) {
|
||||||
|
if (!toast) {
|
||||||
|
alert(message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toastBody.textContent = message;
|
||||||
|
toastEl.classList.toggle('text-bg-danger', Boolean(isError));
|
||||||
|
toastEl.classList.toggle('text-bg-primary', !isError);
|
||||||
|
toast.show();
|
||||||
|
}
|
||||||
|
|
||||||
document.getElementById('cards').addEventListener('click', async (event) => {
|
document.getElementById('cards').addEventListener('click', async (event) => {
|
||||||
const button = event.target.closest('button[data-action]');
|
const button = event.target.closest('button[data-action]');
|
||||||
if (!button) return;
|
if (!button) return;
|
||||||
@ -6,18 +25,19 @@ document.getElementById('cards').addEventListener('click', async (event) => {
|
|||||||
if (action === 'logs') {
|
if (action === 'logs') {
|
||||||
const res = await fetch(`/api/compose/${composeId}/logs`);
|
const res = await fetch(`/api/compose/${composeId}/logs`);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
alert(`ログ取得に失敗しました (status: ${res.status})`);
|
showToast(`ログ取得に失敗しました (status: ${res.status})`, true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
alert(data.logs || 'ログなし');
|
logModalBody.textContent = data.logs || 'ログなし';
|
||||||
|
logModal.show();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await fetch(`/api/compose/${composeId}/${action}`, { method: 'POST' });
|
const res = await fetch(`/api/compose/${composeId}/${action}`, { method: 'POST' });
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
alert(`${action} を実行しました`);
|
showToast(`${action} を実行しました`, false);
|
||||||
} else {
|
} else {
|
||||||
alert(`${action} に失敗しました (status: ${res.status})`);
|
showToast(`${action} に失敗しました (status: ${res.status})`, true);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@ -6,55 +6,84 @@ function escapeHtml(value) {
|
|||||||
.replace(/"/g, '"');
|
.replace(/"/g, '"');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const BOOTSTRAP_CSS =
|
||||||
|
'<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">';
|
||||||
|
const BOOTSTRAP_JS =
|
||||||
|
'<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>';
|
||||||
|
|
||||||
function renderLoginPage(errorMessage) {
|
function renderLoginPage(errorMessage) {
|
||||||
const errorHtml = errorMessage ? `<p class="error">${escapeHtml(errorMessage)}</p>` : '';
|
const errorHtml = errorMessage
|
||||||
|
? `<div class="alert alert-danger" role="alert">${escapeHtml(errorMessage)}</div>`
|
||||||
|
: '';
|
||||||
return `<!DOCTYPE html>
|
return `<!DOCTYPE html>
|
||||||
<html>
|
<html lang="ja">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>App Portal Admin Login</title>
|
<title>App Portal Admin Login</title>
|
||||||
|
${BOOTSTRAP_CSS}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body class="bg-body-tertiary d-flex align-items-center" style="min-height:100vh">
|
||||||
<h1>Admin Login</h1>
|
<main class="container" style="max-width:400px">
|
||||||
${errorHtml}
|
<div class="card shadow-sm">
|
||||||
<form method="POST" action="/admin/login">
|
<div class="card-body">
|
||||||
<input type="password" name="masterKey" placeholder="Master Key" required>
|
<h1 class="h4 mb-3">Admin Login</h1>
|
||||||
<button type="submit">Login</button>
|
${errorHtml}
|
||||||
</form>
|
<form method="POST" action="/admin/login">
|
||||||
|
<div class="mb-3">
|
||||||
|
<input type="password" class="form-control" name="masterKey" placeholder="Master Key" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary w-100">Login</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
</body>
|
</body>
|
||||||
</html>`;
|
</html>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderAdminPage(emails, errorMessage) {
|
function renderAdminPage(emails, errorMessage) {
|
||||||
const errorHtml = errorMessage ? `<p class="error">${escapeHtml(errorMessage)}</p>` : '';
|
const errorHtml = errorMessage
|
||||||
|
? `<div class="alert alert-danger" role="alert">${escapeHtml(errorMessage)}</div>`
|
||||||
|
: '';
|
||||||
const rows = emails
|
const rows = emails
|
||||||
.map(
|
.map(
|
||||||
(email) => `<li>
|
(email) => `<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||||
${escapeHtml(email)}
|
${escapeHtml(email)}
|
||||||
<form method="POST" action="/admin/allowlist/remove" style="display:inline">
|
<form method="POST" action="/admin/allowlist/remove" class="mb-0">
|
||||||
<input type="hidden" name="email" value="${escapeHtml(email)}">
|
<input type="hidden" name="email" value="${escapeHtml(email)}">
|
||||||
<button type="submit">削除</button>
|
<button type="submit" class="btn btn-outline-danger btn-sm">削除</button>
|
||||||
</form>
|
</form>
|
||||||
</li>`
|
</li>`
|
||||||
)
|
)
|
||||||
.join('\n');
|
.join('\n');
|
||||||
return `<!DOCTYPE html>
|
return `<!DOCTYPE html>
|
||||||
<html>
|
<html lang="ja">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Allowlist Admin</title>
|
<title>Allowlist Admin</title>
|
||||||
|
${BOOTSTRAP_CSS}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body class="bg-body-tertiary">
|
||||||
<h1>Allowlist Admin</h1>
|
<nav class="navbar navbar-dark bg-dark mb-4">
|
||||||
${errorHtml}
|
<div class="container">
|
||||||
<ul>${rows}</ul>
|
<span class="navbar-brand mb-0 h1">Allowlist Admin</span>
|
||||||
<form method="POST" action="/admin/allowlist/add">
|
<a class="btn btn-outline-light btn-sm" href="/">ダッシュボード</a>
|
||||||
<input type="email" name="email" placeholder="user@example.com" required>
|
</div>
|
||||||
<button type="submit">追加</button>
|
</nav>
|
||||||
</form>
|
<main class="container pb-5" style="max-width:600px">
|
||||||
<form method="POST" action="/admin/logout">
|
${errorHtml}
|
||||||
<button type="submit">ログアウト</button>
|
<ul class="list-group mb-3">${rows}</ul>
|
||||||
</form>
|
<form method="POST" action="/admin/allowlist/add" class="input-group mb-4">
|
||||||
|
<input type="email" class="form-control" name="email" placeholder="user@example.com" required>
|
||||||
|
<button type="submit" class="btn btn-primary">追加</button>
|
||||||
|
</form>
|
||||||
|
<form method="POST" action="/admin/logout">
|
||||||
|
<button type="submit" class="btn btn-outline-secondary btn-sm">ログアウト</button>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
${BOOTSTRAP_JS}
|
||||||
</body>
|
</body>
|
||||||
</html>`;
|
</html>`;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -37,30 +37,50 @@ async function buildCards(environmentId) {
|
|||||||
return cards;
|
return cards;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const STATE_BADGE = {
|
||||||
|
running: 'bg-success',
|
||||||
|
stopped: 'bg-secondary',
|
||||||
|
unknown: 'bg-warning text-dark',
|
||||||
|
};
|
||||||
|
|
||||||
function renderCard(card) {
|
function renderCard(card) {
|
||||||
if (card.error) {
|
if (card.error) {
|
||||||
return `<div class="card card-error" data-compose-id="${escapeHtml(card.composeId)}">
|
return `<div class="col-12 col-md-6 col-lg-4">
|
||||||
<h3>${escapeHtml(card.name || card.composeId)}</h3>
|
<div class="card border-danger-subtle h-100" data-compose-id="${escapeHtml(card.composeId)}">
|
||||||
<p>状態取得失敗</p>
|
<div class="card-body">
|
||||||
|
<h3 class="card-title h5">${escapeHtml(card.name || card.composeId)}</h3>
|
||||||
|
<p class="card-text text-danger mb-0">状態取得失敗</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
const { composeId, composeStatus, containerState, meta } = card;
|
const { composeId, composeStatus, containerState, meta } = card;
|
||||||
|
const badgeClass = STATE_BADGE[containerState] || 'bg-warning text-dark';
|
||||||
const openLink =
|
const openLink =
|
||||||
meta.appType === 'web' && meta.url
|
meta.appType === 'web' && meta.url
|
||||||
? `<a href="${escapeHtml(meta.url)}" target="_blank" rel="noopener">開く</a>`
|
? `<a class="btn btn-outline-primary btn-sm" href="${escapeHtml(meta.url)}" target="_blank" rel="noopener">開く</a>`
|
||||||
: '';
|
: '';
|
||||||
const actionButtons =
|
const actionButtons =
|
||||||
meta.appType === 'web'
|
meta.appType === 'web'
|
||||||
? `<button data-action="start" data-compose-id="${escapeHtml(composeId)}">Start</button>
|
? `<button class="btn btn-success btn-sm" data-action="start" data-compose-id="${escapeHtml(composeId)}">Start</button>
|
||||||
<button data-action="stop" data-compose-id="${escapeHtml(composeId)}">Stop</button>
|
<button class="btn btn-danger btn-sm" data-action="stop" data-compose-id="${escapeHtml(composeId)}">Stop</button>
|
||||||
<button data-action="restart" data-compose-id="${escapeHtml(composeId)}">Restart</button>`
|
<button class="btn btn-warning btn-sm" data-action="restart" data-compose-id="${escapeHtml(composeId)}">Restart</button>`
|
||||||
: `<button data-action="trigger" data-compose-id="${escapeHtml(composeId)}">今すぐ実行</button>`;
|
: `<button class="btn btn-primary btn-sm" data-action="trigger" data-compose-id="${escapeHtml(composeId)}">今すぐ実行</button>`;
|
||||||
return `<div class="card" data-compose-id="${escapeHtml(composeId)}">
|
return `<div class="col-12 col-md-6 col-lg-4">
|
||||||
<h3>${escapeHtml(meta.label)}</h3>
|
<div class="card h-100" data-compose-id="${escapeHtml(composeId)}">
|
||||||
<p>compose: ${escapeHtml(composeStatus)} / container: ${escapeHtml(containerState)}</p>
|
<div class="card-body d-flex flex-column">
|
||||||
${openLink}
|
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||||
${actionButtons}
|
<h3 class="card-title h5 mb-0">${escapeHtml(meta.label)}</h3>
|
||||||
<button data-action="logs" data-compose-id="${escapeHtml(composeId)}">ログ表示</button>
|
<span class="badge ${badgeClass}">${escapeHtml(containerState)}</span>
|
||||||
|
</div>
|
||||||
|
<p class="card-text text-muted small mb-3">compose: ${escapeHtml(composeStatus)}</p>
|
||||||
|
<div class="mt-auto d-flex flex-wrap gap-2">
|
||||||
|
${openLink}
|
||||||
|
${actionButtons}
|
||||||
|
<button class="btn btn-outline-secondary btn-sm" data-action="logs" data-compose-id="${escapeHtml(composeId)}">ログ表示</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -68,14 +88,45 @@ async function renderDashboard(environmentId) {
|
|||||||
const cards = await buildCards(environmentId);
|
const cards = await buildCards(environmentId);
|
||||||
const cardsHtml = cards.map(renderCard).join('\n');
|
const cardsHtml = cards.map(renderCard).join('\n');
|
||||||
return `<!DOCTYPE html>
|
return `<!DOCTYPE html>
|
||||||
<html>
|
<html lang="ja">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>App Portal</title>
|
<title>App Portal</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body class="bg-body-tertiary">
|
||||||
<h1>App Portal</h1>
|
<nav class="navbar navbar-dark bg-dark mb-4">
|
||||||
<div id="cards">${cardsHtml}</div>
|
<div class="container">
|
||||||
|
<span class="navbar-brand mb-0 h1">App Portal</span>
|
||||||
|
<a class="btn btn-outline-light btn-sm" href="/admin">管理</a>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
<main class="container pb-5">
|
||||||
|
<div id="cards" class="row g-3">${cardsHtml}</div>
|
||||||
|
</main>
|
||||||
|
<div class="modal fade" id="logModal" tabindex="-1" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">ログ</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<pre id="logModalBody" class="mb-0 small"></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="toast-container position-fixed bottom-0 end-0 p-3">
|
||||||
|
<div id="actionToast" class="toast align-items-center text-bg-primary border-0" role="status" aria-live="polite" aria-atomic="true">
|
||||||
|
<div class="d-flex">
|
||||||
|
<div class="toast-body" id="actionToastBody"></div>
|
||||||
|
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
<script src="/portal.js"></script>
|
<script src="/portal.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>`;
|
</html>`;
|
||||||
|
|||||||
4
NodeSrv/apps/get-voice-mail/.dockerignore
Normal file
4
NodeSrv/apps/get-voice-mail/.dockerignore
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
node_modules
|
||||||
|
npm-debug.log
|
||||||
|
.env
|
||||||
|
.git
|
||||||
14
NodeSrv/apps/get-voice-mail/.env.example
Normal file
14
NodeSrv/apps/get-voice-mail/.env.example
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
PORT=3000
|
||||||
|
NODE_ENV=development
|
||||||
|
|
||||||
|
# app-portalキッカー登録用
|
||||||
|
PORTAL_APP_TYPE=batch
|
||||||
|
PORTAL_APP_LABEL=ボイスメール取得
|
||||||
|
PORTAL_APP_URL=http://get-voice-mail:3000
|
||||||
|
PORTAL_TRIGGER_PATH=/trigger
|
||||||
|
PORTAL_SECRET=
|
||||||
|
|
||||||
|
# n8nワークフロー入口(Webhookノード)のURL。/trigger起動時にPOSTする先
|
||||||
|
N8N_WEBHOOK_URL=https://n8n32.next-hd.net/webhook/get-voice-mail
|
||||||
|
# n8n Webhookノード側のHeader Auth Credential値(X-Api-Keyヘッダーに設定)
|
||||||
|
N8N_WEBHOOK_SECRET=
|
||||||
13
NodeSrv/apps/get-voice-mail/Dockerfile
Normal file
13
NodeSrv/apps/get-voice-mail/Dockerfile
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
FROM node:22-alpine AS deps
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
RUN npm ci --omit=dev
|
||||||
|
|
||||||
|
FROM node:22-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
USER node
|
||||||
|
EXPOSE 3000
|
||||||
|
CMD ["node", "src/index.js"]
|
||||||
7
NodeSrv/apps/get-voice-mail/docker-compose.local.yml
Normal file
7
NodeSrv/apps/get-voice-mail/docker-compose.local.yml
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
services:
|
||||||
|
get-voice-mail:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "3002:3000"
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
14
NodeSrv/apps/get-voice-mail/docker-compose.yml
Normal file
14
NodeSrv/apps/get-voice-mail/docker-compose.yml
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
services:
|
||||||
|
get-voice-mail:
|
||||||
|
build: .
|
||||||
|
expose:
|
||||||
|
- 3000
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
networks:
|
||||||
|
- dokploy-network
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
networks:
|
||||||
|
dokploy-network:
|
||||||
|
external: true
|
||||||
14
NodeSrv/apps/get-voice-mail/package.json
Normal file
14
NodeSrv/apps/get-voice-mail/package.json
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "get-voice-mail",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "commonjs",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "node --watch src/index.js",
|
||||||
|
"start": "node src/index.js",
|
||||||
|
"test": "node --test"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"express": "^4.21.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
59
NodeSrv/apps/get-voice-mail/spec.md
Normal file
59
NodeSrv/apps/get-voice-mail/spec.md
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
# get-voice-mail
|
||||||
|
## 仕様
|
||||||
|
|
||||||
|
|
||||||
|
- n8nを利用
|
||||||
|
- SCPでサーバーから録音データ取得
|
||||||
|
- プリザンターに音声保存
|
||||||
|
|
||||||
|
## サーバー情報
|
||||||
|
pbx-server-secret.md 参照
|
||||||
|
|
||||||
|
## voiceメールのパス
|
||||||
|
/var/spool/asterisk/monitor
|
||||||
|
|
||||||
|
この下に、年フォルダ、月フォルダ、日フォルダが階層化されている
|
||||||
|
|
||||||
|
取得ファイルルール ※あとで変わるかもだがいまは固定ルール
|
||||||
|
- ファイル名の先頭がin-0420002706-で始まるもの
|
||||||
|
{方向}-{番号A}-{番号B}-{日付}-{時刻}-{uniqueid}.wav
|
||||||
|
|
||||||
|
- 方向: in=着信 / out=発信 / generic=内線間 or 方向判定不能時
|
||||||
|
- 番号A: 着信の場合は着信先(DID/内線番号)、発信の場合は発信元内線
|
||||||
|
- 番号B: 相手側番号(外部番号 or 内線)
|
||||||
|
- 日付: YYYYMMDD
|
||||||
|
- 時刻: HHMMSS
|
||||||
|
- uniqueid: Asteriskチャンネル固有ID (epoch秒.マイクロ秒)。通話開始時刻ベース、重複なし識別子
|
||||||
|
|
||||||
|
例:
|
||||||
|
- in-0420002706-05057998188-20260918-165846-1789718326.114385.wav
|
||||||
|
→ 着信、着信先0420002706、相手05057998188、2026-09-18 16:58:46
|
||||||
|
- generic-0425400505-05017446045-20260918-170343-1789718623.114390.wav
|
||||||
|
→ 内線間 or 方向不明、番号0425400505⇔05017446045
|
||||||
|
|
||||||
|
ファイル名からいつの音声データなのか日付情報も算出
|
||||||
|
ファイル名の日付はJST
|
||||||
|
|
||||||
|
|
||||||
|
## プリザンター
|
||||||
|
取得した音声ファイルと内容をプリザンターのテーブルへ書き出し
|
||||||
|
https://nextoffice.next-hd.co.jp/pleasanter/items/515888/index
|
||||||
|
|
||||||
|
- ClassA:番号A
|
||||||
|
- ClassB:番号B
|
||||||
|
- ClassC:WAVファイル名 ※すでに同じファイルが登録済みの場合はスキップ
|
||||||
|
- AttachmentA:WAV本体保存
|
||||||
|
|
||||||
|
|
||||||
|
## n8nワークフロー起動方式
|
||||||
|
入口=Webhookノード固定。cron等の自前スケジューラは使わない。
|
||||||
|
起動元=app-portal(`NodeSrv/apps/app-portal`)のキッカー(batch型トリガー)。
|
||||||
|
|
||||||
|
実装=`NodeSrv/apps/get-voice-mail`直下のNode.jsアプリ(他アプリと同じ1フォルダ1Dokploy Compose)。
|
||||||
|
役割=中継のみ。`/trigger`受信→n8n Webhook URLへPOST→202即返却。SCP取得・WAV判定・プリザンター書込は全部n8n側workflow内で実施。
|
||||||
|
|
||||||
|
- POST `/trigger`(ヘッダ`X-Portal-Secret`必須、`PORTAL_SECRET`と一致時のみ許可)
|
||||||
|
- 認証通過後、環境変数`N8N_WEBHOOK_URL`(n8n Webhookノードの入口URL)へPOST、結果を待たず202返却
|
||||||
|
- `.env.example`にPORTAL_APP_TYPE=batch等のポータル登録変数一式を用意済み。Dokploy Environment画面で`PORTAL_SECRET`・`N8N_WEBHOOK_URL`を設定してから使う
|
||||||
|
- ポータル管理・トリガー実装パターンの詳細は`NodeSrv/apps/_template/README.md`「アプリポータルへの登録」章、実装例は`NodeSrv/apps/portal-sample-batch`参照
|
||||||
|
|
||||||
39
NodeSrv/apps/get-voice-mail/src/index.js
Normal file
39
NodeSrv/apps/get-voice-mail/src/index.js
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const { isTriggerAuthorized } = require('./triggerAuth');
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const PORT = process.env.PORT || 3000;
|
||||||
|
const PORTAL_SECRET = process.env.PORTAL_SECRET;
|
||||||
|
const N8N_WEBHOOK_URL = process.env.N8N_WEBHOOK_URL;
|
||||||
|
const N8N_WEBHOOK_SECRET = process.env.N8N_WEBHOOK_SECRET;
|
||||||
|
|
||||||
|
app.get('/', (req, res) => {
|
||||||
|
res.json({ app: 'get-voice-mail', status: 'ok' });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/health', (req, res) => {
|
||||||
|
res.status(200).json({ status: 'healthy' });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/trigger', (req, res) => {
|
||||||
|
if (!isTriggerAuthorized(req.headers['x-portal-secret'], PORTAL_SECRET)) {
|
||||||
|
res.sendStatus(403);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!N8N_WEBHOOK_URL) {
|
||||||
|
console.error('get-voice-mail: N8N_WEBHOOK_URL未設定');
|
||||||
|
res.sendStatus(500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fetch(N8N_WEBHOOK_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: N8N_WEBHOOK_SECRET ? { 'X-Api-Key': N8N_WEBHOOK_SECRET } : {},
|
||||||
|
}).catch((err) => {
|
||||||
|
console.error('get-voice-mail: n8n webhook起動失敗', err.message);
|
||||||
|
});
|
||||||
|
res.sendStatus(202);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`get-voice-mail listening on port ${PORT}`);
|
||||||
|
});
|
||||||
5
NodeSrv/apps/get-voice-mail/src/triggerAuth.js
Normal file
5
NodeSrv/apps/get-voice-mail/src/triggerAuth.js
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
function isTriggerAuthorized(headerValue, expectedSecret) {
|
||||||
|
return Boolean(expectedSecret) && headerValue === expectedSecret;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { isTriggerAuthorized };
|
||||||
11
NodeSrv/apps/get-voice-mail/test/triggerAuth.test.js
Normal file
11
NodeSrv/apps/get-voice-mail/test/triggerAuth.test.js
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
const { isTriggerAuthorized } = require('../src/triggerAuth');
|
||||||
|
|
||||||
|
test('isTriggerAuthorized returns true only when the header matches a non-empty expected secret', () => {
|
||||||
|
assert.strictEqual(isTriggerAuthorized('correct', 'correct'), true);
|
||||||
|
assert.strictEqual(isTriggerAuthorized('wrong', 'correct'), false);
|
||||||
|
assert.strictEqual(isTriggerAuthorized(undefined, 'correct'), false);
|
||||||
|
assert.strictEqual(isTriggerAuthorized('anything', undefined), false);
|
||||||
|
assert.strictEqual(isTriggerAuthorized('anything', ''), false);
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user