feat(ops-patrol): Layer 3 巡检与 Telegram 配置打通
保存 Telegram 时同步至 .env,health_monitor 支持 MySQL 回退, 设置页展示巡检状态,部署时自动安装 cron。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -126,8 +126,16 @@ echo " /health → ${HEALTH}"
|
|||||||
echo " /app/ → ${SPA}"
|
echo " /app/ → ${SPA}"
|
||||||
if [[ "${HEALTH}" == "ok" && "${SPA}" == "200" ]]; then
|
if [[ "${HEALTH}" == "ok" && "${SPA}" == "200" ]]; then
|
||||||
echo "✓ Deploy successful"
|
echo "✓ Deploy successful"
|
||||||
ssh_cmd "command -v crontab >/dev/null && crontab -l 2>/dev/null | grep -q nexus-health-monitor" 2>/dev/null || \
|
if ssh_cmd "command -v crontab >/dev/null && crontab -l 2>/dev/null | grep -q nexus-health-monitor" 2>/dev/null; then
|
||||||
echo " 提示: 远程未安装巡检 cron,可在服务器执行 sudo nx cron"
|
echo "✓ Ops cron (health_monitor) already installed"
|
||||||
|
else
|
||||||
|
echo "▶ Installing ops cron (health_monitor + db_backup)…"
|
||||||
|
if ssh_cmd "sudo env NEXUS_ROOT=${DEPLOY_PATH} bash ${DEPLOY_PATH}/deploy/install_ops_cron.sh" 2>/dev/null; then
|
||||||
|
echo "✓ Ops cron installed"
|
||||||
|
else
|
||||||
|
echo " 提示: 远程 cron 安装失败,可在服务器执行: sudo nx cron"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
else
|
else
|
||||||
echo "✗ Verification failed — check logs on server" >&2
|
echo "✗ Verification failed — check logs on server" >&2
|
||||||
exit 1
|
exit 1
|
||||||
|
|||||||
@@ -396,3 +396,33 @@
|
|||||||
{"ts":"2026-06-07T19:44:50+08:00","date":"2026-06-07","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
{"ts":"2026-06-07T19:44:50+08:00","date":"2026-06-07","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||||
{"ts":"2026-06-07T19:44:50+08:00","date":"2026-06-07","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
{"ts":"2026-06-07T19:44:50+08:00","date":"2026-06-07","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||||
{"ts":"2026-06-07T19:44:50+08:00","date":"2026-06-07","gate":"summary","result":"PASS","detail":"7/7"}
|
{"ts":"2026-06-07T19:44:50+08:00","date":"2026-06-07","gate":"summary","result":"PASS","detail":"7/7"}
|
||||||
|
{"ts":"2026-06-07T19:45:16+08:00","date":"2026-06-07","gate":"changelog","result":"PASS","detail":"2026-06-07-settings-telegram-fix.md 37lines"}
|
||||||
|
{"ts":"2026-06-07T19:45:16+08:00","date":"2026-06-07","gate":"audit","result":"PASS","detail":"2026-06-07-settings-telegram-fix.md"}
|
||||||
|
{"ts":"2026-06-07T19:45:16+08:00","date":"2026-06-07","gate":"test","result":"PASS","detail":"all passed"}
|
||||||
|
{"ts":"2026-06-07T19:45:16+08:00","date":"2026-06-07","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||||
|
{"ts":"2026-06-07T19:45:16+08:00","date":"2026-06-07","gate":"import","result":"PASS","detail":"ok"}
|
||||||
|
{"ts":"2026-06-07T19:45:16+08:00","date":"2026-06-07","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||||
|
{"ts":"2026-06-07T19:45:16+08:00","date":"2026-06-07","gate":"review","result":"PASS","detail":"audit covers changes"}
|
||||||
|
{"ts":"2026-06-07T19:45:16+08:00","date":"2026-06-07","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||||
|
{"ts":"2026-06-07T19:45:16+08:00","date":"2026-06-07","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||||
|
{"ts":"2026-06-07T19:45:16+08:00","date":"2026-06-07","gate":"summary","result":"PASS","detail":"7/7"}
|
||||||
|
{"ts":"2026-06-07T20:16:43+08:00","date":"2026-06-07","gate":"changelog","result":"PASS","detail":"2026-06-07-ops-patrol-layer3-telegram.md 40lines"}
|
||||||
|
{"ts":"2026-06-07T20:16:43+08:00","date":"2026-06-07","gate":"audit","result":"PASS","detail":"2026-06-07-settings-telegram-fix.md"}
|
||||||
|
{"ts":"2026-06-07T20:16:43+08:00","date":"2026-06-07","gate":"test","result":"PASS","detail":"all passed"}
|
||||||
|
{"ts":"2026-06-07T20:16:43+08:00","date":"2026-06-07","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||||
|
{"ts":"2026-06-07T20:16:43+08:00","date":"2026-06-07","gate":"import","result":"PASS","detail":"ok"}
|
||||||
|
{"ts":"2026-06-07T20:16:43+08:00","date":"2026-06-07","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||||
|
{"ts":"2026-06-07T20:16:43+08:00","date":"2026-06-07","gate":"review","result":"BLOCK","detail":"audit missing changed files"}
|
||||||
|
{"ts":"2026-06-07T20:16:43+08:00","date":"2026-06-07","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||||
|
{"ts":"2026-06-07T20:16:43+08:00","date":"2026-06-07","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||||
|
{"ts":"2026-06-07T20:16:43+08:00","date":"2026-06-07","gate":"summary","result":"BLOCK","detail":"6/7"}
|
||||||
|
{"ts":"2026-06-07T20:17:03+08:00","date":"2026-06-07","gate":"changelog","result":"PASS","detail":"2026-06-07-ops-patrol-layer3-telegram.md 40lines"}
|
||||||
|
{"ts":"2026-06-07T20:17:03+08:00","date":"2026-06-07","gate":"audit","result":"PASS","detail":"2026-06-07-ops-patrol-layer3-telegram.md"}
|
||||||
|
{"ts":"2026-06-07T20:17:03+08:00","date":"2026-06-07","gate":"test","result":"PASS","detail":"all passed"}
|
||||||
|
{"ts":"2026-06-07T20:17:03+08:00","date":"2026-06-07","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||||
|
{"ts":"2026-06-07T20:17:03+08:00","date":"2026-06-07","gate":"import","result":"PASS","detail":"ok"}
|
||||||
|
{"ts":"2026-06-07T20:17:03+08:00","date":"2026-06-07","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||||
|
{"ts":"2026-06-07T20:17:03+08:00","date":"2026-06-07","gate":"review","result":"PASS","detail":"audit covers changes"}
|
||||||
|
{"ts":"2026-06-07T20:17:03+08:00","date":"2026-06-07","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||||
|
{"ts":"2026-06-07T20:17:03+08:00","date":"2026-06-07","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||||
|
{"ts":"2026-06-07T20:17:03+08:00","date":"2026-06-07","gate":"summary","result":"PASS","detail":"7/7"}
|
||||||
|
|||||||
@@ -33,6 +33,35 @@ read_env_var() {
|
|||||||
grep -E "^${key}=" "$file" 2>/dev/null | head -1 | cut -d= -f2- | tr -d '\r"' || true
|
grep -E "^${key}=" "$file" 2>/dev/null | head -1 | cut -d= -f2- | tr -d '\r"' || true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
load_telegram_from_db() {
|
||||||
|
local cname="$1"
|
||||||
|
[[ -n "$cname" ]] || return 1
|
||||||
|
[[ -n "${BOT_TOKEN:-}" && -n "${CHAT_ID:-}" ]] && return 0
|
||||||
|
local out token chat
|
||||||
|
out="$(docker exec "$cname" python3 -c "
|
||||||
|
import asyncio
|
||||||
|
from server.config import settings as s
|
||||||
|
from server.infrastructure.database.session import AsyncSessionLocal
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
|
await s.load_settings_from_db(db)
|
||||||
|
t = (s.TELEGRAM_BOT_TOKEN or '').strip()
|
||||||
|
c = (s.TELEGRAM_CHAT_ID or '').strip()
|
||||||
|
if t and c:
|
||||||
|
print(t)
|
||||||
|
print(c)
|
||||||
|
|
||||||
|
asyncio.run(main())
|
||||||
|
" 2>/dev/null)" || true
|
||||||
|
[[ -n "$out" ]] || return 1
|
||||||
|
token="$(printf '%s\n' "$out" | sed -n '1p')"
|
||||||
|
chat="$(printf '%s\n' "$out" | sed -n '2p')"
|
||||||
|
[[ -n "$token" && -n "$chat" ]] || return 1
|
||||||
|
BOT_TOKEN="$token"
|
||||||
|
CHAT_ID="$chat"
|
||||||
|
}
|
||||||
|
|
||||||
load_telegram_config() {
|
load_telegram_config() {
|
||||||
local cname="$1" dotenv
|
local cname="$1" dotenv
|
||||||
BOT_TOKEN=""
|
BOT_TOKEN=""
|
||||||
@@ -54,6 +83,8 @@ load_telegram_config() {
|
|||||||
NEXUS_TELEGRAM_CHAT_ID=*) CHAT_ID="${line#NEXUS_TELEGRAM_CHAT_ID=}" ;;
|
NEXUS_TELEGRAM_CHAT_ID=*) CHAT_ID="${line#NEXUS_TELEGRAM_CHAT_ID=}" ;;
|
||||||
esac
|
esac
|
||||||
done < <(docker exec "$cname" grep -E '^NEXUS_TELEGRAM_' /app/.env 2>/dev/null || true)
|
done < <(docker exec "$cname" grep -E '^NEXUS_TELEGRAM_' /app/.env 2>/dev/null || true)
|
||||||
|
[[ -n "$BOT_TOKEN" && -n "$CHAT_ID" ]] && return 0
|
||||||
|
load_telegram_from_db "$cname" || true
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -363,6 +363,8 @@ keys = (
|
|||||||
"NEXUS_ENCRYPTION_KEY",
|
"NEXUS_ENCRYPTION_KEY",
|
||||||
"NEXUS_REDIS_URL",
|
"NEXUS_REDIS_URL",
|
||||||
"NEXUS_DATABASE_URL",
|
"NEXUS_DATABASE_URL",
|
||||||
|
"NEXUS_TELEGRAM_BOT_TOKEN",
|
||||||
|
"NEXUS_TELEGRAM_CHAT_ID",
|
||||||
)
|
)
|
||||||
prod = {}
|
prod = {}
|
||||||
for line in env_file.read_text(encoding="utf-8").splitlines():
|
for line in env_file.read_text(encoding="utf-8").splitlines():
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# 审计 — Layer 3 宿主机巡检 Telegram 打通
|
||||||
|
|
||||||
|
## 范围
|
||||||
|
|
||||||
|
| 文件 | 变更 |
|
||||||
|
|------|------|
|
||||||
|
| `server/infrastructure/env_file.py` | `.env` upsert/read(UTF-8 LF) |
|
||||||
|
| `server/application/services/ops_patrol_sync.py` | Telegram → `/app/.env`、persist 卷、host `.env.prod` |
|
||||||
|
| `server/api/settings.py` | `set_setting` 钩子;`GET/POST ops-patrol/*` |
|
||||||
|
| `deploy/health_monitor.sh` | `load_telegram_from_db` MySQL 回退 |
|
||||||
|
| `deploy/deploy-production.sh` | 部署成功自动 `install_ops_cron.sh` |
|
||||||
|
| `deploy/nexus-1panel.sh` | `sync_env_prod_to_volume` 含 Telegram 键 |
|
||||||
|
| `frontend/src/pages/SettingsPage.vue` | Layer 3 巡检状态与同步 UI |
|
||||||
|
| `frontend/src/types/api.ts` | `OpsPatrol*` 类型 |
|
||||||
|
| `tests/test_ops_patrol_sync.py` | 7 用例 |
|
||||||
|
| `docs/changelog/2026-06-07-ops-patrol-layer3-telegram.md` | changelog |
|
||||||
|
| `docs/design/plans/2026-06-07-ops-patrol-layer3-telegram.md` | 技术说明 |
|
||||||
|
| `deploy/gate_log.jsonl` | 门控记录(自动生成) |
|
||||||
|
|
||||||
|
## Step 3 规则扫描
|
||||||
|
|
||||||
|
| H | 规则 | 结论 |
|
||||||
|
|---|------|------|
|
||||||
|
| H1 | Layer-3 与设置页 Telegram 脱节 | FIXED — 保存时 sync + DB 回退 |
|
||||||
|
| H2 | `.env` 写入破坏不可变键 | SAFE — 仅 upsert `NEXUS_TELEGRAM_*` |
|
||||||
|
| H3 | 宿主机 cron 未安装 | FIXED — deploy 自动 install_ops_cron |
|
||||||
|
| H4 | env 同步失败静默 | SAFE — 记录 warning;DB 回退保底 |
|
||||||
|
| H5 | 敏感 Token 泄露至日志 | SAFE — 无 log token;审计仅写布尔标志 |
|
||||||
|
|
||||||
|
## Closure
|
||||||
|
|
||||||
|
| H | 判定 | 依据 |
|
||||||
|
|---|------|------|
|
||||||
|
| H1–H5 | FIXED / SAFE | 代码 + pytest 7/7 |
|
||||||
|
|
||||||
|
## DoD
|
||||||
|
|
||||||
|
- [x] pytest `test_ops_patrol_sync.py` + `test_settings_telegram.py` 13 passed
|
||||||
|
- [x] changelog / audit / plan
|
||||||
|
- [x] 无 SECRET_KEY/API_KEY/ENCRYPTION_KEY/DATABASE_URL 变更
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
`.venv/bin/pytest tests/test_ops_patrol_sync.py tests/test_settings_telegram.py -q`
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Changelog — Layer 3 宿主机巡检完美实现
|
||||||
|
|
||||||
|
**日期**: 2026-06-07
|
||||||
|
|
||||||
|
## 摘要
|
||||||
|
|
||||||
|
打通设置页 Telegram 配置与 Layer-3 宿主机巡检(`health_monitor.sh` + cron):保存时自动同步 `.env`、脚本支持从 MySQL 回退读取、设置页展示巡检状态与手动同步、部署时自动安装 cron。
|
||||||
|
|
||||||
|
## 动机
|
||||||
|
|
||||||
|
用户在设置页配置 Telegram 后,应用内告警与测试发送可用,但宿主机 cron 巡检仅读 `docker/.env.prod` / 容器 `/app/.env`,导致 Layer-3 重启告警 Telegram 可能静默失败。
|
||||||
|
|
||||||
|
## 涉及文件
|
||||||
|
|
||||||
|
| 文件 | 变更 |
|
||||||
|
|------|------|
|
||||||
|
| `server/infrastructure/env_file.py` | 新增 `.env` upsert/read 工具 |
|
||||||
|
| `server/application/services/ops_patrol_sync.py` | Telegram → `/app/.env`、persist 卷、host `.env.prod` |
|
||||||
|
| `server/api/settings.py` | `set_setting` 钩子;`GET/POST ops-patrol/*` |
|
||||||
|
| `deploy/health_monitor.sh` | `load_telegram_from_db` MySQL 回退 |
|
||||||
|
| `deploy/deploy-production.sh` | 部署成功时自动 `install_ops_cron.sh` |
|
||||||
|
| `deploy/nexus-1panel.sh` | `sync_env_prod_to_volume` 含 Telegram 键 |
|
||||||
|
| `frontend/src/pages/SettingsPage.vue` | Layer 3 巡检状态与同步 UI |
|
||||||
|
| `frontend/src/types/api.ts` | OpsPatrol 类型 |
|
||||||
|
| `tests/test_ops_patrol_sync.py` | 单元测试 |
|
||||||
|
|
||||||
|
## 迁移 / 重启
|
||||||
|
|
||||||
|
- 无需 DB 迁移
|
||||||
|
- 后端需重启/重新部署容器;前端需 vite build
|
||||||
|
- 生产需确保 cron 已安装(部署脚本现自动尝试)
|
||||||
|
|
||||||
|
## 验证方式
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pytest tests/test_ops_patrol_sync.py -q
|
||||||
|
bash scripts/local_verify.sh
|
||||||
|
# 设置页保存 Telegram → 刷新 Layer 3 状态 → 「同步巡检 Telegram」
|
||||||
|
# 生产: crontab -l | grep nexus-health-monitor; tail /var/log/nexus_health.log
|
||||||
|
```
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# 技术说明 — Layer 3 宿主机巡检 Telegram 打通
|
||||||
|
|
||||||
|
**日期**: 2026-06-07
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
设置页保存 `telegram_bot_token` / `telegram_chat_id` 后,Layer-3 `health_monitor.sh` 能可靠发送 Telegram(/health 连续失败 → 重启通知)。
|
||||||
|
|
||||||
|
## 三层配置读取顺序(health_monitor.sh)
|
||||||
|
|
||||||
|
1. 宿主机 `${NEXUS_ROOT}/.env`、`docker/.env.prod`
|
||||||
|
2. 容器 `/app/.env`(`docker exec grep`)
|
||||||
|
3. **新增** 容器内 Python 从 MySQL `settings` 表加载(与运行时 `load_settings_from_db` 一致)
|
||||||
|
|
||||||
|
## 应用内同步(保存 Telegram 时)
|
||||||
|
|
||||||
|
`ops_patrol_sync.sync_telegram_to_env` 写入:
|
||||||
|
|
||||||
|
- `/app/.env` + `NEXUS_PERSIST_DIR/.env`(nexus-state 卷)
|
||||||
|
- 若 `NEXUS_HOST_ROOT` 可写:`{NEXUS_HOST_ROOT}/docker/.env.prod`
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| GET | `/api/settings/ops-patrol/status` | 巡检 Telegram / env 同步状态 |
|
||||||
|
| POST | `/api/settings/ops-patrol/sync-telegram` | 手动同步 DB → .env |
|
||||||
|
|
||||||
|
## 部署
|
||||||
|
|
||||||
|
`deploy-production.sh` 健康检查通过后,若远程无 `nexus-health-monitor` cron,自动执行 `install_ops_cron.sh`。
|
||||||
|
|
||||||
|
## 回滚
|
||||||
|
|
||||||
|
- revert 本 commit;cron 条目可保留(行为与旧版兼容,仅多 DB 回退)
|
||||||
|
- 删除 `.env` 中 `NEXUS_TELEGRAM_*` 不影响应用内 DB 配置
|
||||||
@@ -99,6 +99,48 @@
|
|||||||
@update:model-value="(v: boolean | null) => toggleNotify(item.key, Boolean(v))"
|
@update:model-value="(v: boolean | null) => toggleNotify(item.key, Boolean(v))"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<v-divider class="my-4" />
|
||||||
|
<div class="text-subtitle-2 mb-1">Layer 3 宿主机巡检</div>
|
||||||
|
<p class="text-caption text-medium-emphasis mb-3">
|
||||||
|
每分钟由宿主机 cron 执行 <code>health_monitor.sh</code>:连续 3 次 /health 失败则自动重启并推送 Telegram。
|
||||||
|
配置保存后会自动同步至 .env;亦可手动同步。未同步时脚本会从数据库读取 Telegram 配置。
|
||||||
|
</p>
|
||||||
|
<div class="d-flex flex-wrap align-center ga-2 mb-3">
|
||||||
|
<v-chip
|
||||||
|
size="small"
|
||||||
|
:color="patrolStatus?.telegram_layer3_ready ? 'success' : 'warning'"
|
||||||
|
variant="tonal"
|
||||||
|
>
|
||||||
|
{{ patrolStatus?.telegram_layer3_ready ? '巡检 Telegram 就绪' : '请先配置 Telegram' }}
|
||||||
|
</v-chip>
|
||||||
|
<v-chip
|
||||||
|
v-if="patrolStatus"
|
||||||
|
size="small"
|
||||||
|
:color="patrolStatus.telegram_env_synced ? 'success' : 'grey'"
|
||||||
|
variant="tonal"
|
||||||
|
>
|
||||||
|
{{ patrolStatus.telegram_env_synced ? '.env 已同步' : '.env 未同步(可用 DB 回退)' }}
|
||||||
|
</v-chip>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex flex-wrap ga-2">
|
||||||
|
<v-btn
|
||||||
|
size="small"
|
||||||
|
variant="tonal"
|
||||||
|
prepend-icon="mdi-sync"
|
||||||
|
@click="syncPatrolTelegram"
|
||||||
|
:loading="syncingPatrol"
|
||||||
|
:disabled="!patrolStatus?.telegram_app_configured"
|
||||||
|
>
|
||||||
|
同步巡检 Telegram
|
||||||
|
</v-btn>
|
||||||
|
<v-btn size="small" variant="text" prepend-icon="mdi-refresh" @click="loadPatrolStatus" :loading="patrolLoading">
|
||||||
|
刷新状态
|
||||||
|
</v-btn>
|
||||||
|
</div>
|
||||||
|
<p v-if="patrolStatus" class="text-caption text-medium-emphasis mt-2 mb-0">
|
||||||
|
宿主机安装 cron:<code>{{ patrolStatus.cron_install_command }}</code> · 日志:<code>{{ patrolStatus.health_log_path }}</code>
|
||||||
|
</p>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
</v-card>
|
</v-card>
|
||||||
</v-col>
|
</v-col>
|
||||||
@@ -330,6 +372,8 @@ import type {
|
|||||||
SettingItem,
|
SettingItem,
|
||||||
TelegramChatItem,
|
TelegramChatItem,
|
||||||
TelegramChatsResponse,
|
TelegramChatsResponse,
|
||||||
|
OpsPatrolStatusResponse,
|
||||||
|
OpsPatrolSyncResponse,
|
||||||
SubscriptionParseResponse,
|
SubscriptionParseResponse,
|
||||||
IpAllowlistSaveRequest,
|
IpAllowlistSaveRequest,
|
||||||
} from '@/types/api'
|
} from '@/types/api'
|
||||||
@@ -358,6 +402,9 @@ const showToken = ref(false)
|
|||||||
const savingTelegram = ref(false)
|
const savingTelegram = ref(false)
|
||||||
const notifyToggles = ref<Record<string, boolean>>({})
|
const notifyToggles = ref<Record<string, boolean>>({})
|
||||||
const notifyToggleSaving = ref<string | null>(null)
|
const notifyToggleSaving = ref<string | null>(null)
|
||||||
|
const patrolStatus = ref<OpsPatrolStatusResponse | null>(null)
|
||||||
|
const patrolLoading = ref(false)
|
||||||
|
const syncingPatrol = ref(false)
|
||||||
|
|
||||||
const notifyToggleGroups = computed(() => {
|
const notifyToggleGroups = computed(() => {
|
||||||
const groups: Record<string, typeof NOTIFY_TOGGLE_ITEMS> = {}
|
const groups: Record<string, typeof NOTIFY_TOGGLE_ITEMS> = {}
|
||||||
@@ -500,6 +547,38 @@ async function persistTelegramConfig(options?: { requireToken?: boolean; require
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadPatrolStatus() {
|
||||||
|
patrolLoading.value = true
|
||||||
|
try {
|
||||||
|
patrolStatus.value = await http.get<OpsPatrolStatusResponse>('/settings/ops-patrol/status')
|
||||||
|
} catch {
|
||||||
|
patrolStatus.value = null
|
||||||
|
} finally {
|
||||||
|
patrolLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncPatrolTelegram() {
|
||||||
|
syncingPatrol.value = true
|
||||||
|
try {
|
||||||
|
if (!await persistTelegramConfig({ requireToken: true, requireChatId: true })) return
|
||||||
|
const res = await http.post<OpsPatrolSyncResponse>('/settings/ops-patrol/sync-telegram', {})
|
||||||
|
patrolStatus.value = {
|
||||||
|
...(patrolStatus.value || {} as OpsPatrolStatusResponse),
|
||||||
|
telegram_layer3_ready: res.telegram_layer3_ready,
|
||||||
|
telegram_env_synced: res.env_status.env_synced,
|
||||||
|
env_status: res.env_status,
|
||||||
|
telegram_app_configured: true,
|
||||||
|
}
|
||||||
|
snackbar('巡检 Telegram 已同步至 .env')
|
||||||
|
await loadPatrolStatus()
|
||||||
|
} catch (e: unknown) {
|
||||||
|
snackbar(e instanceof Error ? e.message : '同步失败', 'error')
|
||||||
|
} finally {
|
||||||
|
syncingPatrol.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function saveTelegramConfig() {
|
async function saveTelegramConfig() {
|
||||||
savingTelegram.value = true
|
savingTelegram.value = true
|
||||||
try {
|
try {
|
||||||
@@ -510,6 +589,7 @@ async function saveTelegramConfig() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
await persistTelegramConfig()
|
await persistTelegramConfig()
|
||||||
|
await loadPatrolStatus()
|
||||||
snackbar('Telegram 配置已保存')
|
snackbar('Telegram 配置已保存')
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
snackbar(e instanceof Error ? e.message : '保存失败', 'error')
|
snackbar(e instanceof Error ? e.message : '保存失败', 'error')
|
||||||
@@ -755,6 +835,7 @@ async function saveAllowlist() {
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadSettings()
|
loadSettings()
|
||||||
loadAllowlist()
|
loadAllowlist()
|
||||||
|
loadPatrolStatus()
|
||||||
auth.fetchProfile()
|
auth.fetchProfile()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -35,6 +35,30 @@ export interface TelegramChatsResponse {
|
|||||||
count: number
|
count: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface OpsPatrolEnvStatus {
|
||||||
|
app_env_token: boolean
|
||||||
|
app_env_chat_id: boolean
|
||||||
|
host_env_prod_token: boolean
|
||||||
|
host_env_prod_chat_id: boolean
|
||||||
|
env_synced: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OpsPatrolStatusResponse {
|
||||||
|
telegram_app_configured: boolean
|
||||||
|
telegram_layer3_ready: boolean
|
||||||
|
telegram_env_synced: boolean
|
||||||
|
env_status: OpsPatrolEnvStatus
|
||||||
|
db_fallback_enabled: boolean
|
||||||
|
cron_install_command: string
|
||||||
|
health_log_path: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OpsPatrolSyncResponse {
|
||||||
|
synced: Record<string, boolean | string>
|
||||||
|
env_status: OpsPatrolEnvStatus
|
||||||
|
telegram_layer3_ready: boolean
|
||||||
|
}
|
||||||
|
|
||||||
/** Telegram notification toggle (settings key → label) */
|
/** Telegram notification toggle (settings key → label) */
|
||||||
export interface NotifyToggleItem {
|
export interface NotifyToggleItem {
|
||||||
key: string
|
key: string
|
||||||
|
|||||||
@@ -377,6 +377,14 @@ async def set_setting(
|
|||||||
ip_address=request.client.host if request.client else "",
|
ip_address=request.client.host if request.client else "",
|
||||||
))
|
))
|
||||||
|
|
||||||
|
if key in ("telegram_bot_token", "telegram_chat_id"):
|
||||||
|
try:
|
||||||
|
from server.application.services.ops_patrol_sync import sync_telegram_to_env
|
||||||
|
|
||||||
|
sync_telegram_to_env(_settings.TELEGRAM_BOT_TOKEN, _settings.TELEGRAM_CHAT_ID)
|
||||||
|
except Exception as exc:
|
||||||
|
_logger.warning("Layer-3 Telegram env sync failed after %s update: %s", key, exc)
|
||||||
|
|
||||||
return {"key": setting.key, "value": setting.value}
|
return {"key": setting.key, "value": setting.value}
|
||||||
|
|
||||||
|
|
||||||
@@ -898,6 +906,66 @@ async def alert_stats(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Layer-3 Host Patrol (health_monitor.sh) ──
|
||||||
|
|
||||||
|
@router.get("/ops-patrol/status", response_model=dict)
|
||||||
|
async def ops_patrol_status(
|
||||||
|
admin: Admin = Depends(get_current_admin),
|
||||||
|
):
|
||||||
|
"""Report Telegram readiness for Layer-3 cron patrol and env sync state."""
|
||||||
|
from server.config import settings as _settings
|
||||||
|
from server.application.services.ops_patrol_sync import read_telegram_env_status
|
||||||
|
|
||||||
|
app_configured = bool(_settings.TELEGRAM_BOT_TOKEN and _settings.TELEGRAM_CHAT_ID)
|
||||||
|
env_status = read_telegram_env_status()
|
||||||
|
return {
|
||||||
|
"telegram_app_configured": app_configured,
|
||||||
|
"telegram_layer3_ready": app_configured,
|
||||||
|
"telegram_env_synced": env_status["env_synced"],
|
||||||
|
"env_status": env_status,
|
||||||
|
"db_fallback_enabled": True,
|
||||||
|
"cron_install_command": "sudo nx cron",
|
||||||
|
"health_log_path": "/var/log/nexus_health.log",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/ops-patrol/sync-telegram", response_model=dict)
|
||||||
|
async def ops_patrol_sync_telegram(
|
||||||
|
request: Request,
|
||||||
|
admin: Admin = Depends(get_current_admin),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Sync DB Telegram credentials to .env files readable by health_monitor.sh."""
|
||||||
|
from server.config import settings as _settings
|
||||||
|
from server.application.services.ops_patrol_sync import (
|
||||||
|
read_telegram_env_status,
|
||||||
|
sync_telegram_to_env,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not _settings.TELEGRAM_BOT_TOKEN or not _settings.TELEGRAM_CHAT_ID:
|
||||||
|
raise HTTPException(status_code=400, detail="请先在上方保存 Bot Token 与 Chat ID")
|
||||||
|
|
||||||
|
result = sync_telegram_to_env(_settings.TELEGRAM_BOT_TOKEN, _settings.TELEGRAM_CHAT_ID)
|
||||||
|
if result.get("skipped"):
|
||||||
|
raise HTTPException(status_code=400, detail="Telegram 未配置,无法同步")
|
||||||
|
|
||||||
|
audit_repo = AuditLogRepositoryImpl(db)
|
||||||
|
await audit_repo.create(AuditLog(
|
||||||
|
admin_username=admin.username,
|
||||||
|
action="sync_patrol_telegram",
|
||||||
|
target_type="setting",
|
||||||
|
detail=f"app_env={result.get('app_env')} host_env_prod={result.get('host_env_prod')}",
|
||||||
|
ip_address=request.client.host if request.client else "",
|
||||||
|
))
|
||||||
|
|
||||||
|
env_status = read_telegram_env_status()
|
||||||
|
return {
|
||||||
|
"synced": result,
|
||||||
|
"env_status": env_status,
|
||||||
|
"telegram_layer3_ready": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# ── Telegram Test + Chat ID Detection ──
|
# ── Telegram Test + Chat ID Detection ──
|
||||||
|
|
||||||
async def _telegram_clear_webhook_if_set(client, base_url: str) -> None:
|
async def _telegram_clear_webhook_if_set(client, base_url: str) -> None:
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
"""Sync Telegram credentials for Layer-3 host patrol (health_monitor.sh)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from server.infrastructure.env_file import read_env_var, upsert_env_vars
|
||||||
|
|
||||||
|
logger = logging.getLogger("nexus.ops_patrol")
|
||||||
|
|
||||||
|
NEXUS_ROOT = Path(__file__).resolve().parents[3]
|
||||||
|
ENV_FILE = NEXUS_ROOT / ".env"
|
||||||
|
|
||||||
|
_TELEGRAM_ENV_KEYS = (
|
||||||
|
"NEXUS_TELEGRAM_BOT_TOKEN",
|
||||||
|
"NEXUS_TELEGRAM_CHAT_ID",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _persist_env_path() -> Path:
|
||||||
|
raw = os.environ.get("NEXUS_PERSIST_DIR", "/var/lib/nexus").strip() or "/var/lib/nexus"
|
||||||
|
return Path(raw) / ".env"
|
||||||
|
|
||||||
|
|
||||||
|
def _host_env_prod_path() -> Path | None:
|
||||||
|
host_root = os.environ.get("NEXUS_HOST_ROOT", "").strip()
|
||||||
|
if not host_root:
|
||||||
|
return None
|
||||||
|
return Path(host_root) / "docker" / ".env.prod"
|
||||||
|
|
||||||
|
|
||||||
|
def _build_updates(token: str | None, chat_id: str | None) -> dict[str, str]:
|
||||||
|
updates: dict[str, str] = {}
|
||||||
|
if token and token.strip():
|
||||||
|
updates["NEXUS_TELEGRAM_BOT_TOKEN"] = token.strip()
|
||||||
|
if chat_id and str(chat_id).strip():
|
||||||
|
updates["NEXUS_TELEGRAM_CHAT_ID"] = str(chat_id).strip()
|
||||||
|
return updates
|
||||||
|
|
||||||
|
|
||||||
|
def _sync_to_persist_volume() -> bool:
|
||||||
|
if not ENV_FILE.is_file():
|
||||||
|
return False
|
||||||
|
dest = _persist_env_path()
|
||||||
|
try:
|
||||||
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copy2(ENV_FILE, dest)
|
||||||
|
dest.chmod(0o600)
|
||||||
|
return True
|
||||||
|
except OSError as exc:
|
||||||
|
logger.warning("Failed to sync .env to persist volume %s: %s", dest, exc)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def read_telegram_env_status() -> dict[str, bool]:
|
||||||
|
"""Report whether Layer-3 readable env files contain Telegram credentials."""
|
||||||
|
app_token = bool(read_env_var(ENV_FILE, "NEXUS_TELEGRAM_BOT_TOKEN"))
|
||||||
|
app_chat = bool(read_env_var(ENV_FILE, "NEXUS_TELEGRAM_CHAT_ID"))
|
||||||
|
|
||||||
|
host_token = False
|
||||||
|
host_chat = False
|
||||||
|
host_path = _host_env_prod_path()
|
||||||
|
if host_path is not None:
|
||||||
|
host_token = bool(read_env_var(host_path, "NEXUS_TELEGRAM_BOT_TOKEN"))
|
||||||
|
host_chat = bool(read_env_var(host_path, "NEXUS_TELEGRAM_CHAT_ID"))
|
||||||
|
|
||||||
|
env_synced = (app_token and app_chat) or (host_token and host_chat)
|
||||||
|
return {
|
||||||
|
"app_env_token": app_token,
|
||||||
|
"app_env_chat_id": app_chat,
|
||||||
|
"host_env_prod_token": host_token,
|
||||||
|
"host_env_prod_chat_id": host_chat,
|
||||||
|
"env_synced": env_synced,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def sync_telegram_to_env(token: str | None, chat_id: str | None) -> dict[str, bool | str]:
|
||||||
|
"""Write Telegram credentials to /app/.env, persist volume, and host .env.prod."""
|
||||||
|
updates = _build_updates(token, chat_id)
|
||||||
|
if not updates:
|
||||||
|
return {
|
||||||
|
"app_env": False,
|
||||||
|
"persist_env": False,
|
||||||
|
"host_env_prod": False,
|
||||||
|
"skipped": "telegram_not_configured",
|
||||||
|
}
|
||||||
|
|
||||||
|
result: dict[str, bool | str] = {
|
||||||
|
"app_env": False,
|
||||||
|
"persist_env": False,
|
||||||
|
"host_env_prod": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
if upsert_env_vars(ENV_FILE, updates):
|
||||||
|
ENV_FILE.chmod(0o600)
|
||||||
|
result["app_env"] = True
|
||||||
|
result["persist_env"] = _sync_to_persist_volume()
|
||||||
|
except OSError as exc:
|
||||||
|
logger.warning("Failed to update %s: %s", ENV_FILE, exc)
|
||||||
|
|
||||||
|
host_path = _host_env_prod_path()
|
||||||
|
if host_path is not None:
|
||||||
|
try:
|
||||||
|
if host_path.is_file() and upsert_env_vars(host_path, updates):
|
||||||
|
host_path.chmod(0o600)
|
||||||
|
result["host_env_prod"] = True
|
||||||
|
except OSError as exc:
|
||||||
|
logger.warning("Failed to update host .env.prod %s: %s", host_path, exc)
|
||||||
|
|
||||||
|
return result
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"""Shared .env read/write helpers (UTF-8 LF, quoted values)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from server.utils.text_io import read_utf8_text, write_utf8_lf
|
||||||
|
|
||||||
|
_ENV_KEY_RE = re.compile(r"^([A-Z0-9_]+)=(.*)$")
|
||||||
|
|
||||||
|
|
||||||
|
def escape_env_value(value: str) -> str:
|
||||||
|
escaped = value.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n")
|
||||||
|
return f'"{escaped}"'
|
||||||
|
|
||||||
|
|
||||||
|
def read_env_var(path: Path, key: str) -> str | None:
|
||||||
|
"""Return unquoted value for key, or None if missing/empty."""
|
||||||
|
if not path.is_file():
|
||||||
|
return None
|
||||||
|
for line in read_utf8_text(path).splitlines():
|
||||||
|
m = _ENV_KEY_RE.match(line.strip())
|
||||||
|
if not m or m.group(1) != key:
|
||||||
|
continue
|
||||||
|
raw = m.group(2).strip()
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
if len(raw) >= 2 and raw[0] == raw[-1] == '"':
|
||||||
|
inner = raw[1:-1]
|
||||||
|
return inner.replace("\\n", "\n").replace('\\"', '"').replace("\\\\", "\\")
|
||||||
|
return raw
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_env_vars(path: Path, updates: dict[str, str]) -> bool:
|
||||||
|
"""Upsert keys in a .env file. Creates file if missing. Returns True if written."""
|
||||||
|
if not updates:
|
||||||
|
return False
|
||||||
|
|
||||||
|
lines: list[str] = []
|
||||||
|
if path.is_file():
|
||||||
|
lines = read_utf8_text(path).splitlines()
|
||||||
|
|
||||||
|
seen: set[str] = set()
|
||||||
|
out: list[str] = []
|
||||||
|
for line in lines:
|
||||||
|
m = _ENV_KEY_RE.match(line.strip())
|
||||||
|
if m and m.group(1) in updates:
|
||||||
|
key = m.group(1)
|
||||||
|
out.append(f"{key}={escape_env_value(updates[key])}")
|
||||||
|
seen.add(key)
|
||||||
|
else:
|
||||||
|
out.append(line)
|
||||||
|
|
||||||
|
for key, value in updates.items():
|
||||||
|
if key not in seen:
|
||||||
|
out.append(f"{key}={escape_env_value(value)}")
|
||||||
|
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
body = "\n".join(out)
|
||||||
|
if body and not body.endswith("\n"):
|
||||||
|
body += "\n"
|
||||||
|
write_utf8_lf(path, body)
|
||||||
|
return True
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
"""Tests for Layer-3 ops patrol Telegram env sync."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from server.infrastructure.env_file import escape_env_value, read_env_var, upsert_env_vars
|
||||||
|
from server.application.services import ops_patrol_sync
|
||||||
|
|
||||||
|
|
||||||
|
def test_escape_env_value_quotes_special_chars():
|
||||||
|
assert escape_env_value('abc"def') == '"abc\\"def"'
|
||||||
|
assert escape_env_value("line\nbreak") == '"line\\nbreak"'
|
||||||
|
|
||||||
|
|
||||||
|
def test_upsert_env_vars_creates_and_updates(tmp_path: Path):
|
||||||
|
env = tmp_path / ".env"
|
||||||
|
upsert_env_vars(env, {"NEXUS_TELEGRAM_BOT_TOKEN": "123:ABC"})
|
||||||
|
assert read_env_var(env, "NEXUS_TELEGRAM_BOT_TOKEN") == "123:ABC"
|
||||||
|
|
||||||
|
upsert_env_vars(env, {
|
||||||
|
"NEXUS_TELEGRAM_BOT_TOKEN": "456:XYZ",
|
||||||
|
"NEXUS_TELEGRAM_CHAT_ID": "-1001",
|
||||||
|
})
|
||||||
|
assert read_env_var(env, "NEXUS_TELEGRAM_BOT_TOKEN") == "456:XYZ"
|
||||||
|
assert read_env_var(env, "NEXUS_TELEGRAM_CHAT_ID") == "-1001"
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_telegram_to_env_writes_app_env(tmp_path: Path, monkeypatch):
|
||||||
|
env_file = tmp_path / ".env"
|
||||||
|
env_file.write_text("# test\nNEXUS_PORT=8600\n", encoding="utf-8")
|
||||||
|
persist = tmp_path / "persist"
|
||||||
|
persist.mkdir()
|
||||||
|
monkeypatch.setattr(ops_patrol_sync, "ENV_FILE", env_file)
|
||||||
|
monkeypatch.setenv("NEXUS_PERSIST_DIR", str(persist))
|
||||||
|
|
||||||
|
result = ops_patrol_sync.sync_telegram_to_env("111:TOKEN", "-999")
|
||||||
|
assert result["app_env"] is True
|
||||||
|
assert result["persist_env"] is True
|
||||||
|
assert read_env_var(env_file, "NEXUS_TELEGRAM_BOT_TOKEN") == "111:TOKEN"
|
||||||
|
assert read_env_var(persist / ".env", "NEXUS_TELEGRAM_CHAT_ID") == "-999"
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_telegram_skips_when_empty():
|
||||||
|
result = ops_patrol_sync.sync_telegram_to_env("", "")
|
||||||
|
assert result.get("skipped") == "telegram_not_configured"
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_telegram_env_status(tmp_path: Path, monkeypatch):
|
||||||
|
env_file = tmp_path / ".env"
|
||||||
|
upsert_env_vars(env_file, {
|
||||||
|
"NEXUS_TELEGRAM_BOT_TOKEN": "t",
|
||||||
|
"NEXUS_TELEGRAM_CHAT_ID": "c",
|
||||||
|
})
|
||||||
|
monkeypatch.setattr(ops_patrol_sync, "ENV_FILE", env_file)
|
||||||
|
status = ops_patrol_sync.read_telegram_env_status()
|
||||||
|
assert status["env_synced"] is True
|
||||||
|
assert status["app_env_token"] is True
|
||||||
|
assert status["app_env_chat_id"] is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ops_patrol_status_endpoint():
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
from server.api.settings import ops_patrol_status
|
||||||
|
from server.config import settings
|
||||||
|
|
||||||
|
settings.TELEGRAM_BOT_TOKEN = "123:ABC"
|
||||||
|
settings.TELEGRAM_CHAT_ID = "-100"
|
||||||
|
admin = MagicMock()
|
||||||
|
|
||||||
|
res = await ops_patrol_status(admin=admin)
|
||||||
|
assert res["telegram_app_configured"] is True
|
||||||
|
assert res["telegram_layer3_ready"] is True
|
||||||
|
assert res["db_fallback_enabled"] is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ops_patrol_sync_400_when_telegram_missing():
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from server.api.settings import ops_patrol_sync_telegram
|
||||||
|
from server.config import settings
|
||||||
|
|
||||||
|
settings.TELEGRAM_BOT_TOKEN = ""
|
||||||
|
settings.TELEGRAM_CHAT_ID = ""
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
await ops_patrol_sync_telegram(
|
||||||
|
request=MagicMock(client=MagicMock(host="127.0.0.1")),
|
||||||
|
admin=MagicMock(username="admin"),
|
||||||
|
db=MagicMock(),
|
||||||
|
)
|
||||||
|
assert exc.value.status_code == 400
|
||||||
Reference in New Issue
Block a user