diff --git a/.env.example b/.env.example index f353b832ca..b13ab4945d 100644 --- a/.env.example +++ b/.env.example @@ -255,3 +255,24 @@ WORLDMONITOR_VALID_KEYS= # Convex deployment URL for email registration storage. # Set up at: https://dashboard.convex.dev/ CONVEX_URL= + + +# ------ Discord Notifications (scripts/discord-notify.mjs) ------ + +# Discord Webhook URL — create via: Channel Settings → Integrations → Webhooks +# Required for periodic Discord notifications. +DISCORD_WEBHOOK_URL= + +# Google Gemini API key — used to summarize world events for Discord posts. +# Free tier available at: https://aistudio.google.com/apikey +# If unset, OPENROUTER_API_KEY is used as fallback (google/gemini-2.5-flash). +GEMINI_API_KEY= + +# Gemini model name (optional — defaults to gemini-2.0-flash) +GEMINI_MODEL=gemini-2.0-flash + +# Notification interval in minutes (optional — defaults to 60) +DISCORD_NOTIFY_INTERVAL_MINUTES=60 + +# Summary language: ja (Japanese) or en (English) — defaults to ja +DISCORD_NOTIFY_LANGUAGE=ja diff --git a/Dockerfile b/Dockerfile index d72880e588..89cff247f5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,6 +10,7 @@ FROM node:22-alpine AS builder WORKDIR /app +ENV NODE_OPTIONS=--max-old-space-size=4096 # Install root dependencies (layer-cached until package.json changes) COPY package.json package-lock.json ./ @@ -40,6 +41,8 @@ WORKDIR /app # API server COPY --from=builder /app/src-tauri/sidecar/local-api-server.mjs ./local-api-server.mjs COPY --from=builder /app/src-tauri/sidecar/package.json ./package.json +COPY --from=builder /app/scripts/discord-notify.mjs ./scripts/discord-notify.mjs +COPY --from=builder /app/scripts/_seed-utils.mjs ./scripts/_seed-utils.mjs # API handler modules (JS originals + compiled TS bundles) COPY --from=builder /app/api ./api @@ -65,8 +68,8 @@ USER appuser EXPOSE 8080 -# Healthcheck via nginx +# Healthcheck checks container readiness, not seed freshness. HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ - CMD wget -qO- http://localhost:8080/api/health || exit 1 + CMD wget -q --spider http://127.0.0.1:8080/ || exit 1 CMD ["/app/entrypoint.sh"] diff --git a/Dockerfile.relay b/Dockerfile.relay index 52ff79b0d7..66d4023a78 100644 --- a/Dockerfile.relay +++ b/Dockerfile.relay @@ -10,7 +10,9 @@ FROM node:22-alpine # curl required by OREF polling (Node.js JA3 fingerprint blocked by Akamai; curl passes) -RUN apk add --no-cache curl +# python3/make/g++ are required when ws native addons (bufferutil, utf-8-validate) +# do not have a matching prebuilt binary for the target architecture. +RUN apk add --no-cache curl python3 make g++ WORKDIR /app diff --git a/SELF_HOSTING.md b/SELF_HOSTING.md index 6e398015ab..28fc85d6fe 100644 --- a/SELF_HOSTING.md +++ b/SELF_HOSTING.md @@ -97,6 +97,8 @@ To automate, add a cron job: */30 * * * * cd /path/to/worldmonitor && ./scripts/run-seeders.sh >> /tmp/wm-seeders.log 2>&1 ``` +For 24/7 VPS operation, keep this cron job for seeders, but do not also schedule `scripts/discord-notify.mjs` from the host if the container is already running it under `supervisord`. + ### 🔧 Manual seeder invocation If you prefer to run seeders individually: @@ -204,3 +206,23 @@ services: | 🚢 No vessel data | Set `AISSTREAM_API_KEY` in both `worldmonitor` and `ais-relay` services | | 🔥 No wildfire data | Set `NASA_FIRMS_API_KEY` | | 🌐 No outage data | Requires `CLOUDFLARE_API_TOKEN` (paid Radar access) | + +## 🖥️ Hetzner VPS Operations + +For Hetzner Cloud, treat the VNC console as an emergency path only. Day-to-day management should happen over SSH with `systemd` and `docker compose`. + +```bash +ssh root@your-server +systemctl status worldmonitor +journalctl -u worldmonitor -f +docker compose ps +docker compose logs -f worldmonitor +systemctl reload worldmonitor +``` + +Recommended baseline for 24/7 uptime: + +- Enable Hetzner Backups or take a Snapshot before deployments. +- Mirror allowed ports in Hetzner Cloud Firewalls; do not rely on UFW alone for Docker-published ports. +- Use `/api/health` plus the bundled `scripts/health-check.sh` for alerting. +- Keep Discord notifications on a single execution path to avoid duplicate posts. diff --git a/VPS_REFACTORING_PLAN.md b/VPS_REFACTORING_PLAN.md new file mode 100644 index 0000000000..43f3ce71c8 --- /dev/null +++ b/VPS_REFACTORING_PLAN.md @@ -0,0 +1,664 @@ +# VPS Refactoring Plan — Hetzner CAX21 (24/7 運用) + +**対象環境:** Hetzner CAX21 · Debian · 8GB RAM · ARM64 (Ampere Altra) +**目標:** 24時間365日の安定稼働 + Gemini による Discord 定期通知 + +--- + +## 0. 前提確認 — CAX21 固有の制約 + +| 項目 | CAX21 仕様 | 影響 | +|------|-----------|------| +| **CPU アーキテクチャ** | ARM64 (Ampere Altra) | Dockerイメージは `linux/arm64` でビルド必須 | +| **RAM** | 8GB | Redis 256MB は過小。1GB に拡張可能 | +| **ストレージ** | SSD (40GB~) | Redis パーシスタンス / ログローテーションを追加 | +| **IPv6** | 2a01:4f8:1c18:4a36::/64 | アプリ側はすでに IPv4-only を強制(問題なし) | +| **OS** | Debian | systemd でDocker自動起動を管理 | + +--- + +## 1. 優先度マップ + +``` +[P0 クリティカル] — 本番投入前に必須 +[P1 高] — 初週中に対応 +[P2 中] — 初月中に対応 +[P3 低] — 余裕があれば +``` + +--- + +## 2. P0 — ARM64 対応 (ビルド互換性) + +### 問題 + +CAX21 は ARM64 CPU。Docker イメージを x86_64 でビルドすると実行不可。 + +### 対応 + +**① ビルド時にプラットフォームを明示する** + +```bash +# 現状 (プラットフォーム未指定 → ホストアーキテクチャ依存) +docker build -t worldmonitor:latest -f Dockerfile . + +# 修正後 (ARM64 を明示) +docker build --platform linux/arm64 -t worldmonitor:latest -f Dockerfile . +docker build --platform linux/arm64 -t worldmonitor-ais-relay:latest -f Dockerfile.relay . +``` + +**② `docker-compose.yml` にプラットフォーム指定を追加** + +```yaml +services: + worldmonitor: + build: + context: . + dockerfile: Dockerfile + platforms: # ← 追加 + - linux/arm64 + ais-relay: + build: + context: . + dockerfile: Dockerfile.relay + platforms: + - linux/arm64 + redis-rest: + build: + context: docker + dockerfile: Dockerfile.redis-rest + platforms: + - linux/arm64 +``` + +**③ ベースイメージは既存のまま OK** + +- `node:22-alpine` → マルチアーキテクチャ対応済み ✅ +- `redis:7-alpine` → マルチアーキテクチャ対応済み ✅ + +--- + +## 3. P0 — グレースフルシャットダウン + +### 問題 + +`local-api-server.mjs` に SIGTERM ハンドラがない。 +`docker compose restart` や更新デプロイ時に、処理中のリクエストが強制切断される。 + +### 対応 + +`src-tauri/sidecar/local-api-server.mjs` の末尾付近に追加: + +```javascript +// === Graceful Shutdown === +let isShuttingDown = false; + +function shutdown(signal) { + if (isShuttingDown) return; + isShuttingDown = true; + console.log(`[local-api] ${signal} received — graceful shutdown`); + server.close(() => { + console.log('[local-api] HTTP server closed'); + process.exit(0); + }); + // 強制終了タイムアウト (30秒) + setTimeout(() => { + console.error('[local-api] Forced exit after 30s timeout'); + process.exit(1); + }, 30_000).unref(); +} + +process.on('SIGTERM', () => shutdown('SIGTERM')); +process.on('SIGINT', () => shutdown('SIGINT')); +``` + +--- + +## 4. P0 — Redis パーシスタンス + +### 問題 + +現状: `redis-server --maxmemory 256mb --maxmemory-policy allkeys-lru` +RDB/AOF が無効のため、コンテナ再起動で全キャッシュが失われる。 + +### 対応 + +`docker-compose.yml` の redis サービスを修正: + +```yaml +redis: + image: docker.io/redis:7-alpine + container_name: worldmonitor-redis + command: > + redis-server + --maxmemory 1gb + --maxmemory-policy allkeys-lru + --save 300 100 + --save 60 1000 + --appendonly yes + --appendfsync everysec + volumes: + - redis-data:/data + restart: unless-stopped +``` + +| 変更点 | 説明 | +|--------|------| +| `--maxmemory 1gb` | CAX21 の 8GB RAM に合わせて拡張 | +| `--save 300 100` | 5分間に100件変更でスナップショット | +| `--appendonly yes` | AOF ログ有効化(再起動後もデータ復元可能) | +| `--appendfsync everysec` | 1秒ごとに fsync | + +--- + +## 5. P0 — Docker ログローテーション + +### 問題 + +Docker のデフォルトでは json-file ドライバがログを無制限に蓄積する。長期運用でディスクフルになる。 + +### 対応 + +`docker-compose.yml` の各サービスに追加: + +```yaml +services: + worldmonitor: + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "5" + ais-relay: + logging: + driver: "json-file" + options: + max-size: "20m" + max-file: "3" + redis: + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + redis-rest: + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" +``` + +--- + +## 6. P0 — systemd によるサービス自動起動 + +### 問題 + +VPS 再起動後に `docker compose up -d` を手動実行しない限りサービスが起動しない。 + +### 対応 + +**① Docker daemon の自動起動** +```bash +sudo systemctl enable docker +sudo systemctl start docker +``` + +**② `/etc/systemd/system/worldmonitor.service` を作成** + +```ini +[Unit] +Description=World Monitor Stack +Requires=docker.service +After=docker.service network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +RemainAfterExit=yes +WorkingDirectory=/home/user/worldmonitor +ExecStart=/usr/bin/docker compose up -d --remove-orphans +ExecReload=/usr/bin/docker compose up -d --build --remove-orphans +ExecStop=/usr/bin/docker compose down +TimeoutStartSec=180 + +[Install] +WantedBy=multi-user.target +``` + +```bash +sudo systemctl daemon-reload +sudo systemctl enable worldmonitor +sudo systemctl start worldmonitor +``` + +--- + +## 7. P1 — Discord 定期通知 (Gemini 要約) + +### 概要 + +Redis に蓄積されたリアルタイムデータを Gemini で要約し、Discord に定期投稿する。 +通知間隔は環境変数で可変。 + +``` +Redis (地震・紛争・市場・気象等) + ↓ redisGet() +scripts/discord-notify.mjs + ↓ Gemini API (Direct or OpenRouter fallback) + ↓ 要約テキスト生成 +Discord Webhook → #worldmonitor チャンネル +``` + +### 新規ファイル: `scripts/discord-notify.mjs` + +**主要機能:** + +| 機能 | 詳細 | +|------|------| +| **データ取得** | Redis から8カテゴリのデータを並列 fetch | +| **フィルタリング** | M5.0以上の地震、HIGH 重大度の不安定情勢、CRITICAL サイバー脅威 など | +| **AI 要約** | Gemini 2.0 Flash で 200〜300文字の状況報告を生成 | +| **フォールバック** | `GEMINI_API_KEY` → `OPENROUTER_API_KEY` の順で試行 | +| **Discord 投稿** | リッチ Embed (カラーコード + カテゴリ別フィールド) | +| **実行モード** | 1回実行 (cron 向け) / デーモンモード (`--daemon`) | + +**取得・要約する8カテゴリ:** + +| カテゴリ | Redis キー | フィルタ条件 | +|---------|-----------|------------| +| 地震 | `seismology:earthquakes:v1` | M5.0以上・過去24時間 | +| 社会不安 | `unrest:events:v1` | severity=HIGH・過去24時間 | +| 軍用機 | `military:flights:v1` | riskLevel=HIGH | +| 自然災害 | `natural:events:v1` | アクティブ・重大カテゴリ | +| 気象警報 | `weather:alerts:v1` | EXTREME/SEVERE | +| サイバー脅威 | `cyber:threats:v2` | CRITICAL・過去24時間 | +| 武力紛争 | `conflict:ucdp-events:v1` | 過去7日間 | +| 市場動向 | `market:stocks-bootstrap:v1` | 変動率±2%以上 | + +**Gemini API 呼び出し:** + +```javascript +// Direct Gemini API (GEMINI_API_KEY) +POST https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key={KEY} +{ contents: [{ parts: [{ text: prompt }] }], generationConfig: { temperature: 0.4, maxOutputTokens: 1200 } } + +// OpenRouter 経由 (OPENROUTER_API_KEY, フォールバック) +POST https://openrouter.ai/api/v1/chat/completions +{ model: "google/gemini-2.5-flash", messages: [...] } +``` + +**Discord Embed 例:** + +``` +🌍 World Monitor — グローバル状況レポート +━━━━━━━━━━━━━━━━━━━━━━━━━━ +【脅威レベル: 高】 +• M6.2の地震がトルコ西部で発生。津波の懸念なし。 +• イランで大規模抗議デモ。治安部隊と衝突。 +• ロシア・ウクライナ前線でHIGH リスク軍用機複数を検知。 + +🌊 地震 (M5.0+) ✊ 社会不安 ✈️ 軍用機 (HIGH) +M6.2 トルコ西部 イラン (HIGH) RRR7171 (rus) +M5.4 チリ北部 ミャンマー (HIGH) ... + +⛈️ 気象警報 🔴 サイバー脅威 📈 市場動向 +Tornado Warning C2_SERVER (CN) ▲ NVDA +3.4% +━━━━━━━━━━━━━━━━━━━━━━━━━━ +12 件のイベントを検出 • Gemini gemini-2.0-flash • World Monitor +``` + +### 必須環境変数 + +```bash +DISCORD_WEBHOOK_URL # Discord チャンネルの Webhook URL +GEMINI_API_KEY # Google AI Studio で取得 (無料枠あり) + # https://aistudio.google.com/apikey +``` + +### 任意環境変数 + +```bash +OPENROUTER_API_KEY # Gemini が使えない場合のフォールバック +GEMINI_MODEL # デフォルト: gemini-2.0-flash +DISCORD_NOTIFY_INTERVAL_MINUTES # 通知間隔(分) デフォルト: 60 +DISCORD_NOTIFY_LANGUAGE # ja | en デフォルト: ja +``` + +### `docker-compose.yml` への追記 + +```yaml +services: + worldmonitor: + environment: + DISCORD_WEBHOOK_URL: "${DISCORD_WEBHOOK_URL:-}" + GEMINI_API_KEY: "${GEMINI_API_KEY:-}" + GEMINI_MODEL: "${GEMINI_MODEL:-gemini-2.0-flash}" + DISCORD_NOTIFY_INTERVAL_MINUTES: "${DISCORD_NOTIFY_INTERVAL_MINUTES:-60}" + DISCORD_NOTIFY_LANGUAGE: "${DISCORD_NOTIFY_LANGUAGE:-ja}" +``` + +### 実行方式 + +Discord 通知は 24/7 運用では 1 つの経路だけに統一する。 + +- 推奨: コンテナ内 `supervisord` で `discord-notify --daemon` を常駐 +- 非推奨: ホスト cron とコンテナ常駐を同時に有効化すること + +### cron 設定 (ホスト側で実行する場合) + +```cron +# 60分ごとに Discord へ通知 (間隔は DISCORD_NOTIFY_INTERVAL_MINUTES で変更可) +0 * * * * cd /home/user/worldmonitor && node scripts/discord-notify.mjs >> /var/log/worldmonitor-discord.log 2>&1 +``` + +### デーモンモード (コンテナ内で常駐させる場合) + +`docker/supervisord.conf` に追加: + +```ini +[program:discord-notify] +command=node /app/scripts/discord-notify.mjs --daemon +directory=/app +autostart=true +autorestart=unexpected +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 +``` + +> **推奨:** CAX21 の 24/7 運用ではコンテナ常駐方式を採用し、ホスト cron と二重化しない。 + +--- + +## 8. P1 — メモリ制限と Swap 設定 + +### 問題 + +Swap がないとメモリ不足時に OOM Killer が発動し、サービスが突然終了する。 + +### 対応 + +**① VPS に Swap を追加 (2GB)** + +```bash +sudo fallocate -l 2G /swapfile +sudo chmod 600 /swapfile +sudo mkswap /swapfile +sudo swapon /swapfile +echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab +sudo sysctl vm.swappiness=10 +echo 'vm.swappiness=10' | sudo tee -a /etc/sysctl.d/99-worldmonitor.conf +``` + +**② docker-compose.yml にメモリ上限を設定** + +```yaml +services: + worldmonitor: + mem_limit: 2g + memswap_limit: 2g + ais-relay: + mem_limit: 3g # AIS リレーはメモリを多く使う + memswap_limit: 3g + redis: + mem_limit: 1.2g + memswap_limit: 1.2g + redis-rest: + mem_limit: 256m + memswap_limit: 256m +``` + +--- + +## 9. P1 — シードスクリプトのリトライ機構 + +### 問題 + +`run-seeders.sh` はシードに失敗しても再試行しない。外部 API の一時エラーでデータが長時間陳腐化する。 + +### 対応 + +`scripts/run-seeders.sh` のループを以下に置き換え: + +```sh +run_with_retry() { + f="$1" + name="$(basename "$f")" + max_attempts=3 + attempt=1 + while [ $attempt -le $max_attempts ]; do + output=$(node "$f" 2>&1) + rc=$? + last=$(echo "$output" | tail -1) + if echo "$last" | grep -qi "skip\|not set\|missing.*key\|not found"; then + printf "→ %s ... SKIP (%s)\n" "$name" "$last" + return 2 + elif [ $rc -eq 0 ]; then + printf "→ %s ... OK%s\n" "$name" "$([ $attempt -gt 1 ] && echo " (attempt $attempt)")" + return 0 + else + printf "→ %s ... RETRY %d/%d (%s)\n" "$name" "$attempt" "$max_attempts" "$last" + attempt=$((attempt + 1)) + [ $attempt -le $max_attempts ] && sleep $((attempt * attempt)) # 指数バックオフ + fi + done + printf "→ %s ... FAIL after %d attempts (%s)\n" "$name" "$max_attempts" "$last" + return 1 +} +``` + +--- + +## 10. P1 — ファイアウォール設定 (ufw) + +### 対応 + +```bash +sudo apt install -y ufw +sudo ufw default deny incoming +sudo ufw default allow outgoing +sudo ufw allow 22/tcp # SSH +sudo ufw allow 3000/tcp # World Monitor HTTP +sudo ufw allow 80/tcp # 後で nginx/Cloudflare 用 +sudo ufw allow 443/tcp +sudo ufw enable +``` + +Docker が ufw をバイパスする問題への対策: + +```json +// /etc/docker/daemon.json +{ "iptables": true, "userland-proxy": false } +``` + +> Docker 公開ポートの最終防御は UFW だけに依存しない。Hetzner Cloud Firewalls 側にも同じ許可ポートを設定し、通常運用は SSH で行う。 + +--- + +## 11. P1 — 定期シードの cron 設定 + +```bash +crontab -e +``` + +```cron +# シードデータ更新 (30分ごと) +*/30 * * * * cd /home/user/worldmonitor && ./scripts/run-seeders.sh >> /var/log/worldmonitor-seed.log 2>&1 + +# ログローテーション (週1回) +0 3 * * 0 truncate -s 0 /var/log/worldmonitor-seed.log +``` + +--- + +## 12. P2 — 構造化ログの追加 + +### 問題 + +`local-api-server.mjs` のログは平文 `console.log`。エラー集計や障害トリアージが困難。 + +### 対応 + +```javascript +const logger = { + log: (msg, meta = {}) => console.log(JSON.stringify({ level: 'info', ts: new Date().toISOString(), msg, ...meta })), + warn: (msg, meta = {}) => console.log(JSON.stringify({ level: 'warn', ts: new Date().toISOString(), msg, ...meta })), + error: (msg, meta = {}) => console.log(JSON.stringify({ level: 'error', ts: new Date().toISOString(), msg, ...meta })), +}; +``` + +フィルタ例: `docker logs worldmonitor | grep '"level":"error"'` + +--- + +## 13. P2 — ヘルスチェック監視の自動化 + +`/api/health` が DEGRADED になっても誰も気づかない問題への対応。アラートは状態変化時と一定クールダウン経過時のみに絞る。 + +**オプション A: Cron + メール** + +```sh +#!/bin/sh +# scripts/health-check.sh +HEALTH=$(curl -sf http://localhost:3000/api/health 2>/dev/null | grep -o '"status":"[^"]*"' | head -1) +echo "$(date -Iseconds) $HEALTH" +if echo "$HEALTH" | grep -qE '"DEGRADED"|"UNHEALTHY"'; then + echo "$(date -Iseconds) ALERT: $HEALTH" | mail -s "[WM] Health Alert" admin@example.com +fi +``` + +```cron +*/2 * * * * /home/user/worldmonitor/scripts/health-check.sh >> /var/log/worldmonitor-health.log 2>&1 +``` + +**オプション B: UptimeRobot (無料プラン)** + +- `http://your-vps-ip:3000/api/health` を HTTP キーワードモニターとして登録 +- キーワード: `HEALTHY` または `WARNING` で OK 判定 + +--- + +## 14. P2 — Docker Secrets の有効化 + +```bash +mkdir -p /home/user/worldmonitor/secrets +echo "your-groq-key" > secrets/groq_api_key.txt +echo "your-gemini-key" > secrets/gemini_api_key.txt +echo "your-discord-url" > secrets/discord_webhook_url.txt +chmod 600 secrets/* +``` + +`docker-compose.yml` の `secrets:` セクションを有効化後、`docker/entrypoint.sh` でシークレットを環境変数に読み込む。 + +--- + +## 15. P2 — コンソール運用フロー + +Hetzner の VNC コンソールは緊急用途に限定し、日常運用は SSH と `systemd` / `docker compose` を使う。 + +```bash +ssh root@your-server +systemctl status worldmonitor +journalctl -u worldmonitor -f +docker compose ps +docker compose logs -f worldmonitor +systemctl reload worldmonitor +``` + +インフラ操作は `hcloud` CLI を併用すると管理しやすい。 + +```bash +hcloud server list +hcloud server poweroff +hcloud server poweron +hcloud server create-image --type snapshot --description "pre-deploy" +``` + +--- + +## 16. P3 — nginx リバースプロキシ + TLS + +**Cloudflare 推奨 (最も簡単):** + +1. ドメインを Cloudflare に向ける +2. Cloudflare Proxy (オレンジ雲) を有効化 +3. SSL/TLS モードを "Full (strict)" に設定 +4. CAX21 の公開ポートは 80/443 のみ開放 + +**Certbot 直接:** + +```bash +sudo apt install -y certbot python3-certbot-nginx +sudo certbot --nginx -d your-domain.com +``` + +--- + +## 17. 実装ロードマップ + +``` +Week 1 (P0) — 本番投入前 +├── ARM64 プラットフォーム指定追加 +├── グレースフルシャットダウン追加 +├── Redis パーシスタンス有効化 + maxmemory 1GB 拡張 +├── Docker ログローテーション追加 +└── systemd サービスファイル作成 + +Week 2 (P1) — 安定運用 + Discord 通知 +├── Swap 設定 (2GB) +├── docker-compose メモリ上限設定 +├── scripts/discord-notify.mjs 作成 +│ ├── Redis 8カテゴリ取得 +│ ├── Gemini 2.0 Flash 要約 (OpenRouter フォールバック) +│ └── Discord Webhook Embed 投稿 +├── シードリトライロジック追加 +├── ufw ファイアウォール設定 +├── cron 設定 (シード 30分 / ヘルスチェック 2分) +└── Discord 通知経路をコンテナ常駐に統一 + +Month 1 (P2) +├── 構造化ログ (JSON) +├── ヘルスチェック監視スクリプト +├── コンソール運用フロー整備 +└── Docker Secrets 有効化 + +Month 2+ (P3) +└── TLS / Cloudflare Proxy 設定 +``` + +--- + +## 18. 変更ファイル一覧 + +| ファイル | 変更内容 | 優先度 | +|---------|---------|--------| +| `docker-compose.yml` | platforms, mem_limit, logging, redis persistence, Discord/Gemini env vars | P0/P1 | +| `src-tauri/sidecar/local-api-server.mjs` | SIGTERM/SIGINT グレースフルシャットダウン | P0 | +| `scripts/discord-notify.mjs` | **新規作成** — Redis → Gemini → Discord 通知スクリプト | P1 | +| `scripts/run-seeders.sh` | リトライロジック (指数バックオフ) | P1 | +| `docker/supervisord.conf` | discord-notify デーモン追加 (デーモンモード採用時) | P1 | +| `/etc/systemd/system/worldmonitor.service` | **新規作成** (自動起動) | P0 | +| `scripts/health-check.sh` | **新規作成** (ヘルス監視、重複アラート抑制) | P2 | + +--- + +## 19. 現状評価サマリー + +| 項目 | 現状 | リスク | 対応後 | +|------|------|--------|--------| +| ARM64 互換性 | ❌ 未対応 | **起動不可** | ✅ プラットフォーム明示 | +| グレースフルシャットダウン | ❌ なし | 中 (リクエスト断) | ✅ SIGTERM ハンドラ追加 | +| Redis パーシスタンス | ⚠️ 部分的 | **高 (再起動でデータ消失)** | ✅ AOF + RDB 有効化 | +| 自動起動 | ❌ なし | 高 (VPS 再起動後に停止) | ✅ systemd 管理 | +| ログローテーション | ❌ なし | 中 (ディスクフル) | ✅ max-size 設定 | +| Swap | ❌ なし | 高 (OOM Kill) | ✅ 2GB Swap 追加 | +| メモリ制限 | ❌ なし | 中 (暴走で全滅) | ✅ per-service 制限 | +| シードリトライ | ❌ なし | 中 (データ陳腐化) | ✅ 指数バックオフ | +| ファイアウォール | ❌ なし | 高 (ポート開放) | ✅ ufw + Hetzner Firewall | +| cron シード | ❌ なし | 高 (手動更新のみ) | ✅ 30分周期 cron | +| **Discord 通知** | ❌ なし | — (新機能) | ✅ Gemini 要約 + Webhook 常駐 | +| ヘルス監視 | ⚠️ 受動的 | 中 (障害に気づかない) | ✅ 2分周期チェック + 抑制 | +| TLS/HTTPS | ❌ なし | 低 (HTTP のみ) | ✅ Cloudflare Proxy | diff --git a/docker-compose.yml b/docker-compose.yml index 5b348e17d9..1537f69d5e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,6 +2,7 @@ # World Monitor — Docker / Podman Compose # ============================================================================= # Self-contained stack: app + Redis + AIS relay. +# AIS relay stays healthy in disabled mode when AISSTREAM_API_KEY is unset. # # Quick start: # cp .env.example .env # add your API keys @@ -16,6 +17,8 @@ services: build: context: . dockerfile: Dockerfile + platforms: + - linux/arm64 image: worldmonitor:latest container_name: worldmonitor ports: @@ -32,6 +35,7 @@ services: LLM_API_KEY: "${LLM_API_KEY:-}" LLM_MODEL: "${LLM_MODEL:-}" GROQ_API_KEY: "${GROQ_API_KEY:-}" + OPENROUTER_API_KEY: "${OPENROUTER_API_KEY:-}" # Data source API keys (optional — features degrade gracefully) AISSTREAM_API_KEY: "${AISSTREAM_API_KEY:-}" FINNHUB_API_KEY: "${FINNHUB_API_KEY:-}" @@ -41,6 +45,12 @@ services: NASA_FIRMS_API_KEY: "${NASA_FIRMS_API_KEY:-}" CLOUDFLARE_API_TOKEN: "${CLOUDFLARE_API_TOKEN:-}" AVIATIONSTACK_API: "${AVIATIONSTACK_API:-}" + # Discord periodic notifications via Gemini + DISCORD_WEBHOOK_URL: "${DISCORD_WEBHOOK_URL:-}" + GEMINI_API_KEY: "${GEMINI_API_KEY:-}" + GEMINI_MODEL: "${GEMINI_MODEL:-gemini-2.0-flash}" + DISCORD_NOTIFY_INTERVAL_MINUTES: "${DISCORD_NOTIFY_INTERVAL_MINUTES:-60}" + DISCORD_NOTIFY_LANGUAGE: "${DISCORD_NOTIFY_LANGUAGE:-ja}" # Docker secrets (recommended for API keys — keeps them out of docker inspect). # Create secrets/ dir with one file per key, then uncomment below. # See SELF_HOSTING.md or docker-compose.override.yml for details. @@ -51,36 +61,70 @@ services: # - FRED_API_KEY # - NASA_FIRMS_API_KEY # - LLM_API_KEY + # - GEMINI_API_KEY + # - DISCORD_WEBHOOK_URL depends_on: redis-rest: condition: service_started ais-relay: condition: service_started restart: unless-stopped + mem_limit: 2g + memswap_limit: 2g + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "5" ais-relay: build: context: . dockerfile: Dockerfile.relay + platforms: + - linux/arm64 image: worldmonitor-ais-relay:latest container_name: worldmonitor-ais-relay environment: AISSTREAM_API_KEY: "${AISSTREAM_API_KEY:-}" PORT: "3004" restart: unless-stopped + mem_limit: 3g + memswap_limit: 3g + logging: + driver: "json-file" + options: + max-size: "20m" + max-file: "3" redis: image: docker.io/redis:7-alpine container_name: worldmonitor-redis - command: redis-server --maxmemory 256mb --maxmemory-policy allkeys-lru + command: > + redis-server + --maxmemory 1gb + --maxmemory-policy allkeys-lru + --save 300 100 + --save 60 1000 + --appendonly yes + --appendfsync everysec volumes: - redis-data:/data restart: unless-stopped + mem_limit: 1200m + memswap_limit: 1200m + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" redis-rest: build: context: docker dockerfile: Dockerfile.redis-rest + platforms: + - linux/arm64 image: worldmonitor-redis-rest:latest container_name: worldmonitor-redis-rest ports: @@ -91,6 +135,13 @@ services: depends_on: - redis restart: unless-stopped + mem_limit: 256m + memswap_limit: 256m + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" # Docker secrets — uncomment and point to your secret files. # Example: echo "gsk_abc123" > secrets/groq_api_key.txt @@ -107,6 +158,10 @@ services: # file: ./secrets/nasa_firms_api_key.txt # LLM_API_KEY: # file: ./secrets/llm_api_key.txt +# GEMINI_API_KEY: +# file: ./secrets/gemini_api_key.txt +# DISCORD_WEBHOOK_URL: +# file: ./secrets/discord_webhook_url.txt volumes: redis-data: diff --git a/docker/supervisord.conf b/docker/supervisord.conf index 456f23cf40..781de4ba77 100644 --- a/docker/supervisord.conf +++ b/docker/supervisord.conf @@ -22,3 +22,17 @@ stdout_logfile=/dev/stdout stdout_logfile_maxbytes=0 stderr_logfile=/dev/stderr stderr_logfile_maxbytes=0 + +[program:discord-notify] +command=node /app/scripts/discord-notify.mjs --daemon +directory=/app +autostart=true +; autorestart=unexpected: only restart on non-zero exit. +; The script exits 0 (no restart) when DISCORD_WEBHOOK_URL is unset. +autorestart=unexpected +exitcodes=0 +startsecs=0 +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 diff --git a/docker/worldmonitor.service b/docker/worldmonitor.service new file mode 100644 index 0000000000..8636ab9dde --- /dev/null +++ b/docker/worldmonitor.service @@ -0,0 +1,19 @@ +[Unit] +Description=World Monitor Stack +Documentation=https://github.com/koala73/worldmonitor +Requires=docker.service +After=docker.service network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +RemainAfterExit=yes +WorkingDirectory=__REPO_DIR__ +ExecStart=/usr/bin/docker compose up -d --remove-orphans +ExecReload=/usr/bin/docker compose up -d --build --remove-orphans +ExecStop=/usr/bin/docker compose down +TimeoutStartSec=180 +TimeoutStopSec=120 + +[Install] +WantedBy=multi-user.target diff --git a/scripts/ais-relay.cjs b/scripts/ais-relay.cjs index 628b4df7e0..c5e5c63855 100644 --- a/scripts/ais-relay.cjs +++ b/scripts/ais-relay.cjs @@ -34,11 +34,11 @@ console.log(`[Relay] Heap limit: ${(_heapStats.heap_size_limit / 1024 / 1024).to const AISSTREAM_URL = 'wss://stream.aisstream.io/v0/stream'; const API_KEY = process.env.AISSTREAM_API_KEY || process.env.VITE_AISSTREAM_API_KEY; const PORT = process.env.PORT || 3004; +const AIS_DISABLED = !API_KEY; -if (!API_KEY) { - console.error('[Relay] Error: AISSTREAM_API_KEY environment variable not set'); - console.error('[Relay] Get a free key at https://aisstream.io'); - process.exit(1); +if (AIS_DISABLED) { + console.warn('[Relay] AISSTREAM_API_KEY not set; starting in disabled mode'); + console.warn('[Relay] Set AISSTREAM_API_KEY to enable live AIS relay data'); } const MAX_WS_CLIENTS = 10; // Cap WS clients — app uses HTTP snapshots, not WS @@ -8302,6 +8302,7 @@ CSS variables are pre-defined in the iframe: --bg, --surface, --text, --text-sec // ─── End Widget Agent ──────────────────────────────────────────────────────── function connectUpstream() { + if (AIS_DISABLED) return; // Skip if already connected or connecting if (upstreamSocket?.readyState === WebSocket.OPEN || upstreamSocket?.readyState === WebSocket.CONNECTING) return; @@ -8404,7 +8405,10 @@ function connectUpstream() { const wss = new WebSocketServer({ server }); server.listen(PORT, () => { - console.log(`[Relay] WebSocket relay on port ${PORT} (OpenSky: ${OPENSKY_PROXY_ENABLED ? 'via proxy' : 'direct'})`); + console.log( + `[Relay] WebSocket relay on port ${PORT} ` + + `(AIS: ${AIS_DISABLED ? 'disabled' : 'enabled'}, OpenSky: ${OPENSKY_PROXY_ENABLED ? 'via proxy' : 'direct'})` + ); startTelegramPollLoop(); startOrefPollLoop(); startUcdpSeedLoop(); diff --git a/scripts/discord-notify.mjs b/scripts/discord-notify.mjs new file mode 100755 index 0000000000..9c8cd5e020 --- /dev/null +++ b/scripts/discord-notify.mjs @@ -0,0 +1,422 @@ +#!/usr/bin/env node +/** + * discord-notify.mjs + * + * World Monitor → Gemini → Discord 定期通知スクリプト + * + * 使い方: + * node scripts/discord-notify.mjs # 1回実行 (cron 向け) + * node scripts/discord-notify.mjs --daemon # 定期実行 (supervisord 向け) + * + * 必須環境変数: + * DISCORD_WEBHOOK_URL Discord チャンネルの Webhook URL + * GEMINI_API_KEY Google Gemini API キー + * (または OPENROUTER_API_KEY でフォールバック) + * + * 任意環境変数: + * OPENROUTER_API_KEY Gemini が使えない場合のフォールバック + * GEMINI_MODEL デフォルト: gemini-2.0-flash + * DISCORD_NOTIFY_INTERVAL_MINUTES 通知間隔(分) デフォルト: 60 + * DISCORD_NOTIFY_LANGUAGE ja | en デフォルト: ja + * UPSTASH_REDIS_REST_URL Redis REST プロキシ URL + * UPSTASH_REDIS_REST_TOKEN Redis REST トークン + */ + +import { loadEnvFile, getRedisCredentials } from './_seed-utils.mjs'; + +loadEnvFile(import.meta.url); + +// ─── 設定 ──────────────────────────────────────────────────────────────────── + +const DISCORD_WEBHOOK_URL = process.env.DISCORD_WEBHOOK_URL; +const GEMINI_API_KEY = process.env.GEMINI_API_KEY; +const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY; +const GEMINI_MODEL = process.env.GEMINI_MODEL || 'gemini-2.0-flash'; +const LANGUAGE = (process.env.DISCORD_NOTIFY_LANGUAGE || 'ja').toLowerCase(); +const INTERVAL_MIN = Math.max(1, parseInt(process.env.DISCORD_NOTIFY_INTERVAL_MINUTES || '60', 10)); +const IS_DAEMON = process.argv.includes('--daemon'); + +const COLOR = { + ALERT: 0xE74C3C, + WARNING: 0xE67E22, + INFO: 0x3498DB, + OK: 0x2ECC71, +}; + +// ─── Redis ─────────────────────────────────────────────────────────────────── + +async function redisGet(key) { + let creds; + try { creds = getRedisCredentials(); } catch { return null; } + try { + const resp = await fetch(`${creds.url}/get/${encodeURIComponent(key)}`, { + headers: { Authorization: `Bearer ${creds.token}` }, + signal: AbortSignal.timeout(8_000), + }); + if (!resp.ok) return null; + const data = await resp.json(); + return data.result ? JSON.parse(data.result) : null; + } catch { + return null; + } +} + +// ─── データ取得 ─────────────────────────────────────────────────────────────── + +const ONE_DAY_MS = 24 * 60 * 60 * 1000; + +function isRecent(ts, withinMs = ONE_DAY_MS) { + if (!ts) return false; + const t = typeof ts === 'number' ? ts : Date.parse(ts); + return !isNaN(t) && Date.now() - t < withinMs; +} + +async function fetchWorldData() { + const results = await Promise.allSettled([ + redisGet('seismology:earthquakes:v1'), + redisGet('unrest:events:v1'), + redisGet('military:flights:v1'), + redisGet('natural:events:v1'), + redisGet('weather:alerts:v1'), + redisGet('cyber:threats:v2'), + redisGet('market:stocks-bootstrap:v1'), + redisGet('conflict:ucdp-events:v1'), + ]); + + const get = (r) => (r.status === 'fulfilled' ? r.value : null); + const [eqRaw, unrestRaw, milRaw, naturalRaw, weatherRaw, cyberRaw, marketRaw, conflictRaw] = results; + + const quakes = (get(eqRaw)?.earthquakes ?? []) + .filter(q => q.magnitude >= 5.0 && isRecent(q.occurredAt)) + .sort((a, b) => b.magnitude - a.magnitude) + .slice(0, 5); + + const unrest = (get(unrestRaw)?.events ?? []) + .filter(e => e.severity === 'HIGH' && isRecent(e.occurredAt)) + .slice(0, 5); + + const milFlights = (get(milRaw)?.flights ?? []) + .filter(f => f.riskLevel === 'HIGH') + .slice(0, 5); + + const natural = (get(naturalRaw)?.events ?? []) + .filter(e => !e.closed && ['VOLCANOES', 'SEVERE_STORMS', 'FLOODS', 'WILDFIRES'].includes(e.category)) + .sort((a, b) => Date.parse(b.date || 0) - Date.parse(a.date || 0)) + .slice(0, 5); + + const weather = (get(weatherRaw)?.alerts ?? []) + .filter(a => ['EXTREME', 'SEVERE'].includes(a.severity)) + .slice(0, 5); + + const cyber = (get(cyberRaw)?.threats ?? []) + .filter(t => t.severity === 'CRITICAL' && isRecent(t.firstSeen)) + .slice(0, 5); + + const stocks = get(marketRaw)?.stocks ?? get(marketRaw)?.quotes ?? []; + const topMovers = [...stocks] + .filter(s => typeof s.changePercent === 'number' && Math.abs(s.changePercent) >= 2) + .sort((a, b) => Math.abs(b.changePercent) - Math.abs(a.changePercent)) + .slice(0, 5); + + const conflicts = (get(conflictRaw)?.events ?? []) + .filter(e => isRecent(e.date ?? e.occurredAt, 7 * ONE_DAY_MS)) + .slice(0, 5); + + return { quakes, unrest, milFlights, natural, weather, cyber, topMovers, conflicts }; +} + +// ─── Gemini 呼び出し ────────────────────────────────────────────────────────── + +async function callGeminiDirect(prompt) { + if (!GEMINI_API_KEY) return null; + const url = `https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODEL}:generateContent?key=${GEMINI_API_KEY}`; + try { + const resp = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + contents: [{ parts: [{ text: prompt }] }], + generationConfig: { temperature: 0.4, maxOutputTokens: 1200 }, + }), + signal: AbortSignal.timeout(30_000), + }); + if (!resp.ok) { + const err = await resp.text().catch(() => ''); + console.warn(`[discord-notify] Gemini HTTP ${resp.status}: ${err.slice(0, 200)}`); + return null; + } + const data = await resp.json(); + return data.candidates?.[0]?.content?.parts?.[0]?.text?.trim() ?? null; + } catch (err) { + console.warn(`[discord-notify] Gemini error: ${err.message}`); + return null; + } +} + +async function callGeminiViaOpenRouter(prompt) { + if (!OPENROUTER_API_KEY) return null; + try { + const resp = await fetch('https://openrouter.ai/api/v1/chat/completions', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${OPENROUTER_API_KEY}`, + 'Content-Type': 'application/json', + 'HTTP-Referer': 'https://worldmonitor.app', + 'X-Title': 'World Monitor', + }, + body: JSON.stringify({ + model: 'google/gemini-2.5-flash', + messages: [{ role: 'user', content: prompt }], + temperature: 0.4, + max_tokens: 1200, + }), + signal: AbortSignal.timeout(30_000), + }); + if (!resp.ok) return null; + const data = await resp.json(); + return data.choices?.[0]?.message?.content?.trim() ?? null; + } catch (err) { + console.warn(`[discord-notify] OpenRouter error: ${err.message}`); + return null; + } +} + +async function summarizeWithGemini(worldData) { + const { quakes, unrest, milFlights, natural, weather, cyber, topMovers, conflicts } = worldData; + + const langInstr = LANGUAGE === 'ja' + ? '日本語で、簡潔かつ具体的に回答してください。' + : 'Answer concisely and specifically in English.'; + + const sections = [ + quakes.length > 0 && `## 地震 (M5.0以上)\n${quakes.map(q => + `- M${q.magnitude} ${q.place ?? (q.location ? `${q.location.latitude?.toFixed(1)}, ${q.location.longitude?.toFixed(1)}` : '')}` + ).join('\n')}`, + + unrest.length > 0 && `## 社会不安・抗議活動\n${unrest.map(e => + `- [${e.severity}] ${[e.country, e.region].filter(Boolean).join(' ')}: ${e.eventType ?? ''} ${e.description ? `— ${e.description.slice(0, 80)}` : ''}` + ).join('\n')}`, + + milFlights.length > 0 && `## 軍用機 (HIGH リスク)\n${milFlights.map(f => + `- ${f.callsign ?? '?'} (${f.operator ?? f.country ?? '?'}): ${f.aircraft?.type ?? ''} alt:${f.altitude ?? '?'}ft` + ).join('\n')}`, + + natural.length > 0 && `## 自然災害\n${natural.map(e => + `- [${e.category}] ${e.title}: ${(e.description ?? '').slice(0, 80)}` + ).join('\n')}`, + + weather.length > 0 && `## 気象警報 (EXTREME/SEVERE)\n${weather.map(a => + `- [${a.severity}] ${a.event}: ${a.area ?? ''}` + ).join('\n')}`, + + cyber.length > 0 && `## サイバー脅威 (CRITICAL)\n${cyber.map(t => + `- [${t.threatType}] ${t.indicator} (${t.country ?? '?'}): ${(t.description ?? '').slice(0, 60)}` + ).join('\n')}`, + + conflicts.length > 0 && `## 武力紛争\n${conflicts.map(e => + `- ${e.country ?? ''}: ${(e.description ?? JSON.stringify(e)).slice(0, 80)}` + ).join('\n')}`, + + topMovers.length > 0 && `## 市場動向 (変動率±2%以上)\n${topMovers.map(s => { + const pct = s.changePercent ?? 0; + return `- ${s.symbol ?? s.ticker ?? '?'} ${pct >= 0 ? '+' : ''}${pct.toFixed(2)}%`; + }).join('\n')}`, + ].filter(Boolean).join('\n\n'); + + if (!sections) { + return LANGUAGE === 'ja' + ? '現時点で重大なイベントは検出されていません。' + : 'No significant events detected at this time.'; + } + + const prompt = ` +あなたは世界情勢を監視するインテリジェンスアナリストです。 +以下のリアルタイムデータを分析し、Discord 通知用の簡潔な状況報告を作成してください。 + +要件: +- 最も重要な事象を3〜5点に絞る +- 各事象は1〜2文で説明する +- 全体の脅威レベルを「低・中・高・緊急」で評価する +- 合計200〜300文字程度にまとめる +- ${langInstr} + +現在のデータ: +${sections} + +出力形式: +【脅威レベル: X】 +• (重要事象1) +• (重要事象2) +• ... +`.trim(); + + const result = await callGeminiDirect(prompt) ?? await callGeminiViaOpenRouter(prompt); + return result ?? (LANGUAGE === 'ja' + ? 'AI 要約を取得できませんでした。各データをダッシュボードでご確認ください。' + : 'AI summary unavailable. Please check the dashboard for raw data.'); +} + +// ─── Discord 投稿 ───────────────────────────────────────────────────────────── + +function buildEmbed(summary, worldData) { + const { quakes, unrest, milFlights, natural, weather, cyber, topMovers, conflicts } = worldData; + + const levelMatch = summary.match(/【脅威レベル[::]\s*(緊急|高|中|低|CRITICAL|HIGH|MEDIUM|LOW)/i); + const level = (levelMatch?.[1] ?? '').toLowerCase(); + const color = ['緊急', 'critical'].some(s => level.includes(s)) ? COLOR.ALERT + : ['高', 'high'].some(s => level.includes(s)) ? COLOR.WARNING + : ['中', 'medium'].some(s => level.includes(s)) ? COLOR.INFO + : COLOR.OK; + + const fields = []; + + if (quakes.length > 0) { + fields.push({ + name: '🌊 地震 (M5.0+)', + value: quakes.map(q => `M**${q.magnitude}** ${q.place ?? ''}`).join('\n').slice(0, 1024), + inline: true, + }); + } + if (unrest.length > 0) { + fields.push({ + name: '✊ 社会不安', + value: unrest.map(e => `${e.country ?? ''} — ${e.eventType ?? e.severity}`).join('\n').slice(0, 1024), + inline: true, + }); + } + if (milFlights.length > 0) { + fields.push({ + name: '✈️ 軍用機 (HIGH)', + value: milFlights.map(f => `${f.callsign ?? '?'} (${f.operator ?? f.country ?? '?'})`).join('\n').slice(0, 1024), + inline: true, + }); + } + if (natural.length > 0) { + fields.push({ + name: '🌋 自然災害', + value: natural.map(e => e.title).join('\n').slice(0, 1024), + inline: true, + }); + } + if (weather.length > 0) { + fields.push({ + name: '⛈️ 気象警報', + value: weather.map(a => `${a.event} — ${a.area ?? ''}`).join('\n').slice(0, 1024), + inline: true, + }); + } + if (cyber.length > 0) { + fields.push({ + name: '🔴 サイバー脅威', + value: cyber.map(t => `${t.threatType}: ${t.indicator}`).join('\n').slice(0, 1024), + inline: true, + }); + } + if (conflicts.length > 0) { + const countryList = [...new Set(conflicts.map(e => e.country).filter(Boolean))].join(', '); + fields.push({ + name: '⚔️ 武力紛争', + value: (countryList || '詳細はダッシュボードで確認').slice(0, 1024), + inline: true, + }); + } + if (topMovers.length > 0) { + fields.push({ + name: '📈 市場動向', + value: topMovers.map(s => { + const pct = s.changePercent ?? 0; + return `${pct >= 0 ? '▲' : '▼'} ${s.symbol ?? s.ticker ?? '?'} ${Math.abs(pct).toFixed(2)}%`; + }).join('\n').slice(0, 1024), + inline: true, + }); + } + + const totalEvents = quakes.length + unrest.length + milFlights.length + + natural.length + weather.length + cyber.length + conflicts.length; + + return { + title: '🌍 World Monitor — グローバル状況レポート', + description: summary, + color, + fields, + footer: { text: `${totalEvents} 件検出 • ${GEMINI_MODEL} • World Monitor` }, + timestamp: new Date().toISOString(), + }; +} + +async function postToDiscord(embed) { + if (!DISCORD_WEBHOOK_URL) { + console.error('[discord-notify] DISCORD_WEBHOOK_URL が設定されていません'); + return false; + } + try { + const resp = await fetch(DISCORD_WEBHOOK_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + username: 'World Monitor', + embeds: [embed], + }), + signal: AbortSignal.timeout(10_000), + }); + if (!resp.ok) { + const text = await resp.text().catch(() => ''); + console.error(`[discord-notify] Discord HTTP ${resp.status}: ${text.slice(0, 200)}`); + return false; + } + return true; + } catch (err) { + console.error(`[discord-notify] Discord error: ${err.message}`); + return false; + } +} + +// ─── メイン ─────────────────────────────────────────────────────────────────── + +function validateEnv({ exitOnMissing = true } = {}) { + const missing = []; + if (!DISCORD_WEBHOOK_URL) missing.push('DISCORD_WEBHOOK_URL'); + if (!GEMINI_API_KEY && !OPENROUTER_API_KEY) missing.push('GEMINI_API_KEY (または OPENROUTER_API_KEY)'); + if (missing.length > 0) { + console.warn(`[discord-notify] 必須環境変数が未設定: ${missing.join(', ')} — 通知をスキップします`); + if (exitOnMissing) process.exit(0); // 0 = supervisord に再起動させない + return false; + } + return true; +} + +async function runOnce() { + const start = Date.now(); + console.log(`[discord-notify] ${new Date().toISOString()} 実行開始`); + + const worldData = await fetchWorldData(); + const totalEvents = Object.values(worldData).flat().length; + console.log(`[discord-notify] データ取得完了 (計 ${totalEvents} 件)`); + + const summary = await summarizeWithGemini(worldData); + console.log(`[discord-notify] Gemini 要約完了 (${summary.length} 文字)`); + + const embed = buildEmbed(summary, worldData); + const ok = await postToDiscord(embed); + const elapsed = ((Date.now() - start) / 1000).toFixed(1); + console.log(`[discord-notify] ${ok ? '✓ 投稿成功' : '✗ 投稿失敗'} (${elapsed}s)`); +} + +async function runDaemon() { + console.log(`[discord-notify] デーモン開始 — ${INTERVAL_MIN} 分ごとに通知`); + // 未設定なら終了コード 0 で終了 (supervisord が再起動しない) + validateEnv({ exitOnMissing: true }); + await runOnce(); + setInterval(async () => { + try { await runOnce(); } catch (err) { console.error(`[discord-notify] エラー: ${err.message}`); } + }, INTERVAL_MIN * 60 * 1000); +} + +if (IS_DAEMON) { + runDaemon().catch(err => { console.error(err); process.exit(1); }); +} else { + // cron 実行: 未設定なら SKIP (exit 0) + if (validateEnv({ exitOnMissing: false })) { + runOnce().catch(err => { console.error(err); process.exit(1); }); + } +} diff --git a/scripts/health-check.sh b/scripts/health-check.sh new file mode 100755 index 0000000000..94821afe96 --- /dev/null +++ b/scripts/health-check.sh @@ -0,0 +1,145 @@ +#!/bin/sh +# health-check.sh — World Monitor ヘルスチェックスクリプト +# +# 使い方: +# ./scripts/health-check.sh +# +# 推奨 cron 設定 (2分ごと): +# */2 * * * * /home/user/worldmonitor/scripts/health-check.sh >> /var/log/worldmonitor-health.log 2>&1 +# +# 環境変数: +# WM_URL 監視対象 URL (デフォルト: http://localhost:3000) +# ALERT_EMAIL DEGRADED/UNHEALTHY 時の通知先メールアドレス +# DISCORD_WEBHOOK_URL Discord への障害通知 (設定している場合) + +WM_URL="${WM_URL:-http://localhost:3000}" +ALERT_COOLDOWN_MINUTES="${HEALTH_ALERT_COOLDOWN_MINUTES:-30}" +STATE_DIR="${WM_STATE_DIR:-/tmp/worldmonitor}" +STATE_FILE="${STATE_DIR}/health-check.state" +TIMESTAMP="$(date -Iseconds 2>/dev/null || date '+%Y-%m-%dT%H:%M:%S')" +SCRIPT_DIR="$(CDPATH= cd -- "$(dirname "$0")" && pwd)" +PROJECT_DIR="$(CDPATH= cd -- "${SCRIPT_DIR}/.." && pwd)" + +load_env_file() { + env_path="$1" + [ -f "$env_path" ] || return 0 + while IFS= read -r line || [ -n "$line" ]; do + trimmed=$(printf '%s' "$line" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') + [ -n "$trimmed" ] || continue + case "$trimmed" in + \#*) continue ;; + *=*) + key=${trimmed%%=*} + val=${trimmed#*=} + val=$(printf '%s' "$val" | sed "s/^['\"]//;s/['\"]$//") + eval "current=\${$key:-}" + [ -n "$current" ] || export "$key=$val" + ;; + esac + done < "$env_path" +} + +load_override_env() { + override_path="$1" + [ -f "$override_path" ] || return 0 + while IFS= read -r line || [ -n "$line" ]; do + entry=$(printf '%s' "$line" | sed 's/^[[:space:]]*//') + case "$entry" in + DISCORD_WEBHOOK_URL:*|ALERT_EMAIL:*) + key=${entry%%:*} + val=${entry#*:} + val=$(printf '%s' "$val" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') + val=$(printf '%s' "$val" | sed "s/^['\"]//;s/['\"]$//") + eval "current=\${$key:-}" + [ -n "$current" ] || export "$key=$val" + ;; + esac + done < "$override_path" +} + +load_env_file "${PROJECT_DIR}/.env.local" +load_override_env "${PROJECT_DIR}/docker-compose.override.yml" +mkdir -p "$STATE_DIR" + +# ヘルスエンドポイントを取得 +RESPONSE=$(curl -sf --max-time 10 "${WM_URL}/api/health" 2>/dev/null) +CURL_RC=$? + +if [ $CURL_RC -ne 0 ]; then + STATUS="UNREACHABLE" +else + STATUS=$(echo "$RESPONSE" | grep -o '"status":"[^"]*"' | head -1 | sed 's/"status":"//;s/"//') + [ -z "$STATUS" ] && STATUS="UNKNOWN" +fi + +echo "${TIMESTAMP} [${STATUS}]" + +PREV_STATUS="" +LAST_ALERT_EPOCH=0 +if [ -f "$STATE_FILE" ]; then + IFS='|' read -r PREV_STATUS LAST_ALERT_EPOCH < "$STATE_FILE" || true +fi + +NOW_EPOCH=$(date +%s 2>/dev/null || printf '0') +COOLDOWN_SECONDS=$((ALERT_COOLDOWN_MINUTES * 60)) +SHOULD_ALERT=0 +SHOULD_RESOLVE=0 + +case "$STATUS" in + DEGRADED|UNHEALTHY|UNREACHABLE) + if [ "$PREV_STATUS" != "$STATUS" ]; then + SHOULD_ALERT=1 + elif [ "${NOW_EPOCH:-0}" -ge $(( ${LAST_ALERT_EPOCH:-0} + COOLDOWN_SECONDS )) ]; then + SHOULD_ALERT=1 + fi + ;; + *) + case "$PREV_STATUS" in + DEGRADED|UNHEALTHY|UNREACHABLE) + SHOULD_RESOLVE=1 + ;; + esac + ;; +esac + +# DEGRADED / UNHEALTHY / UNREACHABLE の場合にアラートを送信 +case "$STATUS" in + DEGRADED|UNHEALTHY|UNREACHABLE) + ALERT_MSG="${TIMESTAMP} World Monitor ALERT: status=${STATUS} url=${WM_URL}" + if [ "$SHOULD_ALERT" -eq 1 ]; then + echo "ALERT: $ALERT_MSG" + + # メール通知 (mailutils/sendmail が使える場合) + if [ -n "$ALERT_EMAIL" ] && command -v mail >/dev/null 2>&1; then + echo "$ALERT_MSG" | mail -s "[WM] Health Alert: ${STATUS}" "$ALERT_EMAIL" + fi + + # Discord 通知 (DISCORD_WEBHOOK_URL が設定されている場合) + if [ -n "$DISCORD_WEBHOOK_URL" ]; then + curl -sf -X POST "$DISCORD_WEBHOOK_URL" \ + -H 'Content-Type: application/json' \ + -d "{\"content\":\"🚨 **World Monitor** ヘルスアラート\\nステータス: **${STATUS}**\\n時刻: ${TIMESTAMP}\"}" \ + --max-time 10 >/dev/null 2>&1 + fi + LAST_ALERT_EPOCH="$NOW_EPOCH" + else + echo "ALERT SUPPRESSED: status=${STATUS} cooldown=${ALERT_COOLDOWN_MINUTES}m" + fi + ;; +esac + +if [ "$SHOULD_RESOLVE" -eq 1 ]; then + RESOLVED_MSG="${TIMESTAMP} World Monitor RECOVERED: status=${STATUS} url=${WM_URL}" + echo "RESOLVED: $RESOLVED_MSG" + if [ -n "$ALERT_EMAIL" ] && command -v mail >/dev/null 2>&1; then + echo "$RESOLVED_MSG" | mail -s "[WM] Health Recovered: ${STATUS}" "$ALERT_EMAIL" + fi + if [ -n "$DISCORD_WEBHOOK_URL" ]; then + curl -sf -X POST "$DISCORD_WEBHOOK_URL" \ + -H 'Content-Type: application/json' \ + -d "{\"content\":\"✅ **World Monitor** 復旧\\nステータス: **${STATUS}**\\n時刻: ${TIMESTAMP}\"}" \ + --max-time 10 >/dev/null 2>&1 + fi +fi + +printf '%s|%s\n' "$STATUS" "${LAST_ALERT_EPOCH:-0}" > "$STATE_FILE" diff --git a/scripts/run-seeders.sh b/scripts/run-seeders.sh index f079786b05..57e52369c9 100755 --- a/scripts/run-seeders.sh +++ b/scripts/run-seeders.sh @@ -27,23 +27,54 @@ if [ -f "$OVERRIDE" ]; then . "$_env_tmp" rm -f "$_env_tmp" fi + ok=0 fail=0 skip=0 -for f in "$SCRIPT_DIR"/seed-*.mjs; do +# Run a single seed script with exponential-backoff retry. +# Returns 0 on success, 1 on permanent failure, 2 on skip. +run_with_retry() { + f="$1" name="$(basename "$f")" - printf "→ %s ... " "$name" - output=$(node "$f" 2>&1) - rc=$? - last=$(echo "$output" | tail -1) + max_attempts=3 + attempt=1 - if echo "$last" | grep -qi "skip\|not set\|missing.*key\|not found"; then - printf "SKIP (%s)\n" "$last" - skip=$((skip + 1)) - elif [ $rc -eq 0 ]; then - printf "OK\n" + while [ $attempt -le $max_attempts ]; do + output=$(node "$f" 2>&1) + rc=$? + last=$(echo "$output" | tail -1) + + if echo "$last" | grep -qi "skip\|not set\|missing.*key\|not found"; then + printf "→ %s ... SKIP (%s)\n" "$name" "$last" + return 2 + elif [ $rc -eq 0 ]; then + if [ $attempt -gt 1 ]; then + printf "→ %s ... OK (attempt %d/%d)\n" "$name" "$attempt" "$max_attempts" + else + printf "→ %s ... OK\n" "$name" + fi + return 0 + else + if [ $attempt -lt $max_attempts ]; then + delay=$((attempt * attempt)) # 1s, 4s (exponential backoff) + printf "→ %s ... RETRY %d/%d in %ds (%s)\n" "$name" "$attempt" "$max_attempts" "$delay" "$last" + sleep "$delay" + else + printf "→ %s ... FAIL after %d attempts (%s)\n" "$name" "$max_attempts" "$last" + fi + attempt=$((attempt + 1)) + fi + done + return 1 +} + +for f in "$SCRIPT_DIR"/seed-*.mjs; do + run_with_retry "$f" + rc=$? + if [ $rc -eq 0 ]; then ok=$((ok + 1)) + elif [ $rc -eq 2 ]; then + skip=$((skip + 1)) else - printf "FAIL (%s)\n" "$last" fail=$((fail + 1)) fi done diff --git a/scripts/setup-vps.sh b/scripts/setup-vps.sh new file mode 100755 index 0000000000..564f1f595b --- /dev/null +++ b/scripts/setup-vps.sh @@ -0,0 +1,159 @@ +#!/bin/bash +# setup-vps.sh — Hetzner CAX21 (ARM64 Debian) 初期セットアップスクリプト +# +# 使い方: +# sudo bash scripts/setup-vps.sh +# +# 実行内容: +# 1. Swap 2GB 追加 + vm.swappiness=10 設定 +# 2. Docker インストール (未インストールの場合) +# 3. ufw ファイアウォール設定 +# 4. worldmonitor.service を systemd に登録 +# 5. cron ジョブ設定 (シード 30分 / ヘルスチェック 2分) +# +# 前提条件: +# - このスクリプトは /home/user/worldmonitor から実行することを想定 +# - root または sudo 権限が必要 + +set -euo pipefail + +REPO_DIR="$(cd "$(dirname "$0")/.." && pwd)" +APP_USER="${SUDO_USER:-$(whoami)}" + +log() { echo "[setup-vps] $*"; } +warn() { echo "[setup-vps] WARN: $*" >&2; } +die() { echo "[setup-vps] ERROR: $*" >&2; exit 1; } + +[ "$(id -u)" -eq 0 ] || die "root または sudo で実行してください" + +# ─── 1. Swap (2GB) ──────────────────────────────────────────────────────────── +log "=== Swap 設定 ===" +if swapon --show | grep -q '/swapfile'; then + log "Swap already enabled — skip" +else + log "Creating 2GB swapfile..." + fallocate -l 2G /swapfile + chmod 600 /swapfile + mkswap /swapfile + swapon /swapfile + grep -q '/swapfile' /etc/fstab || echo '/swapfile none swap sw 0 0' >> /etc/fstab + log "Swap enabled" +fi + +# Swappiness (RAM が余裕ある間は Swap を使わない) +sysctl -w vm.swappiness=10 +grep -q 'vm.swappiness' /etc/sysctl.d/99-worldmonitor.conf 2>/dev/null || \ + echo 'vm.swappiness=10' >> /etc/sysctl.d/99-worldmonitor.conf +log "vm.swappiness=10 設定完了" + +# ─── 2. Docker ─────────────────────────────────────────────────────────────── +log "=== Docker ===" +if command -v docker >/dev/null 2>&1; then + log "Docker already installed: $(docker --version)" +else + log "Installing Docker..." + apt-get update -qq + apt-get install -y -qq ca-certificates curl gnupg + install -m 0755 -d /etc/apt/keyrings + curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg + chmod a+r /etc/apt/keyrings/docker.gpg + echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian $(. /etc/os-release && echo "$VERSION_CODENAME") stable" \ + > /etc/apt/sources.list.d/docker.list + apt-get update -qq + apt-get install -y -qq docker-ce docker-ce-cli containerd.io docker-compose-plugin + usermod -aG docker "$APP_USER" + log "Docker installed" +fi +systemctl enable --now docker +log "Docker daemon enabled" + +# ─── 3. ufw ファイアウォール ───────────────────────────────────────────────── +log "=== ufw ファイアウォール ===" +if ! command -v ufw >/dev/null 2>&1; then + apt-get install -y -qq ufw +fi + +ufw --force reset +ufw default deny incoming +ufw default allow outgoing +ufw allow 22/tcp comment 'SSH' +ufw allow 3000/tcp comment 'World Monitor HTTP' +ufw allow 80/tcp comment 'HTTP (for TLS/Cloudflare)' +ufw allow 443/tcp comment 'HTTPS' +ufw --force enable +log "ufw enabled (allowed: 22, 80, 443, 3000)" +warn "Docker published ports can bypass ufw rules. Mirror ingress rules in Hetzner Cloud Firewalls and use SSH for routine management." + +# Docker が ufw をバイパスする問題の対策 +DOCKER_DAEMON=/etc/docker/daemon.json +if [ ! -f "$DOCKER_DAEMON" ]; then + echo '{"iptables": true, "userland-proxy": false}' > "$DOCKER_DAEMON" + systemctl reload docker 2>/dev/null || true + log "Docker daemon.json 設定" +fi + +# ─── 4. systemd サービス ───────────────────────────────────────────────────── +log "=== systemd サービス ===" +SERVICE_SRC="$REPO_DIR/docker/worldmonitor.service" +SERVICE_DST="/etc/systemd/system/worldmonitor.service" + +if [ -f "$SERVICE_SRC" ]; then + # REPO_DIR をサービスファイルに埋め込む + sed "s|__REPO_DIR__|${REPO_DIR}|g" "$SERVICE_SRC" > "$SERVICE_DST" + systemctl daemon-reload + systemctl enable worldmonitor + log "worldmonitor.service 有効化" +else + warn "docker/worldmonitor.service が見つかりません — systemd 設定をスキップ" +fi + +# ─── 5. cron ジョブ ────────────────────────────────────────────────────────── +log "=== cron ジョブ ===" + +if ! command -v cron >/dev/null 2>&1; then + apt-get install -y -qq cron +fi +systemctl enable --now cron + +# ログディレクトリ作成 +LOG_DIR=/var/log +touch "$LOG_DIR/worldmonitor-seed.log" \ + "$LOG_DIR/worldmonitor-health.log" +chown "$APP_USER" "$LOG_DIR/worldmonitor-seed.log" \ + "$LOG_DIR/worldmonitor-health.log" + +# ユーザーの crontab を設定 +CRON_TMP=$(mktemp) +# 既存 crontab を取得 (存在しない場合は空) +crontab -u "$APP_USER" -l 2>/dev/null | grep -v worldmonitor > "$CRON_TMP" || true + +cat >> "$CRON_TMP" <> /var/log/worldmonitor-seed.log 2>&1 + +# World Monitor — ヘルスチェック (2分ごと) +*/2 * * * * ${REPO_DIR}/scripts/health-check.sh >> /var/log/worldmonitor-health.log 2>&1 + +# World Monitor — ログローテーション (週1回) +0 3 * * 0 truncate -s 0 /var/log/worldmonitor-seed.log /var/log/worldmonitor-health.log +EOF + +crontab -u "$APP_USER" "$CRON_TMP" +rm -f "$CRON_TMP" +log "cron ジョブ設定完了" + +# ─── 完了 ──────────────────────────────────────────────────────────────────── +log "" +log "=== セットアップ完了 ===" +log " Swap: $(free -h | awk '/Swap:/ {print $2}')" +log " Docker: $(docker --version 2>/dev/null || echo 'n/a')" +log " ufw: $(ufw status | head -1)" +log " systemd: worldmonitor.service" +log " crontab: $(crontab -u "$APP_USER" -l 2>/dev/null | grep -c worldmonitor) ジョブ登録済み" +log "" +log "次のステップ:" +log " 1. docker-compose.override.yml に API キーを設定" +log " 2. systemctl start worldmonitor でスタック起動" +log " 3. ./scripts/run-seeders.sh で初回シード実行" +log " 4. Hetzner Backups / Snapshots を有効化" diff --git a/src-tauri/sidecar/local-api-server.mjs b/src-tauri/sidecar/local-api-server.mjs index 07f82e7b31..f0794b7e1b 100644 --- a/src-tauri/sidecar/local-api-server.mjs +++ b/src-tauri/sidecar/local-api-server.mjs @@ -1569,12 +1569,64 @@ export async function createLocalApiServer(options = {}) { }; } +// ============================================================================= +// Structured JSON logger (Docker mode) / plain console (desktop mode) +// ============================================================================= +function createLogger(mode) { + const isDocker = (mode ?? process.env.LOCAL_API_MODE ?? '').includes('docker'); + if (!isDocker) return console; + const fmt = (level, msg, ...args) => { + const extra = args.length > 0 ? { detail: args.map(String).join(' ') } : {}; + process.stdout.write(JSON.stringify({ level, ts: new Date().toISOString(), msg: String(msg), ...extra }) + '\n'); + }; + return { + log: (msg, ...a) => fmt('info', msg, ...a), + info: (msg, ...a) => fmt('info', msg, ...a), + warn: (msg, ...a) => fmt('warn', msg, ...a), + error: (msg, ...a) => fmt('error', msg, ...a), + debug: (msg, ...a) => fmt('debug', msg, ...a), + }; +} + if (isMainModule()) { + const mode = process.env.LOCAL_API_MODE ?? 'desktop-sidecar'; + const logger = createLogger(mode); + let app; try { - const app = await createLocalApiServer(); + app = await createLocalApiServer({ logger }); await app.start(); } catch (error) { - console.error('[local-api] startup failed', error); + logger.error('[local-api] startup failed', error); process.exit(1); } + + // ── Graceful Shutdown ────────────────────────────────────────────────────── + let isShuttingDown = false; + let forceExitTimer = null; + + async function shutdown(signal) { + if (isShuttingDown) return; + isShuttingDown = true; + logger.log(`[local-api] ${signal} received — graceful shutdown`); + forceExitTimer = setTimeout(() => { + process.stderr.write('[local-api] forced exit after 30s timeout\n'); + process.exit(1); + }, 30_000); + forceExitTimer.unref(); + try { + await app.close(); + logger.log('[local-api] HTTP server closed'); + } catch (err) { + logger.error('[local-api] error during shutdown', err.message); + } finally { + if (forceExitTimer) { + clearTimeout(forceExitTimer); + forceExitTimer = null; + } + } + process.exit(0); + } + + process.on('SIGTERM', () => { shutdown('SIGTERM').catch(() => {}); }); + process.on('SIGINT', () => { shutdown('SIGINT').catch(() => {}); }); }