sync: 主项目全量同步(含 btpanel 一键登录回归修复与前端构建产物)

- bootstrap 失败恢复 SSH 临时登录回退
- ssh_bootstrap 真安装检测与 config 目录自愈
- axs Gitea 发布源切换为 https://axs.kuma1xn.vip
This commit is contained in:
r
2026-07-08 23:52:40 +08:00
parent d976cd03a3
commit 6859846b7c
4105 changed files with 60373 additions and 544 deletions
+6 -1
View File
@@ -38,11 +38,16 @@ FastAPI + Async SQLAlchemy + Redis · Vue 3 + Vuetify 4 SPA15 页)· Vite
## 部署
默认 **本机 rsync 直传**(不 push Gitea):
```bash
bash deploy/pre_deploy_check.sh && bash deploy/deploy-production.sh
```
前端: `cd frontend && npx vite build` → tar/scp(见 `deploy/deploy-frontend.sh`
仅同步源码(不重建镜像):`bash deploy/rsync-local-to-server.sh`
仍走 Gitea 拉取:`NEXUS_DEPLOY_VIA_GIT=1 bash deploy/deploy-production.sh`
前端: `cd frontend && npx vite build` → 部署脚本内 tar/scp 到 `web/app/`
## 进度条(改代码时输出)
+5 -1
View File
@@ -40,11 +40,15 @@ cd frontend && npm run dev # :3000/app/
## 部署
默认 **本机 rsync → SSH 生产机**(不 push Gitea):
```bash
bash deploy/pre_deploy_check.sh
bash deploy/deploy-production.sh # 需 SSH nexus + 用户批准
bash deploy/deploy-production.sh # rsync + Docker upgrade --skip-git + 前端
```
可选:`NEXUS_DEPLOY_VIA_GIT=1` 恢复远程 `git pull`。仅 `git push` 当你明确要求时。
详情见功能指南 §1718。
## 归档文档
+58 -24
View File
@@ -1,16 +1,19 @@
#!/usr/bin/env bash
# Full production deploy: push (optional) + backend upgrade + frontend + health
# Full production deploy: local rsync + backend upgrade + frontend + health
#
# 自动识别远程 Docker/opt/nexus)或 Supervisor/www/wwwroot/...)运行时
# 默认从本机工作区 rsync 到生产机,不 push Gitea、不远程 git pull
#
# Prerequisites on YOUR machine:
# - SSH: ~/.ssh/config Host nexus OR export NEXUS_SSH="azureuser@20.24.218.235"
# - rsync, SSH Host nexus OR export NEXUS_SSH="azureuser@20.24.218.235"
# - Key: export NEXUS_SSH_KEY=~/.ssh/id_rsa_nexus
#
# Usage:
# cd "$NEXUS_ROOT"
# bash deploy/pre_deploy_check.sh
# bash deploy/deploy-production.sh
#
# 仍走 Gitea(仅当显式设置):
# NEXUS_DEPLOY_VIA_GIT=1 bash deploy/deploy-production.sh
set -euo pipefail
@@ -31,8 +34,15 @@ fi
ssh_cmd() { ssh "${SSH_OPTS[@]}" "${NEXUS_SSH_HOST}" "$@"; }
scp_cmd() { scp "${SSH_OPTS[@]}" "$@"; }
DEPLOY_VIA_GIT="${NEXUS_DEPLOY_VIA_GIT:-0}"
case "${DEPLOY_VIA_GIT}" in
1|true|yes|on) DEPLOY_VIA_GIT=1 ;;
*) DEPLOY_VIA_GIT=0 ;;
esac
echo "═══ Nexus production deploy ═══"
echo "SSH target: ${NEXUS_SSH_HOST}"
echo "Mode: $([[ "$DEPLOY_VIA_GIT" == 1 ]] && echo 'Gitea git pull' || echo 'local rsync')"
if ! ssh_cmd "echo SSH OK" >/dev/null 2>&1; then
echo "ERROR: Cannot SSH to ${NEXUS_SSH_HOST}" >&2
@@ -94,37 +104,61 @@ fi
echo "Deploy path: ${DEPLOY_PATH}"
echo "Runtime: ${RUNTIME}"
if [[ "$RUNTIME" == "docker" ]]; then
echo "▶ Docker: git pull + 镜像重建(前端在镜像内 vite build..."
ssh_cmd "cd ${DEPLOY_PATH} && sudo git fetch --all && sudo git reset --hard origin/main && \
sudo env NEXUS_ROOT=${DEPLOY_PATH} bash ${DEPLOY_PATH}/deploy/nexus-1panel.sh upgrade"
elif [[ "$RUNTIME" == "supervisor" ]]; then
echo "▶ Supervisor: git pull + restart..."
ssh_cmd "cd ${DEPLOY_PATH} && git fetch --all && git reset --hard origin/main && supervisorctl restart nexus"
resolve_deploy_chown() {
if [[ -n "${NEXUS_DEPLOY_CHOWN:-}" ]]; then
echo "${NEXUS_DEPLOY_CHOWN}"
return
fi
if ssh_cmd "getent passwd www >/dev/null 2>&1"; then
echo "www:www"
return
fi
ssh_cmd "stat -c '%U:%G' '${DEPLOY_PATH}'" 2>/dev/null || echo "azureuser:azureuser"
}
DEPLOY_CHOWN="$(resolve_deploy_chown)"
echo "File owner: ${DEPLOY_CHOWN}"
echo "▶ Frontend: build + upload..."
cd frontend && npx vite build && cd ..
tar czf /tmp/nexus-frontend.tar.gz -C web/app index.html assets/
scp_cmd /tmp/nexus-frontend.tar.gz "${NEXUS_SSH_HOST}:/tmp/nexus-frontend.tar.gz"
ssh_cmd "cd ${DEPLOY_PATH}/web/app && tar xzf /tmp/nexus-frontend.tar.gz && rm /tmp/nexus-frontend.tar.gz"
if ssh_cmd "test -f ${DEPLOY_PATH}/deploy/prune_frontend_assets.py"; then
ssh_cmd "python3 ${DEPLOY_PATH}/deploy/prune_frontend_assets.py --dry-run"
ssh_cmd "python3 ${DEPLOY_PATH}/deploy/prune_frontend_assets.py"
if [[ "$DEPLOY_VIA_GIT" == 1 ]]; then
echo "▶ Git mode: remote git pull..."
if [[ "$RUNTIME" == "docker" ]]; then
ssh_cmd "cd ${DEPLOY_PATH} && sudo git fetch --all && sudo git reset --hard origin/main && \
sudo env NEXUS_ROOT=${DEPLOY_PATH} bash ${DEPLOY_PATH}/deploy/nexus-1panel.sh upgrade"
elif [[ "$RUNTIME" == "supervisor" ]]; then
ssh_cmd "cd ${DEPLOY_PATH} && git fetch --all && git reset --hard origin/main && supervisorctl restart nexus"
else
echo "ERROR: 无法识别远程运行时" >&2
exit 1
fi
else
echo "ERROR: 无法识别远程运行时(非 Docker 也非 Supervisor" >&2
echo " 请手动: ssh ${NEXUS_SSH_HOST} 'sudo nx update'" >&2
exit 1
echo "▶ Local sync → ${DEPLOY_PATH}..."
NEXUS_DEPLOY_PATH="${DEPLOY_PATH}" bash "${ROOT}/deploy/rsync-local-to-server.sh"
if [[ "$RUNTIME" == "docker" ]]; then
echo "▶ Docker: 重建镜像(--skip-git..."
ssh_cmd "sudo env NEXUS_ROOT=${DEPLOY_PATH} bash ${DEPLOY_PATH}/deploy/nexus-1panel.sh upgrade --skip-git"
elif [[ "$RUNTIME" == "supervisor" ]]; then
echo "▶ Supervisor: restart..."
ssh_cmd "supervisorctl restart nexus"
else
echo "ERROR: 无法识别远程运行时(非 Docker 也非 Supervisor" >&2
echo " 请手动: ssh ${NEXUS_SSH_HOST} 'sudo nx update'" >&2
exit 1
fi
fi
if [[ "$RUNTIME" == "docker" ]]; then
# Host web/app must match image build before sync_webapp_to_container (else stale assets overwrite container).
if [[ "$RUNTIME" == "docker" || "$RUNTIME" == "supervisor" ]]; then
echo "▶ Frontend: local vite build + upload to host web/app..."
cd frontend && npx vite build && cd ..
tar czf /tmp/nexus-frontend.tar.gz -C web/app index.html assets/
scp_cmd /tmp/nexus-frontend.tar.gz "${NEXUS_SSH_HOST}:/tmp/nexus-frontend.tar.gz"
ssh_cmd "cd ${DEPLOY_PATH}/web/app && sudo tar xzf /tmp/nexus-frontend.tar.gz && rm /tmp/nexus-frontend.tar.gz"
ssh_cmd "cd ${DEPLOY_PATH}/web/app && sudo tar xzf /tmp/nexus-frontend.tar.gz && rm /tmp/nexus-frontend.tar.gz && sudo chown -R ${DEPLOY_CHOWN} ${DEPLOY_PATH}/web/app"
if ssh_cmd "test -f ${DEPLOY_PATH}/deploy/prune_frontend_assets.py"; then
ssh_cmd "sudo python3 ${DEPLOY_PATH}/deploy/prune_frontend_assets.py ${DEPLOY_PATH}/web/app --dry-run"
ssh_cmd "sudo python3 ${DEPLOY_PATH}/deploy/prune_frontend_assets.py ${DEPLOY_PATH}/web/app"
fi
fi
if [[ "$RUNTIME" == "docker" ]]; then
echo "▶ Sync host web/app into container..."
ssh_cmd "sudo env NEXUS_ROOT=${DEPLOY_PATH} NEXUS_PUBLISH_PORT=\$(grep -E '^NEXUS_PUBLISH_PORT=' ${DEPLOY_PATH}/docker/.env.prod 2>/dev/null | cut -d= -f2 | tr -d '\"' || echo 8600) bash ${DEPLOY_PATH}/deploy/sync_webapp_to_container.sh" || {
echo "WARN: web/app sync failed — run sync_webapp_to_container.sh on server manually" >&2
+604
View File
@@ -2378,3 +2378,607 @@
{"ts":"2026-06-17T09:37:14+08:00","date":"2026-06-17","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-17T09:37:14+08:00","date":"2026-06-17","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-17T09:37:14+08:00","date":"2026-06-17","gate":"summary","result":"BLOCK","detail":"6/8"}
{"ts":"2026-06-18T12:37:07+08:00","date":"2026-06-18","gate":"changelog","result":"PASS","detail":"2026-06-18-terminal-persist-navigation.md 40lines"}
{"ts":"2026-06-18T12:37:07+08:00","date":"2026-06-18","gate":"audit","result":"BLOCK","detail":"file not found"}
{"ts":"2026-06-18T12:37:07+08:00","date":"2026-06-18","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-18T12:37:07+08:00","date":"2026-06-18","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-18T12:37:07+08:00","date":"2026-06-18","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-18T12:37:07+08:00","date":"2026-06-18","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-18T12:37:07+08:00","date":"2026-06-18","gate":"review","result":"BLOCK","detail":"no audit file"}
{"ts":"2026-06-18T12:37:07+08:00","date":"2026-06-18","gate":"ai_review","result":"BLOCK","detail":"see gate_ai_review.py output"}
{"ts":"2026-06-18T12:37:07+08:00","date":"2026-06-18","gate":"shell_eol","result":"BLOCK","detail":"CR in tracked sh"}
{"ts":"2026-06-18T12:37:07+08:00","date":"2026-06-18","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-18T12:37:07+08:00","date":"2026-06-18","gate":"summary","result":"BLOCK","detail":"5/8"}
{"ts":"2026-06-21T08:09:43+08:00","date":"2026-06-21","gate":"changelog","result":"BLOCK","detail":"file not found"}
{"ts":"2026-06-21T08:09:43+08:00","date":"2026-06-21","gate":"audit","result":"BLOCK","detail":"file not found"}
{"ts":"2026-06-21T08:09:43+08:00","date":"2026-06-21","gate":"test","result":"BLOCK","detail":"tests failed"}
{"ts":"2026-06-21T08:09:43+08:00","date":"2026-06-21","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-21T08:09:43+08:00","date":"2026-06-21","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-21T08:09:43+08:00","date":"2026-06-21","gate":"security","result":"BLOCK","detail":"2 HIGH findings"}
{"ts":"2026-06-21T08:09:43+08:00","date":"2026-06-21","gate":"review","result":"BLOCK","detail":"no audit file"}
{"ts":"2026-06-21T08:12:56+08:00","date":"2026-06-21","gate":"changelog","result":"BLOCK","detail":"file not found"}
{"ts":"2026-06-21T08:12:56+08:00","date":"2026-06-21","gate":"audit","result":"BLOCK","detail":"file not found"}
{"ts":"2026-06-21T08:12:56+08:00","date":"2026-06-21","gate":"test","result":"BLOCK","detail":"tests failed"}
{"ts":"2026-06-21T08:12:56+08:00","date":"2026-06-21","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-21T08:12:56+08:00","date":"2026-06-21","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-21T08:12:56+08:00","date":"2026-06-21","gate":"security","result":"BLOCK","detail":"2 HIGH findings"}
{"ts":"2026-06-21T08:12:56+08:00","date":"2026-06-21","gate":"review","result":"BLOCK","detail":"no audit file"}
{"ts":"2026-06-21T08:12:56+08:00","date":"2026-06-21","gate":"ai_review","result":"BLOCK","detail":"see gate_ai_review.py output"}
{"ts":"2026-06-21T08:12:56+08:00","date":"2026-06-21","gate":"shell_eol","result":"BLOCK","detail":"CR in tracked sh"}
{"ts":"2026-06-21T08:12:56+08:00","date":"2026-06-21","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-21T08:12:56+08:00","date":"2026-06-21","gate":"summary","result":"BLOCK","detail":"2/8"}
{"ts":"2026-06-21T08:14:10+08:00","date":"2026-06-21","gate":"changelog","result":"PASS","detail":"2026-06-21-login-allowlist-deploy.md 29lines"}
{"ts":"2026-06-21T08:14:10+08:00","date":"2026-06-21","gate":"audit","result":"PASS","detail":"2026-06-21-login-allowlist-deploy.md"}
{"ts":"2026-06-21T08:14:10+08:00","date":"2026-06-21","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-21T08:14:10+08:00","date":"2026-06-21","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-21T08:14:10+08:00","date":"2026-06-21","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-21T08:14:10+08:00","date":"2026-06-21","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-21T08:14:10+08:00","date":"2026-06-21","gate":"review","result":"BLOCK","detail":"audit missing changed files"}
{"ts":"2026-06-21T08:14:10+08:00","date":"2026-06-21","gate":"ai_review","result":"PASS","detail":"skipped no code"}
{"ts":"2026-06-21T08:14:10+08:00","date":"2026-06-21","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-21T08:14:10+08:00","date":"2026-06-21","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-21T08:14:10+08:00","date":"2026-06-21","gate":"summary","result":"BLOCK","detail":"7/8"}
{"ts":"2026-06-21T08:14:26+08:00","date":"2026-06-21","gate":"changelog","result":"PASS","detail":"2026-06-21-login-allowlist-deploy.md 29lines"}
{"ts":"2026-06-21T08:14:26+08:00","date":"2026-06-21","gate":"audit","result":"PASS","detail":"2026-06-21-login-allowlist-deploy.md"}
{"ts":"2026-06-21T08:14:26+08:00","date":"2026-06-21","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-21T08:14:26+08:00","date":"2026-06-21","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-21T08:14:26+08:00","date":"2026-06-21","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-21T08:14:26+08:00","date":"2026-06-21","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-21T08:14:26+08:00","date":"2026-06-21","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-06-21T08:14:26+08:00","date":"2026-06-21","gate":"ai_review","result":"PASS","detail":"skipped no code"}
{"ts":"2026-06-21T08:14:26+08:00","date":"2026-06-21","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-21T08:14:26+08:00","date":"2026-06-21","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-21T08:14:26+08:00","date":"2026-06-21","gate":"summary","result":"PASS","detail":"8/8"}
{"ts":"2026-06-21T09:13:54+08:00","date":"2026-06-21","gate":"changelog","result":"PASS","detail":"2026-06-21-login-allowlist-deploy.md 29lines"}
{"ts":"2026-06-21T09:13:54+08:00","date":"2026-06-21","gate":"audit","result":"PASS","detail":"2026-06-21-login-allowlist-deploy.md"}
{"ts":"2026-06-21T09:13:54+08:00","date":"2026-06-21","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-21T09:13:54+08:00","date":"2026-06-21","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-21T09:13:54+08:00","date":"2026-06-21","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-21T09:13:54+08:00","date":"2026-06-21","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-21T09:13:54+08:00","date":"2026-06-21","gate":"review","result":"BLOCK","detail":"audit missing changed files"}
{"ts":"2026-06-21T09:13:54+08:00","date":"2026-06-21","gate":"ai_review","result":"BLOCK","detail":"see gate_ai_review.py output"}
{"ts":"2026-06-21T09:13:54+08:00","date":"2026-06-21","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-21T09:13:54+08:00","date":"2026-06-21","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-21T09:13:54+08:00","date":"2026-06-21","gate":"summary","result":"BLOCK","detail":"6/8"}
{"ts":"2026-06-21T09:14:50+08:00","date":"2026-06-21","gate":"changelog","result":"PASS","detail":"2026-06-21-btpanel-deploy.md 30lines"}
{"ts":"2026-06-21T09:14:50+08:00","date":"2026-06-21","gate":"audit","result":"PASS","detail":"2026-06-21-btpanel-ssh-bootstrap.md"}
{"ts":"2026-06-21T09:14:50+08:00","date":"2026-06-21","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-21T09:14:50+08:00","date":"2026-06-21","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-21T09:14:50+08:00","date":"2026-06-21","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-21T09:14:50+08:00","date":"2026-06-21","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-21T09:14:50+08:00","date":"2026-06-21","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-06-21T09:14:50+08:00","date":"2026-06-21","gate":"ai_review","result":"PASS","detail":"skipped no code"}
{"ts":"2026-06-21T09:14:50+08:00","date":"2026-06-21","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-21T09:14:50+08:00","date":"2026-06-21","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-21T09:14:50+08:00","date":"2026-06-21","gate":"summary","result":"PASS","detail":"8/8"}
{"ts":"2026-06-21T09:19:48+08:00","date":"2026-06-21","gate":"changelog","result":"PASS","detail":"2026-06-21-btpanel-deploy.md 32lines"}
{"ts":"2026-06-21T09:19:48+08:00","date":"2026-06-21","gate":"audit","result":"PASS","detail":"2026-06-21-btpanel-ssh-bootstrap.md"}
{"ts":"2026-06-21T09:19:48+08:00","date":"2026-06-21","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-21T09:19:48+08:00","date":"2026-06-21","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-21T09:19:48+08:00","date":"2026-06-21","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-21T09:19:48+08:00","date":"2026-06-21","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-21T09:19:48+08:00","date":"2026-06-21","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-06-21T09:19:48+08:00","date":"2026-06-21","gate":"ai_review","result":"PASS","detail":"skipped no code"}
{"ts":"2026-06-21T09:19:48+08:00","date":"2026-06-21","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-21T09:19:48+08:00","date":"2026-06-21","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-21T09:19:48+08:00","date":"2026-06-21","gate":"summary","result":"PASS","detail":"8/8"}
{"ts":"2026-06-21T12:09:06+08:00","date":"2026-06-21","gate":"changelog","result":"PASS","detail":"2026-06-21-btpanel-login-auto-bootstrap.md 33lines"}
{"ts":"2026-06-21T12:09:06+08:00","date":"2026-06-21","gate":"audit","result":"PASS","detail":"2026-06-21-btpanel-login-auto-bootstrap.md"}
{"ts":"2026-06-21T12:09:06+08:00","date":"2026-06-21","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-21T12:09:06+08:00","date":"2026-06-21","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-21T12:09:06+08:00","date":"2026-06-21","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-21T12:09:06+08:00","date":"2026-06-21","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-21T12:09:06+08:00","date":"2026-06-21","gate":"review","result":"BLOCK","detail":"audit missing changed files"}
{"ts":"2026-06-21T12:09:06+08:00","date":"2026-06-21","gate":"ai_review","result":"BLOCK","detail":"see gate_ai_review.py output"}
{"ts":"2026-06-21T12:09:06+08:00","date":"2026-06-21","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-21T12:09:06+08:00","date":"2026-06-21","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-21T12:09:06+08:00","date":"2026-06-21","gate":"summary","result":"BLOCK","detail":"6/8"}
{"ts":"2026-06-21T12:09:38+08:00","date":"2026-06-21","gate":"changelog","result":"PASS","detail":"2026-06-21-btpanel-login-auto-bootstrap.md 33lines"}
{"ts":"2026-06-21T12:09:38+08:00","date":"2026-06-21","gate":"audit","result":"PASS","detail":"2026-06-21-btpanel-login-auto-bootstrap.md"}
{"ts":"2026-06-21T12:09:38+08:00","date":"2026-06-21","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-21T12:09:38+08:00","date":"2026-06-21","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-21T12:09:38+08:00","date":"2026-06-21","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-21T12:09:38+08:00","date":"2026-06-21","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-21T12:09:38+08:00","date":"2026-06-21","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-06-21T12:09:38+08:00","date":"2026-06-21","gate":"ai_review","result":"PASS","detail":"skipped no code"}
{"ts":"2026-06-21T12:09:38+08:00","date":"2026-06-21","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-21T12:09:38+08:00","date":"2026-06-21","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-21T12:09:38+08:00","date":"2026-06-21","gate":"summary","result":"PASS","detail":"8/8"}
{"ts":"2026-06-21T12:17:16+08:00","date":"2026-06-21","gate":"changelog","result":"PASS","detail":"2026-06-21-btpanel-login-auto-bootstrap.md 38lines"}
{"ts":"2026-06-21T12:17:16+08:00","date":"2026-06-21","gate":"audit","result":"PASS","detail":"2026-06-21-btpanel-login-auto-bootstrap.md"}
{"ts":"2026-06-21T12:17:16+08:00","date":"2026-06-21","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-21T12:17:16+08:00","date":"2026-06-21","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-21T12:17:16+08:00","date":"2026-06-21","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-21T12:17:16+08:00","date":"2026-06-21","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-21T12:17:16+08:00","date":"2026-06-21","gate":"review","result":"BLOCK","detail":"audit missing changed files"}
{"ts":"2026-06-21T12:17:16+08:00","date":"2026-06-21","gate":"ai_review","result":"PASS","detail":"skipped no code"}
{"ts":"2026-06-21T12:17:16+08:00","date":"2026-06-21","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-21T12:17:16+08:00","date":"2026-06-21","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-21T12:17:16+08:00","date":"2026-06-21","gate":"summary","result":"BLOCK","detail":"7/8"}
{"ts":"2026-06-21T12:17:29+08:00","date":"2026-06-21","gate":"changelog","result":"PASS","detail":"2026-06-21-btpanel-login-auto-bootstrap.md 38lines"}
{"ts":"2026-06-21T12:17:29+08:00","date":"2026-06-21","gate":"audit","result":"PASS","detail":"2026-06-21-btpanel-login-auto-bootstrap.md"}
{"ts":"2026-06-21T12:17:29+08:00","date":"2026-06-21","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-21T12:17:29+08:00","date":"2026-06-21","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-21T12:17:29+08:00","date":"2026-06-21","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-21T12:17:29+08:00","date":"2026-06-21","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-21T12:17:29+08:00","date":"2026-06-21","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-06-21T12:17:29+08:00","date":"2026-06-21","gate":"ai_review","result":"PASS","detail":"skipped no code"}
{"ts":"2026-06-21T12:17:29+08:00","date":"2026-06-21","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-21T12:17:29+08:00","date":"2026-06-21","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-21T12:17:29+08:00","date":"2026-06-21","gate":"summary","result":"PASS","detail":"8/8"}
{"ts":"2026-06-21T12:22:14+08:00","date":"2026-06-21","gate":"changelog","result":"PASS","detail":"2026-06-21-btpanel-nav-hidden.md 23lines"}
{"ts":"2026-06-21T12:22:14+08:00","date":"2026-06-21","gate":"audit","result":"PASS","detail":"2026-06-21-btpanel-nav-hidden.md"}
{"ts":"2026-06-21T12:22:14+08:00","date":"2026-06-21","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-21T12:22:14+08:00","date":"2026-06-21","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-21T12:22:14+08:00","date":"2026-06-21","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-21T12:22:14+08:00","date":"2026-06-21","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-21T12:22:14+08:00","date":"2026-06-21","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-06-21T12:22:14+08:00","date":"2026-06-21","gate":"ai_review","result":"PASS","detail":"skipped no code"}
{"ts":"2026-06-21T12:22:14+08:00","date":"2026-06-21","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-21T12:22:14+08:00","date":"2026-06-21","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-21T12:22:14+08:00","date":"2026-06-21","gate":"summary","result":"PASS","detail":"8/8"}
{"ts":"2026-06-21T12:48:54+08:00","date":"2026-06-21","gate":"changelog","result":"PASS","detail":"2026-06-21-server-file-transfer-audit-fixes.md 35lines"}
{"ts":"2026-06-21T12:48:54+08:00","date":"2026-06-21","gate":"audit","result":"PASS","detail":"2026-06-21-server-file-transfer.md"}
{"ts":"2026-06-21T12:48:54+08:00","date":"2026-06-21","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-21T12:48:54+08:00","date":"2026-06-21","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-21T12:48:54+08:00","date":"2026-06-21","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-21T12:48:54+08:00","date":"2026-06-21","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-21T12:48:54+08:00","date":"2026-06-21","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-06-21T12:48:54+08:00","date":"2026-06-21","gate":"ai_review","result":"BLOCK","detail":"see gate_ai_review.py output"}
{"ts":"2026-06-21T12:48:54+08:00","date":"2026-06-21","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-21T12:48:54+08:00","date":"2026-06-21","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-21T12:48:54+08:00","date":"2026-06-21","gate":"summary","result":"BLOCK","detail":"7/8"}
{"ts":"2026-06-21T12:49:05+08:00","date":"2026-06-21","gate":"changelog","result":"PASS","detail":"2026-06-21-server-file-transfer-audit-fixes.md 35lines"}
{"ts":"2026-06-21T12:49:05+08:00","date":"2026-06-21","gate":"audit","result":"PASS","detail":"2026-06-21-server-file-transfer.md"}
{"ts":"2026-06-21T12:49:05+08:00","date":"2026-06-21","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-21T12:49:05+08:00","date":"2026-06-21","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-21T12:49:05+08:00","date":"2026-06-21","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-21T12:49:05+08:00","date":"2026-06-21","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-21T12:49:05+08:00","date":"2026-06-21","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-06-21T12:49:05+08:00","date":"2026-06-21","gate":"ai_review","result":"PASS","detail":"skipped no code"}
{"ts":"2026-06-21T12:49:05+08:00","date":"2026-06-21","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-21T12:49:05+08:00","date":"2026-06-21","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-21T12:49:05+08:00","date":"2026-06-21","gate":"summary","result":"PASS","detail":"8/8"}
{"ts":"2026-06-21T12:59:50+08:00","date":"2026-06-21","gate":"changelog","result":"PASS","detail":"2026-06-21-nav-watch-metrics-under-system.md 23lines"}
{"ts":"2026-06-21T12:59:50+08:00","date":"2026-06-21","gate":"audit","result":"PASS","detail":"2026-06-21-server-transfer-page.md"}
{"ts":"2026-06-21T12:59:50+08:00","date":"2026-06-21","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-21T12:59:50+08:00","date":"2026-06-21","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-21T12:59:50+08:00","date":"2026-06-21","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-21T12:59:50+08:00","date":"2026-06-21","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-21T12:59:50+08:00","date":"2026-06-21","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-06-21T12:59:50+08:00","date":"2026-06-21","gate":"ai_review","result":"PASS","detail":"skipped no code"}
{"ts":"2026-06-21T12:59:50+08:00","date":"2026-06-21","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-21T12:59:50+08:00","date":"2026-06-21","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-21T12:59:50+08:00","date":"2026-06-21","gate":"summary","result":"PASS","detail":"8/8"}
{"ts":"2026-06-21T13:05:39+08:00","date":"2026-06-21","gate":"changelog","result":"PASS","detail":"2026-06-21-server-transfer-server-picker-fix.md 24lines"}
{"ts":"2026-06-21T13:05:39+08:00","date":"2026-06-21","gate":"audit","result":"PASS","detail":"2026-06-21-server-transfer-page.md"}
{"ts":"2026-06-21T13:05:39+08:00","date":"2026-06-21","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-21T13:05:39+08:00","date":"2026-06-21","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-21T13:05:39+08:00","date":"2026-06-21","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-21T13:05:39+08:00","date":"2026-06-21","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-21T13:05:39+08:00","date":"2026-06-21","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-06-21T13:05:39+08:00","date":"2026-06-21","gate":"ai_review","result":"PASS","detail":"skipped no code"}
{"ts":"2026-06-21T13:05:39+08:00","date":"2026-06-21","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-21T13:05:39+08:00","date":"2026-06-21","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-21T13:05:39+08:00","date":"2026-06-21","gate":"summary","result":"PASS","detail":"8/8"}
{"ts":"2026-06-21T13:09:48+08:00","date":"2026-06-21","gate":"changelog","result":"PASS","detail":"2026-06-21-server-transfer-target-path-default.md 28lines"}
{"ts":"2026-06-21T13:09:48+08:00","date":"2026-06-21","gate":"audit","result":"PASS","detail":"2026-06-21-server-transfer-page.md"}
{"ts":"2026-06-21T13:09:48+08:00","date":"2026-06-21","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-21T13:09:48+08:00","date":"2026-06-21","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-21T13:09:48+08:00","date":"2026-06-21","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-21T13:09:48+08:00","date":"2026-06-21","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-21T13:09:48+08:00","date":"2026-06-21","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-06-21T13:09:48+08:00","date":"2026-06-21","gate":"ai_review","result":"PASS","detail":"skipped no code"}
{"ts":"2026-06-21T13:09:48+08:00","date":"2026-06-21","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-21T13:09:48+08:00","date":"2026-06-21","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-21T13:09:48+08:00","date":"2026-06-21","gate":"summary","result":"PASS","detail":"8/8"}
{"ts":"2026-06-21T13:15:01+08:00","date":"2026-06-21","gate":"changelog","result":"PASS","detail":"2026-06-21-server-transfer-dest-browser.md 20lines"}
{"ts":"2026-06-21T13:15:01+08:00","date":"2026-06-21","gate":"audit","result":"PASS","detail":"2026-06-21-server-transfer-page.md"}
{"ts":"2026-06-21T13:15:01+08:00","date":"2026-06-21","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-21T13:15:01+08:00","date":"2026-06-21","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-21T13:15:01+08:00","date":"2026-06-21","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-21T13:15:01+08:00","date":"2026-06-21","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-21T13:15:01+08:00","date":"2026-06-21","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-06-21T13:15:01+08:00","date":"2026-06-21","gate":"ai_review","result":"PASS","detail":"skipped no code"}
{"ts":"2026-06-21T13:15:01+08:00","date":"2026-06-21","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-21T13:15:01+08:00","date":"2026-06-21","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-21T13:15:01+08:00","date":"2026-06-21","gate":"summary","result":"PASS","detail":"8/8"}
{"ts":"2026-06-21T13:19:34+08:00","date":"2026-06-21","gate":"changelog","result":"PASS","detail":"2026-06-21-server-transfer-source-site-link.md 17lines"}
{"ts":"2026-06-21T13:19:34+08:00","date":"2026-06-21","gate":"audit","result":"BLOCK","detail":"missing sections"}
{"ts":"2026-06-21T13:19:34+08:00","date":"2026-06-21","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-21T13:19:34+08:00","date":"2026-06-21","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-21T13:19:34+08:00","date":"2026-06-21","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-21T13:19:34+08:00","date":"2026-06-21","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-21T13:19:34+08:00","date":"2026-06-21","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-06-21T13:19:34+08:00","date":"2026-06-21","gate":"ai_review","result":"PASS","detail":"skipped no code"}
{"ts":"2026-06-21T13:19:34+08:00","date":"2026-06-21","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-21T13:19:34+08:00","date":"2026-06-21","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-21T13:19:34+08:00","date":"2026-06-21","gate":"summary","result":"BLOCK","detail":"7/8"}
{"ts":"2026-06-21T13:19:43+08:00","date":"2026-06-21","gate":"changelog","result":"PASS","detail":"2026-06-21-server-transfer-source-site-link.md 17lines"}
{"ts":"2026-06-21T13:19:43+08:00","date":"2026-06-21","gate":"audit","result":"BLOCK","detail":"missing sections"}
{"ts":"2026-06-21T13:19:43+08:00","date":"2026-06-21","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-21T13:19:43+08:00","date":"2026-06-21","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-21T13:19:43+08:00","date":"2026-06-21","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-21T13:19:43+08:00","date":"2026-06-21","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-21T13:19:43+08:00","date":"2026-06-21","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-06-21T13:19:43+08:00","date":"2026-06-21","gate":"ai_review","result":"PASS","detail":"skipped no code"}
{"ts":"2026-06-21T13:19:43+08:00","date":"2026-06-21","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-21T13:19:43+08:00","date":"2026-06-21","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-21T13:19:43+08:00","date":"2026-06-21","gate":"summary","result":"BLOCK","detail":"7/8"}
{"ts":"2026-06-21T13:20:03+08:00","date":"2026-06-21","gate":"changelog","result":"PASS","detail":"2026-06-21-server-transfer-source-site-link.md 17lines"}
{"ts":"2026-06-21T13:20:03+08:00","date":"2026-06-21","gate":"audit","result":"PASS","detail":"2026-06-21-server-transfer-ui-polish.md"}
{"ts":"2026-06-21T13:20:03+08:00","date":"2026-06-21","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-21T13:20:03+08:00","date":"2026-06-21","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-21T13:20:03+08:00","date":"2026-06-21","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-21T13:20:03+08:00","date":"2026-06-21","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-21T13:20:03+08:00","date":"2026-06-21","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-06-21T13:20:03+08:00","date":"2026-06-21","gate":"ai_review","result":"PASS","detail":"skipped no code"}
{"ts":"2026-06-21T13:20:03+08:00","date":"2026-06-21","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-21T13:20:03+08:00","date":"2026-06-21","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-21T13:20:03+08:00","date":"2026-06-21","gate":"summary","result":"PASS","detail":"8/8"}
{"ts":"2026-06-21T13:25:39+08:00","date":"2026-06-21","gate":"changelog","result":"PASS","detail":"2026-06-21-deploy-local-rsync.md 39lines"}
{"ts":"2026-06-21T13:25:39+08:00","date":"2026-06-21","gate":"audit","result":"PASS","detail":"2026-06-21-server-transfer-ui-polish.md"}
{"ts":"2026-06-21T13:25:39+08:00","date":"2026-06-21","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-21T13:25:39+08:00","date":"2026-06-21","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-21T13:25:39+08:00","date":"2026-06-21","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-21T13:25:39+08:00","date":"2026-06-21","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-21T13:25:39+08:00","date":"2026-06-21","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-06-21T13:25:39+08:00","date":"2026-06-21","gate":"ai_review","result":"PASS","detail":"skipped no code"}
{"ts":"2026-06-21T13:25:39+08:00","date":"2026-06-21","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-21T13:25:39+08:00","date":"2026-06-21","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-21T13:25:39+08:00","date":"2026-06-21","gate":"summary","result":"PASS","detail":"8/8"}
{"ts":"2026-06-21T13:28:40+08:00","date":"2026-06-21","gate":"changelog","result":"PASS","detail":"2026-06-21-deploy-local-rsync.md 47lines"}
{"ts":"2026-06-21T13:28:40+08:00","date":"2026-06-21","gate":"audit","result":"PASS","detail":"2026-06-21-server-transfer-ui-polish.md"}
{"ts":"2026-06-21T13:28:40+08:00","date":"2026-06-21","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-21T13:28:40+08:00","date":"2026-06-21","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-21T13:28:40+08:00","date":"2026-06-21","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-21T13:28:40+08:00","date":"2026-06-21","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-21T13:28:40+08:00","date":"2026-06-21","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-06-21T13:28:40+08:00","date":"2026-06-21","gate":"ai_review","result":"PASS","detail":"skipped no code"}
{"ts":"2026-06-21T13:28:40+08:00","date":"2026-06-21","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-21T13:28:40+08:00","date":"2026-06-21","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-21T13:28:40+08:00","date":"2026-06-21","gate":"summary","result":"PASS","detail":"8/8"}
{"ts":"2026-06-21T13:35:37+08:00","date":"2026-06-21","gate":"changelog","result":"PASS","detail":"2026-06-21-deploy-local-rsync.md 48lines"}
{"ts":"2026-06-21T13:35:37+08:00","date":"2026-06-21","gate":"audit","result":"PASS","detail":"2026-06-21-server-transfer-ui-polish.md"}
{"ts":"2026-06-21T13:35:37+08:00","date":"2026-06-21","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-21T13:35:37+08:00","date":"2026-06-21","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-21T13:35:37+08:00","date":"2026-06-21","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-21T13:35:37+08:00","date":"2026-06-21","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-21T13:35:37+08:00","date":"2026-06-21","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-06-21T13:35:37+08:00","date":"2026-06-21","gate":"ai_review","result":"PASS","detail":"skipped no code"}
{"ts":"2026-06-21T13:35:37+08:00","date":"2026-06-21","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-21T13:35:37+08:00","date":"2026-06-21","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-21T13:35:37+08:00","date":"2026-06-21","gate":"summary","result":"PASS","detail":"8/8"}
{"ts":"2026-06-21T13:40:17+08:00","date":"2026-06-21","gate":"changelog","result":"PASS","detail":"2026-06-21-server-transfer-manual-dest.md 31lines"}
{"ts":"2026-06-21T13:40:17+08:00","date":"2026-06-21","gate":"audit","result":"PASS","detail":"2026-06-21-server-transfer-ui-polish.md"}
{"ts":"2026-06-21T13:40:17+08:00","date":"2026-06-21","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-21T13:40:17+08:00","date":"2026-06-21","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-21T13:40:17+08:00","date":"2026-06-21","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-21T13:40:17+08:00","date":"2026-06-21","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-21T13:40:17+08:00","date":"2026-06-21","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-06-21T13:40:17+08:00","date":"2026-06-21","gate":"ai_review","result":"PASS","detail":"skipped no code"}
{"ts":"2026-06-21T13:40:17+08:00","date":"2026-06-21","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-21T13:40:17+08:00","date":"2026-06-21","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-21T13:40:17+08:00","date":"2026-06-21","gate":"summary","result":"PASS","detail":"8/8"}
{"ts":"2026-06-21T14:46:10+08:00","date":"2026-06-21","gate":"changelog","result":"PASS","detail":"2026-06-21-server-transfer-dest-write-elevation.md 31lines"}
{"ts":"2026-06-21T14:46:10+08:00","date":"2026-06-21","gate":"audit","result":"PASS","detail":"2026-06-21-server-transfer-ui-polish.md"}
{"ts":"2026-06-21T14:46:10+08:00","date":"2026-06-21","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-21T14:46:10+08:00","date":"2026-06-21","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-21T14:46:10+08:00","date":"2026-06-21","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-21T14:46:10+08:00","date":"2026-06-21","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-21T14:46:10+08:00","date":"2026-06-21","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-06-21T14:46:10+08:00","date":"2026-06-21","gate":"ai_review","result":"PASS","detail":"skipped no code"}
{"ts":"2026-06-21T14:46:10+08:00","date":"2026-06-21","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-21T14:46:10+08:00","date":"2026-06-21","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-21T14:46:10+08:00","date":"2026-06-21","gate":"summary","result":"PASS","detail":"8/8"}
{"ts":"2026-06-21T15:07:14+08:00","date":"2026-06-21","gate":"changelog","result":"PASS","detail":"2026-06-21-server-transfer-curl-tmp-staging.md 29lines"}
{"ts":"2026-06-21T15:07:14+08:00","date":"2026-06-21","gate":"audit","result":"PASS","detail":"2026-06-21-server-transfer-ui-polish.md"}
{"ts":"2026-06-21T15:07:14+08:00","date":"2026-06-21","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-21T15:07:14+08:00","date":"2026-06-21","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-21T15:07:14+08:00","date":"2026-06-21","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-21T15:07:14+08:00","date":"2026-06-21","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-21T15:07:14+08:00","date":"2026-06-21","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-06-21T15:07:14+08:00","date":"2026-06-21","gate":"ai_review","result":"PASS","detail":"skipped no code"}
{"ts":"2026-06-21T15:07:14+08:00","date":"2026-06-21","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-21T15:07:14+08:00","date":"2026-06-21","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-21T15:07:14+08:00","date":"2026-06-21","gate":"summary","result":"PASS","detail":"8/8"}
{"ts":"2026-06-21T15:28:20+08:00","date":"2026-06-21","gate":"changelog","result":"PASS","detail":"2026-06-21-server-transfer-btpanel-download-tar-overwrite.md 39lines"}
{"ts":"2026-06-21T15:28:20+08:00","date":"2026-06-21","gate":"audit","result":"PASS","detail":"2026-06-21-server-transfer-ui-polish.md"}
{"ts":"2026-06-21T15:28:20+08:00","date":"2026-06-21","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-21T15:28:20+08:00","date":"2026-06-21","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-21T15:28:20+08:00","date":"2026-06-21","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-21T15:28:20+08:00","date":"2026-06-21","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-21T15:28:20+08:00","date":"2026-06-21","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-06-21T15:28:20+08:00","date":"2026-06-21","gate":"ai_review","result":"PASS","detail":"skipped no code"}
{"ts":"2026-06-21T15:28:20+08:00","date":"2026-06-21","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-21T15:28:20+08:00","date":"2026-06-21","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-21T15:28:20+08:00","date":"2026-06-21","gate":"summary","result":"PASS","detail":"8/8"}
{"ts":"2026-06-21T15:32:54+08:00","date":"2026-06-21","gate":"changelog","result":"PASS","detail":"2026-06-21-server-transfer-btpanel-download-tar-overwrite.md 43lines"}
{"ts":"2026-06-21T15:32:54+08:00","date":"2026-06-21","gate":"audit","result":"PASS","detail":"2026-06-21-server-transfer-ui-polish.md"}
{"ts":"2026-06-21T15:32:54+08:00","date":"2026-06-21","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-21T15:32:54+08:00","date":"2026-06-21","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-21T15:32:54+08:00","date":"2026-06-21","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-21T15:32:54+08:00","date":"2026-06-21","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-21T15:32:54+08:00","date":"2026-06-21","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-06-21T15:32:54+08:00","date":"2026-06-21","gate":"ai_review","result":"PASS","detail":"skipped no code"}
{"ts":"2026-06-21T15:32:54+08:00","date":"2026-06-21","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-21T15:32:54+08:00","date":"2026-06-21","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-21T15:32:54+08:00","date":"2026-06-21","gate":"summary","result":"PASS","detail":"8/8"}
{"ts":"2026-06-22T05:47:49+08:00","date":"2026-06-22","gate":"changelog","result":"BLOCK","detail":"file not found"}
{"ts":"2026-06-22T05:47:49+08:00","date":"2026-06-22","gate":"audit","result":"BLOCK","detail":"file not found"}
{"ts":"2026-06-22T05:47:49+08:00","date":"2026-06-22","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-22T05:47:49+08:00","date":"2026-06-22","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-22T05:47:49+08:00","date":"2026-06-22","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-22T05:47:49+08:00","date":"2026-06-22","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-22T05:47:49+08:00","date":"2026-06-22","gate":"review","result":"BLOCK","detail":"no audit file"}
{"ts":"2026-06-22T05:47:49+08:00","date":"2026-06-22","gate":"ai_review","result":"PASS","detail":"skipped no code"}
{"ts":"2026-06-22T05:47:49+08:00","date":"2026-06-22","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-22T05:47:49+08:00","date":"2026-06-22","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-22T05:47:49+08:00","date":"2026-06-22","gate":"summary","result":"BLOCK","detail":"5/8"}
{"ts":"2026-06-22T05:48:15+08:00","date":"2026-06-22","gate":"changelog","result":"PASS","detail":"2026-06-22-btpanel-temp-login-24h.md 30lines"}
{"ts":"2026-06-22T05:48:15+08:00","date":"2026-06-22","gate":"audit","result":"PASS","detail":"2026-06-22-btpanel-temp-login-24h.md"}
{"ts":"2026-06-22T05:48:15+08:00","date":"2026-06-22","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-22T05:48:15+08:00","date":"2026-06-22","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-22T05:48:15+08:00","date":"2026-06-22","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-22T05:48:15+08:00","date":"2026-06-22","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-22T05:48:15+08:00","date":"2026-06-22","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-06-22T05:48:15+08:00","date":"2026-06-22","gate":"ai_review","result":"PASS","detail":"skipped no code"}
{"ts":"2026-06-22T05:48:15+08:00","date":"2026-06-22","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-22T05:48:15+08:00","date":"2026-06-22","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-22T05:48:15+08:00","date":"2026-06-22","gate":"summary","result":"PASS","detail":"8/8"}
{"ts":"2026-06-23T10:39:14+08:00","date":"2026-06-23","gate":"changelog","result":"PASS","detail":"2026-06-23-subscription-ip-refresh-primary-fix.md 36lines"}
{"ts":"2026-06-23T10:39:14+08:00","date":"2026-06-23","gate":"audit","result":"BLOCK","detail":"file not found"}
{"ts":"2026-06-23T10:39:14+08:00","date":"2026-06-23","gate":"test","result":"BLOCK","detail":"tests failed"}
{"ts":"2026-06-23T10:39:14+08:00","date":"2026-06-23","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-23T10:39:14+08:00","date":"2026-06-23","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-23T10:39:14+08:00","date":"2026-06-23","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-23T10:39:14+08:00","date":"2026-06-23","gate":"review","result":"BLOCK","detail":"no audit file"}
{"ts":"2026-06-23T10:39:14+08:00","date":"2026-06-23","gate":"ai_review","result":"BLOCK","detail":"see gate_ai_review.py output"}
{"ts":"2026-06-23T10:39:14+08:00","date":"2026-06-23","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-23T10:39:14+08:00","date":"2026-06-23","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-23T10:39:14+08:00","date":"2026-06-23","gate":"summary","result":"BLOCK","detail":"4/8"}
{"ts":"2026-06-23T21:06:19+08:00","date":"2026-06-23","gate":"changelog","result":"PASS","detail":"2026-06-23-detect-path-nginx-root.md 43lines"}
{"ts":"2026-06-23T21:06:19+08:00","date":"2026-06-23","gate":"audit","result":"BLOCK","detail":"file not found"}
{"ts":"2026-06-23T21:06:19+08:00","date":"2026-06-23","gate":"test","result":"BLOCK","detail":"tests failed"}
{"ts":"2026-06-23T21:06:19+08:00","date":"2026-06-23","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-23T21:06:19+08:00","date":"2026-06-23","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-23T21:06:19+08:00","date":"2026-06-23","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-23T21:06:19+08:00","date":"2026-06-23","gate":"review","result":"BLOCK","detail":"no audit file"}
{"ts":"2026-06-23T21:06:19+08:00","date":"2026-06-23","gate":"ai_review","result":"BLOCK","detail":"see gate_ai_review.py output"}
{"ts":"2026-06-23T21:06:19+08:00","date":"2026-06-23","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-23T21:06:19+08:00","date":"2026-06-23","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-23T21:06:19+08:00","date":"2026-06-23","gate":"summary","result":"BLOCK","detail":"4/8"}
{"ts":"2026-06-23T21:06:32+08:00","date":"2026-06-23","gate":"changelog","result":"PASS","detail":"2026-06-23-detect-path-nginx-root.md 43lines"}
{"ts":"2026-06-23T21:06:32+08:00","date":"2026-06-23","gate":"audit","result":"BLOCK","detail":"file not found"}
{"ts":"2026-06-23T21:06:32+08:00","date":"2026-06-23","gate":"test","result":"BLOCK","detail":"tests failed"}
{"ts":"2026-06-23T21:09:02+08:00","date":"2026-06-23","gate":"changelog","result":"PASS","detail":"2026-06-23-detect-path-nginx-root.md 43lines"}
{"ts":"2026-06-23T21:09:02+08:00","date":"2026-06-23","gate":"audit","result":"PASS","detail":"2026-06-23-detect-path-nginx-root.md"}
{"ts":"2026-06-23T21:09:02+08:00","date":"2026-06-23","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-23T21:09:02+08:00","date":"2026-06-23","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-23T21:09:02+08:00","date":"2026-06-23","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-23T21:09:02+08:00","date":"2026-06-23","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-23T21:09:02+08:00","date":"2026-06-23","gate":"review","result":"BLOCK","detail":"audit missing changed files"}
{"ts":"2026-06-23T21:09:02+08:00","date":"2026-06-23","gate":"ai_review","result":"PASS","detail":"skipped no code"}
{"ts":"2026-06-23T21:09:02+08:00","date":"2026-06-23","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-23T21:09:02+08:00","date":"2026-06-23","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-23T21:09:02+08:00","date":"2026-06-23","gate":"summary","result":"BLOCK","detail":"7/8"}
{"ts":"2026-06-23T21:09:24+08:00","date":"2026-06-23","gate":"changelog","result":"PASS","detail":"2026-06-23-detect-path-nginx-root.md 43lines"}
{"ts":"2026-06-23T21:09:24+08:00","date":"2026-06-23","gate":"audit","result":"PASS","detail":"2026-06-23-detect-path-nginx-root.md"}
{"ts":"2026-06-23T21:09:24+08:00","date":"2026-06-23","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-23T21:09:24+08:00","date":"2026-06-23","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-23T21:09:24+08:00","date":"2026-06-23","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-23T21:09:24+08:00","date":"2026-06-23","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-23T21:09:24+08:00","date":"2026-06-23","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-06-23T21:09:24+08:00","date":"2026-06-23","gate":"ai_review","result":"PASS","detail":"skipped no code"}
{"ts":"2026-06-23T21:09:24+08:00","date":"2026-06-23","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-23T21:09:24+08:00","date":"2026-06-23","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-23T21:09:24+08:00","date":"2026-06-23","gate":"summary","result":"PASS","detail":"8/8"}
{"ts":"2026-06-24T02:11:18+08:00","date":"2026-06-24","gate":"changelog","result":"PASS","detail":"2026-06-24-ip-domain-detect-schedule.md 42lines"}
{"ts":"2026-06-24T02:11:18+08:00","date":"2026-06-24","gate":"audit","result":"BLOCK","detail":"file not found"}
{"ts":"2026-06-24T02:11:18+08:00","date":"2026-06-24","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-24T02:11:18+08:00","date":"2026-06-24","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-24T02:11:18+08:00","date":"2026-06-24","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-24T02:11:18+08:00","date":"2026-06-24","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-24T02:11:18+08:00","date":"2026-06-24","gate":"review","result":"BLOCK","detail":"no audit file"}
{"ts":"2026-06-24T02:11:18+08:00","date":"2026-06-24","gate":"ai_review","result":"BLOCK","detail":"see gate_ai_review.py output"}
{"ts":"2026-06-24T02:11:18+08:00","date":"2026-06-24","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-24T02:11:18+08:00","date":"2026-06-24","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-24T02:11:18+08:00","date":"2026-06-24","gate":"summary","result":"BLOCK","detail":"5/8"}
{"ts":"2026-06-24T02:11:57+08:00","date":"2026-06-24","gate":"changelog","result":"PASS","detail":"2026-06-24-ip-domain-detect-schedule.md 42lines"}
{"ts":"2026-06-24T02:11:57+08:00","date":"2026-06-24","gate":"audit","result":"PASS","detail":"2026-06-24-ip-domain-detect-schedule.md"}
{"ts":"2026-06-24T02:11:57+08:00","date":"2026-06-24","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-24T02:11:57+08:00","date":"2026-06-24","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-24T02:11:57+08:00","date":"2026-06-24","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-24T02:11:57+08:00","date":"2026-06-24","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-24T02:11:57+08:00","date":"2026-06-24","gate":"review","result":"BLOCK","detail":"audit missing changed files"}
{"ts":"2026-06-24T02:11:57+08:00","date":"2026-06-24","gate":"ai_review","result":"PASS","detail":"skipped no code"}
{"ts":"2026-06-24T02:11:57+08:00","date":"2026-06-24","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-24T02:11:57+08:00","date":"2026-06-24","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-24T02:11:57+08:00","date":"2026-06-24","gate":"summary","result":"BLOCK","detail":"7/8"}
{"ts":"2026-06-24T02:12:07+08:00","date":"2026-06-24","gate":"changelog","result":"PASS","detail":"2026-06-24-ip-domain-detect-schedule.md 42lines"}
{"ts":"2026-06-24T02:12:07+08:00","date":"2026-06-24","gate":"audit","result":"PASS","detail":"2026-06-24-ip-domain-detect-schedule.md"}
{"ts":"2026-06-24T02:12:07+08:00","date":"2026-06-24","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-24T02:12:07+08:00","date":"2026-06-24","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-24T02:12:07+08:00","date":"2026-06-24","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-24T02:12:07+08:00","date":"2026-06-24","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-24T02:12:07+08:00","date":"2026-06-24","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-06-24T02:12:07+08:00","date":"2026-06-24","gate":"ai_review","result":"PASS","detail":"skipped no code"}
{"ts":"2026-06-24T02:12:07+08:00","date":"2026-06-24","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-24T02:12:07+08:00","date":"2026-06-24","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-24T02:12:07+08:00","date":"2026-06-24","gate":"summary","result":"PASS","detail":"8/8"}
{"ts":"2026-06-24T15:04:07+08:00","date":"2026-06-24","gate":"changelog","result":"PASS","detail":"2026-06-24-servers-unset-path-table-align.md 77lines"}
{"ts":"2026-06-24T15:04:07+08:00","date":"2026-06-24","gate":"audit","result":"PASS","detail":"2026-06-24-ip-domain-detect-schedule.md"}
{"ts":"2026-06-24T15:04:07+08:00","date":"2026-06-24","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-24T15:04:07+08:00","date":"2026-06-24","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-24T15:04:07+08:00","date":"2026-06-24","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-24T15:04:07+08:00","date":"2026-06-24","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-24T15:04:07+08:00","date":"2026-06-24","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-06-24T15:04:07+08:00","date":"2026-06-24","gate":"ai_review","result":"BLOCK","detail":"see gate_ai_review.py output"}
{"ts":"2026-06-24T15:04:07+08:00","date":"2026-06-24","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-24T15:04:07+08:00","date":"2026-06-24","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-24T15:04:07+08:00","date":"2026-06-24","gate":"summary","result":"BLOCK","detail":"7/8"}
{"ts":"2026-06-24T15:04:27+08:00","date":"2026-06-24","gate":"changelog","result":"PASS","detail":"2026-06-24-servers-unset-path-table-align.md 77lines"}
{"ts":"2026-06-24T15:04:27+08:00","date":"2026-06-24","gate":"audit","result":"PASS","detail":"2026-06-24-ip-domain-detect-schedule.md"}
{"ts":"2026-06-24T15:04:27+08:00","date":"2026-06-24","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-24T15:04:27+08:00","date":"2026-06-24","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-24T15:04:27+08:00","date":"2026-06-24","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-24T15:04:27+08:00","date":"2026-06-24","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-24T15:04:27+08:00","date":"2026-06-24","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-06-24T15:04:27+08:00","date":"2026-06-24","gate":"ai_review","result":"PASS","detail":"skipped no code"}
{"ts":"2026-06-24T15:04:27+08:00","date":"2026-06-24","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-24T15:04:27+08:00","date":"2026-06-24","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-24T15:04:27+08:00","date":"2026-06-24","gate":"summary","result":"PASS","detail":"8/8"}
{"ts":"2026-06-24T15:08:09+08:00","date":"2026-06-24","gate":"changelog","result":"PASS","detail":"2026-06-24-servers-unset-path-table-align.md 77lines"}
{"ts":"2026-06-24T15:08:09+08:00","date":"2026-06-24","gate":"audit","result":"PASS","detail":"2026-06-24-ip-domain-detect-schedule.md"}
{"ts":"2026-06-24T15:08:09+08:00","date":"2026-06-24","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-24T15:08:09+08:00","date":"2026-06-24","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-24T15:08:09+08:00","date":"2026-06-24","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-24T15:08:09+08:00","date":"2026-06-24","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-24T15:08:09+08:00","date":"2026-06-24","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-06-24T15:08:09+08:00","date":"2026-06-24","gate":"ai_review","result":"PASS","detail":"skipped no code"}
{"ts":"2026-06-24T15:08:09+08:00","date":"2026-06-24","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-24T15:08:09+08:00","date":"2026-06-24","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-24T15:08:09+08:00","date":"2026-06-24","gate":"summary","result":"PASS","detail":"8/8"}
{"ts":"2026-06-29T18:22:15+08:00","date":"2026-06-29","gate":"changelog","result":"BLOCK","detail":"file not found"}
{"ts":"2026-06-29T18:22:15+08:00","date":"2026-06-29","gate":"audit","result":"BLOCK","detail":"file not found"}
{"ts":"2026-06-29T18:22:15+08:00","date":"2026-06-29","gate":"test","result":"BLOCK","detail":"tests failed"}
{"ts":"2026-06-29T18:22:15+08:00","date":"2026-06-29","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-29T18:22:15+08:00","date":"2026-06-29","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-29T18:22:15+08:00","date":"2026-06-29","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-29T18:22:15+08:00","date":"2026-06-29","gate":"review","result":"BLOCK","detail":"no audit file"}
{"ts":"2026-06-29T18:22:15+08:00","date":"2026-06-29","gate":"ai_review","result":"BLOCK","detail":"see gate_ai_review.py output"}
{"ts":"2026-06-29T18:22:15+08:00","date":"2026-06-29","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-29T18:22:15+08:00","date":"2026-06-29","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-29T18:22:15+08:00","date":"2026-06-29","gate":"summary","result":"BLOCK","detail":"3/8"}
{"ts":"2026-07-01T12:03:53+08:00","date":"2026-07-01","gate":"changelog","result":"BLOCK","detail":"file not found"}
{"ts":"2026-07-01T12:03:53+08:00","date":"2026-07-01","gate":"audit","result":"BLOCK","detail":"file not found"}
{"ts":"2026-07-01T12:03:53+08:00","date":"2026-07-01","gate":"test","result":"BLOCK","detail":"tests failed"}
{"ts":"2026-07-01T12:03:53+08:00","date":"2026-07-01","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-07-01T12:03:53+08:00","date":"2026-07-01","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-07-01T12:03:53+08:00","date":"2026-07-01","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-07-01T12:03:53+08:00","date":"2026-07-01","gate":"review","result":"BLOCK","detail":"no audit file"}
{"ts":"2026-07-01T12:03:53+08:00","date":"2026-07-01","gate":"ai_review","result":"BLOCK","detail":"see gate_ai_review.py output"}
{"ts":"2026-07-01T12:03:53+08:00","date":"2026-07-01","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-07-01T12:03:53+08:00","date":"2026-07-01","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-07-01T12:03:53+08:00","date":"2026-07-01","gate":"summary","result":"BLOCK","detail":"3/8"}
{"ts":"2026-07-01T12:09:17+08:00","date":"2026-07-01","gate":"changelog","result":"PASS","detail":"2026-07-01-gate-catchup-terminal-btpanel.md 54lines"}
{"ts":"2026-07-01T12:09:17+08:00","date":"2026-07-01","gate":"audit","result":"PASS","detail":"2026-07-01-gate-catchup-terminal-btpanel.md"}
{"ts":"2026-07-01T12:09:17+08:00","date":"2026-07-01","gate":"test","result":"PASS","detail":"pytest fallback :8600 down"}
{"ts":"2026-07-01T12:09:17+08:00","date":"2026-07-01","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-07-01T12:09:17+08:00","date":"2026-07-01","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-07-01T12:09:17+08:00","date":"2026-07-01","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-07-01T12:09:17+08:00","date":"2026-07-01","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-07-01T12:09:17+08:00","date":"2026-07-01","gate":"ai_review","result":"BLOCK","detail":"see gate_ai_review.py output"}
{"ts":"2026-07-01T12:09:17+08:00","date":"2026-07-01","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-07-01T12:09:17+08:00","date":"2026-07-01","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-07-01T12:09:17+08:00","date":"2026-07-01","gate":"summary","result":"BLOCK","detail":"7/8"}
{"ts":"2026-07-01T12:09:49+08:00","date":"2026-07-01","gate":"changelog","result":"PASS","detail":"2026-07-01-gate-catchup-terminal-btpanel.md 54lines"}
{"ts":"2026-07-01T12:09:49+08:00","date":"2026-07-01","gate":"audit","result":"PASS","detail":"2026-07-01-gate-catchup-terminal-btpanel.md"}
{"ts":"2026-07-01T12:09:49+08:00","date":"2026-07-01","gate":"test","result":"PASS","detail":"pytest fallback :8600 down"}
{"ts":"2026-07-01T12:09:49+08:00","date":"2026-07-01","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-07-01T12:09:49+08:00","date":"2026-07-01","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-07-01T12:09:49+08:00","date":"2026-07-01","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-07-01T12:09:49+08:00","date":"2026-07-01","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-07-01T12:09:49+08:00","date":"2026-07-01","gate":"ai_review","result":"PASS","detail":"skipped no code"}
{"ts":"2026-07-01T12:09:49+08:00","date":"2026-07-01","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-07-01T12:09:49+08:00","date":"2026-07-01","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-07-01T12:09:49+08:00","date":"2026-07-01","gate":"summary","result":"PASS","detail":"8/8"}
{"ts":"2026-07-01T12:10:11+08:00","date":"2026-07-01","gate":"changelog","result":"PASS","detail":"2026-07-01-gate-catchup-terminal-btpanel.md 54lines"}
{"ts":"2026-07-01T12:10:11+08:00","date":"2026-07-01","gate":"audit","result":"PASS","detail":"2026-07-01-gate-catchup-terminal-btpanel.md"}
{"ts":"2026-07-01T12:10:11+08:00","date":"2026-07-01","gate":"test","result":"PASS","detail":"pytest fallback :8600 down"}
{"ts":"2026-07-01T12:10:11+08:00","date":"2026-07-01","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-07-01T12:10:11+08:00","date":"2026-07-01","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-07-01T12:10:11+08:00","date":"2026-07-01","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-07-01T12:10:11+08:00","date":"2026-07-01","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-07-01T12:10:11+08:00","date":"2026-07-01","gate":"ai_review","result":"PASS","detail":"skipped no code"}
{"ts":"2026-07-01T12:10:11+08:00","date":"2026-07-01","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-07-01T12:10:11+08:00","date":"2026-07-01","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-07-01T12:10:11+08:00","date":"2026-07-01","gate":"summary","result":"PASS","detail":"8/8"}
{"ts":"2026-07-01T18:29:45+08:00","date":"2026-07-01","gate":"changelog","result":"PASS","detail":"2026-07-01-agent-watch-dual-mode.md 38lines"}
{"ts":"2026-07-01T18:29:45+08:00","date":"2026-07-01","gate":"audit","result":"PASS","detail":"2026-07-01-agent-watch-dual-mode.md"}
{"ts":"2026-07-01T18:29:45+08:00","date":"2026-07-01","gate":"test","result":"PASS","detail":"pytest fallback :8600 down"}
{"ts":"2026-07-01T18:29:45+08:00","date":"2026-07-01","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-07-01T18:29:45+08:00","date":"2026-07-01","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-07-01T18:29:45+08:00","date":"2026-07-01","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-07-01T18:29:45+08:00","date":"2026-07-01","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-07-01T18:29:45+08:00","date":"2026-07-01","gate":"ai_review","result":"PASS","detail":"skipped no code"}
{"ts":"2026-07-01T18:29:45+08:00","date":"2026-07-01","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-07-01T18:29:45+08:00","date":"2026-07-01","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-07-01T18:29:45+08:00","date":"2026-07-01","gate":"summary","result":"PASS","detail":"8/8"}
{"ts":"2026-07-02T00:20:36+08:00","date":"2026-07-02","gate":"changelog","result":"PASS","detail":"2026-07-02-agent-cpu-sampling.md 35lines"}
{"ts":"2026-07-02T00:20:36+08:00","date":"2026-07-02","gate":"audit","result":"PASS","detail":"2026-07-02-agent-cpu-sampling.md"}
{"ts":"2026-07-02T00:20:36+08:00","date":"2026-07-02","gate":"test","result":"PASS","detail":"pytest fallback :8600 down"}
{"ts":"2026-07-02T00:20:36+08:00","date":"2026-07-02","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-07-02T00:20:36+08:00","date":"2026-07-02","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-07-02T00:20:36+08:00","date":"2026-07-02","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-07-02T00:20:36+08:00","date":"2026-07-02","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-07-02T00:20:36+08:00","date":"2026-07-02","gate":"ai_review","result":"PASS","detail":"skipped no code"}
{"ts":"2026-07-02T00:20:36+08:00","date":"2026-07-02","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-07-02T00:20:36+08:00","date":"2026-07-02","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-07-02T00:20:36+08:00","date":"2026-07-02","gate":"summary","result":"PASS","detail":"8/8"}
{"ts":"2026-07-02T00:36:42+08:00","date":"2026-07-02","gate":"changelog","result":"PASS","detail":"2026-07-02-stats-unset-path-offline-card.md 29lines"}
{"ts":"2026-07-02T00:36:42+08:00","date":"2026-07-02","gate":"audit","result":"PASS","detail":"2026-07-02-stats-unset-path-offline-card.md"}
{"ts":"2026-07-02T00:36:42+08:00","date":"2026-07-02","gate":"test","result":"PASS","detail":"pytest fallback :8600 down"}
{"ts":"2026-07-02T00:36:42+08:00","date":"2026-07-02","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-07-02T00:36:42+08:00","date":"2026-07-02","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-07-02T00:36:42+08:00","date":"2026-07-02","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-07-02T00:36:42+08:00","date":"2026-07-02","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-07-02T00:36:42+08:00","date":"2026-07-02","gate":"ai_review","result":"PASS","detail":"skipped no code"}
{"ts":"2026-07-02T00:36:42+08:00","date":"2026-07-02","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-07-02T00:36:42+08:00","date":"2026-07-02","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-07-02T00:36:42+08:00","date":"2026-07-02","gate":"summary","result":"PASS","detail":"8/8"}
{"ts":"2026-07-02T00:47:58+08:00","date":"2026-07-02","gate":"changelog","result":"PASS","detail":"2026-07-02-stats-unset-path-offline-card.md 29lines"}
{"ts":"2026-07-02T00:47:58+08:00","date":"2026-07-02","gate":"audit","result":"PASS","detail":"2026-07-02-stats-unset-path-offline-card.md"}
{"ts":"2026-07-02T00:47:58+08:00","date":"2026-07-02","gate":"test","result":"PASS","detail":"pytest fallback :8600 down"}
{"ts":"2026-07-02T00:47:58+08:00","date":"2026-07-02","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-07-02T00:47:58+08:00","date":"2026-07-02","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-07-02T00:47:58+08:00","date":"2026-07-02","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-07-02T00:47:58+08:00","date":"2026-07-02","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-07-02T00:47:58+08:00","date":"2026-07-02","gate":"ai_review","result":"PASS","detail":"skipped no code"}
{"ts":"2026-07-02T00:47:58+08:00","date":"2026-07-02","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-07-02T00:47:58+08:00","date":"2026-07-02","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-07-02T00:47:58+08:00","date":"2026-07-02","gate":"summary","result":"PASS","detail":"8/8"}
{"ts":"2026-07-02T02:45:32+08:00","date":"2026-07-02","gate":"changelog","result":"PASS","detail":"2026-07-02-stats-unset-path-offline-card.md 29lines"}
{"ts":"2026-07-02T02:45:32+08:00","date":"2026-07-02","gate":"audit","result":"PASS","detail":"2026-07-02-stats-unset-path-offline-card.md"}
{"ts":"2026-07-02T02:45:32+08:00","date":"2026-07-02","gate":"test","result":"PASS","detail":"pytest fallback :8600 down"}
{"ts":"2026-07-02T02:45:32+08:00","date":"2026-07-02","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-07-02T02:45:32+08:00","date":"2026-07-02","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-07-02T02:45:32+08:00","date":"2026-07-02","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-07-02T02:45:32+08:00","date":"2026-07-02","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-07-02T02:45:32+08:00","date":"2026-07-02","gate":"ai_review","result":"PASS","detail":"skipped no code"}
{"ts":"2026-07-02T02:45:32+08:00","date":"2026-07-02","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-07-02T02:45:32+08:00","date":"2026-07-02","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-07-02T02:45:32+08:00","date":"2026-07-02","gate":"summary","result":"PASS","detail":"8/8"}
{"ts":"2026-07-02T03:10:05+08:00","date":"2026-07-02","gate":"changelog","result":"PASS","detail":"2026-07-02-stats-unset-path-offline-card.md 29lines"}
{"ts":"2026-07-02T03:10:05+08:00","date":"2026-07-02","gate":"audit","result":"PASS","detail":"2026-07-02-stats-unset-path-offline-card.md"}
{"ts":"2026-07-02T03:10:05+08:00","date":"2026-07-02","gate":"test","result":"PASS","detail":"pytest fallback :8600 down"}
{"ts":"2026-07-02T03:10:05+08:00","date":"2026-07-02","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-07-02T03:10:05+08:00","date":"2026-07-02","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-07-02T03:10:05+08:00","date":"2026-07-02","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-07-02T03:10:05+08:00","date":"2026-07-02","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-07-02T03:10:05+08:00","date":"2026-07-02","gate":"ai_review","result":"PASS","detail":"skipped no code"}
{"ts":"2026-07-02T03:10:05+08:00","date":"2026-07-02","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-07-02T03:10:05+08:00","date":"2026-07-02","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-07-02T03:10:05+08:00","date":"2026-07-02","gate":"summary","result":"PASS","detail":"8/8"}
{"ts":"2026-07-02T03:13:21+08:00","date":"2026-07-02","gate":"changelog","result":"PASS","detail":"2026-07-02-stats-unset-path-offline-card.md 29lines"}
{"ts":"2026-07-02T03:13:21+08:00","date":"2026-07-02","gate":"audit","result":"PASS","detail":"2026-07-02-stats-unset-path-offline-card.md"}
{"ts":"2026-07-02T03:13:21+08:00","date":"2026-07-02","gate":"test","result":"PASS","detail":"pytest fallback :8600 down"}
{"ts":"2026-07-02T03:13:21+08:00","date":"2026-07-02","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-07-02T03:13:21+08:00","date":"2026-07-02","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-07-02T03:13:21+08:00","date":"2026-07-02","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-07-02T03:13:21+08:00","date":"2026-07-02","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-07-02T03:13:21+08:00","date":"2026-07-02","gate":"ai_review","result":"PASS","detail":"skipped no code"}
{"ts":"2026-07-02T03:13:21+08:00","date":"2026-07-02","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-07-02T03:13:21+08:00","date":"2026-07-02","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-07-02T03:13:21+08:00","date":"2026-07-02","gate":"summary","result":"PASS","detail":"8/8"}
{"ts":"2026-07-03T23:19:41+08:00","date":"2026-07-03","gate":"changelog","result":"PASS","detail":"2026-07-03-terminal-btpanel-login-button.md 29lines"}
{"ts":"2026-07-03T23:19:41+08:00","date":"2026-07-03","gate":"audit","result":"PASS","detail":"2026-07-03-terminal-btpanel-login-button.md"}
{"ts":"2026-07-03T23:19:41+08:00","date":"2026-07-03","gate":"test","result":"PASS","detail":"pytest fallback :8600 down"}
{"ts":"2026-07-03T23:19:41+08:00","date":"2026-07-03","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-07-03T23:19:41+08:00","date":"2026-07-03","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-07-03T23:19:41+08:00","date":"2026-07-03","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-07-03T23:19:41+08:00","date":"2026-07-03","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-07-03T23:19:41+08:00","date":"2026-07-03","gate":"ai_review","result":"PASS","detail":"ok"}
{"ts":"2026-07-03T23:19:41+08:00","date":"2026-07-03","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-07-03T23:19:41+08:00","date":"2026-07-03","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-07-03T23:19:41+08:00","date":"2026-07-03","gate":"summary","result":"PASS","detail":"8/8"}
+1
View File
@@ -2,6 +2,7 @@
# 用于 git pushscripts/git-push.sh);clone/pull 仍可匿名
# NEXUS_GITEA_HOST='66.154.115.8:3000'
# NEXUS_GITEA_HOST='axs.kuma1xn.vip' # HTTPS Gitea2026-07-08 起,分支 release/20260708-axs
# NEXUS_GITEA_REPO='admin/Nexus.git'
# admin 账号密码或 Access Token
# NEXUS_GITEA_TOKEN='你的密码或令牌'
+15 -2
View File
@@ -50,6 +50,7 @@ error() { echo -e "${RED}[ERROR]${NC} $*" >&2; }
step() { echo -e "${CYAN}[STEP]${NC} $*"; }
SKIP_CLONE=false
SKIP_GIT=false
FRESH_INSTALL=false
REUSE_SECRETS=false
FROM_ENV="${NEXUS_FROM_ENV}"
@@ -1138,8 +1139,12 @@ cmd_upgrade() {
load_saved_profile "$NEXUS_ROOT"
resolve_profile
check_ports_preflight "$NEXUS_ROOT"
git_fetch
show_pending || true
if [[ "$SKIP_GIT" == true ]]; then
info "跳过 Git 同步(本机 rsync 已推送代码)"
else
git_fetch
show_pending || true
fi
if [[ "$CHECK_ONLY" == true ]]; then
exit 0
@@ -1149,6 +1154,12 @@ cmd_upgrade() {
exit 0
fi
if [[ "$SKIP_GIT" == true ]]; then
info "本机直传模式:跳过 git reset,仍备份并重建镜像"
if [[ "$NO_BACKUP" != true ]]; then
backup_mysql "$NEXUS_ROOT"
fi
else
local behind
behind="$(commits_behind)"
if [[ "$behind" != "0" ]]; then
@@ -1160,6 +1171,7 @@ cmd_upgrade() {
backup_mysql "$NEXUS_ROOT"
fi
fi
fi
apply_pool_to_env_prod "$NEXUS_ROOT" "$(profile_env_file "$NEXUS_ROOT")"
upsert_env_var "$NEXUS_ROOT/$ENV_PROD" "NEXUS_HOST_ROOT" "$NEXUS_ROOT"
@@ -1272,6 +1284,7 @@ parse_common_upgrade() {
while [[ $# -gt 0 ]]; do
case "$1" in
-h|--help) usage ;;
--skip-git) SKIP_GIT=true; shift ;;
--profile) NEXUS_PROFILE="$2"; shift 2 ;;
--cpus) CUSTOM_CPUS="$2"; shift 2 ;;
--mem-gb|--memory-gb) CUSTOM_MEM_GB="$2"; shift 2 ;;
+104
View File
@@ -0,0 +1,104 @@
#!/usr/bin/env bash
# Sync local Nexus working tree to production host (no Gitea push/pull).
# Transfer via tar|ssh; remote ownership auto-detected (www:www on BT, else deploy path owner).
#
# Usage:
# bash deploy/rsync-local-to-server.sh
# NEXUS_DEPLOY_PATH=/opt/nexus bash deploy/rsync-local-to-server.sh
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "${ROOT}"
SECRETS="${ROOT}/deploy/nexus-1panel.secrets.sh"
if [[ -f "$SECRETS" ]]; then
# shellcheck disable=SC1090
source "$SECRETS"
fi
NEXUS_SSH_HOST="${NEXUS_SSH:-nexus}"
SSH_OPTS=(-o BatchMode=yes -o ConnectTimeout=15)
if [[ -n "${NEXUS_SSH_KEY:-}" ]]; then
SSH_OPTS+=(-i "${NEXUS_SSH_KEY}")
fi
ssh_cmd() { ssh "${SSH_OPTS[@]}" "${NEXUS_SSH_HOST}" "$@"; }
if ! ssh_cmd "echo SSH OK" >/dev/null 2>&1; then
echo "ERROR: Cannot SSH to ${NEXUS_SSH_HOST}" >&2
exit 1
fi
DEPLOY_PATH="${NEXUS_DEPLOY_PATH:-}"
if [[ -z "$DEPLOY_PATH" ]]; then
DEPLOY_PATH="$(ssh_cmd 'for d in /opt/nexus /www/wwwroot/api.synaglobal.vip; do [[ -d "$d/server" ]] && echo "$d" && exit 0; done; echo /opt/nexus')"
fi
resolve_deploy_chown() {
if [[ -n "${NEXUS_DEPLOY_CHOWN:-}" ]]; then
echo "${NEXUS_DEPLOY_CHOWN}"
return
fi
if ssh_cmd "getent passwd www >/dev/null 2>&1"; then
echo "www:www"
return
fi
ssh_cmd "stat -c '%U:%G' '${DEPLOY_PATH}'" 2>/dev/null || echo "azureuser:azureuser"
}
DEPLOY_CHOWN="$(resolve_deploy_chown)"
echo "═══ Nexus local → server sync ═══"
echo "SSH target: ${NEXUS_SSH_HOST}"
echo "Remote path: ${DEPLOY_PATH}"
echo "Owner: ${DEPLOY_CHOWN}"
TAR_EXCLUDES=(
--exclude='.git'
--exclude='.env'
--exclude='.env.*'
--exclude='SECRETS.md'
--exclude='deploy/nexus-1panel.secrets.sh'
--exclude='docker/.env.prod'
--exclude='web/data'
--exclude='frontend/node_modules'
--exclude='node_modules'
--exclude='.venv'
--exclude='venv'
--exclude='__pycache__'
--exclude='.cursor'
--exclude='.install_locked'
--exclude='.install_state.json'
--exclude='web/app/assets'
--exclude='tmp'
--exclude='frontend/test-results'
--exclude='frontend/e2e/.auth'
)
echo "▶ tar stream → ${DEPLOY_PATH} ..."
tar -C "${ROOT}" -czf - "${TAR_EXCLUDES[@]}" . | \
ssh "${SSH_OPTS[@]}" "${NEXUS_SSH_HOST}" "sudo tar -xzf - -C '${DEPLOY_PATH}'"
echo "▶ chown ${DEPLOY_CHOWN} (skip docker/.env.prod, web/data, .env) ..."
ssh_cmd "sudo bash -s" <<REMOTE
set -euo pipefail
root='${DEPLOY_PATH}'
own='${DEPLOY_CHOWN}'
chown_tree() {
local p="\$1"
[[ -e "\$p" ]] || return 0
sudo chown -R "\$own" "\$p"
}
for top in server deploy scripts tests standards docs frontend web; do
chown_tree "\$root/\$top"
done
if [[ -d "\$root/docker" ]]; then
while IFS= read -r -d '' p; do
sudo chown -R "\$own" "\$p"
done < <(find "\$root/docker" \( -path "\$root/docker/.env.prod" \) -prune -o -print0)
fi
for f in pyproject.toml requirements.txt requirements-dev.txt AGENTS.md CLAUDE.md; do
[[ -f "\$root/\$f" ]] && sudo chown "\$own" "\$root/\$f"
done
REMOTE
echo "✓ sync complete (owner ${DEPLOY_CHOWN})"
@@ -0,0 +1,46 @@
# 审计 — 检测路径改为 nginx 网站目录
## 范围(文件清单)
| 文件 | 变更 |
|------|------|
| `server/utils/nginx_path_detect.py` | 新增:构建远程 nginx root 检测命令 |
| `server/application/server_batch_common.py` | `detect_and_save_target_path` 改用 nginx |
| `frontend/src/pages/ServersPage.vue` | 确认对话框文案 |
| `scripts/batch_detect_target_path.py` | 脚本说明 |
| `tests/test_nginx_path_detect.py` | 单元测试 |
| `tests/test_server_onboarding.py` | mock 错误文案 |
| `server/application/services/btpanel_service.py` | 同批部署:宝塔临时登录 TTL |
| `server/infrastructure/btpanel/constants.py` | 同上 |
| `server/infrastructure/btpanel/ssh_login.py` | 同上 |
| `tests/test_btpanel_temp_login_ttl.py` | 同上 |
## Step 3 规则扫描
| H | 规则 | 结论 |
|---|------|------|
| H1 | 远程命令 domain 须 `shlex.quote` | PASS |
| H2 | 解析路径走 `normalize_remote_abs_path` | PASS |
| H3 | 非 root SSH 仍 `sudo_wrap` | PASS |
| H4 | 未静默吞 SSH 错误 | PASS — 无 stdout 返回明确错误 |
## Closure
| H | 判定 | 依据 |
|---|------|------|
| H1 | SAFE | `nginx_path_detect.py` `shlex.quote(host)` |
| H2 | SAFE | `server_batch_common.py` normalize 后写入 |
| H3 | SAFE | 沿用既有 `sudo_wrap` |
| H4 | SAFE | `未找到 nginx 网站目录` |
## DoD
- [x] pytest tests/test_nginx_path_detect.py
- [x] changelog 2026-06-23-detect-path-nginx-root.md
- [x] 功能指南 § 检测目标路径 已更新
## 验证
```bash
pytest tests/test_nginx_path_detect.py tests/test_server_onboarding.py -q
```
@@ -0,0 +1,53 @@
# 审计 — IP-only 服务器自动补全站点域名
## 范围(文件清单)
| 文件 | 变更 |
|------|------|
| `server/utils/site_host.py` | 新增:IP 判定与站点 host 解析 |
| `server/utils/nginx_domain_detect.py` | 新增:远程 nginx 域名探测命令 |
| `server/application/server_batch_common.py` | `detect_and_save_site_url` / `update_server_site_url` |
| `server/application/services/ip_domain_detect_schedule.py` | 新增:23:30 定时筛选与触发 |
| `server/background/ip_domain_detect_loop.py` | 新增:background loop |
| `server/application/services/server_batch_service.py` | batch op `detect-domain` |
| `server/config.py` | `IP_DOMAIN_DETECT_*` 配置 |
| `server/main.py` | 注册 loop |
| `tests/test_site_host.py` | 单元测试 |
| `tests/test_nginx_domain_detect.py` | 单元测试 |
| `tests/test_ip_domain_detect_schedule.py` | 单元测试 |
| `server/application/services/btpanel_service.py` | 同批未提交:宝塔临时登录 TTL |
| `server/infrastructure/btpanel/constants.py` | 同上 |
| `server/infrastructure/btpanel/ssh_login.py` | 同上 |
| `tests/test_btpanel_temp_login_ttl.py` | 同上 |
## Step 3 规则扫描
| H | 规则 | 结论 |
|---|------|------|
| H1 | 远程 shell 参数须 `shlex.quote` | PASS — `target_hint` 已 quote |
| H2 | 不改 SSH `domain` 字段 | PASS — 仅写 `extra_attrs.site_url` |
| H3 | 非 root SSH 仍 `sudo_wrap` | PASS — 沿用 detect-path 模式 |
| H4 | 未静默吞 SSH 错误 | PASS — 无 stdout 返回明确错误 |
| H5 | 每日 Redis 去重防重复 batch | PASS — `already_ran_today` |
## Closure
| H | 判定 | 依据 |
|---|------|------|
| H1 | SAFE | `nginx_domain_detect.py` `shlex.quote(hint)` |
| H2 | SAFE | `update_server_site_url` 只 merge extra_attrs |
| H3 | SAFE | `detect_and_save_site_url` 调用 `sudo_wrap` |
| H4 | SAFE | `未找到 nginx 站点域名` |
| H5 | SAFE | `ip_domain_detect_schedule.py` Redis key |
## DoD
- [x] pytest tests/test_site_host.py tests/test_nginx_domain_detect.py tests/test_ip_domain_detect_schedule.py
- [x] changelog 2026-06-24-ip-domain-detect-schedule.md
- [x] 设计文档 specs + plans
## 验证
```bash
.venv/bin/pytest tests/test_site_host.py tests/test_nginx_domain_detect.py tests/test_ip_domain_detect_schedule.py -q
```
@@ -0,0 +1,41 @@
# 审计 — Agent 双模式四槽监测
**Changelog**: `docs/changelog/2026-07-01-agent-watch-dual-mode.md`
## Step 3 规则扫描
| 文件 | 规则 | 结论 |
|------|------|------|
| `agent.py` (子机) | watch 模式每 5s 全量;退出 watch 回 60s | PASS |
| `agent.py` (中心) | `watch_active` 仅查 monitoring pin 计数 | PASS |
| `watch_probe_runner` | 仅 `watch_mode`+新鲜心跳跳过 SSH | PASS |
| 进程列表 | 仍 SSH 偶发拉取 | PASS(已知限制) |
## Closure
| 项 | 状态 |
|----|------|
| 四槽长期 SSH 风控 | 有 Agent 时改走出站 5s |
| 双 Agent 并存 | 不需要,单进程双模式 |
| 旧 Agent 兼容 | SSH 兜底 |
## 文件清单
- `web/agent/agent.py`
- `web/agent/config.example.json`
- `server/api/agent.py`
- `server/background/watch_probe_runner.py`
- `server/utils/watch_metrics.py`
- `tests/test_agent_watch_mode.py`
## DoD
- [x] pytest 19 passedwatch + agent_watch_mode
- [x] 心跳响应含 watch_active
- [x] Agent 2.1.0 watch_mode 字段
## 验证
```bash
.venv/bin/pytest tests/test_agent_watch_mode.py tests/test_watch_metrics.py -q
```
@@ -0,0 +1,39 @@
# 审计 — Agent 2.1.1 CPU 采样
**Changelog**: `docs/changelog/2026-07-02-agent-cpu-sampling.md`
## Step 3 规则扫描
| 文件 | 规则 | 结论 |
|------|------|------|
| `cpu_metrics.py` | watch 首次 1s bootstrap,后续 interval=None 对齐 ~5s 心跳 | PASS |
| `agent.py` | 进入 watch 时 `reset_watch_cpu_bootstrap()` | PASS |
| `server_batch_common` | 升级命令含 cpu_metrics.py | PASS |
| 常态 60s 心跳 | 仍用 `sample_cpu_percent(watch_mode=False)` | PASS |
## Closure
| 项 | 状态 |
|----|------|
| CPU 曲线长期 0% | 监测模式改长窗口采样 |
| 双模心跳 | 不变,仍 5s/60s |
| 旧 Agent 2.1.0 | 可继续用,仅 CPU 显示偏 0 |
## 文件清单
- `web/agent/cpu_metrics.py`
- `web/agent/agent.py`
- `server/application/server_batch_common.py`
- `tests/test_agent_cpu_metrics.py`
## DoD
- [x] pytest cpu_metrics + watch_mode 通过
- [x] AGENT_VERSION 2.1.1
- [x] 升级脚本拉取 cpu_metrics.py
## 验证
```bash
.venv/bin/pytest tests/test_agent_cpu_metrics.py tests/test_agent_watch_mode.py -q
```
@@ -0,0 +1,39 @@
# 审计 — 统计卡「未设路径·离线」
**Changelog**: `docs/changelog/2026-07-02-stats-unset-path-offline-card.md`
## Step 3 规则扫描
| 文件 | 规则 | 结论 |
|------|------|------|
| `servers.py` | `unset_path_offline` 由 fleet/main 离线差值推导 | PASS |
| `useServerStatsCards.ts` | 6 卡顺序与字段映射 | PASS |
| `ServersPage.vue` | 点击展开未设路径 + 离线筛 | PASS |
| `DashboardPage.vue` | 跳转 query 一致 | PASS |
## Closure
| 项 | 状态 |
|----|------|
| 在线+离线≠总数 | 拆出未设路径·离线后可对齐 |
| 主列表离线筛选 | 不变,仍 8 台口径 |
## 文件清单
- `server/api/servers.py`
- `frontend/src/composables/useServerStatsCards.ts`
- `frontend/src/pages/ServersPage.vue`
- `frontend/src/pages/DashboardPage.vue`
- `tests/integration/test_servers_dashboard.py`
## DoD
- [x] API 返回 `unset_path_offline`
- [x] 前后端 6 卡展示
- [x] integration test 断言差值公式
## 验证
```bash
.venv/bin/pytest tests/integration/test_servers_dashboard.py -q
```
@@ -0,0 +1,48 @@
# 部署:本机 rsync 直传(默认不走 Gitea
**日期**2026-06-21
## 摘要
生产部署默认从本机工作区 `rsync` 到服务器,远程 `nexus-1panel.sh upgrade --skip-git` 重建镜像;不再依赖 `git push` + 远程 `git pull`
## 动机
用户要求平时不推 Gitea;部署时直接同步本机已验证代码即可。
## 涉及文件
| 路径 | 说明 |
|------|------|
| `deploy/rsync-local-to-server.sh` | 新建:rsync 到 `/opt/nexus`,排除 `.env` / `docker/.env.prod` / `web/data` |
| `deploy/deploy-production.sh` | 默认 rsync`NEXUS_DEPLOY_VIA_GIT=1` 可恢复旧流程 |
| `deploy/nexus-1panel.sh` | `upgrade --skip-git` 跳过 git fetch/reset |
| `AGENTS.md``.cursorrules` | 部署说明更新 |
## 用法
```bash
bash deploy/pre_deploy_check.sh
bash deploy/deploy-production.sh
```
## 保护项(不同步/不改属主)
- `docker/.env.prod``web/data/``.env`、secrets
## 属主
- **子机跨传**`NEXUS_RSYNC_PUSH_CHOWN`(默认 `www:www`),见 `docs/changelog/2026-06-21-server-transfer-www-permissions.md`
- **中心机部署**:有 `www` 用户则 `www:www`,否则用部署目录现有属主(Azure 为 `azureuser:azureuser`);`NEXUS_DEPLOY_CHOWN` 可覆盖
## 传输方式
本机 `tar | ssh sudo tar -x`(不依赖远程 rsync 命令)。
## 验证
`bash -n deploy/*.sh`;用户批准后执行完整 deploy 并检查 `/health``/app/`
## 回滚
服务器保留 Docker 回滚镜像;或本机 checkout 旧版再 rsync 部署。
@@ -0,0 +1,43 @@
# 打包投递:tar 覆盖解压 + 宝塔签名下载通道
**日期**2026-06-21
## 摘要
1. 解压 tar 时加 `--overwrite`,修复 `404.html: Cannot open: File exists`
2. 源机已配置宝塔 API 时,打包投递改走宝塔官方 **`/download?filename=…` 签名 URL**(B 机直拉 A 机面板),不经 Nexus 中转;效果等同自动化「下载」,**不是** UI 右键分享外链(该接口无公开 API)。
## 动机
- 站点目录已有文件时默认 tar 拒绝覆盖。
- 用户希望用宝塔下载能力;分享 `/down/{token}` 无 token API,签名 `/download` 为可自动化等价方案。
## 涉及文件
| 路径 | 说明 |
|------|------|
| `server/infrastructure/btpanel/file_urls.py` | `signed_download_url` |
| `server/application/services/server_file_transfer_service.py` | `_extract_archive_on_server``_deliver_via_btpanel_download` |
| `tests/test_btpanel_file_urls.py` | 签名 URL 单测 |
| `tests/test_server_file_transfer.py` | 覆盖解压 / 宝塔投递分支 |
## 前提
- 源机 `extra_attrs.bt_panel` 已配置且 API IP 白名单允许 **B 机公网 IP** 访问面板端口。
- 未配置宝塔 API 时仍走 Nexus `transfer-download` 原流程。
## 验证
```bash
pytest tests/test_btpanel_file_urls.py tests/test_server_file_transfer.py -q
```
打包投递至已有文件的站点目录,应覆盖成功。
## 回滚
去掉 `bt_panel` 分支与 `--overwrite`,重启 API。
## 补充(2026-06-21
宝塔面板 HTTPS 多为自签证书;B 机 `curl` 下载签名 URL 时,当源机 `verify_ssl=false`(默认)自动加 `curl -k`,与 Nexus 连面板行为一致。
@@ -0,0 +1,29 @@
# 打包投递:curl 先落 /tmp 再 sudo mv
**日期**2026-06-21
## 摘要
`pull_transfer_package` / deliver 在目标机下载归档时,改为 `curl → /tmp/nexus-xfer-*``mv` 到目标路径(走 `exec_ssh_command_with_fallback` 提权),修复 `curl: (23) Failed writing body`(直写 `/www/...` 无权限)。
## 动机
打包投递时 Nexus 中心返回 200,但 B 机 `curl -o /www/...` 因 SSH 用户无写权限失败。
## 涉及文件
| 路径 | 说明 |
|------|------|
| `server/application/services/server_file_transfer_service.py` | `_download_url_to_remote_path` |
| `server/api/sync_v2.py` | deliver 未捕获异常 → 502 |
| `tests/test_server_file_transfer.py` | tmp+mv 单测 |
## 验证
```bash
pytest tests/test_server_file_transfer.py::test_pull_downloads_via_tmp_then_mv -q
```
## 回滚
恢复 `curl -o dest` 直写并重启 API。
@@ -0,0 +1,31 @@
# 跨服务器直传:目标机写入提权
**日期**2026-06-21
## 摘要
直传(relay)在目标机写入时改用 `remote_write_bytes`SFTP 失败自动 `sudo tee`),与文件上传一致;修复目标目录无写权限时 500 且前端仅显示「操作失败」。
## 动机
生产日志:`asyncssh.sftp.SFTPPermissionDenied``dst_sftp.open(..., "wb")`;宝塔子机常见 www 目录需 sudo 写入。
## 涉及文件
| 路径 | 说明 |
|------|------|
| `server/application/services/server_file_transfer_service.py` | relay 写入/建目录提权 |
| `server/api/sync_v2.py` | relay 未捕获异常 → 502 + detail |
| `frontend/src/composables/useServerFileTransfer.ts` | `formatApiError` 展示后端 detail |
## 验证
```bash
pytest tests/test_server_file_transfer.py -q
```
传输页直传至 B 机 `target_path` 下目录;失败时应显示具体原因而非「操作失败」。
## 回滚
恢复裸 SFTP `dst_sftp.open` 并重启 API。
@@ -0,0 +1,31 @@
# 跨服务器传输:目标机须手动选择
**日期**2026-06-21
## 摘要
移除传输页在选定源服务器后自动选中列表第一台作为目标机(B)的行为;B 机默认留空,用户须在目标下拉框中手动选择。
## 动机
用户反馈 B 服务器不应自动选择,避免误传到错误子机。
## 涉及文件
| 路径 | 说明 |
|------|------|
| `frontend/src/composables/useServerFileTransfer.ts` | `onSourceServerChange``applyRouteQuery` 去掉 `candidates[0]` |
## 行为
- 从文件页跳转(`?source=&path=&paths=`)仅预填 A 机与选中路径,B 机为空。
- 换 A 机时:若当前 B 与 A 相同则清空 B;否则保留用户已选手动 B。
- URL 显式带 `?dest=``?dest_server_id=` 时仍可预填 B(深链场景)。
## 验证
打开 `#/server-transfer?source=…`,确认目标服务器下拉为空,须手动选择后方可传输。
## 回滚
恢复 `candidates[0]` 自动赋值逻辑并重新构建前端。
@@ -0,0 +1,40 @@
# 跨服务器传输:传输后 www 权限
**日期**2026-06-21
## 摘要
跨服务器直传(relay)与打包投递(deliver/pull)完成后,在目标机上自动执行 `chown -R www:www``NEXUS_RSYNC_PUSH_CHMOD` 对应 chmod,与 rsync 推送、文件上传后权限策略一致。
## 动机
用户要求「传输过去的文件都要是 www 权限」;此前 SFTP 中继与 curl 解压只写文件,不调整属主,导致宝塔站点无法读文件。
## 涉及文件
| 路径 | 说明 |
|------|------|
| `server/utils/files_upload_permissions.py` | 新增 `apply_transfer_permissions``parse_upload_dir_mode` |
| `server/application/services/server_file_transfer_service.py` | relay / pull 完成后调用权限修复 |
| `tests/test_files_upload_permissions.py` | 递归 chown/chmod 单测 |
| `tests/test_server_file_transfer.py` | relay mock `_apply_dest_permissions` |
| `deploy/rsync-local-to-server.sh` | 中心机无 `www` 用户时自动用部署目录属主 |
| `deploy/deploy-production.sh` | 同上,前端 tar 解压 chown |
## 配置
- `NEXUS_RSYNC_PUSH_CHOWN`(默认 `www:www`
- `NEXUS_RSYNC_PUSH_CHMOD`(默认 `D755,F755`
- 中心机部署属主:`NEXUS_DEPLOY_CHOWN` 可覆盖;未设置且无 `www` 用户时用 `stat` 检测
## 验证
```bash
pytest tests/test_files_upload_permissions.py tests/test_server_file_transfer.py -q
```
生产传输页直传/投递后,目标路径 `ls -la` 应为 `www:www`
## 回滚
还原上述 Python 文件并重启 API;部署脚本属主检测为向后兼容,可保留。
@@ -0,0 +1,32 @@
# Changelog — 宝塔改端口后一键登录失败修复
**日期**2026-06-23
## 摘要
修复子机宝塔面板改端口后,Nexus「一键登录」返回「宝塔操作失败」的问题。旧 `base_url` 连接失败时现会 SSH 同步新端口并重试 API,仍失败则走 SSH 临时登录。
## 动机
用户改宝塔面板端口后,库内 `extra_attrs.bt_panel.base_url` 仍为旧端口。`httpx.ConnectError` 未被捕获,未触发既有 SSH 回退,接口直接 500。
生产日志:`POST /api/btpanel/servers/404/login-url``httpx.ConnectError: All connection attempts failed`
## 涉及文件
- `server/infrastructure/btpanel/client.py` — 网络错误包装为 `BtPanelApiError`
- `server/application/services/btpanel_service.py``_try_login_url_via_api`:失败时 `_repair_ip_whitelist``port.pl` 更新地址后重试,再 SSH 回退
- `tests/test_btpanel_login_url.py`
- `tests/test_btpanel_client.py`
## 迁移 / 重启
- 无 DB 迁移;部署后端并重启 API 容器。
## 验证
```bash
pytest tests/test_btpanel_login_url.py tests/test_btpanel_client.py -q
```
改端口子机:服务器列表 → 一键登录宝塔 → 应打开新端口临时登录页。
@@ -0,0 +1,43 @@
# 检测路径改为 nginx 网站目录
**日期**2026-06-23
## 变更摘要
批量「检测路径」由搜索 `workerman.bat` 改为读取 nginx 虚拟主机配置中的 `root` 网站目录,并写入 `target_path`
## 动机
站点目录应以 nginx 配置为准;`workerman.bat` 并非通用标志,大量服务器检测失败。
## 检测逻辑
1. 取服务器 `domain`(域名或 IP
2. SSH 远程执行:
- 优先 `/www/server/panel/vhost/nginx/{domain}.conf`(宝塔)
- 再扫描宝塔 vhost 中 `server_name` 匹配项
- 最后尝试 `/etc/nginx/sites-enabled``/etc/nginx/conf.d`
3. 解析首个 `root` 指令,校验为合法绝对路径后写入 DB
## 涉及文件
| 文件 | 变更 |
|------|------|
| `server/utils/nginx_path_detect.py` | 新增:构建远程检测命令 |
| `server/application/server_batch_common.py` | `detect_and_save_target_path` 改用 nginx |
| `frontend/src/pages/ServersPage.vue` | 确认对话框文案 |
| `scripts/batch_detect_target_path.py` | 脚本说明 |
| `tests/test_nginx_path_detect.py` | 单元测试 |
| `docs/project/nexus-functional-development-guide.md` | 功能说明 |
## 迁移 / 重启
无需 DB 迁移;后端热更新或重启 API 即可。前端需 `vite build` 后部署。
## 验证
```bash
.venv/bin/pytest tests/test_nginx_path_detect.py tests/test_server_onboarding.py -q
```
服务器页勾选多台 →「检测路径」→ 确认说明含 nginx `root` → 任务结果 `target_path → /www/wwwroot/...`
@@ -0,0 +1,36 @@
# Changelog — 订阅 IP 自动刷新 Primary 锁与 last_refresh 持久化
**日期**2026-06-23
## 摘要
修复单 worker 生产环境下 Redis 主锁竞态导致 `ip_allowlist_refresh_loop` 永不启动、进程内存订阅 IP 与 DB/实时订阅脱节的问题;将「上次刷新」时间写入 `login_subscription_last_refresh` 并广播。
## 动机
生产排查发现:
- `GET /api/settings/ip-allowlist` 返回的 `subscription_ips` 与 DB、实时拉取订阅结果不一致(内存陈旧)。
- `last_refresh` 长期为空;`nexus:primary_worker` 锁未持有,2 小时后台刷新未运行。
- 容器重启时若 Redis 仍残留旧 PID 锁,单 worker 启动一次抢锁失败后不再重试,后台任务永久禁用。
## 涉及文件
- `server/main.py` — 单 worker 跳过 Redis 选举;多 worker 启动重试抢锁
- `server/background/ip_allowlist_refresh.py` — 持久化 `login_subscription_last_refresh`
- `server/config.py` — 新 settings 键映射
- `server/api/settings.py` — 敏感键列表
- `tests/test_ip_allowlist_sync.py`
## 迁移 / 重启
- 无 DB 迁移(新 key 首次刷新时自动写入)。
- 需重启 API 容器使 primary 修复与刷新循环生效。
## 验证
```bash
pytest tests/test_ip_allowlist_sync.py tests/test_login_access.py -q
```
生产:重启后约 5s 内应完成首次订阅刷新;设置页「上次刷新」有值且节点 IP 与订阅一致。
@@ -0,0 +1,42 @@
# IP-only 服务器自动补全站点域名
**日期**: 2026-06-24
**类型**: feat(backend)
## 背景
大量子机 SSH 地址登记为 IP,服务器列表「地址」列缺少可点击站点链接。需每日自动 SSH 探测 nginx 站点名并写入元数据。
## 改动
- 新增 `server/utils/site_host.py`:判定 IP-only 与解析已有站点 host(对齐前端 `serverSiteUrl.ts`)。
- 新增 `server/utils/nginx_domain_detect.py`:远程扫描 Baota/nginx vhost 提取域名。
- 新增 batch op `detect-domain`SSH 探测并写 `extra_attrs.site_url`(不改 SSH `domain` 字段)。
- 新增定时任务 `ip_domain_detect_loop`:北京时间 **23:30** 每日触发(`IP_DOMAIN_DETECT_CRON=30 23 * * *`)。
- 配置:`IP_DOMAIN_DETECT_ENABLED`(默认 true)。
## 涉及文件
- `server/application/services/ip_domain_detect_schedule.py`
- `server/background/ip_domain_detect_loop.py`
- `server/application/server_batch_common.py`
- `server/application/services/server_batch_service.py`
- `server/config.py`, `server/main.py`
- `docs/design/specs/2026-06-24-ip-domain-detect-schedule-design.md`
- `docs/design/plans/2026-06-24-ip-domain-detect-schedule.md`
## 迁移 / 重启
- 无 DB 迁移;部署后重启 API 容器使 background loop 生效。
## 验证
```bash
pytest tests/test_site_host.py tests/test_nginx_domain_detect.py tests/test_ip_domain_detect_schedule.py -q
```
- 生产:23:30 后审计日志应出现 `batch_detect_domain``#/servers` 中 IP-only 机器地址列下方出现域名链接。
## 回滚
`.env` 设置 `IP_DOMAIN_DETECT_ENABLED=false` 并重启。
@@ -0,0 +1,77 @@
# 2026-06-24 — 未设路径区块与服务器列表对齐
## 变更摘要
调整 `#/servers` 页面「未设置路径服务器」与「服务器列表」的布局顺序与表格列宽,使两表视觉对齐并符合设计文档区块顺序。
## 动机
用户反馈未设路径区块与主列表未对齐:区块顺序与设计(主列表 → 未设路径 → 连接失败)不一致,且两张独立 `v-data-table` 列宽各自计算导致列边界错位。
## 涉及文件
- `frontend/src/pages/ServersPage.vue` — 将 `ServerUnsetPathPanel` 移至主列表卡片之后;提示文案「上方」改「下方」
- `frontend/src/components/servers/ServerUnsetPathPanel.vue` — 标题栏样式与主表一致;统一 `server-data-table` 类;修复 install/upgrade emit 类型
- `frontend/src/constants/serverTableHeaders.ts` — 补全各列 `minWidth`
- `frontend/src/styles/server-data-table.css` — 共享 `table-layout: fixed` 与展开列隐藏
- `frontend/src/main.ts` — 全局引入表格样式
## 迁移 / 重启
无。前端 rebuild 后生效。
## 验证
1. 打开 `#/servers`,确认顺序:分类筛选 → **服务器列表****未设置路径服务器** → 连接失败列表
2. 对比两表列头(状态/名称/地址/分类/目标路径/Agent/心跳/操作)左右边界对齐
3. 分类筛选后,主表为空且存在未设路径机器时,提示「已在下方区块展示」,点击「定位」滚动至未设路径卡片
---
# 2026-06-24 — 未设路径区块工具栏与主列表对齐
## 变更摘要
「未设置路径服务器」卡片标题栏右侧增加与「服务器列表」相同的搜索、添加、凭据、检测路径、列表/分类切换;保留刷新按钮。
## 动机
用户要求未设路径区块具备与主列表一致的操作入口,避免只能在上方主表标题栏操作。
## 涉及文件
- `frontend/src/components/servers/ServerListToolbarActions.vue` — 新建共享工具栏
- `frontend/src/components/servers/ServerUnsetPathPanel.vue``#toolbar` 插槽
- `frontend/src/pages/ServersPage.vue` — 主表与未设路径表共用组件
## 迁移 / 重启
无。前端 rebuild 后生效。
## 验证
1. `#/servers` 未设路径卡片标题栏可见搜索框及添加/快速添加/批量添加/凭据/检测路径/列表·分类切换/刷新
2. 在未设路径区搜索、添加服务器,主表与未设路径表同步刷新
3. 未设路径区「检测路径」按本区勾选数启用/禁用
---
# 2026-06-24 — 未设路径工具栏内嵌修复
## 变更摘要
将工具栏从 slot 改为 `ServerUnsetPathPanel` 内直接渲染;标题栏允许换行,避免 `v-card-title` 裁切右侧按钮。
## 动机
用户反馈未设路径区块仍看不到搜索/快速添加等工具栏(slot + 单行标题溢出)。
## 涉及文件
- `frontend/src/components/servers/ServerListToolbarActions.vue` — 单根容器 + flex-wrap
- `frontend/src/components/servers/ServerUnsetPathPanel.vue` — 内嵌工具栏
- `frontend/src/pages/ServersPage.vue` — v-model 绑定搜索与视图模式
## 验证
`npx vite build` 通过后,刷新 `#/servers` 向下滚动至「未设置路径服务器」,标题行右侧应可见完整工具栏。
@@ -0,0 +1,38 @@
# Agent 双模式监测:四槽 5s 出站推送,跳过 SSH
**日期**2026-07-01
## 变更摘要
单 Agent 双模式落地:
1. **常态**60s 智能心跳(变化 <10% 可 skip**30s keepalive** 拉取 `watch_active`
2. **监测**:四槽 Pin 且开启实时监测 → 中心响应 `watch_active` → Agent **5s 全量上报**(含 `load_avg``watch_mode`
3. **探针**`watch_probe_runner` 在 Agent watch 心跳新鲜时 **跳过 SSH**;进程表仍偶发 SSH 拉取;无 Agent/旧版仍 SSH 兜底
## 动机
四槽 5s SSH 探针对子机形成长期连接与频繁握手,易触发风控;子机已有 nexus-agent 出站通道,应按需切高频而非双 Agent。
## 涉及文件
- `web/agent/agent.py` — v2.1.0 双模式心跳
- `web/agent/config.example.json``heartbeat_keepalive_sec` / `watch_interval_sec`
- `server/api/agent.py` — 心跳响应 `watch_active` / `watch_interval_sec`
- `server/background/watch_probe_runner.py` — Agent 路径优先
- `server/utils/watch_metrics.py``WATCH_AGENT_FRESH_SEC`、usable 判定
- `tests/test_agent_watch_mode.py`
## 迁移
- 中心:部署 API 镜像
- 子机:**仅 Pin 监测中的机器**需升级 Agent 至 2.1.0(面板推送或 batch push
- 未升级 Agent:行为与改前相同(SSH 5s
## 验证
```bash
.venv/bin/pytest tests/test_agent_watch_mode.py tests/test_watch_metrics.py -q
```
Pin 一台已装 Agent 的子机 → 开实时监测 → 探针记录 `source=agent`;移除槽后 Agent 日志恢复 60s。
@@ -0,0 +1,35 @@
# Agent 2.1.1:监测模式 CPU 采样优化
**日期**2026-07-02
## 变更摘要
修复四槽实时监测下 CPU 曲线长期显示 **0%** 的问题:
1. 新增 `web/agent/cpu_metrics.py`:监测模式用 `interval=None` 测约 **5s 窗口**(与心跳间隔对齐);首次进入 watch 用 **1s bootstrap**
2. `agent.py` 升至 **v2.1.1**`_build_system_info` 改用 `sample_watch_cpu_bundle`(含 `per_cpu_pct`
3. 升级/安装脚本同步下载 `cpu_metrics.py`
## 动机
v2.1.0 监测模式用 `psutil.cpu_percent(interval=0.3)`,低负载机器常四舍五入为 0;内存/负载正常,属采样窗口过短而非探针故障。
## 涉及文件
- `web/agent/cpu_metrics.py`(新建)
- `web/agent/agent.py` — v2.1.1
- `server/application/server_batch_common.py` — curl 下载 cpu_metrics
- `tests/test_agent_cpu_metrics.py`(新建)
## 迁移
- 中心:部署后 `/agent/cpu_metrics.py` 可访问
- 子机:**Pin 且开实时监测**的机器点槽内「升级 Agent」至 2.1.1
## 验证
```bash
.venv/bin/pytest tests/test_agent_cpu_metrics.py tests/test_agent_watch_mode.py -q
```
升级后探针 `cpu_pct` 在低负载下应出现非零小幅波动,不再长期贴 0。
@@ -0,0 +1,29 @@
# 统计卡:新增「未设路径·离线」
**日期**2026-07-02
## 变更摘要
仪表盘 / 服务器页顶部统计由 5 卡扩为 6 卡,新增 **未设路径·离线**
- 口径:`unset_path_offline = 全库 Agent 离线 主列表离线`
- 使 **在线 + 离线 + 未设路径·离线 ≈ 服务器总数**(消除原先 390+8≠428 的困惑)
- 点击跳转 `/servers?unset_path=1&status=offline`,定位未设路径区块并筛离线
## 动机
在线卡统计全库 Agent 在线(含未设路径),离线卡仅主列表离线;约 30 台「未设路径且离线」无处展示,用户误以为统计错误。
## 涉及文件
- `server/api/servers.py``unset_path_offline` 字段
- `frontend/src/composables/useServerStatsCards.ts`
- `frontend/src/pages/ServersPage.vue`
- `frontend/src/pages/DashboardPage.vue`
- `tests/integration/test_servers_dashboard.py`
## 验证
```bash
.venv/bin/pytest tests/integration/test_servers_dashboard.py -q
```
@@ -0,0 +1,27 @@
# 告警连续超阈门控(C2
**日期**2026-07-04
## 变更
CPU/内存/磁盘告警需 **连续 N 次**(默认 **3**)心跳超阈才触发 `broadcast_alert`;单次尖峰不再入库/Telegram。
| 项 | 说明 |
|----|------|
| 配置 | `alert_streak_required`(设置页「连续超阈次数」,默认 3) |
| 常态心跳 60s | 约 **3 分钟**持续超阈才告警 |
| 监测槽 5s | 约 **15 秒**持续超阈才告警 |
| 设为 1 | 恢复旧行为(单次超阈即告警) |
| Redis | `alert_streak:{server_id}:{metric}`,回落正常后清零 |
## 文件
- `server/utils/alert_metrics.py` — 阈值检测共用
- `server/utils/alert_streak.py` — 连续计数 + 告警/recovery 门控
- `server/api/agent.py` — 心跳接入
- `server/utils/watch_alerts.py` — 监测探针接入
- `tests/test_alert_streak.py`
## 部署
中心机 `nx update``deploy-production.sh`**无需**子机 Agent 升级。
@@ -0,0 +1,30 @@
# 检测路径改为搜索 crmeb 目录
**日期**2026-07-05
## 变更摘要
批量「检测路径」由在 `/www/wwwroot` 下搜索 `workerman.bat` 改为搜索名为 `crmeb` 的目录,并将其完整路径写入 `target_path`
## 检测逻辑
```bash
find /www/wwwroot -iname 'crmeb' -type d -print -quit 2>/dev/null
```
示例:`/www/wwwroot/shop.example.com/crmeb``target_path` = `/www/wwwroot/shop.example.com/crmeb`
## 涉及文件
| 文件 | 变更 |
|------|------|
| `server/application/server_batch_common.py` | `detect_and_save_target_path` |
| `frontend/src/pages/ServersPage.vue` | 确认对话框文案 |
| `scripts/batch_detect_target_path.py` | 脚本说明 |
| `tests/test_nginx_path_detect.py` | 单元测试 |
## 验证
```bash
.venv/bin/pytest tests/test_nginx_path_detect.py -q
```
@@ -0,0 +1,33 @@
# 宝塔 bootstrap:真安装检测 + config 目录自愈
**日期**2026-07-08
## 摘要
SSH bootstrap 写 `api.json` 前校验 `class/common.py` 存在(空 `panel` 目录不再误判为已装宝塔);写盘前 `makedirs` config 目录,避免 `FileNotFoundError` 被归类为模糊的 `ssh_command_failed`
## 动机
新机 **山西鑫途汇**`42.193.230.144`)入库时仅有空 `/www/server/panel/`,一键登录反复 `ssh_command_failed`;同公司 M 机宝塔正常。根因是远程脚本只检查 `panel` 目录存在,写 `config/api.json` 时父目录缺失崩溃。
## 涉及文件
- `server/infrastructure/btpanel/ssh_bootstrap.py``common.py` 检测、`os.makedirs` config
- `tests/test_btpanel_ssh_bootstrap.py` — 远程脚本断言
## 迁移 / 重启
仅后端;`rsync-local-to-server.sh` + Docker `upgrade --skip-git`
## 验证
```bash
.venv/bin/pytest tests/test_btpanel_ssh_bootstrap.py -q
```
生产:对 `bt_installed=false` 的机器一键登录应返回 `bt_not_installed`installing 状态),不再 `ssh_command_failed`
## 2026-07-08 热修(回归)
- **根因**2026-06-21「一键登录自动 bootstrap」在 `bootstrap` 失败时直接 `raise`,不再走 SSH `tools.py` 临时登录;新机 API 未就绪时整链中断。
- **修复**`create_login_url` 对非凭据类 bootstrap 错误(如 `ssh_command_failed`)记录 warning 后 **继续 SSH 回退**;仅 `ssh_auth_failed` / `ssh_no_credentials` 等硬失败仍中断。
@@ -0,0 +1,37 @@
# IP 域名自动探测 — 实施说明
**日期**: 2026-06-24
## 涉及文件
| 文件 | 动作 |
|------|------|
| `server/utils/site_host.py` | 新增:IP 判定与站点 host 解析 |
| `server/utils/nginx_domain_detect.py` | 新增:远程 nginx 域名探测命令 |
| `server/application/server_batch_common.py` | 扩展:探测并写 site_url |
| `server/application/services/ip_domain_detect_schedule.py` | 新增 |
| `server/background/ip_domain_detect_loop.py` | 新增 |
| `server/application/services/server_batch_service.py` | 新增 op `detect-domain` |
| `server/config.py` | 新增配置项 |
| `server/main.py` | 注册 loop |
| `tests/test_*.py` | 单元测试 |
| `docs/changelog/2026-06-24-ip-domain-detect-schedule.md` | changelog |
## 实现步骤
1. `site_host.py` 镜像前端 `serverSiteUrl.ts` 逻辑。
2. `build_nginx_domain_detect_command` 扫描 Baota vhost 文件名 + server_name。
3. `detect_and_save_site_url` SSH 执行并更新 `extra_attrs`
4. schedule 列出候选 → `start_batch_job(op="detect-domain")`
5. main 注册 loopconfig 默认 23:30。
## 测试要点
- cron 23:30 北京时区匹配
- `list_ip_only_without_site_server_ids` 过滤逻辑
- nginx 命令含 quote、跳过 IP basename
- schedule 幂等(already_ran_today
## 回滚
- `.env``IP_DOMAIN_DETECT_ENABLED=false` 并重启 API;或删除新增 loop 注册。
@@ -0,0 +1,44 @@
# IP 服务器自动补全站点域名 — 设计
**日期**: 2026-06-24
## 背景与目标
部分子机 SSH 地址(`servers.domain`)登记为 IP,列表「地址」列无法展示可点击站点链接。运维曾用手工脚本写 `extra_attrs.site_url`;需每日自动补全。
**目标**:北京时间每天 23:30,对「domain 为 IP 且尚无站点域名」的服务器 SSH 探测 nginx 站点名,写入 `extra_attrs.site_url`(不改 SSH 连接字段)。
## 方案对比
| 方案 | 优点 | 缺点 |
|------|------|------|
| A. SSH 读 Baota/nginx vhost | 与 detect-path 一致,无面板依赖 | 需 SSH 凭据 |
| B. 宝塔 API list_sites | 结构化 | 需先 bootstrap 面板凭据,覆盖不全 |
| C. 仅读 target_path 推断 | 无 SSH | 大量机器 target_path 仍为默认 |
**选定 A**:复用现有 SSH 池与 batch job 框架;target_path 仅作优选 hint。
## 数据模型
- **输入**`Server.domain` 为 IPv4/IPv6`resolve_server_site_host(server)` 为空。
- **输出**`extra_attrs.site_url = "<hostname>"`(裸域名,与现有运维脚本一致)。
- **不修改**`domain`SSH 仍走 IP)。
## 接口与任务
- 新 batch op`detect-domain`
- 新后台 loop`ip_domain_detect_loop`60s 轮询 cron
- 配置:`IP_DOMAIN_DETECT_ENABLED`(默认 true)、`IP_DOMAIN_DETECT_CRON`(默认 `30 23 * * *`
## 安全与性能
- 远程命令经 `shlex.quote`;只读 nginx 配置。
- 并发沿用 batch `CONCURRENCY=5`;每日 Redis 去重一次。
- 仅 primary worker 执行。
## 验收标准
1. cron `30 23 * * *` 在北京时间 23:30 触发一次 batch。
2. IP-only 且无 site_url 的服务器被纳入;已有 site_url 的跳过。
3. 成功探测后列表 `#/servers` 地址列 IP 下出现可点击域名。
4. pytest 覆盖 cron、候选筛选、命令构建。
@@ -266,11 +266,11 @@ Header: `X-API-Key` 或 queryAgent 脚本约定)。
| `SshKeyPreset` | `ssh_key_presets` | SSH 密钥模板 | `encrypted_private_key` |
| `PushSchedule` | `push_schedules` | 定时任务 | `cron_expr`, `server_ids`, `schedule_type` push/script |
| `PushRetryJob` | `push_retry_jobs` | 失败重试 | 指数退避, `max_retries=3` |
| `AuditLog` | `audit_logs` | 审计 | `action`, `target_*`, `ip_address` |
| `AuditLog` | `audit_logs` | 审计**7 天**自动清理) | `action`, `target_*`, `ip_address` |
| `Script` | `scripts` | 脚本库 | `content`, `category` |
| `ScriptExecution` | `script_executions` | 执行记录 | `results` JSON, `status` |
| `DbCredential` | `db_credentials` | DB 凭据 | 脚本内 `$DB_*` 替换 |
| `AlertLog` | `alert_logs` | 告警历史 | `is_recovery` |
| `AlertLog` | `alert_logs` | 告警历史**7 天**自动清理) | `is_recovery` |
| `SshSession` | `ssh_sessions` | WebSSH 会话 | UUID `id` |
| `CommandLog` | `command_logs` | 终端命令 | `command` ≤2000 字符 |
| `QuickCommand` | `quick_commands` | 终端快捷命令 | `sort_order` |
@@ -314,6 +314,8 @@ Agent(60s) → POST /api/agent/heartbeat
| `retry_runner` | 300s | `background/retry_runner.py` | `push_retry_jobs` SKIP LOCKED 重试 |
| `ip_allowlist_refresh` | 7200s | `background/ip_allowlist_refresh.py` | 订阅 URL 更新白名单 |
| `upload_staging_cleanup` | 3600s | `background/upload_staging_cleanup.py` | 清理 `/tmp/nexus_upload_*` >24h |
| `alert_log_purge` | 24h | `background/alert_log_purge.py` | 清理 `alert_logs` >7d |
| `audit_log_purge` | 24h | `background/audit_log_purge.py` | 清理 `audit_logs` >7d |
---
@@ -340,7 +342,7 @@ Agent(60s) → POST /api/agent/heartbeat
### 9.3 服务器 `/api/servers`
CRUD、stats、categories、logs、CSV import/export、batch agent install/upgrade/uninstall、**batch detect-path**SSH 搜索 `workerman.bat` 自动写入 `target_path`)、health check、files-capability、setup-files-sudo、单台 agent 操作。
CRUD、stats、categories、logs、CSV import/export、batch agent install/upgrade/uninstall、**batch detect-path**SSH `/www/wwwroot` 下搜索 `crmeb` 目录写入 `target_path`)、health check、files-capability、setup-files-sudo、单台 agent 操作。
### 9.4 同步 `/api/sync``sync_v2.py`,核心)
@@ -537,7 +539,7 @@ main.ts → App.vue(侧栏/搜索)→ router-view
| **连接失败列表** | `GET /servers/pending` |
| **失败重试 / 删除** | `POST /servers/pending/{id}/retry``DELETE /servers/pending/{id}` |
| 批量选择 | 安装/升级/卸载 Agent、**检测目标路径**、健康检查、删除 |
| **检测目标路径** | `POST /servers/batch/detect-path` — Body: `{ server_ids }`SSH 在 `/www/wwwroot` `workerman.bat`,将所在目录写入 `target_path`;结果弹窗逐台展示 |
| **检测目标路径** | `POST /servers/batch/detect-path` — Body: `{ server_ids }`SSH 在 `/www/wwwroot`搜索 `crmeb` 目录写入 `target_path`;结果弹窗逐台展示 |
| **列表快捷编辑** | 目标路径列 ✏️ → `PUT /servers/{id}` `{ target_path }` |
| CSV 导出 | 客户端生成(无密码列) |
| CSV 导入 | `POST /servers/import` FormData |
@@ -0,0 +1,15 @@
{
"schema_version": 1,
"head_sha": "d976cd03a31caab2472111c5dbfa9384f83d93de",
"base_ref": "HEAD~1..HEAD",
"branch": "main",
"reviewed_at": "2026-07-03T15:20:00+08:00",
"engine": "manual",
"verdict": "Ready to merge",
"summary": "Terminal toolbar adds Baota login button reusing existing useBtPanelLogin; no backend or security surface change.",
"files_reviewed": [
"frontend/src/components/terminal/TerminalToolbar.vue",
"frontend/src/pages/TerminalPage.vue"
],
"findings": []
}
@@ -0,0 +1,56 @@
<template>
<div>
<v-chip
v-if="searchActive"
size="small"
variant="tonal"
color="info"
label
class="mb-2"
>
全库搜索中含未设路径与连接失败已忽略分类/在线筛选
</v-chip>
<div class="d-flex align-center flex-wrap ga-2">
<span class="text-caption text-medium-emphasis mr-1">分类筛选</span>
<v-chip
:color="categoryFilter === '' ? 'primary' : undefined"
:variant="categoryFilter === '' ? 'flat' : 'outlined'"
size="small"
label
@click="$emit('select', '')"
>
全部 {{ totalCount }}
</v-chip>
<v-chip
v-for="cat in categories"
:key="cat.name"
:color="categoryFilter === cat.name ? 'primary' : undefined"
:variant="categoryFilter === cat.name ? 'flat' : 'outlined'"
size="small"
label
@click="$emit('select', cat.name)"
>
{{ cat.label }} {{ cat.count }}
</v-chip>
<v-progress-circular v-if="categoriesLoading" indeterminate size="16" width="2" />
<v-spacer />
<slot name="trailing" />
</div>
</div>
</template>
<script setup lang="ts">
import type { ServerCategoryOption } from '@/composables/useServerCategories'
defineProps<{
categories: ServerCategoryOption[]
categoryFilter: string
totalCount: number
categoriesLoading: boolean
searchActive: boolean
}>()
defineEmits<{
select: [name: string]
}>()
</script>
@@ -70,7 +70,7 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { categoryDisplayLabel } from '@/composables/useServerCategories'
import { categoryDisplayLabel, groupServersByCategory } from '@/composables/useServerCategories'
import { serverOnlineColor } from '@/composables/push/labels'
import type { ServerBrief } from '@/composables/useServerList'
@@ -100,25 +100,12 @@ const filteredServers = computed(() => {
)
})
const serversByCategory = computed(() => {
const groups: Record<string, ServerBrief[]> = {}
for (const s of filteredServers.value) {
const cat = s.category?.trim() || 'uncategorized'
if (!groups[cat]) groups[cat] = []
groups[cat].push(s)
}
return Object.entries(groups)
.map(([category, list]) => ({
category,
label: categoryDisplayLabel(category),
servers: [...list].sort((a, b) => a.name.localeCompare(b.name, 'zh')),
}))
.sort((a, b) => {
if (a.category === 'uncategorized') return 1
if (b.category === 'uncategorized') return -1
return a.label.localeCompare(b.label, 'zh')
})
})
const serversByCategory = computed(() =>
groupServersByCategory(filteredServers.value).map(g => ({
...g,
servers: [...g.servers].sort((a, b) => a.name.localeCompare(b.name, 'zh')),
})),
)
const selectAll = computed(() => {
const visible = filteredServers.value
@@ -144,6 +144,17 @@
clearable
hint="可从已有分类选择或输入新分类"
persistent-hint
class="mb-2"
/>
<v-textarea
v-model="form.description"
label="备注"
variant="outlined"
density="compact"
rows="2"
auto-grow
hint="业务别名、tonex 对照名等;列表可快速编辑"
persistent-hint
/>
<v-expansion-panels variant="accordion" class="mt-4">
@@ -71,6 +71,21 @@
@retry="() => { const id = ++requestId; void loadMetrics(id) }"
/>
<div class="d-flex align-center ga-2 mb-2">
<span class="text-caption text-medium-emphasis shrink-0">备注</span>
<span class="text-body-2 text-truncate" :title="displayServer.description || '无备注'">
{{ displayServer.description?.trim() || '—' }}
</span>
<v-btn
icon="mdi-pencil"
size="x-small"
variant="text"
density="compact"
title="编辑备注"
@click.stop="$emit('edit-remark')"
/>
</div>
<div class="d-flex align-center ga-2 mb-2">
<span class="text-caption text-medium-emphasis shrink-0">目标路径</span>
<span class="text-body-2 text-truncate">
@@ -131,6 +146,7 @@ const props = defineProps<{
defineEmits<{
'edit-path': []
'edit-remark': []
'install-agent': []
'upgrade-agent': []
'diagnose-agent': []
@@ -0,0 +1,115 @@
<template>
<div class="server-list-toolbar d-flex align-center flex-wrap ga-2 flex-grow-1">
<v-combobox
:model-value="searchDraft"
:items="searchHistory"
prepend-inner-icon="mdi-magnify"
label="搜索名称/IP(含未设路径)"
density="compact"
hide-details
rounded
clearable
:auto-select-first="false"
class="server-list-toolbar__search"
variant="outlined"
placeholder="输入后回车,或从历史选择"
@update:model-value="onSearchUpdate"
@keydown.enter.prevent="emit('commit-search')"
/>
<v-btn color="primary" variant="flat" prepend-icon="mdi-plus" size="small" @click="emit('add')">
添加
</v-btn>
<v-btn variant="tonal" prepend-icon="mdi-flash" size="small" @click="emit('quick-add')">
快速添加(IP)
</v-btn>
<v-btn variant="tonal" prepend-icon="mdi-ip-network" size="small" @click="emit('batch-add')">
批量添加
</v-btn>
<v-btn variant="tonal" prepend-icon="mdi-key-outline" size="small" @click="emit('credentials')">
凭据
</v-btn>
<v-btn
variant="tonal"
prepend-icon="mdi-folder-search"
size="small"
:disabled="detectPathDisabled"
title="请先勾选至少一台服务器"
@click="emit('detect-path')"
>
检测路径
</v-btn>
<v-btn
v-if="showRefresh"
variant="text"
size="small"
prepend-icon="mdi-refresh"
:loading="refreshLoading"
@click="emit('refresh')"
>
刷新
</v-btn>
<v-btn-toggle
v-if="showViewToggle"
:model-value="viewMode"
mandatory
density="compact"
color="primary"
variant="outlined"
divided
@update:model-value="emit('update:viewMode', $event)"
>
<v-btn value="table" size="small" prepend-icon="mdi-table">列表</v-btn>
<v-btn value="group" size="small" prepend-icon="mdi-folder-multiple">分类</v-btn>
</v-btn-toggle>
</div>
</template>
<script setup lang="ts">
withDefaults(
defineProps<{
searchDraft: string
searchHistory: readonly string[]
detectPathDisabled: boolean
showViewToggle?: boolean
viewMode?: 'table' | 'group'
showRefresh?: boolean
refreshLoading?: boolean
}>(),
{
showViewToggle: false,
showRefresh: false,
refreshLoading: false,
},
)
const emit = defineEmits<{
'update:searchDraft': [value: string]
'update:viewMode': [mode: 'table' | 'group']
'commit-search': []
'search-combobox-update': [value: string | null]
add: []
'quick-add': []
'batch-add': []
credentials: []
'detect-path': []
refresh: []
}>()
function onSearchUpdate(val: string | null) {
emit('update:searchDraft', val ?? '')
emit('search-combobox-update', val)
}
</script>
<style scoped>
.server-list-toolbar {
min-width: 0;
justify-content: flex-end;
}
.server-list-toolbar__search {
flex: 1 1 200px;
max-width: 240px;
min-width: 160px;
}
</style>
@@ -0,0 +1,40 @@
<template>
<div class="server-name-cell d-flex align-start ga-1" @click.stop="emit('toggle')">
<v-icon size="small" class="mt-1 shrink-0">
{{ expanded ? 'mdi-chevron-down' : 'mdi-chevron-right' }}
</v-icon>
<div class="server-name-cell__body min-width-0">
<div class="font-weight-medium text-primary text-truncate" :title="name">{{ name }}</div>
<div class="d-flex align-start ga-1 mt-1">
<span
v-if="remark"
class="server-name-cell__remark text-caption text-medium-emphasis"
:title="remark"
>{{ remark }}</span>
<span v-else class="text-caption text-disabled">无备注</span>
<v-btn
icon="mdi-pencil-outline"
size="x-small"
variant="text"
density="compact"
class="shrink-0 mt-n1"
title="编辑备注"
@click.stop="emit('edit-remark')"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
defineProps<{
name: string
remark?: string | null
expanded?: boolean
}>()
const emit = defineEmits<{
toggle: []
'edit-remark': []
}>()
</script>
@@ -0,0 +1,43 @@
<template>
<v-dialog :model-value="modelValue" max-width="560" persistent @update:model-value="emit('update:modelValue', $event)">
<v-card border>
<v-card-title class="text-h6">编辑备注</v-card-title>
<v-card-subtitle v-if="serverName" class="text-wrap pb-2">{{ serverName }}</v-card-subtitle>
<v-divider />
<v-card-text class="pt-4">
<v-textarea
:model-value="modelValueText"
label="备注"
variant="outlined"
density="comfortable"
rows="4"
auto-grow
hide-details
placeholder="tonex 对照名、业务别名等"
@update:model-value="emit('update:modelValueText', $event)"
/>
</v-card-text>
<v-divider />
<v-card-actions class="pa-4">
<v-spacer />
<v-btn variant="text" :disabled="saving" @click="emit('update:modelValue', false)">取消</v-btn>
<v-btn color="primary" variant="flat" :loading="saving" @click="emit('save')">保存</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
defineProps<{
modelValue: boolean
modelValueText: string
serverName?: string
saving?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
'update:modelValueText': [value: string]
save: []
}>()
</script>
@@ -21,6 +21,7 @@
prepend-icon="mdi-login-variant"
title="一键登录宝塔面板"
:loading="btLoginLoading"
:disabled="btLoginLoading"
@click.stop="$emit('bt-login')"
>
一键登录
@@ -7,32 +7,28 @@
:class="{ 'unset-path-panel--highlight': highlight }"
border
>
<v-card-title class="d-flex align-center flex-wrap ga-2">
未设置路径服务器
<v-card-title class="server-panel-card-title d-flex align-center flex-wrap ga-2">
<span class="text-h6 font-weight-medium text-no-wrap">未设置路径服务器</span>
<v-chip v-if="total > 0" size="small" color="warning" variant="tonal" label>
{{ total }}
</v-chip>
<v-spacer />
<v-btn
variant="tonal"
prepend-icon="mdi-folder-search"
size="small"
:disabled="selectedCount === 0"
title="请先勾选至少一台服务器"
@click="$emit('detect-path')"
>
检测路径
</v-btn>
<v-btn
variant="text"
size="small"
prepend-icon="mdi-refresh"
class="ml-2"
:loading="loading"
@click="$emit('refresh')"
>
刷新
</v-btn>
<ServerListToolbarActions
v-model:search-draft="searchDraftModel"
v-model:view-mode="viewModeModel"
:search-history="searchHistory"
:detect-path-disabled="selectedCount === 0"
show-view-toggle
show-refresh
:refresh-loading="loading"
@commit-search="emit('commit-search')"
@search-combobox-update="emit('search-combobox-update', $event)"
@add="emit('add-server')"
@quick-add="emit('quick-add')"
@batch-add="emit('batch-add')"
@credentials="emit('open-credentials')"
@detect-path="emit('detect-path')"
@refresh="emit('refresh')"
/>
</v-card-title>
<v-card-text class="pt-0 pb-2">
@@ -70,7 +66,7 @@
/>
<v-data-table-server
class="server-unset-path-table"
class="server-data-table"
:items="servers"
:headers="headers"
:items-length="total"
@@ -100,19 +96,30 @@
</template>
<template #item.name="{ item }">
<div
class="d-flex align-center cursor-pointer text-primary"
@click.stop="$emit('toggle-expand', item)"
>
<v-icon size="small" class="mr-1">
{{ isExpandedId(item.id) ? 'mdi-chevron-down' : 'mdi-chevron-right' }}
</v-icon>
<span class="font-weight-medium">{{ item.name }}</span>
</div>
<ServerNameWithRemark
:name="item.name"
:remark="item.description"
:expanded="isExpandedId(item.id)"
@toggle="$emit('toggle-expand', item)"
@edit-remark="$emit('edit-remark', item)"
/>
</template>
<template #item.domain="{ item }">
<span class="text-medium-emphasis">{{ item.domain }}:{{ item.port }}</span>
<div class="d-flex flex-column align-start py-1 ga-0">
<span class="text-medium-emphasis">{{ item.domain }}:{{ item.port }}</span>
<a
v-if="resolveServerSiteUrl(item)"
:href="resolveServerSiteUrl(item)!"
target="_blank"
rel="noopener noreferrer"
class="text-caption text-primary site-link"
:title="`打开 ${resolveServerSiteHost(item)}`"
@click.stop
>
{{ resolveServerSiteHost(item) }}
</a>
</div>
</template>
<template #item.category="{ item }">
@@ -123,14 +130,15 @@
</template>
<template #item.target_path="{ item }">
<div v-if="editingPathId === item.id" class="d-flex align-center ga-1" @click.stop>
<div v-if="editingPathId === item.id" class="d-flex align-center ga-1 server-path-cell" @click.stop>
<v-text-field
:model-value="editingPathValue"
density="compact"
variant="outlined"
hide-details
placeholder="/www/wwwroot"
style="max-width: 140px"
class="flex-grow-1"
style="min-width: 140px"
@update:model-value="$emit('update:editing-path-value', $event)"
@keyup.enter="$emit('save-path', item.id)"
@keyup.esc="$emit('cancel-edit-path')"
@@ -145,10 +153,9 @@
/>
<v-btn icon="mdi-close" size="x-small" variant="text" @click="$emit('cancel-edit-path')" />
</div>
<div v-else class="d-flex align-center ga-1">
<div v-else class="d-flex align-center ga-1 server-path-cell">
<span
class="text-caption text-medium-emphasis text-truncate d-inline-block"
style="max-width: 140px"
class="text-caption text-medium-emphasis server-path-cell__text text-truncate d-inline-block flex-grow-1"
:title="item.target_path || '未配置'"
>
{{ item.target_path?.trim() || '—' }}
@@ -165,13 +172,39 @@
</template>
<template #item.agent_version="{ item }">
<span class="text-medium-emphasis">{{ item.agent_version || '—' }}</span>
<div>
<span class="text-medium-emphasis">{{ item.agent_version || '—' }}</span>
<v-chip
v-if="item.agent_action === 'install'"
size="x-small"
color="info"
variant="tonal"
label
class="mt-1"
>
需安装
</v-chip>
<v-chip
v-else-if="item.agent_action === 'upgrade'"
size="x-small"
color="warning"
variant="tonal"
label
class="mt-1"
>
需升级
</v-chip>
</div>
</template>
<template #item.last_heartbeat="{ item }">
<span class="text-caption text-medium-emphasis">{{ formatRelativeTime(item.last_heartbeat) }}</span>
</template>
<template #item.created_at="{ item }">
<span class="text-caption text-medium-emphasis">{{ formatDateTimeBeijing(item.created_at) }}</span>
</template>
<template #item.actions="{ item }">
<ServerTableRowActions
:bt-login-loading="btLoginLoadingId === item.id"
@@ -188,7 +221,12 @@
<ServerInlineDetail
:server="item"
:active="isExpandedId(item.id)"
:agent-action-loading="agentActionLoadingId === item.id"
@edit-path="$emit('edit-path', item)"
@edit-remark="$emit('edit-remark', item)"
@install-agent="$emit('install-agent', item)"
@upgrade-agent="$emit('upgrade-agent', item)"
@diagnose-agent="$emit('diagnose-agent', item)"
/>
</td>
</tr>
@@ -206,11 +244,15 @@ import { computed } from 'vue'
import type { ServerApiItem } from '@/types/api'
import type { TableSortItem } from '@/utils/serverTableSort'
import { statusChipColor, statusLabel, formatRelativeTime } from '@/utils/status'
import { formatDateTimeBeijing } from '@/utils/datetime'
import { normalizeServerIds } from '@/utils/serverSelection'
import ServerInlineDetail from '@/components/servers/ServerInlineDetail.vue'
import ServerBatchActionBar from '@/components/servers/ServerBatchActionBar.vue'
import ServerListToolbarActions from '@/components/servers/ServerListToolbarActions.vue'
import ServerTableRowActions from '@/components/servers/ServerTableRowActions.vue'
import ServerNameWithRemark from '@/components/servers/ServerNameWithRemark.vue'
import { SERVER_DATA_TABLE_HEADERS } from '@/constants/serverTableHeaders'
import { resolveServerSiteHost, resolveServerSiteUrl } from '@/utils/serverSiteUrl'
const props = defineProps<{
highlight?: boolean
@@ -232,10 +274,21 @@ const props = defineProps<{
btUnconfiguredCount: number
btSelectedUnconfiguredCount: number
btBatchLoading: boolean
agentActionLoadingId: number | null
searchHistory: readonly string[]
}>()
const searchDraftModel = defineModel<string>('searchDraft', { required: true })
const viewModeModel = defineModel<'table' | 'group'>('viewMode', { required: true })
const emit = defineEmits<{
refresh: []
'commit-search': []
'search-combobox-update': [value: string | null]
'add-server': []
'quick-add': []
'batch-add': []
'open-credentials': []
'update:sort-by': [sortBy: TableSortItem[]]
'update:page': [page: number]
'update:items-per-page': [n: number]
@@ -249,6 +302,7 @@ const emit = defineEmits<{
files: [item: ServerApiItem]
edit: [item: ServerApiItem]
'edit-path': [item: ServerApiItem]
'edit-remark': [item: ServerApiItem]
'save-path': [serverId: number]
'cancel-edit-path': []
'batch-category': []
@@ -257,14 +311,26 @@ const emit = defineEmits<{
'agent-diagnose': []
'bt-bootstrap-all': []
'bt-bootstrap-selected': []
'install-agent': []
'upgrade-agent': []
'install-agent': [item?: ServerApiItem]
'upgrade-agent': [item?: ServerApiItem]
'diagnose-agent': [item: ServerApiItem]
'uninstall-agent': []
'batch-delete': []
'clear-selection': []
}>()
const headers = SERVER_DATA_TABLE_HEADERS
const headers = computed(() => {
const base = [...SERVER_DATA_TABLE_HEADERS]
const actionsIdx = base.findIndex((h) => h.key === 'actions')
const insertAt = actionsIdx >= 0 ? actionsIdx : base.length
base.splice(insertAt, 0, {
title: '添加时间',
key: 'created_at',
width: 148,
minWidth: 148,
})
return base
})
const selectedCount = computed(() => normalizeServerIds(props.selectedItems).length)
@@ -284,22 +350,14 @@ function isExpandedId(id: number): boolean {
</script>
<style scoped>
.server-unset-path-table :deep(.v-data-table__td--expand-column),
.server-unset-path-table :deep(.v-data-table-column--expand),
.server-unset-path-table :deep(th.v-data-table__th--expand) {
display: none !important;
width: 0 !important;
padding: 0 !important;
}
.server-unset-path-table :deep(tr.v-data-table__expanded__row > td) {
background: transparent !important;
border-bottom: none;
}
.unset-path-panel--highlight {
border-color: rgb(var(--v-theme-warning)) !important;
box-shadow: 0 0 0 2px rgba(var(--v-theme-warning), 0.35) !important;
scroll-margin-top: 80px;
}
.server-panel-card-title {
white-space: normal;
overflow: visible;
}
</style>
@@ -8,12 +8,15 @@ import {
type WatchTtlMinutes,
} from '@/constants/watchTtl'
import { formatRemaining, probeStatusColor, probeStatusLabel, formatProbeError, isPsutilMissingError, isWatchMetricsPending, formatLoadAvg, formatCpuSpec, formatMemSpec, formatMemSub, formatDiskSpec, formatDiskSub, formatWatchSystemBanner, formatUsagePair, loadUsagePct, loadStatusLabel } from '@/utils/watchFormat'
import ServerAgentHint from '@/components/servers/ServerAgentHint.vue'
import type { ServerAgentAction } from '@/types/api'
import WatchSparkline from '@/components/watch/WatchSparkline.vue'
import WatchMetricRing from '@/components/watch/WatchMetricRing.vue'
const props = defineProps<{
slot: WatchSlot
psutilInstallLoading?: boolean
agentActionLoading?: boolean
}>()
const emit = defineEmits<{
@@ -24,6 +27,8 @@ const emit = defineEmits<{
monitoring: [slotIndex: number, enabled: boolean]
ttl: [slotIndex: number, minutes: WatchTtlMinutes]
installPsutil: [slotIndex: number]
installAgent: [slotIndex: number]
upgradeAgent: [slotIndex: number]
}>()
const monitoringOn = computed(() => props.slot.monitoring_enabled !== false)
@@ -33,6 +38,10 @@ const slotTtlMinutes = computed(() =>
const showPsutilInstall = computed(() =>
monitoringOn.value && isPsutilMissingError(props.slot.metrics?.error, props.slot.metrics?.probe_status),
)
const slotAgentAction = computed(() => props.slot.agent_action as ServerAgentAction | undefined)
const showAgentHint = computed(() =>
monitoringOn.value && slotAgentAction.value && slotAgentAction.value !== 'ok' && !!props.slot.agent_action_message,
)
const chartMetric = ref<'cpu_pct' | 'mem_pct' | 'disk_pct'>('cpu_pct')
@@ -98,6 +107,19 @@ const showSwap = computed(() => (props.slot.metrics?.swap_total_gb ?? 0) > 0)
/>
</template>
<v-list density="compact" class="watch-slot-menu-list" min-width="168">
<v-list-item
v-if="monitoringOn && slotAgentAction === 'upgrade'"
prepend-icon="mdi-arrow-up-bold"
title="升级 Agent"
@click="emit('upgradeAgent', slot.slot_index)"
/>
<v-list-item
v-if="monitoringOn && slotAgentAction === 'install'"
prepend-icon="mdi-download-network"
title="安装 Agent"
@click="emit('installAgent', slot.slot_index)"
/>
<v-divider v-if="monitoringOn && (slotAgentAction === 'upgrade' || slotAgentAction === 'install')" class="my-1" />
<v-list-item
v-if="monitoringOn"
prepend-icon="mdi-pause-circle-outline"
@@ -140,6 +162,17 @@ const showSwap = computed(() => (props.slot.metrics?.swap_total_gb ?? 0) > 0)
</div>
</div>
<ServerAgentHint
v-if="showAgentHint"
class="mb-2"
compact
:action="slotAgentAction"
:message="slot.agent_action_message"
:loading="props.agentActionLoading"
@install="emit('installAgent', slot.slot_index)"
@upgrade="emit('upgradeAgent', slot.slot_index)"
/>
<div
v-if="!monitoringOn"
class="watch-slot-paused-body flex-grow-1"
@@ -20,6 +20,7 @@ const showPickDialog = ref(false)
const pickSlotIndex = ref<number | null>(null)
const pickLoading = ref(false)
const psutilLoadingSlot = ref<number | null>(null)
const agentActionLoadingSlot = ref<number | null>(null)
onMounted(() => {
refreshPins()
@@ -92,6 +93,39 @@ async function onInstallPsutil(slotIndex: number) {
}
}
async function onInstallAgent(slotIndex: number) {
const slot = slots.value.find((s) => s.slot_index === slotIndex)
if (!slot?.server_id) return
agentActionLoadingSlot.value = slotIndex
try {
await http.post(`/servers/${slot.server_id}/install-agent`, {})
snackbar(`Agent 安装已提交 · 槽 ${slotIndex + 1}`, 'success')
await refreshPins()
} catch (e: unknown) {
snackbar(formatApiError(e, '安装 Agent 失败'), 'error')
} finally {
agentActionLoadingSlot.value = null
}
}
async function onUpgradeAgent(slotIndex: number) {
const slot = slots.value.find((s) => s.slot_index === slotIndex)
if (!slot?.server_id) return
agentActionLoadingSlot.value = slotIndex
try {
const data = await http.post<{ success?: boolean; stdout?: string }>(
`/servers/${slot.server_id}/upgrade-agent`,
{},
)
snackbar(data.stdout?.trim() || `Agent 升级成功 · 槽 ${slotIndex + 1}`, 'success')
await refreshPins()
} catch (e: unknown) {
snackbar(formatApiError(e, '升级 Agent 失败'), 'error')
} finally {
agentActionLoadingSlot.value = null
}
}
async function onPickServer(serverId: number) {
if (pickSlotIndex.value == null) return
pickLoading.value = true
@@ -129,6 +163,7 @@ async function onPickServer(serverId: number) {
<WatchSlotCard
:slot="slot"
:psutil-install-loading="psutilLoadingSlot === slot.slot_index"
:agent-action-loading="agentActionLoadingSlot === slot.slot_index"
@remove="onRemove"
@processes="openProcesses"
@history="openHistory"
@@ -136,6 +171,8 @@ async function onPickServer(serverId: number) {
@monitoring="onMonitoring"
@ttl="onTtl"
@install-psutil="onInstallPsutil"
@install-agent="onInstallAgent"
@upgrade-agent="onUpgradeAgent"
/>
</v-col>
</v-row>
@@ -1,8 +1,17 @@
import { ref } from 'vue'
import { createBtLoginUrl } from '@/api/btpanel'
import { api } from '@/api'
/** SSH bootstrap 预估耗时(秒),用于一键登录倒计时提示 */
const BOOTSTRAP_ESTIMATE_SEC = 20
/** 含 bootstrap + API 重试 + SSH 回退的上限 */
const LOGIN_URL_TIMEOUT_MS = 180_000
type LoginUrlResult = { url: string; bootstrapped?: boolean }
/** 同一子机并发只请求一次;仅首个点击方 window.open,避免双 tab 抢同一 tmp_token */
const inflightByServer = new Map<number, Promise<LoginUrlResult>>()
/** 全局 loading,终端 / 服务器列表共用 */
const loadingId = ref<number | null>(null)
export type BtLoginOptions = {
/** 为 true 时显示「获取 API」倒计时;未传则根据接口返回 bootstrapped 不预显示 */
@@ -26,25 +35,87 @@ function startBootstrapCountdown(): () => void {
return () => window.clearInterval(timer)
}
export function useBtPanelLogin() {
const loadingId = ref<number | null>(null)
function withTimeout<T>(promise: Promise<T>, ms: number, message: string): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timer = window.setTimeout(() => reject(new Error(message)), ms)
promise.then(
(value) => {
window.clearTimeout(timer)
resolve(value)
},
(error) => {
window.clearTimeout(timer)
reject(error)
},
)
})
}
async function fetchLoginUrl(serverId: number): Promise<LoginUrlResult> {
return api<{ url: string; method: string; bootstrapped?: boolean }>(
`/btpanel/servers/${serverId}/login-url`,
{ method: 'POST', body: '{}' },
)
}
export function useBtPanelLogin() {
async function openBtLogin(serverId: number, options?: BtLoginOptions) {
loadingId.value = serverId
const stopCountdown = options?.needsBootstrap ? startBootstrapCountdown() : undefined
window.$snackbar?.('正在生成宝塔登录链接…', 'info', { timeout: 4000 })
let task = inflightByServer.get(serverId)
const isPrimary = !task
if (!task) {
task = (async () => {
const stopCountdown = options?.needsBootstrap ? startBootstrapCountdown() : undefined
try {
return await withTimeout(
fetchLoginUrl(serverId),
LOGIN_URL_TIMEOUT_MS,
'生成宝塔登录链接超时,请稍后重试',
)
} finally {
stopCountdown?.()
}
})()
inflightByServer.set(serverId, task)
}
try {
const { url, bootstrapped } = await createBtLoginUrl(serverId)
stopCountdown?.()
window.open(url, '_blank', 'noopener,noreferrer')
const { url, bootstrapped } = await withTimeout(
task,
LOGIN_URL_TIMEOUT_MS,
'生成宝塔登录链接超时,请稍后重试',
)
if (!url?.trim()) {
throw new Error('未返回宝塔登录链接')
}
if (!isPrimary) {
window.$snackbar?.('登录链接已在生成/打开中,请勿重复点击', 'info')
return
}
const opened = window.open(url, '_blank', 'noopener,noreferrer')
if (!opened) {
window.$snackbar?.('浏览器拦截了弹窗,请允许本站弹窗后重试', 'warning')
return
}
window.$snackbar?.(
bootstrapped ? '已获取宝塔 API 并打开登录链接' : '已打开宝塔登录链接',
bootstrapped
? '已获取宝塔 API 并打开登录链接(链接仅可用一次,请勿刷新登录页)'
: '已打开宝塔登录链接(仅可用一次,请勿刷新登录页)',
'success',
)
} catch (e: unknown) {
stopCountdown?.()
window.$snackbar?.(e instanceof Error ? e.message : '生成宝塔登录链接失败', 'error')
if (isPrimary) {
window.$snackbar?.(e instanceof Error ? e.message : '生成宝塔登录链接失败', 'error')
}
} finally {
loadingId.value = null
if (inflightByServer.get(serverId) === task) {
inflightByServer.delete(serverId)
}
if (loadingId.value === serverId) {
loadingId.value = null
}
}
}
@@ -163,8 +163,22 @@ export function usePushProgress(
const socket = new WebSocket(url)
return new Promise((resolve, reject) => {
socket.onopen = () => resolve()
socket.onerror = () => reject(new Error('推送进度 WebSocket 连接失败'))
let settled = false
const fail = (message: string) => {
if (settled) return
settled = true
reject(new Error(message))
}
socket.onopen = () => {
if (settled) return
settled = true
resolve()
}
socket.onerror = () => {
// Wait for onclose — browsers often omit close code details on onerror alone.
}
socket.onmessage = (event) => {
if (event.data === 'pong') return
@@ -183,7 +197,18 @@ export function usePushProgress(
socket.onclose = (ev) => {
wsSocket.value = null
if (ev.code === 4001 || ev.code === 4401) {
auth.forceLogout()
if (!settled) {
settled = true
auth.forceLogout()
reject(new Error('登录已过期,请重新登录'))
}
return
}
if (!settled) {
const hint = ev.code === 1006
? '(网络或反代未正确转发 WebSocket,请强刷后重试)'
: ev.code ? `(关闭码 ${ev.code}` : ''
fail(`推送进度 WebSocket 连接失败${hint}`)
return
}
if (ev.code !== 1000 && pushing.value) {
@@ -1,5 +1,5 @@
import { ref, computed, type Ref } from 'vue'
import { categoryDisplayLabel } from '@/composables/useServerCategories'
import { groupServersByCategory } from '@/composables/useServerCategories'
import { useServerSearchHistory } from '@/composables/useServerSearchHistory'
import { fetchPagePerPage } from '@/utils/paginatedFetch'
import type { PushServerItem } from './types'
@@ -89,25 +89,7 @@ export function usePushServers(pushStatus: Ref<Record<number, string>>) {
return visible.length > 0 && visible.every(s => selectedIds.value.has(s.id))
})
const serversByCategory = computed(() => {
const groups: Record<string, PushServerItem[]> = {}
for (const s of filteredServers.value) {
const key = s.category?.trim() || 'uncategorized'
if (!groups[key]) groups[key] = []
groups[key].push(s)
}
return Object.entries(groups)
.map(([category, list]) => ({
category,
label: categoryDisplayLabel(category),
servers: list,
}))
.sort((a, b) => {
if (a.category === 'uncategorized') return 1
if (b.category === 'uncategorized') return -1
return a.label.localeCompare(b.label, 'zh')
})
})
const serversByCategory = computed(() => groupServersByCategory(filteredServers.value))
function isCategoryAllSelected(category: string): boolean {
const group = serversByCategory.value.find(g => g.category === category)
@@ -20,6 +20,7 @@ export interface ServerFormModel {
ssh_key_path: string
ssh_key_private: string
target_path: string
description: string
category: string
platform_id: number | null
node_id: number | null
@@ -46,6 +47,7 @@ function emptyForm(): ServerFormModel {
ssh_key_path: '',
ssh_key_private: '',
target_path: '/www/wwwroot',
description: '',
category: '',
platform_id: null,
node_id: null,
@@ -71,6 +73,7 @@ function fromServer(item: ServerApiItem): ServerFormModel {
ssh_key_path: item.ssh_key_path || '',
ssh_key_private: '',
target_path: item.target_path || '/www/wwwroot',
description: item.description || '',
category: item.category || '',
platform_id: item.platform_id,
node_id: item.node_id,
@@ -195,6 +198,7 @@ export function useServerFormDialog(onSaved: () => void) {
username: f.username.trim() || 'root',
auth_method: f.auth_method,
target_path: f.target_path.trim(),
description: f.description.trim() || null,
category: f.category.trim() || undefined,
platform_id: f.platform_id ?? undefined,
node_id: f.node_id ?? undefined,
+89 -13
View File
@@ -3,14 +3,100 @@ import { http } from '@/api'
import { CACHE_KEYS, cachedFetch } from '@/composables/useCachedQuery'
export interface ServerCategoryOption {
/** API value: `` for all, ``uncategorized`` for empty category */
/** API filter value; merged groups use synthetic id e.g. ``三方/四方`` */
name: string
label: string
count: number
}
/** 展示/筛选层合并:库内 category 字段不变 */
export const CATEGORY_MERGE_GROUPS = [
{ id: '三方/四方', label: '三方/四方', members: ['三方', '四方'] as const },
] as const
const MEMBER_TO_GROUP = new Map<string, string>(
CATEGORY_MERGE_GROUPS.flatMap(g => g.members.map(m => [m, g.id] as const)),
)
const ABSORBED_MEMBERS = new Set<string>(
CATEGORY_MERGE_GROUPS.flatMap(g => [...g.members]),
)
export function categoryGroupKey(rawCategory: string): string {
const c = rawCategory.trim()
if (!c) return 'uncategorized'
return MEMBER_TO_GROUP.get(c) ?? c
}
export function categoryDisplayLabel(name: string): string {
return name === 'uncategorized' ? '未分类' : name
if (name === 'uncategorized') return '未分类'
const group = CATEGORY_MERGE_GROUPS.find(g => g.id === name)
if (group) return group.label
return name
}
/** 分类 chip 筛选 → API query(单 category 或 categories OR */
export function categoryFilterQueryParams(
filter: string,
): Record<string, string | undefined> {
if (!filter) return {}
const group = CATEGORY_MERGE_GROUPS.find(g => g.id === filter)
if (group) return { categories: [...group.members].join(',') }
return { category: filter }
}
export function mergeCategoryOptions(
items: { name: string; count: number }[],
): ServerCategoryOption[] {
const merged = new Map<string, ServerCategoryOption>()
for (const g of CATEGORY_MERGE_GROUPS) {
merged.set(g.id, { name: g.id, label: g.label, count: 0 })
}
for (const c of items) {
if (ABSORBED_MEMBERS.has(c.name)) {
const gid = categoryGroupKey(c.name)
const entry = merged.get(gid)
if (entry) entry.count += c.count || 0
} else {
merged.set(c.name, {
name: c.name,
label: categoryDisplayLabel(c.name),
count: c.count,
})
}
}
return [...merged.values()]
.filter(o => o.count > 0)
.sort((a, b) => {
if (a.name === 'uncategorized') return 1
if (b.name === 'uncategorized') return -1
return a.label.localeCompare(b.label, 'zh')
})
}
export function groupServersByCategory<T extends { category?: string | null }>(
servers: T[],
): { category: string; label: string; servers: T[] }[] {
const groups: Record<string, T[]> = {}
for (const s of servers) {
const key = categoryGroupKey(s.category?.trim() || 'uncategorized')
if (!groups[key]) groups[key] = []
groups[key].push(s)
}
return Object.entries(groups)
.map(([category, list]) => ({
category,
label: categoryDisplayLabel(category),
servers: list,
}))
.sort((a, b) => {
if (a.category === 'uncategorized') return 1
if (b.category === 'uncategorized') return -1
return a.label.localeCompare(b.label, 'zh')
})
}
export function useServerCategories() {
@@ -27,17 +113,7 @@ export function useServerCategories() {
)
const items = res.categories || []
totalCount.value = items.reduce((sum, c) => sum + (c.count || 0), 0)
categories.value = items
.map(c => ({
name: c.name,
label: categoryDisplayLabel(c.name),
count: c.count,
}))
.sort((a, b) => {
if (a.name === 'uncategorized') return 1
if (b.name === 'uncategorized') return -1
return a.label.localeCompare(b.label, 'zh')
})
categories.value = mergeCategoryOptions(items)
} catch {
categories.value = []
totalCount.value = 0
@@ -5,6 +5,7 @@ import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { http } from '@/api'
import { useSnackbar } from '@/composables/useSnackbar'
import { formatApiError } from '@/utils/apiError'
import { useServerList, type ServerBrief } from '@/composables/useServerList'
import type { FileEntry } from '@/types/api'
import { parseBrowseResponse } from '@/utils/fileBrowse'
@@ -226,13 +227,17 @@ export function useServerFileTransfer() {
} else {
currentPath.value = DEFAULT_FILES_ROOT
}
const candidates = destServers.value
if (!destServerId.value || destServerId.value === id) {
destServerId.value = candidates[0]?.id ?? null
if (destServerId.value === id) {
destServerId.value = null
destFiles.value = []
destCurrentPath.value = DEFAULT_FILES_ROOT
destPath.value = ''
}
applyDestPathForServer(destServerId.value, false)
void browse()
if (destServerId.value) void browseDest()
if (destServerId.value) {
applyDestPathForServer(destServerId.value, false)
void browseDest()
}
}
function onDestServerChange(id: number | null) {
@@ -367,13 +372,30 @@ export function useServerFileTransfer() {
selectedPaths.value = pathsQ.split('|').map((p) => normalizeRemotePath(p))
manualPaths.value = ''
}
if (destServerId.value === src) {
destServerId.value = null
destFiles.value = []
destCurrentPath.value = DEFAULT_FILES_ROOT
destPath.value = ''
}
}
if (q.dest !== undefined || q.dest_server_id !== undefined) {
const dest = Number(q.dest ?? q.dest_server_id)
if (Number.isFinite(dest) && dest > 0 && dest !== sourceServerId.value) {
destServerId.value = dest
} else {
destServerId.value = null
destFiles.value = []
destCurrentPath.value = DEFAULT_FILES_ROOT
destPath.value = ''
}
}
if (sourceServerId.value) {
const candidates = serverList.value.filter((s) => s.id !== sourceServerId.value)
destServerId.value = candidates[0]?.id ?? null
transferMode.value = recommendMode(selectedPaths.value)
applyDestPathForServer(destServerId.value, false)
void browse()
}
if (destServerId.value) {
applyDestPathForServer(destServerId.value, false)
void browseDest()
}
}
@@ -429,7 +451,7 @@ export function useServerFileTransfer() {
)
}
} catch (e: unknown) {
snackbar(e instanceof Error ? e.message : '传输失败', 'error')
snackbar(formatApiError(e, '传输失败'), 'error')
} finally {
actionLoading.value = false
}
@@ -8,6 +8,7 @@ import { ref, watch, onUnmounted } from 'vue'
import type { ServerApiItem } from '@/types/api'
import { fetchPagePerPage } from '@/utils/paginatedFetch'
import { toServerApiSort, type TableSortItem } from '@/utils/serverTableSort'
import { categoryFilterQueryParams } from '@/composables/useServerCategories'
export interface UseServerPaginationOptions {
/** When set, passed as target_path_unset query param (true/false). Omit for no filter. */
@@ -35,7 +36,7 @@ export function useServerPagination(options?: UseServerPaginationOptions) {
const applyPathFilter = targetPathUnset !== undefined && !q
return {
search: q || undefined,
...(!q && categoryFilter.value ? { category: categoryFilter.value } : {}),
...(!q ? categoryFilterQueryParams(categoryFilter.value) : {}),
...(!q && isOnlineFilter.value !== null ? { is_online: isOnlineFilter.value } : {}),
...toServerApiSort(sortBy.value),
...(applyPathFilter ? { target_path_unset: targetPathUnset } : {}),
@@ -6,6 +6,7 @@ export const SERVER_STAT_CARD_KEYS = [
'total',
'online',
'offline',
'unset-path-offline',
'unset-path',
'alerts',
] as const
@@ -26,6 +27,7 @@ export interface ServerStatsResponse {
online_list?: number
offline_list?: number
unset_path: number
unset_path_offline?: number
alerts: number
categories?: Record<string, number>
}
@@ -35,21 +37,45 @@ export function createServerStatItems(): ServerStatCardItem[] {
{ subtitle: '服务器总数', title: '0', icon: 'mdi-server-network', color: 'blue' },
{ subtitle: '在线', title: '0', icon: 'mdi-check-circle-outline', color: 'green' },
{ subtitle: '离线', title: '0', icon: 'mdi-alert-circle-outline', color: 'orange' },
{
subtitle: '未设路径·离线',
title: '0',
icon: 'mdi-folder-alert-outline',
color: 'deep-orange',
},
{ subtitle: '未设路径', title: '0', icon: 'mdi-folder-off-outline', color: 'amber' },
{ subtitle: '告警中', title: '0', icon: 'mdi-bell-alert-outline', color: 'red' },
]
}
/** 与服务器页顶部统计卡一致:在线=全库 Agent,离线=主列表 offline_list */
function statValueForKey(key: ServerStatCardKey, s: ServerStatsResponse): string {
switch (key) {
case 'total':
return String(s.total)
case 'online':
return String(s.online)
case 'offline':
return String(s.offline_list ?? s.offline)
case 'unset-path-offline':
return String(s.unset_path_offline ?? 0)
case 'unset-path':
return String(s.unset_path ?? 0)
case 'alerts':
return String(s.alerts ?? 0)
default:
return '0'
}
}
/** 在线=全库 Agent;离线=主列表;未设路径·离线=全库离线 − 主列表离线 */
export function applyServerStats(
statItems: Ref<ServerStatCardItem[]>,
s: ServerStatsResponse,
): void {
statItems.value[0].title = String(s.total)
statItems.value[1].title = String(s.online)
statItems.value[2].title = String(s.offline_list ?? s.offline)
statItems.value[3].title = String(s.unset_path ?? 0)
statItems.value[4].title = String(s.alerts ?? 0)
statItems.value.forEach((item, i) => {
const key = SERVER_STAT_CARD_KEYS[i]
if (key) item.title = statValueForKey(key, s)
})
}
export async function fetchServerStats(): Promise<ServerStatsResponse> {
+4
View File
@@ -83,6 +83,10 @@ export interface WatchSlot {
metrics?: WatchMetrics | null
sparkline?: WatchSparkPoint[]
processes?: WatchProcessBundle | WatchProcess[]
agent_action?: 'install' | 'upgrade' | 'offline' | 'ok'
agent_action_message?: string | null
agent_version?: string | null
central_agent_version?: string | null
}
const slots = ref<WatchSlot[]>([
+2 -9
View File
@@ -15,6 +15,7 @@
import { ref, computed } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { formatTimeBeijing } from '@/utils/datetime'
import { buildWebSocketUrl } from '@/utils/wsUrl'
import {
handleScriptWsMessage,
setScriptQueueWsConnected,
@@ -30,15 +31,7 @@ const MAX_RECONNECT_DELAY = 30000
const MAX_ALERTS = 50
function getWsUrl(): string {
const base = import.meta.env.VITE_API_BASE || ''
if (base) {
// Replace http(s) with ws(s)
const wsBase = base.replace(/^http/, 'ws')
return `${wsBase}/ws/alerts`
}
// Default: same origin
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'
return `${proto}//${location.host}/ws/alerts`
return buildWebSocketUrl('/ws/alerts')
}
export function useWebSocket() {
+8 -8
View File
@@ -1,11 +1,11 @@
/** Shared column config for main / unset-path server tables. */
export const SERVER_DATA_TABLE_HEADERS = [
{ title: '状态', key: 'status', width: 100 },
{ title: '名称', key: 'name' },
{ title: '地址', key: 'domain' },
{ title: '分类', key: 'category', width: 100 },
{ title: '目标路径', key: 'target_path', width: 200 },
{ title: 'Agent', key: 'agent_version', width: 100 },
{ title: '心跳', key: 'last_heartbeat', width: 120 },
{ title: '操作', key: 'actions', width: 330, align: 'end' as const, sortable: false },
{ title: '状态', key: 'status', width: 88, minWidth: 88 },
{ title: '名称 / 备注', key: 'name', minWidth: 220 },
{ title: '地址', key: 'domain', minWidth: 200 },
{ title: '分类', key: 'category', width: 108, minWidth: 108 },
{ title: '目标路径', key: 'target_path', minWidth: 180 },
{ title: 'Agent', key: 'agent_version', width: 100, minWidth: 100 },
{ title: '心跳', key: 'last_heartbeat', width: 104, minWidth: 104 },
{ title: '操作', key: 'actions', width: 300, minWidth: 300, align: 'end' as const, sortable: false },
]
+1
View File
@@ -18,6 +18,7 @@ import '@fontsource/roboto/latin-300.css'
import '@fontsource/roboto/latin-400.css'
import '@fontsource/roboto/latin-500.css'
import '@fontsource/roboto/latin-700.css'
import '@/styles/server-data-table.css'
// NOTE: Roboto has NO Chinese glyphs. CJK text renders via browser fallback
// to the system's default Chinese font (e.g. Microsoft YaHei on Windows).
+4
View File
@@ -227,6 +227,10 @@ function onStatCardClick(key: string) {
router.push({ path: '/servers', query: { status: 'offline' } })
return
}
if (key === 'unset-path-offline') {
router.push({ path: '/servers', query: { unset_path: '1', status: 'offline' } })
return
}
if (key === 'unset-path') {
router.push({ path: '/servers', query: { unset_path: '1' } })
}
+27 -3
View File
@@ -534,7 +534,7 @@
将对选中的 <strong>{{ scopedSelectedCount }}</strong> 台服务器执行 SSH 检测
</p>
<p class="text-body-2 text-medium-emphasis mb-0">
<code>/www/wwwroot</code> 下搜索 <code>workerman.bat</code>取其所在目录写入
<code>/www/wwwroot</code> 下搜索 <code>crmeb</code> 目录将其路径写入
<strong>target_path</strong>
</p>
</v-card-text>
@@ -1387,8 +1387,12 @@ const statItemsDisplay = computed(() =>
statItems.value.map((item, i) => {
const key = SERVER_STAT_CARD_KEYS[i]
let active = false
if (key === 'offline') active = isOnlineFilter.value === false
else if (key === 'unset-path') active = unsetPathFocus.value
if (key === 'offline') active = isOnlineFilter.value === false && !unsetPathFocus.value
else if (key === 'unset-path-offline') {
active = unsetPathFocus.value && isOnlineFilter.value === false
} else if (key === 'unset-path') {
active = unsetPathFocus.value && isOnlineFilter.value !== false
}
return {
...item,
key,
@@ -1441,6 +1445,11 @@ function syncDashboardStatQuery() {
if (route.query.unset_path === '1') {
unsetPathFocus.value = true
scrollToUnsetPathPanel()
if (route.query.status === 'offline' && isOnlineFilter.value !== false) {
isOnlineFilter.value = false
void loadServers()
void loadUnsetPathServers()
}
}
}
@@ -1456,11 +1465,26 @@ function onStatCardClick(key: string) {
router.push({ path: '/alerts' })
return
}
if (key === 'unset-path-offline') {
unsetPathFocus.value = true
onOnlineFilterChange(false)
viewMode.value = 'table'
void loadUnsetPathServers()
const q = { ...route.query, unset_path: '1', status: 'offline' }
router.replace({ path: route.path, query: q })
scrollToUnsetPathPanel()
return
}
if (key === 'unset-path') {
unsetPathFocus.value = !unsetPathFocus.value
const q = { ...route.query }
if (unsetPathFocus.value) {
q.unset_path = '1'
delete q.status
if (isOnlineFilter.value !== null) {
isOnlineFilter.value = null
void loadServers()
}
scrollToUnsetPathPanel()
} else {
delete q.unset_path
+13 -1
View File
@@ -28,6 +28,17 @@
<v-text-field v-model="settings.disk_alert_threshold" label="磁盘 %" variant="outlined" density="compact" type="number" :rules="[required('阈值'), numberRange(1, 100, '阈值')]" />
</v-col>
</v-row>
<v-text-field
v-model="settings.alert_streak_required"
label="连续超阈次数"
hint="默认 3:常态 60s 心跳约 3 分钟才告警;设为 1 则立即告警"
persistent-hint
variant="outlined"
density="compact"
type="number"
class="mt-3"
:rules="[required('连续次数'), numberRange(1, 20, '连续次数')]"
/>
<v-divider class="my-4" />
<div class="text-subtitle-2 mb-3">连接池</div>
@@ -539,6 +550,7 @@ const settingsLoading = ref(false)
const settings = ref({
system_name: '', system_title: '',
cpu_alert_threshold: 80, mem_alert_threshold: 80, disk_alert_threshold: 80,
alert_streak_required: 3,
db_pool_size: 10, db_max_overflow: 20,
telegram_bot_token: '', telegram_chat_id: '',
telegram_offline_chat_id: '',
@@ -698,7 +710,7 @@ async function saveSettings() {
saving.value = true
try {
const entries = Object.entries(settings.value).filter(([key]) =>
['system_name', 'system_title', 'cpu_alert_threshold', 'mem_alert_threshold', 'disk_alert_threshold', 'db_pool_size', 'db_max_overflow'].includes(key)
['system_name', 'system_title', 'cpu_alert_threshold', 'mem_alert_threshold', 'disk_alert_threshold', 'alert_streak_required', 'db_pool_size', 'db_max_overflow'].includes(key)
)
for (const [key, value] of entries) {
await http.put(`/settings/${key}`, { value: String(value ?? '') })
+54
View File
@@ -0,0 +1,54 @@
/**
* Shared layout for main + unset-path server tables on #/servers.
*/
.server-data-table .v-table__wrapper {
overflow-x: auto;
}
.server-data-table .v-table__wrapper > table {
table-layout: auto;
width: 100%;
min-width: 1080px;
}
.server-data-table .v-data-table__td--expand-column,
.server-data-table .v-data-table-column--expand,
.server-data-table .v-data-table__th--expand {
display: none !important;
width: 0 !important;
min-width: 0 !important;
padding: 0 !important;
}
.server-data-table tr.v-data-table__expanded__row > td {
background: transparent !important;
border-bottom: none;
}
.server-data-table .site-link:hover {
text-decoration: underline;
}
.server-data-table .server-name-cell {
max-width: 320px;
cursor: pointer;
}
.server-data-table .server-name-cell__remark {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
line-clamp: 2;
overflow: hidden;
line-height: 1.35;
word-break: break-word;
}
.server-data-table .server-path-cell {
max-width: 220px;
}
.server-data-table .server-path-cell__text {
word-break: break-all;
line-height: 1.35;
}
+4 -1
View File
@@ -35,11 +35,14 @@ export interface ServerNameSource {
id: number
name?: string | null
domain?: string | null
description?: string | null
}
/** Same label order as backend _batch_server_display_name. */
/** Same label order as backend batch_server_display_name. */
export function serverDisplayLabel(source: ServerNameSource | null | undefined): string {
if (!source) return '未知服务器'
const description = (source.description || '').trim()
if (description) return description
const name = (source.name || '').trim()
if (name) return name
const domain = (source.domain || '').trim()
+8 -3
View File
@@ -2,21 +2,26 @@ import { normalizeBrowserUrl } from '@/utils/browserUrl'
import type { ServerApiItem } from '@/types/api'
const IP_RE = /^\d{1,3}(\.\d{1,3}){3}$/
const CJK_RE = /[\u4e00-\u9fff]/
const ASCII_HOST_RE = /^[a-z0-9](?:[a-z0-9-]*\.)+[a-z]{2,63}$/i
function hostFromString(raw: string): string | null {
const trimmed = raw.trim()
if (!trimmed) return null
// 备注(中文 tonex 名等)勿当站点域名
if (CJK_RE.test(trimmed)) return null
const url = normalizeBrowserUrl(trimmed)
if (url) {
try {
return new URL(url).hostname
const host = new URL(url).hostname
if (host && !IP_RE.test(host) && ASCII_HOST_RE.test(host)) return host
} catch {
return null
}
}
const host = trimmed.replace(/^https?:\/\//i, '').split('/')[0]?.split(':')[0]?.trim()
if (!host || IP_RE.test(host)) return null
return host.includes('.') ? host : null
if (!host || IP_RE.test(host) || !ASCII_HOST_RE.test(host)) return null
return host
}
/** 从宝塔 target_path 提取像域名的目录名(非 IP、非 shop/crmeb 占位) */
+1 -1
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Batch detect target_path via SSH (workerman.bat under /www/wwwroot).
"""Batch detect target_path via SSH (find crmeb directory under /www/wwwroot).
Targets servers with「未设路径」(empty or ``/www/wwwroot`` placeholder) by default.
+6 -1
View File
@@ -11,4 +11,9 @@ HOST="${NEXUS_GITEA_HOST:-66.154.115.8:3000}"
REPO="${NEXUS_GITEA_REPO:-admin/Nexus.git}"
TOKEN="${NEXUS_GITEA_TOKEN:?缺少 NEXUS_GITEA_TOKEN,请配置 $SECRETS}"
BRANCH="${1:-main}"
GIT_TERMINAL_PROMPT=0 git -C "$ROOT" push "http://admin:${TOKEN}@${HOST}/${REPO}" "$BRANCH"
if [[ "$HOST" == *:* ]]; then
SCHEME="${NEXUS_GITEA_SCHEME:-http}"
else
SCHEME="${NEXUS_GITEA_SCHEME:-https}"
fi
GIT_TERMINAL_PROMPT=0 git -C "$ROOT" push "${SCHEME}://admin:${TOKEN}@${HOST}/${REPO}" "$BRANCH"
+29 -103
View File
@@ -19,12 +19,14 @@ from sqlalchemy.ext.asyncio import AsyncSession
from server.api.dependencies import get_db, get_server_service
from server.api.schemas import AgentHeartbeat, AgentScriptCallback
from server.api.websocket import broadcast_alert, broadcast_recovery
from server.application.services.server_service import ServerService
from server.domain.models import Server
from server.config import settings
from server.infrastructure.redis.client import get_redis
from server.infrastructure.database.watch_repo import WatchRepositoryImpl
from server.infrastructure.telegram import send_telegram_system_alert
from server.utils.alert_streak import process_metric_alerts
from server.utils.watch_metrics import WATCH_AGENT_INTERVAL_SEC
logger = logging.getLogger("nexus.agent")
@@ -35,7 +37,12 @@ from server.background.heartbeat_flush import FLUSH_INTERVAL
REDIS_KEY_PREFIX = "heartbeat:"
REDIS_KEY_EXPIRE = int(FLUSH_INTERVAL * 1.5) # 900s when flush is 600s
REDIS_ALERT_KEY_PREFIX = "alerts:"
from server.utils.alert_metrics import (
alert_metric_key as _alert_metric_key,
detect_recovery as _detect_recovery,
safe_float as _safe_float,
threshold_for as _threshold_for,
)
def _verify_api_key(x_api_key: str = Header(...)):
@@ -87,82 +94,12 @@ async def _verify_server_api_key(
return server
def _threshold_for(thresholds: dict, metric: str) -> float:
defaults = {
"cpu": settings.CPU_ALERT_THRESHOLD,
"mem": settings.MEM_ALERT_THRESHOLD,
"disk": settings.DISK_ALERT_THRESHOLD,
}
return float(thresholds.get(metric, defaults.get(metric, 80)))
def _safe_float(value, name: str) -> Optional[float]:
"""Convert agent-supplied metric value to float, discarding malformed values."""
if value is None:
return None
try:
return float(value)
except (ValueError, TypeError):
logger.warning(f"Agent sent non-numeric {name}: {value!r:.50} — skipped")
return None
def _detect_alerts(system_info: dict, thresholds: dict) -> list:
"""Detect metrics exceeding alert thresholds
Returns list of (alert_type, value) tuples.
E.g. [("cpu", 85.3), ("mem", 92.1)]
"""
alerts = []
cpu = _safe_float(system_info.get("cpu_usage") or system_info.get("cpu"), "cpu")
mem = _safe_float(system_info.get("mem_usage") or system_info.get("mem"), "mem")
disk = _safe_float(system_info.get("disk_usage") or system_info.get("disk"), "disk")
if cpu is not None and cpu > _threshold_for(thresholds, "cpu"):
alerts.append(("cpu", cpu))
if mem is not None and mem > _threshold_for(thresholds, "mem"):
alerts.append(("mem", mem))
if disk is not None and disk > _threshold_for(thresholds, "disk"):
alerts.append(("disk", disk))
return alerts
def _alert_metric_key(entry: str) -> str | None:
"""Parse Redis alert set member — supports legacy ``metric:value`` and ``metric``."""
if not entry:
return None
metric = entry.split(":", 1)[0].strip()
return metric or None
def _detect_recovery(system_info: dict, prev_alerts: set, thresholds: dict) -> list:
"""Detect previously alerted metrics that have returned to normal
Returns list of (metric, current_value) tuples — at most one entry per metric
even when Redis still holds legacy ``metric:value`` duplicates.
"""
recoveries: list[tuple[str, float]] = []
seen_metrics: set[str] = set()
for prev in prev_alerts:
metric = _alert_metric_key(prev)
if not metric or metric in seen_metrics:
continue
current = _safe_float(
system_info.get(f"{metric}_usage") or system_info.get(metric), metric
)
if current is not None and current < _threshold_for(thresholds, metric):
seen_metrics.add(metric)
recoveries.append((metric, current))
return recoveries
@router.post("/heartbeat", response_model=dict)
async def receive_heartbeat(
payload: AgentHeartbeat,
api_key: str = Depends(_verify_api_key),
service: ServerService = Depends(get_server_service),
db: AsyncSession = Depends(get_db),
):
"""Receive Agent heartbeat data
@@ -278,42 +215,31 @@ async def receive_heartbeat(
"disk": settings.DISK_ALERT_THRESHOLD,
}
if system_info:
alerts = _detect_alerts(system_info, alert_thresholds)
for alert_type, alert_value in alerts:
logger.warning(f"Alert: server {server_id} {alert_type}={alert_value}%")
await broadcast_alert(server_id, alert_type, alert_value, server_name)
# Track active alert in Redis
try:
redis = get_redis()
# Store metric name only — value in member caused duplicate recovery pushes
await redis.sadd(f"{REDIS_ALERT_KEY_PREFIX}{server_id}", alert_type)
await redis.expire(f"{REDIS_ALERT_KEY_PREFIX}{server_id}", 3600) # 1h TTL
except Exception:
logger.debug(f"Failed to track alert in Redis for server {server_id}")
# ── 3. Recovery detection ──
# ── 23. Alert + recovery (consecutive over-threshold gate) ──
if system_info:
try:
redis = get_redis()
prev_alerts = await redis.smembers(f"{REDIS_ALERT_KEY_PREFIX}{server_id}")
if prev_alerts:
recoveries = _detect_recovery(system_info, prev_alerts, alert_thresholds)
for metric, value in recoveries:
logger.info(f"Recovery: server {server_id} {metric}={value}%")
await broadcast_recovery(server_id, metric, value, server_name)
# Remove recovered metric (legacy metric:value members included)
for prev in prev_alerts:
if _alert_metric_key(prev) == metric:
await redis.srem(f"{REDIS_ALERT_KEY_PREFIX}{server_id}", prev)
await process_metric_alerts(
redis=get_redis(),
server_id=server_id,
server_name=server_name,
system_info=system_info,
thresholds=alert_thresholds,
log_prefix="Alert",
)
except Exception as e:
logger.error(f"Recovery detection failed for server {server_id}: {e}")
logger.error(f"Metric alert processing failed for server {server_id}: {e}")
# MySQL history is flushed every 10min by heartbeat_flush (Redis → MySQL).
return {"status": "ok", "server_id": server_id}
watch_repo = WatchRepositoryImpl(db)
watch_active = await watch_repo.count_monitoring_pins_for_server(server_id) > 0
return {
"status": "ok",
"server_id": server_id,
"watch_active": watch_active,
"watch_interval_sec": WATCH_AGENT_INTERVAL_SEC,
}
@router.post("/script-callback", response_model=dict)
async def script_job_callback(
+1
View File
@@ -1247,6 +1247,7 @@ async def init_db(req: InitDbRequest):
"cpu_alert_threshold": "80",
"mem_alert_threshold": "80",
"disk_alert_threshold": "80",
"alert_streak_required": "3",
"telegram_bot_token": "",
"telegram_chat_id": "",
}
+13 -2
View File
@@ -93,9 +93,10 @@ def _apply_agent_guidance(server_data: dict, server: Server, heartbeat: dict | N
@router.get("/", response_model=dict)
async def list_servers(
category: Optional[str] = Query(None, description="Filter by category"),
categories: Optional[str] = Query(None, description="Comma-separated categories (OR filter)"),
platform_id: Optional[int] = Query(None, description="Filter by platform ID"),
node_id: Optional[int] = Query(None, description="Filter by node ID"),
search: Optional[str] = Query(None, description="Search by name or domain (substring)"),
search: Optional[str] = Query(None, description="Search by name, domain, category or description (substring)"),
sort_by: Optional[str] = Query(None, description="Sort field: name/domain/status/heartbeat/agent_version/category/id/created_at"),
sort_order: Optional[str] = Query("asc", description="Sort direction: asc or desc"),
is_online: Optional[bool] = Query(None, description="Filter by online status"),
@@ -125,11 +126,17 @@ async def list_servers(
redis = get_redis()
order = sort_order if sort_order in ("asc", "desc") else "asc"
category_list = None
if categories:
category_list = [c.strip() for c in categories.split(",") if c.strip()]
category = None
use_live_path = sort_by in LIVE_SORT_FIELDS or is_online is not None
if use_live_path:
all_servers, db_total = await repo.get_filtered(
category,
category_list,
platform_id,
node_id,
search=search,
@@ -153,6 +160,7 @@ async def list_servers(
else:
page_servers, total = await repo.get_paginated(
category,
category_list,
platform_id,
node_id,
offset,
@@ -216,6 +224,8 @@ async def server_stats(
select(func.count(Server.id)).where(_target_path_unset_filter(True))
)
unset_path = int(unset_path_result.scalar() or 0)
offline_list = int(main_scope["offline"])
unset_path_offline = max(0, int(offline) - offline_list)
alerts = 0
try:
@@ -234,8 +244,9 @@ async def server_stats(
"offline": offline,
"unknown": unknown,
"online_list": main_scope["online"],
"offline_list": main_scope["offline"],
"offline_list": offline_list,
"unset_path": unset_path,
"unset_path_offline": unset_path_offline,
"alerts": alerts,
"categories": categories,
}
+3
View File
@@ -50,10 +50,12 @@ MUTABLE_KEYS: set[str] = {
"system_name", "system_title", "db_pool_size", "db_max_overflow",
"redis_url", "heartbeat_timeout", "api_base_url",
"cpu_alert_threshold", "mem_alert_threshold", "disk_alert_threshold",
"alert_streak_required",
"telegram_bot_token", "telegram_chat_id",
"telegram_offline_bot_token", "telegram_offline_chat_id",
"login_allowlist_enabled", "login_subscription_url",
"login_subscription_ips", "login_manual_ips", "login_allowed_ips",
"login_subscription_last_refresh",
"notify_alert_cpu", "notify_alert_mem", "notify_alert_disk",
"notify_alert_offline",
"notify_recovery", "notify_time_drift",
@@ -86,6 +88,7 @@ SETTING_VALIDATORS: dict[str, tuple] = {
"cpu_alert_threshold": (int, 1, 100),
"mem_alert_threshold": (int, 1, 100),
"disk_alert_threshold": (int, 1, 100),
"alert_streak_required": (int, 1, 20),
}
+6
View File
@@ -1920,6 +1920,9 @@ async def server_file_transfer_relay(
raise HTTPException(status_code=400, detail=str(exc)) from exc
except ServerTransferError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc:
logger.exception("server_file_transfer_relay failed")
raise HTTPException(status_code=502, detail=f"传输失败:{exc}") from exc
await _audit_sync(
"server_file_relay",
@@ -2047,6 +2050,9 @@ async def server_file_transfer_deliver(
)
except ServerTransferError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc:
logger.exception("server_file_transfer_deliver failed")
raise HTTPException(status_code=502, detail=f"投递失败:{exc}") from exc
if result.get("extracted_to"):
invalidate_browse_parents(
+62 -6
View File
@@ -86,6 +86,9 @@ def install_error_msg(stdout: str, stderr: str, exit_code: int) -> str:
def batch_server_display_name(server: Server | None) -> str:
if server is None:
return "未知服务器"
description = (getattr(server, "description", None) or "").strip()
if description:
return description
name = (server.name or "").strip()
if name:
return name
@@ -146,13 +149,32 @@ async def update_server_target_path(sid: int, target_dir: str) -> None:
await session.commit()
async def update_server_site_url(sid: int, site_url: str) -> None:
from server.infrastructure.database.session import AsyncSessionLocal
from server.infrastructure.database.server_repo import ServerRepositoryImpl
host = (site_url or "").strip().lower()
if not host:
raise ValueError("site_url required")
async with AsyncSessionLocal() as session:
repo = ServerRepositoryImpl(session)
server = await repo.get_by_id(sid)
if not server:
raise ValueError("服务器不存在")
extra = dict(server.extra_attrs) if isinstance(server.extra_attrs, dict) else {}
extra["site_url"] = host
server.extra_attrs = extra
await session.commit()
async def detect_and_save_target_path(server: Server, *, timeout: int = 30) -> dict[str, object]:
"""Find workerman.bat under /www/wwwroot and persist parent dir as target_path."""
"""Find crmeb directory under /www/wwwroot and persist its path as target_path."""
from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command
sid = server.id
ssh_user = (server.username or "root").strip() or "root"
cmd = "find /www/wwwroot -iname 'workerman.bat' -type f -print -quit 2>/dev/null"
cmd = "find /www/wwwroot -iname 'crmeb' -type d -print -quit 2>/dev/null"
cmd = sudo_wrap(cmd, ssh_user)
r = await exec_ssh_command(server, cmd, timeout=timeout)
if r["exit_code"] != 0 and not r.get("stdout", "").strip():
@@ -162,13 +184,43 @@ async def detect_and_save_target_path(server: Server, *, timeout: int = 30) -> d
}
found = (r.get("stdout") or "").strip()
if not found:
return {"success": False, "error": "未找到 workerman.bat"}
first_match = found.split("\n")[0].strip()
target_dir = posix_dirname(first_match)
return {"success": False, "error": "未找到 crmeb 目录"}
target_dir = found.split("\n")[0].strip()
await update_server_target_path(sid, target_dir)
return {"success": True, "target_dir": target_dir, "stdout": f"target_path → {target_dir}"}
async def detect_and_save_site_url(server: Server, *, timeout: int = 30) -> dict[str, object]:
"""SSH-detect nginx site hostname and persist ``extra_attrs.site_url``."""
from server.utils.nginx_domain_detect import build_nginx_domain_detect_command
from server.utils.site_host import parse_site_host_from_target_path
sid = server.id
ssh_user = (server.username or "root").strip() or "root"
target_hint = parse_site_host_from_target_path(server.target_path) or ""
try:
cmd = build_nginx_domain_detect_command(target_hint=target_hint)
except ValueError as e:
return {"success": False, "error": str(e)}
cmd = sudo_wrap(cmd, ssh_user)
r = await exec_ssh_command(server, cmd, timeout=timeout)
raw = (r.get("stdout") or "").strip()
if not raw:
detail = (r.get("stderr") or "").strip()
err = "未找到 nginx 站点域名"
if detail:
err = f"{err} ({detail[:120]})"
return {"success": False, "error": err}
host = raw.split("\n")[0].strip().lower()
if not host or "." not in host:
return {"success": False, "error": f"探测结果无效: {host[:80]}"}
await update_server_site_url(sid, host)
return {"success": True, "site_url": host, "stdout": f"site_url → {host}"}
async def mark_agent_uninstalled(sid: int) -> None:
from server.infrastructure.database.session import AsyncSessionLocal
from server.infrastructure.database.server_repo import ServerRepositoryImpl
@@ -192,10 +244,12 @@ _REMOTE_AGENT_VERSION_CMD = (
def agent_files_curl_cmds(base_url: str, install_dir: str = "/opt/nexus-agent") -> str:
"""Download agent.py and heartbeat_policy.py from central /agent/."""
"""Download agent.py, cpu_metrics.py, heartbeat_policy.py from central /agent/."""
root = base_url.rstrip("/")
return (
f"curl -fsSL {shlex.quote(root + '/agent/agent.py')} -o {install_dir}/agent.py "
f"&& curl -fsSL {shlex.quote(root + '/agent/cpu_metrics.py')} "
f"-o {install_dir}/cpu_metrics.py "
f"&& curl -fsSL {shlex.quote(root + '/agent/heartbeat_policy.py')} "
f"-o {install_dir}/heartbeat_policy.py"
)
@@ -294,4 +348,6 @@ __all__ = [
"result_item_dict",
"sudo_wrap",
"update_server_target_path",
"update_server_site_url",
"detect_and_save_site_url",
]
+119 -8
View File
@@ -29,9 +29,13 @@ from server.infrastructure.btpanel.bootstrap_state import (
_utcnow,
)
from server.infrastructure.btpanel.bootstrap_lock import bootstrap_lock
from server.infrastructure.btpanel.login_url_lock import login_url_lock
from server.infrastructure.btpanel.source_ip import get_bt_panel_source_ip
from server.infrastructure.btpanel.ssh_bootstrap import BootstrapRemoteError, ssh_bootstrap_panel
from server.infrastructure.btpanel.constants import BT_TEMP_LOGIN_TTL_SECONDS
from server.infrastructure.btpanel.constants import (
BT_LOGIN_URL_CACHE_TTL_SECONDS,
BT_TEMP_LOGIN_TTL_SECONDS,
)
from server.infrastructure.btpanel.ssh_login import (
detect_bt_panel_installed,
normalize_temp_login_url,
@@ -44,6 +48,14 @@ from server.infrastructure.database.server_repo import ServerRepositoryImpl
logger = logging.getLogger("nexus.btpanel.service")
# 一键登录:bootstrap 失败时仍允许 SSH 临时登录回退(2026-06-21 前行为)
BOOTSTRAP_LOGIN_HARD_FAIL_ERRORS = frozenset({
"ssh_auth_failed",
"ssh_no_credentials",
"ssh_sudo_required",
"missing_center_ip",
})
class BtPanelService:
def __init__(self, db: AsyncSession):
@@ -407,12 +419,72 @@ class BtPanelService:
ip_address=ip_address,
))
@staticmethod
def _login_url_cache_key(server_id: int) -> str:
return f"btpanel:login_url:{server_id}"
async def _get_cached_login_url(self, server_id: int) -> dict[str, str | bool] | None:
"""短窗口内复用上次生成的登录链接(防连点/多标签/多设备自我顶替)。"""
from server.infrastructure.redis.client import get_redis
try:
cached = await get_redis().get(self._login_url_cache_key(server_id))
except Exception: # noqa: BLE001 — 缓存不可用时退化为直接生成
return None
if not cached:
return None
try:
data = json.loads(cached)
except (TypeError, ValueError):
return None
if isinstance(data, dict) and data.get("url"):
data["reused"] = True
return data
return None
async def _store_cached_login_url(self, server_id: int, result: dict[str, str | bool]) -> None:
from server.infrastructure.redis.client import get_redis
try:
await get_redis().set(
self._login_url_cache_key(server_id),
json.dumps(result, ensure_ascii=False),
ex=BT_LOGIN_URL_CACHE_TTL_SECONDS,
)
except Exception: # noqa: BLE001 — 缓存写失败不影响本次登录
pass
async def create_login_url(
self,
server_id: int,
*,
admin_username: str,
ip_address: str | None,
) -> dict[str, str | bool]:
# 短窗口去重:宝塔 tmp_token 一次性,且新建会作废上一枚;跨请求/多设备连点会互相顶掉刚登录的会话
cached = await self._get_cached_login_url(server_id)
if cached is not None:
return cached
# 串行化:宝塔新建 tmp_token 会作废上一枚;并发 POST 会导致先返回的链 404
async with login_url_lock(server_id):
# 双检:并发方在等锁期间,持锁者可能已生成并写入缓存
cached = await self._get_cached_login_url(server_id)
if cached is not None:
return cached
result = await self._create_login_url_fresh(
server_id,
admin_username=admin_username,
ip_address=ip_address,
)
await self._store_cached_login_url(server_id, result)
return result
async def _create_login_url_fresh(
self,
server_id: int,
*,
admin_username: str,
ip_address: str | None,
) -> dict[str, str | bool]:
server = await self.get_server(server_id)
url: str | None = None
@@ -430,20 +502,26 @@ class BtPanelService:
)
if not result.get("ok") and not result.get("skipped"):
code = str(result.get("error") or "bootstrap_failed")
raise ValueError(f"获取宝塔 API 失败: {code}")
if code in BOOTSTRAP_LOGIN_HARD_FAIL_ERRORS:
raise ValueError(f"获取宝塔 API 失败: {code}")
logger.warning(
"bt panel login bootstrap failed server=%s code=%s; falling back to SSH temp login",
server_id,
code,
)
server = await self.get_server(server_id)
creds = read_bt_panel_credentials(server)
if creds:
try:
url = await self._login_url_via_api(creds, server)
except BtPanelApiError as exc:
logger.warning("bt panel api temp login failed server=%s: %s", server_id, exc)
method = "ssh_fallback"
url, method = await self._try_login_url_via_api(server, creds)
if not url:
method = "ssh"
url = await ssh_temp_login_url(server, base_url=None)
fresh = read_bt_panel_credentials(server)
url = await ssh_temp_login_url(
server,
base_url=fresh.base_url if fresh else None,
)
await self.audit.create(AuditLog(
admin_username=admin_username,
@@ -455,6 +533,39 @@ class BtPanelService:
))
return {"url": url, "method": method, "bootstrapped": bootstrapped}
async def _try_login_url_via_api(
self,
server: Server,
creds: BtPanelCredentials,
) -> tuple[str | None, str]:
"""Try API temp login; on failure refresh base_url from SSH (port.pl) and retry once."""
try:
return await self._login_url_via_api(creds, server), "api"
except BtPanelApiError as exc:
logger.warning("bt panel api temp login failed server=%s: %s", server.id, exc)
except Exception as exc:
logger.warning("bt panel api temp login error server=%s: %s", server.id, exc)
if await self._repair_ip_whitelist(server):
server = await self.get_server(server.id)
creds = read_bt_panel_credentials(server)
if creds:
try:
return await self._login_url_via_api(creds, server), "api"
except BtPanelApiError as exc:
logger.warning(
"bt panel api temp login retry failed server=%s: %s",
server.id,
exc,
)
except Exception as exc:
logger.warning(
"bt panel api temp login retry error server=%s: %s",
server.id,
exc,
)
return None, "ssh_fallback"
async def _login_url_via_api(
self,
creds: BtPanelCredentials,
@@ -0,0 +1,116 @@
"""Scheduled nightly detect-domain for IP-only servers missing site_url."""
from __future__ import annotations
import logging
from datetime import datetime, timezone
from typing import Any
from server.application.services.unset_path_detect_schedule import (
SCHEDULED_OPERATOR,
server_ssh_configured,
)
from server.config import settings
from server.domain.models import Server
from server.infrastructure.database.server_repo import ServerRepositoryImpl
from server.infrastructure.database.session import AsyncSessionLocal
from server.infrastructure.redis import server_batch_store as sbs
from server.infrastructure.redis.client import get_redis
from server.utils.display_time import to_beijing
from server.utils.site_host import is_ip_host, resolve_server_site_host_from_server
logger = logging.getLogger("nexus.ip_domain_detect_schedule")
REDIS_LAST_RUN_KEY = "nexus:ip_domain_detect:last_run_date"
def _enabled() -> bool:
return str(getattr(settings, "IP_DOMAIN_DETECT_ENABLED", "true")).lower() == "true"
def _cron_expr() -> str:
expr = (getattr(settings, "IP_DOMAIN_DETECT_CRON", None) or "30 23 * * *").strip()
return expr or "30 23 * * *"
def server_needs_site_domain(server: Server) -> bool:
if not is_ip_host(server.domain):
return False
return resolve_server_site_host_from_server(server) is None
async def list_ip_only_without_site_server_ids(*, ssh_only: bool = True) -> list[int]:
"""Servers with IP SSH address and no resolvable site hostname yet."""
async with AsyncSessionLocal() as session:
repo = ServerRepositoryImpl(session)
servers, _total = await repo.get_filtered(max_rows=5000)
ids: list[int] = []
for server in servers:
if not server_needs_site_domain(server):
continue
if ssh_only and not server_ssh_configured(server):
continue
ids.append(server.id)
return ids
async def _detect_domain_batch_running() -> bool:
for live in await sbs.list_running_live_jobs():
if live.get("op") == "detect-domain":
return True
return False
async def _mark_ran_today() -> None:
today = to_beijing(datetime.now(timezone.utc)).strftime("%Y-%m-%d")
redis = get_redis()
await redis.set(REDIS_LAST_RUN_KEY, today, ex=86400 * 2)
async def _already_ran_today() -> bool:
today = to_beijing(datetime.now(timezone.utc)).strftime("%Y-%m-%d")
redis = get_redis()
last = await redis.get(REDIS_LAST_RUN_KEY)
if last is None:
return False
if isinstance(last, bytes):
last = last.decode()
return str(last).strip() == today
async def run_scheduled_ip_domain_detect(*, force: bool = False) -> dict[str, Any]:
"""Start batch detect-domain for IP-only servers; once per Beijing calendar day."""
if not _enabled() and not force:
return {"action": "skipped", "reason": "disabled"}
if not force and await _already_ran_today():
return {"action": "skipped", "reason": "already_ran_today"}
if await _detect_domain_batch_running():
return {"action": "skipped", "reason": "detect_domain_batch_running"}
server_ids = await list_ip_only_without_site_server_ids()
if not server_ids:
await _mark_ran_today()
return {"action": "skipped", "reason": "no_ip_only_servers", "server_count": 0}
from server.application.services.server_batch_service import start_batch_job
started = await start_batch_job(
op="detect-domain",
server_ids=server_ids,
operator=SCHEDULED_OPERATOR,
params={"scheduled": True, "cron": _cron_expr()},
)
await _mark_ran_today()
logger.info(
"Scheduled IP domain detect started job_id=%s servers=%s",
started.get("job_id"),
len(server_ids),
)
return {
"action": "started",
"job_id": started.get("job_id"),
"server_count": len(server_ids),
"label": started.get("label"),
}
@@ -18,6 +18,7 @@ from server.utils.display_time import to_beijing
logger = logging.getLogger("nexus.offline_daily_report_schedule")
REDIS_LAST_RUN_KEY = "nexus:offline_daily_report:last_run_date"
REDIS_PREFLIGHT_LAST_RUN_KEY = "nexus:offline_daily_preflight:last_run_date"
def _enabled() -> bool:
@@ -50,6 +51,23 @@ async def _already_ran_today() -> bool:
return str(last).strip() == today
async def _mark_preflight_ran_today() -> None:
today = to_beijing(datetime.now(timezone.utc)).strftime("%Y-%m-%d")
redis = get_redis()
await redis.set(REDIS_PREFLIGHT_LAST_RUN_KEY, today, ex=86400 * 2)
async def _already_ran_preflight_today() -> bool:
today = to_beijing(datetime.now(timezone.utc)).strftime("%Y-%m-%d")
redis = get_redis()
last = await redis.get(REDIS_PREFLIGHT_LAST_RUN_KEY)
if last is None:
return False
if isinstance(last, bytes):
last = last.decode()
return str(last).strip() == today
async def collect_offline_snapshot() -> dict[str, Any]:
"""Scan Agent-monitored servers and return online/offline counts + offline list."""
redis = get_redis()
@@ -57,7 +75,45 @@ async def collect_offline_snapshot() -> dict[str, Any]:
return await scan_monitored_connectivity(redis, session)
async def run_scheduled_offline_daily_report(*, force: bool = False) -> dict[str, Any]:
async def run_scheduled_offline_preflight(*, force: bool = False) -> dict[str, Any]:
"""SSH health-check offline Agent-monitored servers; once per Beijing calendar day."""
if not _health_check_enabled() and not force:
return {"action": "skipped", "reason": "health_check_disabled"}
if not force and await _already_ran_preflight_today():
return {"action": "skipped", "reason": "already_ran_today"}
pre_scan = await collect_offline_snapshot()
offline_ids = [
int(item["id"])
for item in (pre_scan.get("offline_servers") or [])
if item.get("id") is not None
]
if not offline_ids:
await _mark_preflight_ran_today()
return {"action": "skipped", "reason": "no_offline_servers"}
logger.info(
"Offline SSH preflight: health check on %s offline servers",
len(offline_ids),
)
preflight = await health_check_server_ids(offline_ids)
await _mark_preflight_ran_today()
logger.info(
"Offline SSH preflight done: checked=%s online=%s offline=%s errors=%s",
preflight.get("checked"),
preflight.get("online"),
preflight.get("offline"),
preflight.get("errors"),
)
return {"action": "checked", "preflight": preflight}
async def run_scheduled_offline_daily_report(
*,
force: bool = False,
preflight: dict[str, int] | None = None,
) -> dict[str, Any]:
"""Send Telegram summary of offline server count; once per Beijing calendar day."""
if not _enabled() and not force:
return {"action": "skipped", "reason": "disabled"}
@@ -65,8 +121,10 @@ async def run_scheduled_offline_daily_report(*, force: bool = False) -> dict[str
if not force and await _already_ran_today():
return {"action": "skipped", "reason": "already_ran_today"}
preflight: dict[str, int] = {}
if _health_check_enabled():
preflight_stats: dict[str, int] = {}
if preflight is not None:
preflight_stats = preflight
elif _health_check_enabled():
pre_scan = await collect_offline_snapshot()
offline_ids = [
int(item["id"])
@@ -78,7 +136,7 @@ async def run_scheduled_offline_daily_report(*, force: bool = False) -> dict[str
"Offline daily report: SSH health check on %s offline servers",
len(offline_ids),
)
preflight = await health_check_server_ids(offline_ids)
preflight_stats = await health_check_server_ids(offline_ids)
snapshot = await collect_offline_snapshot()
monitored = int(snapshot.get("monitored") or 0)
@@ -93,7 +151,7 @@ async def run_scheduled_offline_daily_report(*, force: bool = False) -> dict[str
online=int(snapshot.get("online") or 0),
offline=int(snapshot.get("offline") or 0),
offline_servers=list(snapshot.get("offline_servers") or []),
preflight=preflight or None,
preflight=preflight_stats or None,
)
await _mark_ran_today()
@@ -116,5 +174,5 @@ async def run_scheduled_offline_daily_report(*, force: bool = False) -> dict[str
"monitored": monitored,
"online": snapshot.get("online"),
"offline": snapshot.get("offline"),
"preflight": preflight or None,
"preflight": preflight_stats or None,
}
@@ -10,6 +10,7 @@ from typing import Any, Optional
from server.application.server_batch_common import (
agent_install_skip_result,
batch_server_display_name,
detect_and_save_site_url,
detect_and_save_target_path,
ensure_agent_api_key,
install_error_msg,
@@ -37,6 +38,7 @@ OP_LABELS = {
"category": "批量修改分类",
"health-check": "健康检查",
"detect-path": "自动检测目标路径",
"detect-domain": "自动检测站点域名",
"onboard": "新服务器初始化",
"install-agent": "批量安装 Agent",
"upgrade-agent": "批量升级 Agent",
@@ -48,6 +50,7 @@ AUDIT_ACTIONS = {
"category": "batch_update_category",
"health-check": "batch_health_check",
"detect-path": "batch_detect_path",
"detect-domain": "batch_detect_domain",
"onboard": "batch_server_onboard",
"install-agent": "batch_install_agent",
"upgrade-agent": "batch_upgrade_agent",
@@ -330,6 +333,8 @@ async def _run_background(job_id: int) -> None:
await _run_health_check(live)
elif op == "detect-path":
await _run_detect_path(live)
elif op == "detect-domain":
await _run_detect_domain(live)
elif op == "onboard":
await _run_onboard(live)
elif op == "install-agent":
@@ -382,6 +387,7 @@ async def _write_audit(live: dict[str, Any]) -> None:
"category": f"批量修改分类 更新{stats['success']}",
"health-check": f"批量健康检查: {stats['success']}/{stats['total']} 在线",
"detect-path": f"批量检测路径: {stats['success']}/{stats['total']} 成功",
"detect-domain": f"批量检测站点域名: {stats['success']}/{stats['total']} 成功",
"onboard": f"新服务器初始化: {stats['success']}/{stats['total']} 成功",
"install-agent": f"批量安装Agent: {stats['success']}/{stats['total']} 成功",
"upgrade-agent": f"批量升级Agent: {stats['success']}/{stats['total']} 成功",
@@ -538,6 +544,33 @@ async def _run_detect_path(live: dict[str, Any]) -> None:
await _run_with_semaphore(live, handler)
async def _run_detect_domain(live: dict[str, Any]) -> None:
async def handler(sid: int, server_map: dict[int, Server], labels: dict[int, str], _live) -> dict:
server = server_map.get(sid)
label = labels[sid]
if not server:
return result_item_dict(server_id=sid, server_name=label, success=False, error="服务器不存在")
try:
outcome = await detect_and_save_site_url(server)
if outcome.get("success"):
return result_item_dict(
server_id=sid,
server_name=label,
success=True,
stdout=str(outcome.get("stdout") or ""),
)
return result_item_dict(
server_id=sid,
server_name=label,
success=False,
error=str(outcome.get("error") or "探测失败"),
)
except Exception as e:
return result_item_dict(server_id=sid, server_name=label, success=False, error=str(e)[:300])
await _run_with_semaphore(live, handler)
async def _run_onboard(live: dict[str, Any]) -> None:
from server.application.services.files_sudoers_service import install_nexus_files_sudoers
from server.utils.posix_paths import is_unset_target_path
@@ -10,6 +10,7 @@ from collections.abc import AsyncIterator
from datetime import datetime, timedelta, timezone
from typing import Any
import asyncssh
from sqlalchemy.ext.asyncio import AsyncSession
from server.api.remote_path_validation import assert_clipboard_transfer_safe
@@ -22,9 +23,16 @@ from server.infrastructure.redis.transfer_package_store import (
load_package_meta,
save_package_meta,
)
from server.infrastructure.ssh.asyncssh_pool import sftp_session, ssh_pool
from server.infrastructure.btpanel.credentials import (
bt_panel_configured,
read_bt_panel_credentials,
)
from server.infrastructure.btpanel.file_urls import signed_download_url
from server.infrastructure.ssh.asyncssh_pool import remote_write_bytes, sftp_session, ssh_pool
from server.infrastructure.ssh.remote_archive import run_remote_compress
from server.infrastructure.ssh.remote_shell import exec_ssh_command_with_fallback
from server.utils.files_elevation import get_files_elevation
from server.utils.files_upload_permissions import apply_transfer_permissions
from server.utils.posix_paths import (
PosixPathError,
normalize_remote_abs_path,
@@ -74,6 +82,75 @@ async def _ensure_remote_dir(server: Server, path: str) -> None:
raise ServerTransferError(err)
async def _download_url_to_remote_path(
server: Server,
*,
download_url: str,
dest_path: str,
timeout: int = 3700,
curl_insecure: bool = False,
) -> None:
"""Download via curl to /tmp, then mv to dest (supports sudo for /www paths)."""
parent = posixpath.dirname(dest_path) or "/"
await _ensure_remote_dir(server, parent)
suffix = ".tar.gz" if dest_path.endswith(".tar.gz") else ".zip" if dest_path.endswith(".zip") else ""
staging = f"/tmp/nexus-xfer-{secrets.token_hex(12)}{suffix}"
url_q = shlex.quote(download_url)
staging_q = shlex.quote(staging)
dest_q = shlex.quote(dest_path)
insecure_flag = " -k" if curl_insecure else ""
curl_result = await exec_ssh_command_with_fallback(
server,
f"curl -fsSL{insecure_flag} --connect-timeout 30 --max-time 3600 -o {staging_q} {url_q}",
timeout=timeout,
)
if curl_result.get("exit_code") != 0:
err = (curl_result.get("stderr") or curl_result.get("stdout") or "下载失败")[:400]
raise ServerTransferError(err)
move_result = await exec_ssh_command_with_fallback(
server,
f"mv -f {staging_q} {dest_q}",
timeout=120,
)
if move_result.get("exit_code") != 0:
await exec_ssh_command_with_fallback(server, f"rm -f {staging_q}", timeout=30)
err = (move_result.get("stderr") or move_result.get("stdout") or "无法写入目标路径")[:400]
raise ServerTransferError(
f"下载完成但无法移动到 {dest_path}{err}(请确认目标目录可写或已配置免密 sudo)"
)
async def _extract_archive_on_server(
server: Server,
archive_path: str,
extract_dir: str,
fmt: str,
) -> None:
"""Extract archive on remote; tar uses --overwrite to replace existing site files."""
archive_q = shlex.quote(archive_path)
extract_q = shlex.quote(extract_dir)
if fmt == "tar.gz":
commands = [
f"tar -xzf {archive_q} -C {extract_q} --overwrite",
f"tar -xzf {archive_q} -C {extract_q} --overwrite-dir",
f"tar -xzf {archive_q} -C {extract_q}",
]
else:
commands = [f"unzip -o {archive_q} -d {extract_q}"]
last_err = ""
for cmd in commands:
result = await exec_ssh_command_with_fallback(server, cmd, timeout=600)
if result.get("exit_code") == 0:
return
last_err = ((result.get("stderr") or "") + (result.get("stdout") or "")).strip()[:400]
raise ServerTransferError(last_err or "解压失败")
async def _sftp_is_directory(sftp: Any, path: str) -> bool:
stat = await sftp.stat(path)
ftype = getattr(stat, "type", None)
@@ -91,7 +168,8 @@ async def _sftp_is_directory(sftp: Any, path: str) -> bool:
async def _relay_file(
src_sftp: Any,
dst_sftp: Any,
conn_dst: asyncssh.SSHClientConnection,
dest_server: Server,
src_path: str,
dst_path: str,
) -> int:
@@ -101,32 +179,61 @@ async def _relay_file(
raise ServerTransferError(
f"文件 {posix_basename(src_path)} 超过直传上限 {MAX_RELAY_FILE_BYTES} 字节,请使用打包模式"
)
transferred = 0
parts: list[bytes] = []
async with src_sftp.open(src_path, "rb") as rf:
async with dst_sftp.open(dst_path, "wb") as wf:
while chunk := await rf.read(CHUNK_SIZE):
await wf.write(chunk)
transferred += len(chunk)
return transferred
while chunk := await rf.read(CHUNK_SIZE):
parts.append(chunk)
data = b"".join(parts)
try:
await remote_write_bytes(
conn_dst,
dst_path,
data,
elevation=get_files_elevation(dest_server),
)
except (asyncssh.PermissionDenied, asyncssh.SFTPError, PermissionError) as exc:
raise ServerTransferError(
f"目标机写入失败 {dst_path}{exc}(请确认目标目录可写或已配置免密 sudo)"
) from exc
return len(data)
async def _relay_tree(
src_sftp: Any,
dst_sftp: Any,
conn_dst: asyncssh.SSHClientConnection,
dest_server: Server,
src_path: str,
dst_path: str,
) -> int:
if await _sftp_is_directory(src_sftp, src_path):
await dst_sftp.makedirs(dst_path, exist_ok=True)
await _ensure_remote_dir(dest_server, dst_path)
total = 0
for name in await src_sftp.listdir(src_path):
if name in (".", ".."):
continue
child_src = posix_join(src_path, name)
child_dst = posix_join(dst_path, name)
total += await _relay_tree(src_sftp, dst_sftp, child_src, child_dst)
total += await _relay_tree(
src_sftp, conn_dst, dest_server, child_src, child_dst,
)
return total
return await _relay_file(src_sftp, dst_sftp, src_path, dst_path)
return await _relay_file(src_sftp, conn_dst, dest_server, src_path, dst_path)
async def _apply_dest_permissions(
dest_server: Server,
paths: list[str],
) -> list[dict[str, Any]]:
"""Set www:www (NEXUS_RSYNC_PUSH_CHOWN) on transferred paths."""
results: list[dict[str, Any]] = []
for path in paths:
perm = await apply_transfer_permissions(dest_server, path, recursive=True)
results.append(perm)
if perm.get("error"):
raise ServerTransferError(
f"传输完成但设置权限失败 ({path}): {perm['error']}"
)
return results
async def relay_files(
@@ -152,16 +259,23 @@ async def relay_files(
bytes_total = 0
items: list[dict[str, str]] = []
try:
async with sftp_session(conn_src) as src_sftp, sftp_session(conn_dst) as dst_sftp:
async with sftp_session(conn_src) as src_sftp:
for src_path in normalized_sources:
base = posix_basename(src_path.rstrip("/")) or src_path
target = remote_join(dest, base)
bytes_total += await _relay_tree(src_sftp, dst_sftp, src_path, target)
bytes_total += await _relay_tree(
src_sftp, conn_dst, dest_server, src_path, target,
)
items.append({"source": src_path, "dest": target})
except asyncssh.Error as exc:
raise ServerTransferError(f"SSH/SFTP 传输失败:{exc}") from exc
finally:
await ssh_pool.release(source.id)
await ssh_pool.release(dest_server.id)
dest_paths = [item["dest"] for item in items]
permissions = await _apply_dest_permissions(dest_server, dest_paths)
return {
"mode": "relay",
"source_server_id": source_server_id,
@@ -170,6 +284,7 @@ async def relay_files(
"count": len(items),
"bytes_transferred": bytes_total,
"items": items,
"permissions": permissions,
}
@@ -258,33 +373,24 @@ async def pull_transfer_package(
raise ServerTransferError("目标路径必须为绝对路径")
download_url = f"{_api_base_url()}/api/sync/transfer-download/{token}"
url_q = shlex.quote(download_url)
dest_q = shlex.quote(dest)
parent = posixpath.dirname(dest) or "/"
await _ensure_remote_dir(dest_server, parent)
curl_cmd = f"curl -fsSL --connect-timeout 30 --max-time 3600 -o {dest_q} {url_q}"
result = await exec_ssh_command_with_fallback(dest_server, curl_cmd, timeout=3700)
if result.get("exit_code") != 0:
err = (result.get("stderr") or result.get("stdout") or "下载失败")[:400]
raise ServerTransferError(err)
await _download_url_to_remote_path(
dest_server,
download_url=download_url,
dest_path=dest,
)
extracted_to: str | None = None
fmt = str(meta.get("format") or "tar.gz")
if extract:
extract_dir = posixpath.dirname(dest) or "/"
extract_q = shlex.quote(extract_dir)
if fmt == "tar.gz":
extract_cmd = f"tar -xzf {dest_q} -C {extract_q}"
else:
extract_cmd = f"unzip -o {dest_q} -d {extract_q}"
ext_res = await exec_ssh_command_with_fallback(dest_server, extract_cmd, timeout=600)
if ext_res.get("exit_code") != 0:
err = (ext_res.get("stderr") or ext_res.get("stdout") or "解压失败")[:400]
raise ServerTransferError(err)
await _extract_archive_on_server(dest_server, dest, extract_dir, fmt)
extracted_to = extract_dir
perm_targets = [extracted_to] if extract and extracted_to else [dest]
permissions = await _apply_dest_permissions(dest_server, perm_targets)
return {
"mode": "pull",
"dest_server_id": dest_server_id,
@@ -292,6 +398,106 @@ async def pull_transfer_package(
"extracted_to": extracted_to,
"download_url": download_url,
"bytes_hint": meta.get("size"),
"permissions": permissions,
}
async def _deliver_via_btpanel_download(
session: AsyncSession,
*,
source: Server,
source_server_id: int,
paths: list[str],
dest_server_id: int,
dest_path: str,
extract: bool,
fmt: str,
cleanup_source: bool,
) -> dict[str, Any]:
"""Package on source, B curls A's signed Baota /download URL (no Nexus proxy)."""
if not paths:
raise ServerTransferError("paths 不能为空")
creds = read_bt_panel_credentials(source)
if not creds:
raise ServerTransferError("源服务器宝塔 API 凭据无效")
normalized: list[str] = []
for raw in paths:
try:
normalized.append(normalize_remote_abs_path(raw))
except PosixPathError as exc:
raise ServerTransferError(str(exc)) from exc
dest = dest_path.strip()
if not dest.startswith("/"):
raise ServerTransferError("目标路径必须为绝对路径")
suffix = ".tar.gz" if fmt == "tar.gz" else ".zip"
archive_path = f"/tmp/nexus-bt-xfer-{secrets.token_hex(10)}{suffix}"
compress = await run_remote_compress(source, normalized, archive_path, fmt, timeout=600)
if compress.get("exit_code") != 0:
err = (compress.get("stderr") or compress.get("stdout") or "打包失败")[:400]
raise ServerTransferError(err)
stat_res = await exec_ssh_command_with_fallback(
source,
f"stat -c %s {shlex.quote(archive_path)}",
timeout=30,
)
size = 0
if stat_res.get("exit_code") == 0:
try:
size = int((stat_res.get("stdout") or "0").strip())
except ValueError:
size = 0
if size > MAX_RELAY_FILE_BYTES:
await cleanup_source_archive(source, archive_path)
raise ServerTransferError(
f"打包后大小 {size} 字节超过上限 {MAX_RELAY_FILE_BYTES},请缩小范围"
)
download_url = signed_download_url(creds, archive_path)
dest_server = await _get_server(session, dest_server_id)
try:
await _download_url_to_remote_path(
dest_server,
download_url=download_url,
dest_path=dest,
curl_insecure=not creds.verify_ssl,
)
extracted_to: str | None = None
if extract:
extract_dir = posixpath.dirname(dest) or "/"
await _extract_archive_on_server(dest_server, dest, extract_dir, fmt)
extracted_to = extract_dir
perm_targets = [extracted_to] if extract and extracted_to else [dest]
permissions = await _apply_dest_permissions(dest_server, perm_targets)
except Exception:
await cleanup_source_archive(source, archive_path)
raise
if cleanup_source:
try:
await cleanup_source_archive(source, archive_path)
except Exception as exc:
logger.warning("cleanup source archive failed: %s", exc)
return {
"mode": "deliver",
"via": "btpanel_download",
"source_server_id": source_server_id,
"dest_server_id": dest_server_id,
"download_url": download_url,
"archive_path": archive_path,
"size": size,
"dest_path": dest,
"extracted_to": extracted_to,
"permissions": permissions,
}
@@ -309,6 +515,20 @@ async def deliver_transfer_package(
"""One-click: package on source, pull on dest, optional extract and source cleanup."""
_assert_distinct_servers(source_server_id, dest_server_id)
source = await _get_server(session, source_server_id)
if bt_panel_configured(source) and read_bt_panel_credentials(source):
return await _deliver_via_btpanel_download(
session,
source=source,
source_server_id=source_server_id,
paths=paths,
dest_server_id=dest_server_id,
dest_path=dest_path,
extract=extract,
fmt=fmt,
cleanup_source=cleanup_source,
)
package = await create_transfer_package(
session,
source_server_id=source_server_id,
+44 -1
View File
@@ -110,6 +110,7 @@ async def _slot_payload(
*,
slot_index: int,
pin_row: dict[str, Any] | None,
server_repo: ServerRepositoryImpl | None = None,
) -> dict[str, Any]:
if not pin_row:
return {"slot_index": slot_index, "empty": True}
@@ -124,6 +125,40 @@ async def _slot_payload(
if metrics and processes:
metrics = {**metrics, "processes": processes}
remaining = _remaining_sec(pin_row["expires_at"]) if monitoring_enabled else None
agent_action = "ok"
agent_action_message: str | None = None
agent_version: str | None = None
central_agent_version: str | None = None
if pin_row.get("server_id") and server_repo is not None:
server = await server_repo.get_by_id(int(pin_row["server_id"]))
if server:
from server.utils.agent_version import compute_agent_guidance
redis = get_redis()
heartbeat = await redis.hgetall(f"heartbeat:{server.id}")
guidance = compute_agent_guidance(
agent_version_db=server.agent_version,
heartbeat=heartbeat or None,
)
agent_action = str(guidance.get("agent_action") or "ok")
agent_action_message = guidance.get("agent_action_message")
central_agent_version = guidance.get("central_agent_version")
live_ver = ""
if heartbeat:
live_ver = (heartbeat.get("agent_version") or "").strip()
if not live_ver:
live_ver = (server.agent_version or "").strip()
agent_version = live_ver or None
if monitoring_enabled and agent_action == "upgrade":
agent_action_message = (
f"实时监测 5s 推送需 Agent {central_agent_version}(当前 {live_ver or '未知'})。"
"升级后中心将跳过 SSH 探针。"
)
elif monitoring_enabled and agent_action == "install":
agent_action_message = (
f"尚未安装 Agent(主站 {central_agent_version})。"
"安装并开启监测后可走 5s 出站推送,否则仅 SSH 探针。"
)
return {
"slot_index": slot_index,
"empty": False,
@@ -140,6 +175,10 @@ async def _slot_payload(
"metrics": metrics,
"sparkline": sparkline,
"processes": processes,
"agent_action": agent_action,
"agent_action_message": agent_action_message,
"agent_version": agent_version,
"central_agent_version": central_agent_version,
}
@@ -155,7 +194,11 @@ class WatchService:
pins = await self.watch_repo.list_active_pins_for_admin(admin_id)
by_slot = {p["slot_index"]: p for p in pins}
slots = [
await _slot_payload(slot_index=i, pin_row=by_slot.get(i))
await _slot_payload(
slot_index=i,
pin_row=by_slot.get(i),
server_repo=self.server_repo,
)
for i in range(WATCH_MAX_SLOTS)
]
return {"slots": slots}
+42
View File
@@ -0,0 +1,42 @@
"""Periodic purge of alert_logs older than ALERT_LOG_RETENTION_DAYS (7 days)."""
from __future__ import annotations
import asyncio
import logging
from server.infrastructure.database.alert_log_repo import (
ALERT_LOG_RETENTION_DAYS,
AlertLogRepositoryImpl,
)
from server.infrastructure.database.session import AsyncSessionLocal
logger = logging.getLogger("nexus.alert_log_purge")
PURGE_INTERVAL_SECONDS = 24 * 3600
async def purge_stale_alert_logs() -> int:
"""Delete alert_logs rows older than retention window. Returns rows removed."""
async with AsyncSessionLocal() as session:
repo = AlertLogRepositoryImpl(session)
return await repo.purge_older_than(retention_days=ALERT_LOG_RETENTION_DAYS)
async def alert_log_purge_loop() -> None:
"""Run daily on primary worker; first purge runs immediately at startup."""
while True:
try:
count = await purge_stale_alert_logs()
if count:
logger.info(
"Alert log purge: removed %s row(s) older than %s days",
count,
ALERT_LOG_RETENTION_DAYS,
)
await asyncio.sleep(PURGE_INTERVAL_SECONDS)
except asyncio.CancelledError:
raise
except Exception:
logger.exception("Alert log purge loop error")
await asyncio.sleep(PURGE_INTERVAL_SECONDS)
+42
View File
@@ -0,0 +1,42 @@
"""Periodic purge of audit_logs older than AUDIT_LOG_RETENTION_DAYS (7 days)."""
from __future__ import annotations
import asyncio
import logging
from server.infrastructure.database.audit_log_repo import (
AUDIT_LOG_RETENTION_DAYS,
AuditLogRepositoryImpl,
)
from server.infrastructure.database.session import AsyncSessionLocal
logger = logging.getLogger("nexus.audit_log_purge")
PURGE_INTERVAL_SECONDS = 24 * 3600
async def purge_stale_audit_logs() -> int:
"""Delete audit_logs rows older than retention window. Returns rows removed."""
async with AsyncSessionLocal() as session:
repo = AuditLogRepositoryImpl(session)
return await repo.purge_older_than(retention_days=AUDIT_LOG_RETENTION_DAYS)
async def audit_log_purge_loop() -> None:
"""Run daily on primary worker; first purge runs immediately at startup."""
while True:
try:
count = await purge_stale_audit_logs()
if count:
logger.info(
"Audit log purge: removed %s row(s) older than %s days",
count,
AUDIT_LOG_RETENTION_DAYS,
)
await asyncio.sleep(PURGE_INTERVAL_SECONDS)
except asyncio.CancelledError:
raise
except Exception:
logger.exception("Audit log purge loop error")
await asyncio.sleep(PURGE_INTERVAL_SECONDS)
+12 -4
View File
@@ -89,22 +89,27 @@ async def _do_refresh() -> RefreshResult:
from server.infrastructure.database.setting_repo import SettingRepositoryImpl
from server.infrastructure.settings_broadcast import publish_setting_changes
from server.utils.display_time import format_beijing
refresh_at = format_beijing(with_label=False)
async with AsyncSessionLocal() as session:
repo = SettingRepositoryImpl(session)
await repo.set("login_subscription_ips", sub_val)
await repo.set("login_allowed_ips", combined_val)
await repo.set("login_subscription_last_refresh", refresh_at)
settings.LOGIN_SUBSCRIPTION_IPS = sub_val
settings.LOGIN_ALLOWED_IPS = combined_val
settings.LOGIN_SUBSCRIPTION_LAST_REFRESH = refresh_at
await publish_setting_changes({
"login_subscription_ips": sub_val,
"login_allowed_ips": combined_val,
"login_subscription_last_refresh": refresh_at,
})
from server.utils.display_time import format_beijing
_last_refresh_at = format_beijing(with_label=False)
_last_refresh_at = refresh_at
_last_subscription_count = len(new_sub_ips)
logger.info(
"Subscription IPs refreshed: %d → combined %d (+ %d manual)",
@@ -122,7 +127,10 @@ async def _do_refresh() -> RefreshResult:
def get_last_refresh_time() -> str:
return _last_refresh_at
if _last_refresh_at:
return _last_refresh_at
from server.config import settings
return (getattr(settings, "LOGIN_SUBSCRIPTION_LAST_REFRESH", "") or "").strip()
def get_subscription_count() -> int:
@@ -0,0 +1,42 @@
"""Nightly cron: auto-detect site domain for IP-only servers."""
from __future__ import annotations
import asyncio
import logging
from datetime import datetime, timezone
from server.application.services.ip_domain_detect_schedule import (
_cron_expr,
_enabled,
run_scheduled_ip_domain_detect,
)
from server.background.schedule_runner import _cron_match
logger = logging.getLogger("nexus.ip_domain_detect_loop")
CHECK_INTERVAL_SECONDS = 60
async def ip_domain_detect_loop() -> None:
"""Primary worker: fire scheduled detect-domain when cron matches (Beijing wall clock)."""
while True:
try:
if _enabled():
now = datetime.now(timezone.utc)
if _cron_match(_cron_expr(), now):
result = await run_scheduled_ip_domain_detect()
if result.get("action") == "started":
logger.info(
"IP domain detect schedule: job_id=%s servers=%s",
result.get("job_id"),
result.get("server_count"),
)
elif result.get("reason") not in ("already_ran_today",):
logger.debug("IP domain detect schedule: %s", result)
await asyncio.sleep(CHECK_INTERVAL_SECONDS)
except asyncio.CancelledError:
raise
except Exception:
logger.exception("IP domain detect loop error")
await asyncio.sleep(CHECK_INTERVAL_SECONDS)
+25 -6
View File
@@ -1,4 +1,4 @@
"""Daily cron: Telegram report of offline Agent-monitored server count."""
"""Daily cron: SSH preflight on offline servers; optional Telegram offline report."""
from __future__ import annotations
@@ -9,7 +9,9 @@ from datetime import datetime, timezone
from server.application.services.offline_daily_report_schedule import (
_cron_expr,
_enabled,
_health_check_enabled,
run_scheduled_offline_daily_report,
run_scheduled_offline_preflight,
)
from server.background.schedule_runner import _cron_match
@@ -19,13 +21,29 @@ CHECK_INTERVAL_SECONDS = 60
async def offline_daily_report_loop() -> None:
"""Primary worker: send offline count to Telegram when cron matches (Beijing)."""
"""Primary worker: SSH preflight and/or Telegram report when cron matches (Beijing)."""
while True:
try:
if _enabled():
now = datetime.now(timezone.utc)
if _cron_match(_cron_expr(), now):
result = await run_scheduled_offline_daily_report()
now = datetime.now(timezone.utc)
if _cron_match(_cron_expr(), now):
preflight_stats: dict[str, int] | None = None
if _health_check_enabled():
preflight_result = await run_scheduled_offline_preflight()
if preflight_result.get("action") == "checked":
preflight_stats = preflight_result.get("preflight")
logger.info(
"Offline SSH preflight: checked=%s online=%s offline=%s",
preflight_stats.get("checked") if preflight_stats else 0,
preflight_stats.get("online") if preflight_stats else 0,
preflight_stats.get("offline") if preflight_stats else 0,
)
elif preflight_result.get("reason") not in ("already_ran_today",):
logger.debug("Offline SSH preflight: %s", preflight_result)
if _enabled():
result = await run_scheduled_offline_daily_report(
preflight=preflight_stats,
)
if result.get("action") == "sent":
logger.info(
"Offline daily report: monitored=%s offline=%s",
@@ -34,6 +52,7 @@ async def offline_daily_report_loop() -> None:
)
elif result.get("reason") not in ("already_ran_today",):
logger.debug("Offline daily report: %s", result)
await asyncio.sleep(CHECK_INTERVAL_SECONDS)
except asyncio.CancelledError:
raise
+13 -1
View File
@@ -23,6 +23,7 @@ from server.utils.watch_metrics import (
WATCH_PROBE_TIMEOUT_SEC,
WATCH_REDIS_FRESH_SEC,
WatchProbeSample,
agent_watch_sample_usable,
failure_sample,
merge_redis_and_ssh,
parse_redis_watch_payload,
@@ -96,7 +97,18 @@ async def probe_server_metrics(
if heartbeat and _heartbeat_fresh(heartbeat):
redis_sample = parse_redis_watch_payload(heartbeat)
# 实时监测每 5s 走 SSH psutil(与宝塔同源);不再因 Agent 有 IO 而跳过 SSH
if agent_watch_sample_usable(heartbeat, redis_sample) and redis_sample is not None:
sample = redis_sample
sample.source = "agent"
sample.is_online = True
if fetch_processes:
procs = await ssh_watch_processes(server, timeout=WATCH_PROBE_TIMEOUT_SEC)
if procs:
sample.processes_json = procs
fill_hardware_gaps(sample)
return sample
# Agent 未进 watch 模式或心跳过期 → SSH psutil(与宝塔同源)
ssh_result = await ssh_watch_probe(server, timeout=WATCH_PROBE_TIMEOUT_SEC)
if ssh_result.get("ok"):
ssh_sample = parse_ssh_watch_payload(ssh_result["payload"])
+6 -2
View File
@@ -77,9 +77,9 @@ class Settings(BaseSettings):
AGENT_INSTALL_SCHEDULE_CRON: str = "40 23 * * *" # 北京时间 23:40
# Daily offline server count → Telegram (Agent-monitored servers only)
OFFLINE_DAILY_REPORT_ENABLED: str = "true"
OFFLINE_DAILY_REPORT_ENABLED: str = "false"
OFFLINE_DAILY_REPORT_CRON: str = "50 11 * * *" # 北京时间 11:50
OFFLINE_DAILY_REPORT_HEALTH_CHECK: str = "true" # 日报前先 SSH 复检离线子机
OFFLINE_DAILY_REPORT_HEALTH_CHECK: str = "true" # 定时 SSH 复检离线子机(与 Telegram 日报独立)
# Nightly auto-detect site domain for servers whose SSH address is IP-only
IP_DOMAIN_DETECT_ENABLED: str = "true"
@@ -93,6 +93,8 @@ class Settings(BaseSettings):
CPU_ALERT_THRESHOLD: int = 80
MEM_ALERT_THRESHOLD: int = 80
DISK_ALERT_THRESHOLD: int = 80
# Consecutive over-threshold heartbeats before alert (default 3 ≈ 3min @ 60s)
ALERT_STREAK_REQUIRED: int = 3
# Telegram (mutable — configurable from settings UI)
TELEGRAM_BOT_TOKEN: str = ""
@@ -157,6 +159,7 @@ class Settings(BaseSettings):
"cpu_alert_threshold": "CPU_ALERT_THRESHOLD",
"mem_alert_threshold": "MEM_ALERT_THRESHOLD",
"disk_alert_threshold": "DISK_ALERT_THRESHOLD",
"alert_streak_required": "ALERT_STREAK_REQUIRED",
"telegram_bot_token": "TELEGRAM_BOT_TOKEN",
"telegram_chat_id": "TELEGRAM_CHAT_ID",
"telegram_offline_bot_token": "TELEGRAM_OFFLINE_BOT_TOKEN",
@@ -192,6 +195,7 @@ class Settings(BaseSettings):
"DB_POOL_SIZE", "DB_MAX_OVERFLOW", "HEALTH_CHECK_INTERVAL",
"JWT_ACCESS_TOKEN_EXPIRE_MINUTES", "JWT_REFRESH_TOKEN_EXPIRE_DAYS",
"CPU_ALERT_THRESHOLD", "MEM_ALERT_THRESHOLD", "DISK_ALERT_THRESHOLD",
"ALERT_STREAK_REQUIRED",
"BT_PANEL_BOOTSTRAP_BATCH", "BT_PANEL_BOOTSTRAP_INTERVAL",
}
+4 -1
View File
@@ -71,7 +71,10 @@ class BtPanelClient:
cookies.set(name, value)
async with httpx.AsyncClient(verify=self.creds.verify_ssl, timeout=60.0) as client:
response = await client.post(url, data=payload, cookies=cookies)
try:
response = await client.post(url, data=payload, cookies=cookies)
except httpx.HTTPError as exc:
raise BtPanelApiError(f"无法连接宝塔面板(请检查面板地址/端口): {exc}") from exc
# merge Set-Cookie
if response.cookies:
@@ -2,3 +2,10 @@
# 一键登录临时 token / 会话上限(秒);与宝塔 set_temp_login expire_time 对齐
BT_TEMP_LOGIN_TTL_SECONDS = 86400
# 一键登录链接短窗口去重缓存(秒)。
# 宝塔 tmp_token 一次性:request_tmp 只认 state=0 记录,链接被打开后即 state=1、无法再用;
# 且每次 set_temp_login 会 delete 未使用的旧记录再建新的。若同一台在极短时间内被连点/
# 多标签/多设备/刷新后重复触发,会各自新建 token 并互相顶掉,导致刚登录的会话被作废。
# 该窗口内对同一台复用同一链接(不再新建),避免自我顶替。窗口须短,以免复用到已被消费的 token。
BT_LOGIN_URL_CACHE_TTL_SECONDS = 15
@@ -0,0 +1,28 @@
"""Baota panel signed file download URLs (official API token, not UI share links)."""
from __future__ import annotations
import hashlib
import time
from urllib.parse import quote
from server.infrastructure.btpanel.credentials import BtPanelCredentials
def signed_download_url(creds: BtPanelCredentials, remote_path: str) -> str:
"""Build ``/download?filename=…&request_time&request_token`` for remote curl/wget.
Equivalent to automating panel file download with API signature not the UI
``/down/{token}`` share link (that endpoint has no documented API).
"""
path = (remote_path or "").strip()
if not path.startswith("/"):
raise ValueError("remote_path must be absolute")
now = int(time.time())
request_token = hashlib.md5(f"{now}{creds.api_key}".encode()).hexdigest() # nosec B324
filename = quote(path, safe="")
base = creds.base_url.rstrip("/")
return (
f"{base}/download?filename={filename}"
f"&request_time={now}&request_token={request_token}"
)
@@ -0,0 +1,20 @@
"""Per-server asyncio lock — serialize one-click login URL generation."""
from __future__ import annotations
import asyncio
from contextlib import asynccontextmanager
_registry_guard = asyncio.Lock()
_locks: dict[int, asyncio.Lock] = {}
@asynccontextmanager
async def login_url_lock(server_id: int):
async with _registry_guard:
lock = _locks.get(server_id)
if lock is None:
lock = asyncio.Lock()
_locks[server_id] = lock
async with lock:
yield
@@ -43,7 +43,8 @@ def main():
panel_host = (payload.get("panel_host") or "").strip()
if not center_ip:
emit({"ok": False, "error": "missing_center_ip", "message": "center_ip required"})
if not os.path.isdir(PANEL_ROOT):
# 空 panel 目录不算已装宝塔;与 detect_bt_panel_installed 一致
if not os.path.isfile(os.path.join(PANEL_ROOT, "class", "common.py")):
emit({"ok": False, "error": "bt_not_installed", "message": "panel not installed"})
port = read_text(os.path.join(DATA_DIR, "port.pl"), "8888") or "8888"
admin_path = read_text(os.path.join(DATA_DIR, "admin_path.pl"), "")
@@ -77,6 +78,7 @@ def main():
api["limit_addr"] = limit
# 配置未变时不要写盘、不要 bt reload,避免踢掉面板已有登录会话
if actions:
os.makedirs(os.path.dirname(API_PATH), exist_ok=True)
tmp = API_PATH + ".nexus.tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(api, f, ensure_ascii=False)
@@ -0,0 +1,51 @@
"""Nexus — AlertLog retention purge."""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from server.domain.models import AlertLog
ALERT_LOG_RETENTION_DAYS = 7
ALERT_LOG_PURGE_BATCH_SIZE = 1000
def _utc_naive_now() -> datetime:
return datetime.now(timezone.utc).replace(tzinfo=None)
class AlertLogRepositoryImpl:
"""Async SQLAlchemy data access for alert_logs retention."""
def __init__(self, session: AsyncSession):
self.session = session
async def purge_older_than(
self,
retention_days: int = ALERT_LOG_RETENTION_DAYS,
batch_size: int = ALERT_LOG_PURGE_BATCH_SIZE,
) -> int:
"""Delete alert_logs rows with created_at older than retention_days (batched)."""
if retention_days <= 0:
return 0
cutoff = _utc_naive_now() - timedelta(days=retention_days)
total = 0
while True:
result = await self.session.execute(
select(AlertLog.id)
.where(AlertLog.created_at < cutoff)
.order_by(AlertLog.id)
.limit(batch_size)
)
ids = [int(row[0]) for row in result.all()]
if not ids:
break
await self.session.execute(delete(AlertLog).where(AlertLog.id.in_(ids)))
await self.session.commit()
total += len(ids)
if len(ids) < batch_size:
break
return total
@@ -1,12 +1,23 @@
"""Nexus — AuditLog Repository (Async SQLAlchemy)"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from typing import List, Optional, Tuple
from sqlalchemy import select, func
from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from server.domain.models import AuditLog
from server.utils.display_time import beijing_day_range_utc, parse_operator_calendar_date
AUDIT_LOG_RETENTION_DAYS = 7
AUDIT_LOG_PURGE_BATCH_SIZE = 1000
def _utc_naive_now() -> datetime:
return datetime.now(timezone.utc).replace(tzinfo=None)
def _build_filters(
action: Optional[str] = None,
@@ -72,4 +83,31 @@ class AuditLogRepositoryImpl:
async def get_by_action(self, action: str, limit: int = 50, offset: int = 0) -> List[AuditLog]:
rows, _ = await self.query(action=action, limit=limit, offset=offset)
return rows
return rows
async def purge_older_than(
self,
retention_days: int = AUDIT_LOG_RETENTION_DAYS,
batch_size: int = AUDIT_LOG_PURGE_BATCH_SIZE,
) -> int:
"""Delete audit_logs rows with created_at older than retention_days (batched)."""
if retention_days <= 0:
return 0
cutoff = _utc_naive_now() - timedelta(days=retention_days)
total = 0
while True:
result = await self.session.execute(
select(AuditLog.id)
.where(AuditLog.created_at < cutoff)
.order_by(AuditLog.id)
.limit(batch_size)
)
ids = [int(row[0]) for row in result.all()]
if not ids:
break
await self.session.execute(delete(AuditLog).where(AuditLog.id.in_(ids)))
await self.session.commit()
total += len(ids)
if len(ids) < batch_size:
break
return total
+22 -3
View File
@@ -17,12 +17,13 @@ def _escape_like(s: str) -> str:
def _search_filter(search: str):
"""Substring match on name, domain, category (case-insensitive)."""
"""Substring match on name, domain, category, description (case-insensitive)."""
term = f"%{_escape_like(search.strip())}%"
return or_(
Server.name.ilike(term, escape="\\"),
Server.domain.ilike(term, escape="\\"),
Server.category.ilike(term, escape="\\"),
Server.description.ilike(term, escape="\\"),
)
@@ -33,6 +34,14 @@ def _category_filter(category: str):
return Server.category == category
def _categories_in_filter(names: List[str]):
"""OR filter for multiple exact category names (excludes uncategorized alias)."""
cleaned = [n.strip() for n in names if n and n.strip() and n.strip() != UNCATEGORIZED_CATEGORY]
if not cleaned:
return None
return Server.category.in_(cleaned)
def _target_path_unset_filter(unset: bool):
"""Filter servers by whether target_path is unset (empty or BT default).
@@ -106,6 +115,7 @@ class ServerRepositoryImpl:
async def get_paginated(
self,
category: Optional[str] = None,
categories: Optional[List[str]] = None,
platform_id: Optional[int] = None,
node_id: Optional[int] = None,
offset: int = 0,
@@ -125,7 +135,11 @@ class ServerRepositoryImpl:
for Redis overlay post-filter corrections in the API layer.
"""
filters = []
if category:
if categories:
cat_expr = _categories_in_filter(categories)
if cat_expr is not None:
filters.append(cat_expr)
elif category:
filters.append(_category_filter(category))
if platform_id:
filters.append(Server.platform_id == platform_id)
@@ -166,6 +180,7 @@ class ServerRepositoryImpl:
async def get_filtered(
self,
category: Optional[str] = None,
categories: Optional[List[str]] = None,
platform_id: Optional[int] = None,
node_id: Optional[int] = None,
search: Optional[str] = None,
@@ -176,7 +191,11 @@ class ServerRepositoryImpl:
) -> Tuple[List[Server], int]:
"""All servers matching filters (no sort/pagination), capped at *max_rows*."""
filters = []
if category:
if categories:
cat_expr = _categories_in_filter(categories)
if cat_expr is not None:
filters.append(cat_expr)
elif category:
filters.append(_category_filter(category))
if platform_id:
filters.append(Server.platform_id == platform_id)
+53 -3
View File
@@ -82,11 +82,14 @@ from server.background.schedule_runner import schedule_runner_loop
from server.background.retry_runner import retry_runner_loop
from server.background.ip_allowlist_refresh import ip_allowlist_refresh_loop
from server.background.upload_staging_cleanup import upload_staging_cleanup_loop
from server.background.alert_log_purge import alert_log_purge_loop
from server.background.audit_log_purge import audit_log_purge_loop
from server.background.sync_log_purge import sync_log_purge_loop
from server.background.watch_probe_runner import watch_probe_loop
from server.background.unset_path_detect_loop import unset_path_detect_loop
from server.background.agent_install_loop import agent_install_loop
from server.background.offline_daily_report_loop import offline_daily_report_loop
from server.background.ip_domain_detect_loop import ip_domain_detect_loop
from server.background.bt_panel_bootstrap_loop import bt_panel_bootstrap_loop
logger = logging.getLogger("nexus")
@@ -113,13 +116,29 @@ def _try_acquire_file_primary_lock() -> bool:
return False
def _configured_worker_count() -> int:
"""Uvicorn worker count; default 1 when --workers is omitted."""
raw = (os.environ.get("NEXUS_WORKERS") or "1").strip()
try:
return max(1, int(raw))
except ValueError:
return 1
async def _acquire_primary_lock() -> bool:
"""Try to become the primary worker via Redis SETNX.
Only one uvicorn worker will hold this lock at a time.
The lock auto-expires after PRIMARY_LOCK_TTL seconds (safety net for crashes).
Primary worker renews the lock periodically via _renew_primary_lock().
Single-worker deployments skip Redis election a stale lock from a previous
container PID otherwise leaves background tasks permanently disabled.
"""
if _configured_worker_count() <= 1:
logger.info("Single worker mode — background tasks run without Redis primary election")
return True
try:
from server.infrastructure.redis.client import get_redis
import os
@@ -141,6 +160,23 @@ async def _acquire_primary_lock() -> bool:
return acquired
async def _acquire_primary_lock_with_retry(max_wait: int = 90) -> bool:
"""Retry primary election on multi-worker startup (stale Redis lock from old PID)."""
if await _acquire_primary_lock():
return True
if _configured_worker_count() <= 1:
return False
import time
deadline = time.monotonic() + max_wait
while time.monotonic() < deadline:
await asyncio.sleep(5)
if await _acquire_primary_lock():
return True
logger.error("Failed to acquire primary worker lock after %ss", max_wait)
return False
async def _renew_primary_lock():
"""Periodically renew the primary worker lock in Redis."""
import os
@@ -292,7 +328,7 @@ async def lifespan(app: FastAPI):
# 5. Launch background tasks (only on primary worker to avoid duplicate execution)
# When running with --workers N, each worker gets its own lifespan.
# Use Redis-based leader election to ensure only one worker runs background tasks.
is_primary = await _acquire_primary_lock()
is_primary = await _acquire_primary_lock_with_retry()
if is_primary:
from server.application.services.server_batch_service import recover_orphaned_batch_jobs
from server.background.server_batch_reconcile import server_batch_reconcile_loop
@@ -321,6 +357,12 @@ async def lifespan(app: FastAPI):
task_sync_log_purge = asyncio.create_task(
sync_log_purge_loop(), name="sync_log_purge"
)
task_alert_log_purge = asyncio.create_task(
alert_log_purge_loop(), name="alert_log_purge"
)
task_audit_log_purge = asyncio.create_task(
audit_log_purge_loop(), name="audit_log_purge"
)
task_watch_probe = asyncio.create_task(
watch_probe_loop(), name="watch_probe"
)
@@ -333,6 +375,9 @@ async def lifespan(app: FastAPI):
task_offline_daily_report = asyncio.create_task(
offline_daily_report_loop(), name="offline_daily_report"
)
task_ip_domain_detect = asyncio.create_task(
ip_domain_detect_loop(), name="ip_domain_detect"
)
task_bt_panel_bootstrap = asyncio.create_task(
bt_panel_bootstrap_loop(), name="bt_panel_bootstrap"
)
@@ -347,10 +392,13 @@ async def lifespan(app: FastAPI):
task_upload_cleanup,
task_batch_reconcile,
task_sync_log_purge,
task_alert_log_purge,
task_audit_log_purge,
task_watch_probe,
task_unset_path_detect,
task_agent_install,
task_offline_daily_report,
task_ip_domain_detect,
task_bt_panel_bootstrap,
])
logger.info("Primary worker — background tasks launched")
@@ -364,12 +412,14 @@ async def lifespan(app: FastAPI):
logger.info(
f"{settings.SYSTEM_NAME} v6.0.0 started — "
f"background tasks: heartbeat_flush(10min), script_execution_flush(60s), "
f"server_batch_reconcile(60s), sync_log_purge(24h), self_monitor(30s), "
f"server_batch_reconcile(60s), sync_log_purge(24h), alert_log_purge(24h), "
f"audit_log_purge(24h), self_monitor(30s), "
f"server_offline_monitor(30s), "
f"schedule_runner(60s), retry_runner(5min), upload_staging_cleanup(1h), "
f"watch_probe(5s), unset_path_detect(60s, cron={settings.UNSET_PATH_DETECT_CRON}), "
f"agent_install_schedule(60s, cron={settings.AGENT_INSTALL_SCHEDULE_CRON}), "
f"offline_daily_report(60s, cron={settings.OFFLINE_DAILY_REPORT_CRON})"
f"offline_daily_report(60s, cron={settings.OFFLINE_DAILY_REPORT_CRON}), "
f"ip_domain_detect(60s, cron={settings.IP_DOMAIN_DETECT_CRON})"
)
yield
+71
View File
@@ -0,0 +1,71 @@
"""Shared metric threshold helpers for Agent heartbeat and watch probes."""
from __future__ import annotations
import logging
from typing import Optional
from server.config import settings
logger = logging.getLogger("nexus.alert_metrics")
REDIS_ALERT_KEY_PREFIX = "alerts:"
def threshold_for(thresholds: dict, metric: str) -> float:
defaults = {
"cpu": settings.CPU_ALERT_THRESHOLD,
"mem": settings.MEM_ALERT_THRESHOLD,
"disk": settings.DISK_ALERT_THRESHOLD,
}
return float(thresholds.get(metric, defaults.get(metric, 80)))
def safe_float(value, name: str) -> Optional[float]:
if value is None:
return None
try:
return float(value)
except (ValueError, TypeError):
logger.warning(f"Agent sent non-numeric {name}: {value!r:.50} — skipped")
return None
def detect_alerts(system_info: dict, thresholds: dict) -> list:
alerts = []
cpu = safe_float(system_info.get("cpu_usage") or system_info.get("cpu"), "cpu")
mem = safe_float(system_info.get("mem_usage") or system_info.get("mem"), "mem")
disk = safe_float(system_info.get("disk_usage") or system_info.get("disk"), "disk")
if cpu is not None and cpu > threshold_for(thresholds, "cpu"):
alerts.append(("cpu", cpu))
if mem is not None and mem > threshold_for(thresholds, "mem"):
alerts.append(("mem", mem))
if disk is not None and disk > threshold_for(thresholds, "disk"):
alerts.append(("disk", disk))
return alerts
def alert_metric_key(entry: str) -> str | None:
if not entry:
return None
metric = entry.split(":", 1)[0].strip()
return metric or None
def detect_recovery(system_info: dict, prev_alerts: set, thresholds: dict) -> list:
recoveries: list[tuple[str, float]] = []
seen_metrics: set[str] = set()
for prev in prev_alerts:
metric = alert_metric_key(prev)
if not metric or metric in seen_metrics:
continue
current = safe_float(
system_info.get(f"{metric}_usage") or system_info.get(metric), metric
)
if current is not None and current < threshold_for(thresholds, metric):
seen_metrics.add(metric)
recoveries.append((metric, current))
return recoveries
+147
View File
@@ -0,0 +1,147 @@
"""Consecutive over-threshold heartbeats before metric alerts fire (C2 debounce)."""
from __future__ import annotations
import logging
from typing import Any
from server.utils.alert_metrics import (
REDIS_ALERT_KEY_PREFIX,
alert_metric_key as _alert_metric_key,
detect_recovery as _detect_recovery,
safe_float as _safe_float,
threshold_for as _threshold_for,
)
from server.api.websocket import broadcast_alert, broadcast_recovery
from server.config import settings
logger = logging.getLogger("nexus.alert_streak")
REDIS_STREAK_PREFIX = "alert_streak:"
STREAK_TTL_SECONDS = 3600
METRICS = ("cpu", "mem", "disk")
def streak_required() -> int:
"""Minimum consecutive over-threshold samples before alerting (default 3)."""
return max(1, int(getattr(settings, "ALERT_STREAK_REQUIRED", 3)))
def _metric_value(system_info: dict[str, Any], metric: str) -> float | None:
return _safe_float(
system_info.get(f"{metric}_usage") or system_info.get(metric),
metric,
)
async def record_over_threshold(redis, server_id: int, metric: str) -> int:
key = f"{REDIS_STREAK_PREFIX}{server_id}:{metric}"
count = int(await redis.incr(key))
await redis.expire(key, STREAK_TTL_SECONDS)
return count
async def reset_streak(redis, server_id: int, metric: str) -> None:
await redis.delete(f"{REDIS_STREAK_PREFIX}{server_id}:{metric}")
async def process_metric_alerts(
*,
redis,
server_id: int,
server_name: str,
system_info: dict[str, Any],
thresholds: dict[str, float],
log_prefix: str = "Alert",
) -> None:
"""Evaluate CPU/mem/disk with streak gate; broadcast alert/recovery."""
if not system_info:
return
required = streak_required()
alert_key = f"{REDIS_ALERT_KEY_PREFIX}{server_id}"
try:
prev_alerts = await redis.smembers(alert_key)
except Exception:
logger.debug("Failed to read active alerts for server %s", server_id, exc_info=True)
prev_alerts = set()
active_metrics = {
m for m in (_alert_metric_key(x) for x in prev_alerts) if m
}
for metric in METRICS:
value = _metric_value(system_info, metric)
if value is None:
continue
threshold = _threshold_for(thresholds, metric)
if value > threshold:
try:
streak = await record_over_threshold(redis, server_id, metric)
except Exception:
logger.debug(
"Failed to record alert streak for server %s %s",
server_id,
metric,
exc_info=True,
)
continue
if streak >= required and metric not in active_metrics:
logger.warning(
"%s: server %s %s=%s%% (streak %s/%s)",
log_prefix,
server_id,
metric,
value,
streak,
required,
)
await broadcast_alert(server_id, metric, value, server_name)
try:
await redis.sadd(alert_key, metric)
await redis.expire(alert_key, 3600)
active_metrics.add(metric)
except Exception:
logger.debug(
"Failed to track alert in Redis for server %s",
server_id,
exc_info=True,
)
elif streak < required:
logger.debug(
"%s streak %s/%s: server %s %s=%s%% (suppressed)",
log_prefix,
streak,
required,
server_id,
metric,
value,
)
else:
try:
await reset_streak(redis, server_id, metric)
except Exception:
logger.debug(
"Failed to reset alert streak for server %s %s",
server_id,
metric,
exc_info=True,
)
if not prev_alerts:
return
recoveries = _detect_recovery(system_info, prev_alerts, thresholds)
for metric, value in recoveries:
logger.info("Recovery: server %s %s=%s%%", server_id, metric, value)
await broadcast_recovery(server_id, metric, value, server_name)
try:
await reset_streak(redis, server_id, metric)
for prev in prev_alerts:
if _alert_metric_key(prev) == metric:
await redis.srem(alert_key, prev)
except Exception as e:
logger.error("Recovery cleanup failed for server %s: %s", server_id, e)
+91
View File
@@ -15,6 +15,17 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
def parse_upload_dir_mode(chmod_setting: str) -> str | None:
"""Extract octal directory mode from rsync-style ``D755,F755``."""
raw = (chmod_setting or "").strip()
if not raw or not _RSYNC_CHMOD_RE.fullmatch(raw):
return None
m = re.search(r"D([0-7]{3,4})", raw, re.IGNORECASE)
if m:
return m.group(1)
return parse_upload_file_mode(raw)
def parse_upload_file_mode(chmod_setting: str) -> str | None:
"""Extract octal file mode from rsync-style ``D755,F755`` or plain ``644``."""
raw = (chmod_setting or "").strip()
@@ -102,3 +113,83 @@ async def apply_upload_file_permissions(server: Server, remote_path: str) -> dic
"elevated": elevated,
"error": "; ".join(errors) if errors else None,
}
async def apply_transfer_permissions(
server: Server,
remote_path: str,
*,
recursive: bool = True,
) -> dict:
"""chown/chmod after cross-server relay or package deliver (default www:www, D755/F755)."""
from server.config import settings
from server.infrastructure.ssh.remote_shell import exec_ssh_command_with_fallback
chown_pair, file_mode = upload_permission_plan()
dir_mode = parse_upload_dir_mode(settings.RSYNC_PUSH_CHMOD or "")
if not chown_pair and not file_mode:
return {"applied": False, "skipped": True, "path": remote_path}
path_q = shlex.quote(remote_path)
elevated = False
errors: list[str] = []
if chown_pair:
owner_spec = shlex.quote(f"{chown_pair[0]}:{chown_pair[1]}")
chown_flag = "-R " if recursive else ""
result = await exec_ssh_command_with_fallback(
server,
f"chown {chown_flag}{owner_spec} {path_q}",
timeout=120,
)
elevated = bool(result.get("elevated"))
if result.get("exit_code") != 0:
err = ((result.get("stderr") or "") + (result.get("stdout") or "")).strip()[:200]
errors.append(err or "chown 失败")
if file_mode and not errors:
if recursive and dir_mode and dir_mode != file_mode:
for kind, mode in (("d", dir_mode), ("f", file_mode)):
result = await exec_ssh_command_with_fallback(
server,
f"find {path_q} -type {kind} -exec chmod {shlex.quote(mode)} {{}} +",
timeout=120,
)
elevated = elevated or bool(result.get("elevated"))
if result.get("exit_code") != 0:
err = ((result.get("stderr") or "") + (result.get("stdout") or "")).strip()[:200]
errors.append(err or f"chmod {kind} 失败")
break
else:
mode = file_mode
chmod_flag = "-R " if recursive else ""
result = await exec_ssh_command_with_fallback(
server,
f"chmod {chmod_flag}{shlex.quote(mode)} {path_q}",
timeout=120,
)
elevated = elevated or bool(result.get("elevated"))
if result.get("exit_code") != 0:
err = ((result.get("stderr") or "") + (result.get("stdout") or "")).strip()[:200]
errors.append(err or "chmod 失败")
if errors:
logger.warning(
"Transfer permission fixup failed for %s on server %s: %s",
remote_path,
getattr(server, "id", "?"),
"; ".join(errors),
)
owner, group = chown_pair if chown_pair else (None, None)
return {
"applied": not errors and bool(chown_pair or file_mode),
"skipped": False,
"path": remote_path,
"owner": owner,
"group": group,
"dir_mode": dir_mode,
"file_mode": file_mode,
"elevated": elevated,
"error": "; ".join(errors) if errors else None,
}
+63
View File
@@ -0,0 +1,63 @@
"""Detect primary nginx site domain on remote Baota/generic hosts."""
from __future__ import annotations
import shlex
def build_nginx_domain_detect_command(*, target_hint: str = "") -> str:
"""Build remote shell snippet that prints one site hostname to stdout.
Lookup order:
1. Baota vhost basename ``/www/server/panel/vhost/nginx/{domain}.conf``
2. ``server_name`` in Baota vhosts and generic nginx dirs
Prefer *target_hint* when it matches a candidate.
"""
hint = (target_hint or "").strip().lower()
qhint = shlex.quote(hint) if hint else "''"
return (
f"TARGET_HINT={qhint}; "
"_is_domain() { "
'local h="${1,,}"; '
'[[ "$h" == *.* ]] || return 1; '
'[[ "$h" =~ ^[0-9]+(\\.[0-9]+){3}$ ]] && return 1; '
'case "$h" in default|0.default|0.site_total|phpmyadmin|localhost|127.0.0.1|_*|*.local) return 1 ;; esac; '
"return 0; "
"}; "
"_pick() { "
'local best=""; '
"for h in \"$@\"; do "
'[ -n "$h" ] || continue; '
"_is_domain \"$h\" || continue; "
'if [ -n "$TARGET_HINT" ] && [ "$h" = "$TARGET_HINT" ]; then echo "$h"; return 0; fi; '
'if [ -z "$best" ] || [[ "$h" < "$best" ]]; then best="$h"; fi; '
"done; "
'[ -n "$best" ] && echo "$best"; '
"}; "
"cands=(); "
'BT="/www/server/panel/vhost/nginx"; '
'if [ -d "$BT" ]; then '
'for f in "$BT"/*.conf; do '
'[ -f "$f" ] || continue; '
'base=$(basename "$f" .conf); '
"_is_domain \"$base\" && cands+=(\"$base\"); "
"done; "
"fi; "
'if [ ${#cands[@]} -gt 0 ]; then _pick "${cands[@]}"; exit 0; fi; '
"_names_from() { "
'grep -E "^[[:space:]]*server_name[[:space:]]" "$1" 2>/dev/null '
"| head -1 | sed 's/.*server_name[[:space:]]\\+//;s/;//;s/\\$//g'; "
"}; "
'for dir in "$BT" /etc/nginx/sites-enabled /etc/nginx/conf.d; do '
'[ -d "$dir" ] || continue; '
'for f in "$dir"/*; do '
'[ -f "$f" ] || continue; '
'raw=$(_names_from "$f"); '
'for token in $raw; do '
'token="${token,,}"; '
"_is_domain \"$token\" && cands+=(\"$token\"); "
"done; "
"done; "
"done; "
'_pick "${cands[@]}"'
)
+99
View File
@@ -0,0 +1,99 @@
"""Resolve display site hostname from server fields (mirrors frontend serverSiteUrl.ts)."""
from __future__ import annotations
import ipaddress
import re
from typing import Any
from urllib.parse import urlparse
_IP_RE = re.compile(r"^\d{1,3}(\.\d{1,3}){3}$")
_CJK_RE = re.compile(r"[\u4e00-\u9fff]")
_ASCII_HOST_RE = re.compile(
r"^[a-z0-9](?:[a-z0-9-]*\.)+[a-z]{2,63}$",
re.IGNORECASE,
)
_SKIP_TARGET_SEGMENTS = frozenset({"shop", "crmeb", "public", "me", "crmweb"})
def is_ip_host(value: str | None) -> bool:
"""True when *value* is a literal IPv4/IPv6 address (SSH domain field)."""
text = (value or "").strip()
if not text:
return False
try:
ipaddress.ip_address(text.split("/")[0])
return True
except ValueError:
return False
def _host_from_string(raw: str) -> str | None:
trimmed = raw.strip()
if not trimmed:
return None
# 备注(中文 tonex 名等)勿当站点域名
if _CJK_RE.search(trimmed):
return None
if trimmed.startswith(("http://", "https://")):
try:
host = urlparse(trimmed).hostname
except Exception:
return None
if host and not is_ip_host(host) and _ASCII_HOST_RE.match(host):
return host.lower()
return None
host = trimmed.replace("http://", "").replace("https://", "")
host = host.split("/")[0].split(":")[0].strip()
if not host or is_ip_host(host) or not _ASCII_HOST_RE.match(host):
return None
return host.lower()
def parse_site_host_from_target_path(target_path: str | None) -> str | None:
p = (target_path or "").strip().rstrip("/")
if not p:
return None
parts = [seg for seg in p.split("/") if seg]
root_idx = parts.index("wwwroot") if "wwwroot" in parts else -1
candidates: list[str] = []
if root_idx >= 0:
candidates.extend(parts[root_idx + 1 :])
elif parts:
candidates.append(parts[-1])
for seg in reversed(candidates):
if seg.lower() in _SKIP_TARGET_SEGMENTS:
continue
if _IP_RE.match(seg):
continue
if "." in seg:
return seg.lower()
return None
def resolve_server_site_host(
*,
description: str | None = None,
target_path: str | None = None,
extra_attrs: dict[str, Any] | None = None,
) -> str | None:
extra = extra_attrs if isinstance(extra_attrs, dict) else {}
for key in ("site_url", "site_domain", "website"):
val = extra.get(key)
if isinstance(val, str):
host = _host_from_string(val)
if host:
return host
if description and description.strip():
host = _host_from_string(description)
if host:
return host
return parse_site_host_from_target_path(target_path)
def resolve_server_site_host_from_server(server: Any) -> str | None:
return resolve_server_site_host(
description=getattr(server, "description", None),
target_path=getattr(server, "target_path", None),
extra_attrs=getattr(server, "extra_attrs", None),
)
+10 -30
View File
@@ -3,18 +3,16 @@
from __future__ import annotations
import logging
from typing import Any
from server.api.agent import REDIS_ALERT_KEY_PREFIX, _alert_metric_key, _detect_alerts, _detect_recovery
from server.api.websocket import broadcast_alert, broadcast_recovery
from server.config import settings
from server.infrastructure.redis.client import get_redis
from server.utils.alert_streak import process_metric_alerts
from server.utils.watch_metrics import PROBE_OK, WatchProbeSample
logger = logging.getLogger("nexus.watch_alerts")
def _sample_to_system_info(sample: WatchProbeSample) -> dict[str, Any]:
def _sample_to_system_info(sample: WatchProbeSample) -> dict:
return {
"cpu_usage": sample.cpu_pct,
"mem_usage": sample.mem_pct,
@@ -32,35 +30,17 @@ async def process_watch_probe_alerts(
if sample.probe_status != PROBE_OK:
return
system_info = _sample_to_system_info(sample)
thresholds = {
"cpu": settings.CPU_ALERT_THRESHOLD,
"mem": settings.MEM_ALERT_THRESHOLD,
"disk": settings.DISK_ALERT_THRESHOLD,
}
redis = get_redis()
alert_key = f"{REDIS_ALERT_KEY_PREFIX}{server_id}"
alerts = _detect_alerts(system_info, thresholds)
for alert_type, alert_value in alerts:
logger.warning("Watch alert: server %s %s=%s%%", server_id, alert_type, alert_value)
await broadcast_alert(server_id, alert_type, alert_value, server_name)
try:
await redis.sadd(alert_key, alert_type)
await redis.expire(alert_key, 3600)
except Exception:
logger.debug("Failed to track watch alert in Redis for server %s", server_id, exc_info=True)
try:
prev_alerts = await redis.smembers(alert_key)
if prev_alerts:
recoveries = _detect_recovery(system_info, prev_alerts, thresholds)
for metric, value in recoveries:
logger.info("Watch recovery: server %s %s=%s%%", server_id, metric, value)
await broadcast_recovery(server_id, metric, value, server_name)
for prev in prev_alerts:
if _alert_metric_key(prev) == metric:
await redis.srem(alert_key, prev)
except Exception:
logger.debug("Watch recovery detection failed for server %s", server_id, exc_info=True)
await process_metric_alerts(
redis=get_redis(),
server_id=server_id,
server_name=server_name,
system_info=_sample_to_system_info(sample),
thresholds=thresholds,
log_prefix="Watch alert",
)
+50 -1
View File
@@ -40,6 +40,8 @@ def resolve_pin_ttl_minutes(*, ttl_minutes: int | None, ttl_hours: int | None) -
return WATCH_PIN_TTL_DEFAULT_MINUTES
WATCH_PROBE_INTERVAL_SEC = 5
WATCH_REDIS_FRESH_SEC = 90
WATCH_AGENT_FRESH_SEC = 12 # 2× 5s watch interval + slack
WATCH_AGENT_INTERVAL_SEC = 5
WATCH_PROBE_TIMEOUT_SEC = 8
PROBE_OK = "ok"
@@ -151,6 +153,53 @@ def redis_sample_has_basic_metrics(sample: WatchProbeSample | None) -> bool:
return sample.cpu_pct is not None or sample.mem_pct is not None
def parse_heartbeat_system_info(heartbeat: dict[str, str]) -> dict[str, Any]:
raw = heartbeat.get("system_info") or "{}"
try:
info = json.loads(raw) if isinstance(raw, str) else (raw or {})
except json.JSONDecodeError:
return {}
return info if isinstance(info, dict) else {}
def heartbeat_watch_mode_active(heartbeat: dict[str, str]) -> bool:
"""True when Agent explicitly reports watch_mode in the last heartbeat payload."""
return bool(parse_heartbeat_system_info(heartbeat).get("watch_mode"))
def agent_watch_heartbeat_fresh(heartbeat: dict[str, str]) -> bool:
"""Stricter freshness for 5s Agent watch payloads (not 90s fleet heartbeat)."""
from datetime import datetime, timezone
if heartbeat.get("is_online") != "True":
return False
ts = heartbeat.get("last_heartbeat") or ""
if not ts:
return False
try:
agent_ts = datetime.fromisoformat(ts.replace("Z", "+00:00"))
if agent_ts.tzinfo is None:
agent_ts = agent_ts.replace(tzinfo=timezone.utc)
age = (datetime.now(timezone.utc) - agent_ts).total_seconds()
return age <= WATCH_AGENT_FRESH_SEC
except Exception:
return False
def agent_watch_sample_usable(
heartbeat: dict[str, str] | None,
sample: WatchProbeSample | None,
) -> bool:
"""Agent 5s path: fresh heartbeat + watch_mode + basic CPU/mem metrics."""
if not heartbeat or sample is None:
return False
if not agent_watch_heartbeat_fresh(heartbeat):
return False
if not heartbeat_watch_mode_active(heartbeat):
return False
return redis_sample_has_basic_metrics(sample)
def sanitize_processes(raw: Any) -> list[dict[str, Any]] | None:
if not isinstance(raw, list):
return None
@@ -461,7 +510,7 @@ def merge_redis_and_ssh(redis_sample: WatchProbeSample | None, ssh_sample: Watch
source="mixed",
is_online=True,
duration_ms=ssh_sample.duration_ms,
# 5s SSH psutil 与宝塔同源;Agent 60s 心跳仅作兜底
# 5s Agent watch 优先;SSH 仅兜底或无 watch_mode 时
cpu_pct=_pick_ssh_first(ssh_sample.cpu_pct, redis_sample.cpu_pct),
mem_pct=_pick_ssh_first(ssh_sample.mem_pct, redis_sample.mem_pct),
disk_pct=_pick_ssh_first(ssh_sample.disk_pct, redis_sample.disk_pct),
+4 -2
View File
@@ -60,14 +60,15 @@ async def test_heartbeat_then_stats_online_count(
mock_redis.sadd = AsyncMock()
mock_redis.srem = AsyncMock()
mock_redis.delete = AsyncMock()
mock_redis.incr = AsyncMock(return_value=1)
mock_redis.scan = fake_scan
mock_redis.hgetall = fake_hgetall
mock_redis.pipeline = fake_pipeline
monkeypatch.setattr("server.api.agent.get_redis", lambda: mock_redis)
monkeypatch.setattr("server.api.servers.get_redis", lambda: mock_redis)
monkeypatch.setattr("server.api.agent.broadcast_alert", AsyncMock())
monkeypatch.setattr("server.api.agent.broadcast_recovery", AsyncMock())
monkeypatch.setattr("server.utils.alert_streak.broadcast_alert", AsyncMock())
monkeypatch.setattr("server.utils.alert_streak.broadcast_recovery", AsyncMock())
monkeypatch.setattr("server.api.agent.send_telegram_system_alert", AsyncMock())
mock_request.state.db = db_session
@@ -81,6 +82,7 @@ async def test_heartbeat_then_stats_online_count(
),
api_key=api_key,
service=service,
db=db_session,
)
assert hb["status"] == "ok"

Some files were not shown because too many files have changed in this diff Show More