sync: 主项目全量同步(含 btpanel 一键登录回归修复与前端构建产物)
- bootstrap 失败恢复 SSH 临时登录回退 - ssh_bootstrap 真安装检测与 config 目录自愈 - axs Gitea 发布源切换为 https://axs.kuma1xn.vip
This commit is contained in:
+6
-1
@@ -38,11 +38,16 @@ FastAPI + Async SQLAlchemy + Redis · Vue 3 + Vuetify 4 SPA(15 页)· 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/`
|
||||
|
||||
## 进度条(改代码时输出)
|
||||
|
||||
|
||||
@@ -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` 当你明确要求时。
|
||||
|
||||
详情见功能指南 §17–18。
|
||||
|
||||
## 归档文档
|
||||
|
||||
+58
-24
@@ -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
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
# 用于 git push(scripts/git-push.sh);clone/pull 仍可匿名
|
||||
|
||||
# NEXUS_GITEA_HOST='66.154.115.8:3000'
|
||||
# NEXUS_GITEA_HOST='axs.kuma1xn.vip' # HTTPS Gitea(2026-07-08 起,分支 release/20260708-axs)
|
||||
# NEXUS_GITEA_REPO='admin/Nexus.git'
|
||||
# admin 账号密码或 Access Token:
|
||||
# NEXUS_GITEA_TOKEN='你的密码或令牌'
|
||||
|
||||
+15
-2
@@ -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 ;;
|
||||
|
||||
@@ -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 passed(watch + 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 注册 loop;config 默认 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` 或 query(Agent 脚本约定)。
|
||||
| `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,
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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[]>([
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 },
|
||||
]
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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' } })
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ?? '') })
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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,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
@@ -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
@@ -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 ──
|
||||
# ── 2–3. 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(
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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",
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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[@]}"'
|
||||
)
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user