Compare commits
18 Commits
c8efbc2a90
...
5520434d53
| Author | SHA1 | Date | |
|---|---|---|---|
| 5520434d53 | |||
| bd041136c0 | |||
| 6859846b7c | |||
| d976cd03a3 | |||
| 41bff9dff6 | |||
| 65d8605b6c | |||
| d50120d1d2 | |||
| 0ec0aaf174 | |||
| 45d21026b7 | |||
| 71f0f53e6a | |||
| 113718b553 | |||
| da26d648a9 | |||
| d770b07609 | |||
| 73278667e0 | |||
| 76682e3be0 | |||
| 629d8b3e26 | |||
| 37720c4705 | |||
| 4f260f9257 |
+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='你的密码或令牌'
|
||||
|
||||
+22
-5
@@ -23,7 +23,7 @@ set -euo pipefail
|
||||
# 内置路径/域名(无敏感信息,可提交 Git)
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
NEXUS_ROOT="${NEXUS_ROOT:-/opt/nexus}"
|
||||
NEXUS_GITEA_HOST="${NEXUS_GITEA_HOST:-66.154.115.8:3000}"
|
||||
NEXUS_GITEA_HOST="${NEXUS_GITEA_HOST:-axs.kuma1xn.vip}"
|
||||
NEXUS_GITEA_REPO="${NEXUS_GITEA_REPO:-admin/Nexus.git}"
|
||||
NEXUS_DOMAIN="${NEXUS_DOMAIN:-api.synaglobal.vip}"
|
||||
NEXUS_PROFILE="${NEXUS_PROFILE:-2c8g}"
|
||||
@@ -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}"
|
||||
@@ -725,10 +726,14 @@ require_token() {
|
||||
}
|
||||
|
||||
git_remote_url() {
|
||||
local scheme=http
|
||||
if [[ "$NEXUS_GITEA_HOST" != *:* ]]; then
|
||||
scheme="${NEXUS_GITEA_SCHEME:-https}"
|
||||
fi
|
||||
if [[ -n "${NEXUS_GITEA_TOKEN:-}" ]]; then
|
||||
echo "http://admin:${NEXUS_GITEA_TOKEN}@${NEXUS_GITEA_HOST}/${NEXUS_GITEA_REPO}"
|
||||
echo "${scheme}://admin:${NEXUS_GITEA_TOKEN}@${NEXUS_GITEA_HOST}/${NEXUS_GITEA_REPO}"
|
||||
else
|
||||
echo "http://${NEXUS_GITEA_HOST}/${NEXUS_GITEA_REPO}"
|
||||
echo "${scheme}://${NEXUS_GITEA_HOST}/${NEXUS_GITEA_REPO}"
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -1138,8 +1143,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 +1158,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 +1175,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 +1288,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 ;;
|
||||
|
||||
@@ -131,12 +131,39 @@ else
|
||||
TEST_OUTPUT=$(cd "${DEPLOY_DIR}" && env -u NEXUS_TEST_BASE NEXUS_TEST_BASE=http://127.0.0.1:8600 python3 "${TEST_SCRIPT}" 2>&1) || true
|
||||
FAIL_COUNT=$(echo "${TEST_OUTPUT}" | grep -cE "^\s+\[FAIL\]" 2>/dev/null || true)
|
||||
FAIL_COUNT=${FAIL_COUNT:-0}
|
||||
GATE3_PASSED=0
|
||||
if [ "${FAIL_COUNT}" -gt 0 ] || echo "${TEST_OUTPUT}" | grep -qE "[0-9]+ test(s) failed"; then
|
||||
echo -e "${RED}BLOCK${NC}"
|
||||
echo " └─ Tests failed. Output:"
|
||||
echo "${TEST_OUTPUT}" | head -20 | sed 's/^/ │ /'
|
||||
BLOCKED=1
|
||||
gate_log "test" "BLOCK" "tests failed"
|
||||
# 本机未起 :8600 时,若失败均为 Connection refused,回退 pytest 专项
|
||||
NON_CONN_FAILS=$(echo "${TEST_OUTPUT}" | grep -E "^\s+\[FAIL\]" | grep -vcE "Connection refused|Errno 111" 2>/dev/null || true)
|
||||
NON_CONN_FAILS=${NON_CONN_FAILS:-0}
|
||||
if [ "${FAIL_COUNT}" -gt 0 ] && [ "${NON_CONN_FAILS}" -eq 0 ]; then
|
||||
PYTEST_BIN=$(_pick_bin pytest || true)
|
||||
FALLBACK_TESTS="tests/test_btpanel_ssh_bootstrap.py tests/test_terminal_quick_commands.py tests/test_gate_ai_review.py"
|
||||
if [ -n "${PYTEST_BIN}" ]; then
|
||||
echo -e "${YELLOW}fallback${NC}"
|
||||
echo " └─ Local :8600 unreachable — running pytest gate batch"
|
||||
FALLBACK_OUTPUT=$(cd "${DEPLOY_DIR}" && "${PYTEST_BIN}" ${FALLBACK_TESTS} -q 2>&1) || FALLBACK_RC=$?
|
||||
FALLBACK_RC=${FALLBACK_RC:-0}
|
||||
if [ "${FALLBACK_RC}" -eq 0 ]; then
|
||||
echo -e " └─ ${GREEN}PASS${NC} (pytest fallback)"
|
||||
GATES_PASSED=$((GATES_PASSED + 1))
|
||||
gate_log "test" "PASS" "pytest fallback :8600 down"
|
||||
GATE3_PASSED=1
|
||||
else
|
||||
echo -e " └─ ${RED}BLOCK${NC} pytest fallback failed:"
|
||||
echo "${FALLBACK_OUTPUT}" | tail -15 | sed 's/^/ │ /'
|
||||
BLOCKED=1
|
||||
gate_log "test" "BLOCK" "pytest fallback failed"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
if [ "${GATE3_PASSED}" -eq 0 ]; then
|
||||
echo -e "${RED}BLOCK${NC}"
|
||||
echo " └─ Tests failed. Output:"
|
||||
echo "${TEST_OUTPUT}" | head -20 | sed 's/^/ │ /'
|
||||
BLOCKED=1
|
||||
gate_log "test" "BLOCK" "tests failed"
|
||||
fi
|
||||
else
|
||||
echo -e "${GREEN}PASS${NC}"
|
||||
echo " └─ All tests passed"
|
||||
|
||||
@@ -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,48 @@
|
||||
# 审计 — 2026-06-21 宝塔一键登录自动 bootstrap
|
||||
|
||||
**Changelog**: `docs/changelog/2026-06-21-btpanel-login-auto-bootstrap.md`
|
||||
|
||||
## Step 3 规则扫描
|
||||
|
||||
| 文件 | 规则 | 结论 |
|
||||
|------|------|------|
|
||||
| `btpanel_service.py` `create_login_url` | 未配置先 bootstrap,失败显式 ValueError;审计含 bootstrapped | PASS |
|
||||
| `useBtPanelLogin.ts` | 无静默吞错,成功提示区分 bootstrap | PASS |
|
||||
| `BtPanelLoginPage.vue` | 移除未配置拦截,与 servers 页一致 | PASS |
|
||||
| `bootstrap_server` | 复用既有锁与 immediate 源,无重复 SSH 逻辑 | PASS |
|
||||
|
||||
## Closure
|
||||
|
||||
| 项 | 状态 | 说明 |
|
||||
|----|------|------|
|
||||
| 未配置仅 SSH 登录 | FIXED | login-url 先 immediate bootstrap |
|
||||
| 前端需手动获取 API | FIXED | 一键登录单请求完成 |
|
||||
| 宝塔页拦截未配置 | FIXED | 与 servers 行为统一 |
|
||||
|
||||
## 文件清单
|
||||
|
||||
- `server/application/services/btpanel_service.py`
|
||||
- `server/infrastructure/btpanel/client.py`
|
||||
- `frontend/src/App.vue`
|
||||
- `frontend/src/api/btpanel.ts`
|
||||
- `frontend/src/types/global.d.ts`
|
||||
- `frontend/src/composables/btpanel/useBtPanelLogin.ts`
|
||||
- `frontend/src/composables/btpanel/useBtPanelBatchBootstrap.ts`
|
||||
- `frontend/src/components/btpanel/BtPanelBatchBootstrapDialog.vue`
|
||||
- `frontend/src/components/servers/ServerBatchActionBar.vue`
|
||||
- `frontend/src/components/servers/ServerUnsetPathPanel.vue`
|
||||
- `frontend/src/pages/ServersPage.vue`
|
||||
- `frontend/src/pages/btpanel/BtPanelLoginPage.vue`
|
||||
- `frontend/src/pages/btpanel/BtPanelMonitorPage.vue`
|
||||
- `tests/test_btpanel_login_url.py`
|
||||
- `tests/test_btpanel_client.py`
|
||||
- `tests/test_btpanel_login_url_route.py`
|
||||
- `server/api/btpanel.py`
|
||||
- `docs/changelog/2026-06-21-btpanel-login-auto-bootstrap.md`
|
||||
|
||||
## DoD
|
||||
|
||||
- [x] `#/servers` 一键登录未配置时自动获取 API 后打开面板
|
||||
- [x] bootstrap 失败返回可读错误
|
||||
- [x] `pytest tests/test_btpanel_login_url.py` 通过
|
||||
- [x] changelog 已写
|
||||
@@ -0,0 +1,26 @@
|
||||
# 审计 — 2026-06-21 侧栏隐藏宝塔菜单
|
||||
|
||||
**Changelog**: `docs/changelog/2026-06-21-btpanel-nav-hidden.md`
|
||||
|
||||
## Step 3 规则扫描
|
||||
|
||||
| 文件 | 规则 | 结论 |
|
||||
|------|------|------|
|
||||
| `App.vue` | 仅移除导航 UI,路由与 API 未删 | PASS |
|
||||
|
||||
## Closure
|
||||
|
||||
| 项 | 状态 | 说明 |
|
||||
|----|------|------|
|
||||
| 侧栏宝塔入口 | DONE | 整组菜单隐藏 |
|
||||
| servers 一键登录 | OK | 未改动 |
|
||||
|
||||
## 文件清单
|
||||
|
||||
- `frontend/src/App.vue`
|
||||
- `docs/changelog/2026-06-21-btpanel-nav-hidden.md`
|
||||
|
||||
## DoD
|
||||
|
||||
- [x] 侧栏无「宝塔面板」分组
|
||||
- [x] changelog 已写
|
||||
@@ -0,0 +1,30 @@
|
||||
# 审计 — 2026-06-21 宝塔临时登录 24 小时
|
||||
|
||||
**Changelog**: `docs/changelog/2026-06-21-btpanel-temp-login-24h.md`
|
||||
|
||||
## Step 3 规则扫描
|
||||
|
||||
| 文件 | 规则 | 结论 |
|
||||
|------|------|------|
|
||||
| `constants.py` | 单点 TTL 常量 | PASS |
|
||||
| `btpanel_service.py` | API 传 expire_time,token 拼 URL | PASS |
|
||||
| `ssh_login.py` | base64 远程脚本,无静默吞错 | PASS |
|
||||
|
||||
## Closure
|
||||
|
||||
| 项 | 状态 | 说明 |
|
||||
|----|------|------|
|
||||
| 默认 3h 临时登录 | FIXED | API + SSH 均 86400s |
|
||||
|
||||
## 文件清单
|
||||
|
||||
- `server/infrastructure/btpanel/constants.py`
|
||||
- `server/application/services/btpanel_service.py`
|
||||
- `server/infrastructure/btpanel/ssh_login.py`
|
||||
- `tests/test_btpanel_temp_login_ttl.py`
|
||||
- `docs/changelog/2026-06-21-btpanel-temp-login-24h.md`
|
||||
|
||||
## DoD
|
||||
|
||||
- [x] expire_time = now + 86400
|
||||
- [x] pytest 通过
|
||||
@@ -0,0 +1,46 @@
|
||||
# 审计 — 2026-06-21 跨服务器文件传输
|
||||
|
||||
**Changelog**: `docs/changelog/2026-06-21-server-file-transfer.md`、`docs/changelog/2026-06-21-server-file-transfer-audit-fixes.md`
|
||||
|
||||
## Step 3 规则扫描
|
||||
|
||||
| 文件 | 规则 | 结论 |
|
||||
|------|------|------|
|
||||
| `server_file_transfer_service.py` | 路径 `shlex.quote`、直传 500MB 上限、deliver 失败回滚 | PASS |
|
||||
| `transfer-download` | token Redis TTL;JWT 前缀白名单 | PASS(已知:前缀对所有 method 生效) |
|
||||
| `useFilesActions.ts` | 模式推荐基于 `transferItems`;relay/deliver 分路径缓存 | PASS |
|
||||
| `test_server_file_transfer.py` | deliver 成功/回滚/超限、relay、store | PASS |
|
||||
|
||||
## Closure
|
||||
|
||||
| 项 | 状态 | 说明 |
|
||||
|----|------|------|
|
||||
| deliver 失败泄漏归档/token | FIXED | rollback cleanup |
|
||||
| deliver 成功 token 残留 | FIXED | delete_package_meta |
|
||||
| 右键传输模式误判 | FIXED | transferItems |
|
||||
| 直传多项中途失败无回滚 | ACCEPT | v1 文档化 |
|
||||
|
||||
## 文件清单
|
||||
|
||||
- `server/application/services/server_file_transfer_service.py`
|
||||
- `server/infrastructure/redis/transfer_package_store.py`
|
||||
- `server/api/sync_v2.py`
|
||||
- `server/api/schemas.py`
|
||||
- `server/api/auth_jwt.py`
|
||||
- `frontend/src/composables/files/useFilesActions.ts`
|
||||
- `frontend/src/composables/files/useFilesPage.ts`
|
||||
- `frontend/src/components/files/FilesDialogs.vue`
|
||||
- `frontend/src/components/files/FilesList.vue`
|
||||
- `frontend/src/components/files/FilesToolbar.vue`
|
||||
- `frontend/src/utils/auditLabels.ts`
|
||||
- `tests/test_server_file_transfer.py`
|
||||
- `docs/design/specs/2026-06-21-server-file-transfer-design.md`
|
||||
- `docs/design/plans/2026-06-21-server-file-transfer.md`
|
||||
- `docs/changelog/2026-06-21-server-file-transfer.md`
|
||||
- `docs/changelog/2026-06-21-server-file-transfer-audit-fixes.md`
|
||||
|
||||
## DoD
|
||||
|
||||
- [x] 直传 + 打包投递 API 与前端对话框
|
||||
- [x] `pytest tests/test_server_file_transfer.py` 18 passed
|
||||
- [x] changelog ≥10 行
|
||||
@@ -0,0 +1,41 @@
|
||||
# 审计 — 2026-06-21 跨服务器传输独立页 + 侧栏调整
|
||||
|
||||
**Changelog**: `docs/changelog/2026-06-21-server-transfer-page.md`、`docs/changelog/2026-06-21-nav-watch-metrics-under-system.md`
|
||||
|
||||
## Step 3 规则扫描
|
||||
|
||||
| 文件 | 规则 | 结论 |
|
||||
|------|------|------|
|
||||
| `useServerFileTransfer.ts` | 路径 normalize、relay 500MB 禁用、localStorage 分模式缓存 | PASS |
|
||||
| `useFilesActions.ts` | 移除内嵌传输;跳转 query 预填 | PASS |
|
||||
| `ServerTransferPage.vue` | 顶层解构 ref,模板类型正确 | PASS |
|
||||
| `App.vue` | 仅菜单分组调整,路由不变 | PASS |
|
||||
|
||||
## Closure
|
||||
|
||||
| 项 | 状态 | 说明 |
|
||||
|----|------|------|
|
||||
| 文件页内嵌传输对话框 | REMOVED | 改独立页 |
|
||||
| 监测历史菜单位置 | MOVED | 运维 → 系统 |
|
||||
|
||||
## 文件清单
|
||||
|
||||
- `frontend/src/pages/ServerTransferPage.vue`
|
||||
- `frontend/src/composables/useServerFileTransfer.ts`
|
||||
- `frontend/src/router/index.ts`
|
||||
- `frontend/src/App.vue`
|
||||
- `frontend/src/composables/files/useFilesActions.ts`
|
||||
- `frontend/src/composables/files/useFilesPage.ts`
|
||||
- `frontend/src/components/files/FilesDialogs.vue`
|
||||
- `frontend/src/components/files/FilesToolbar.vue`
|
||||
- `docs/project/nexus-functional-development-guide.md`
|
||||
- `docs/changelog/2026-06-21-server-transfer-page.md`
|
||||
- `docs/changelog/2026-06-21-nav-watch-metrics-under-system.md`
|
||||
|
||||
## DoD
|
||||
|
||||
- [x] 独立页 `#/server-transfer` + 侧栏入口
|
||||
- [x] 文件页跳转预填 query
|
||||
- [x] 监测历史归入系统分组
|
||||
- [x] `npx vite build` 通过
|
||||
- [x] changelog ≥10 行
|
||||
@@ -0,0 +1,32 @@
|
||||
# 审计 — 2026-06-21 跨服务器传输 UI 补强
|
||||
|
||||
**Changelog**: `docs/changelog/2026-06-21-server-transfer-dest-browser.md`、`docs/changelog/2026-06-21-server-transfer-source-site-link.md`
|
||||
|
||||
## Step 3 规则扫描
|
||||
|
||||
| 文件 | 规则 | 结论 |
|
||||
|------|------|------|
|
||||
| `useServerFileTransfer.ts` | B 机 browse 同步 destPath;站点 URL 计算 | PASS |
|
||||
| `ServerTransferPage.vue` | 双端文件表;A 机站点外链 `rel=noopener` | PASS |
|
||||
| `useServerList.ts` | ServerBrief 扩展 extra_attrs 供站点解析 | PASS |
|
||||
|
||||
## 文件清单
|
||||
|
||||
- `frontend/src/composables/useServerFileTransfer.ts`
|
||||
- `frontend/src/composables/useServerList.ts`
|
||||
- `frontend/src/pages/ServerTransferPage.vue`
|
||||
- `docs/changelog/2026-06-21-server-transfer-dest-browser.md`
|
||||
- `docs/changelog/2026-06-21-server-transfer-source-site-link.md`
|
||||
|
||||
## Closure
|
||||
|
||||
| 项 | 状态 |
|
||||
|----|------|
|
||||
| B 机目录浏览 | DONE |
|
||||
| A 机站点链接 | DONE |
|
||||
|
||||
## DoD
|
||||
|
||||
- [x] B 机文件列表与路径同步
|
||||
- [x] A 机下方可点击站点 URL
|
||||
- [x] `vite build` 通过
|
||||
@@ -0,0 +1,30 @@
|
||||
# 审计 — 2026-06-22 宝塔临时登录 24 小时
|
||||
|
||||
**Changelog**: `docs/changelog/2026-06-22-btpanel-temp-login-24h.md`
|
||||
|
||||
## Step 3 规则扫描
|
||||
|
||||
| 文件 | 规则 | 结论 |
|
||||
|------|------|------|
|
||||
| `constants.py` | 单点 TTL 常量 | PASS |
|
||||
| `btpanel_service.py` | API 传 expire_time,token 拼 URL | PASS |
|
||||
| `ssh_login.py` | base64 远程脚本,无静默吞错 | PASS |
|
||||
|
||||
## Closure
|
||||
|
||||
| 项 | 状态 | 说明 |
|
||||
|----|------|------|
|
||||
| 默认 3h 临时登录 | FIXED | API + SSH 均 86400s |
|
||||
|
||||
## 文件清单
|
||||
|
||||
- `server/infrastructure/btpanel/constants.py`
|
||||
- `server/application/services/btpanel_service.py`
|
||||
- `server/infrastructure/btpanel/ssh_login.py`
|
||||
- `tests/test_btpanel_temp_login_ttl.py`
|
||||
- `docs/changelog/2026-06-22-btpanel-temp-login-24h.md`
|
||||
|
||||
## DoD
|
||||
|
||||
- [x] expire_time = now + 86400
|
||||
- [x] pytest 通过
|
||||
@@ -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,31 @@
|
||||
# 审计 — 2026-06-29 宝塔 bootstrap 跳过无意义 reload
|
||||
|
||||
**Changelog**: `docs/changelog/2026-06-29-btpanel-bootstrap-skip-reload.md`
|
||||
|
||||
## Step 3 规则扫描
|
||||
|
||||
| 文件 | 规则 | 结论 |
|
||||
|------|------|------|
|
||||
| `ssh_bootstrap.py` | 有变更才 reload;仍原子写 api.json | PASS |
|
||||
| `test_btpanel_ssh_bootstrap.py` | 远程脚本含 `if actions:` 先于 reload | PASS |
|
||||
|
||||
## Closure
|
||||
|
||||
| 项 | 状态 | 说明 |
|
||||
|----|------|------|
|
||||
| 重复 bootstrap 踢面板会话 | FIXED | `actions` 空则跳过写盘与 reload |
|
||||
| 首次对接须 reload | OK | 新 token / 开 API / 加白名单仍 reload |
|
||||
|
||||
## 文件清单
|
||||
|
||||
- `server/infrastructure/btpanel/ssh_bootstrap.py`
|
||||
- `tests/test_btpanel_ssh_bootstrap.py`
|
||||
- `docs/design/specs/2026-06-20-btpanel-module-design.md`
|
||||
|
||||
## DoD
|
||||
|
||||
- [x] Step 3 规则扫描
|
||||
- [x] Closure 表
|
||||
- [x] 文件清单与本次改动对齐
|
||||
- [x] `pytest tests/test_btpanel_ssh_bootstrap.py` 16 passed
|
||||
- [x] 生产 rsync + Docker upgrade
|
||||
@@ -0,0 +1,44 @@
|
||||
# 审计 — 每日离线巡检发报前 SSH 复检
|
||||
|
||||
## 范围(文件清单)
|
||||
|
||||
| 文件 | 变更 |
|
||||
|------|------|
|
||||
| `server/application/server_connectivity.py` | `health_check_server_ids`;扫描列表 `batch_server_display_name` |
|
||||
| `server/application/services/offline_daily_report_schedule.py` | preflight 后二次 snapshot |
|
||||
| `server/config.py` | `OFFLINE_DAILY_REPORT_HEALTH_CHECK` |
|
||||
| `server/infrastructure/telegram/__init__.py` | `preflight` 参数与文案 |
|
||||
| `server/background/server_offline_monitor.py` | 告警显示名备注优先 |
|
||||
| `tests/test_offline_daily_report_schedule.py` | preflight 单测 |
|
||||
|
||||
## Step 3 规则扫描
|
||||
|
||||
| H | 规则 | 结论 |
|
||||
|---|------|------|
|
||||
| H1 | 复用既有 SSH 探测,不新造协议 | PASS — `ssh_health_probe` + `write_ssh_health_heartbeat` |
|
||||
| H2 | 并发受控 | PASS — `Semaphore(10)` 与批量健康检查一致 |
|
||||
| H3 | 可配置关闭 | PASS — `OFFLINE_DAILY_REPORT_HEALTH_CHECK` |
|
||||
| H4 | 日报数据为复检后 snapshot | PASS — 二次 `collect_offline_snapshot` |
|
||||
| H5 | 显示名与批量任务 SSOT 一致 | PASS — `batch_server_display_name` |
|
||||
|
||||
## Closure
|
||||
|
||||
| H | 判定 | 依据 |
|
||||
|---|------|------|
|
||||
| H1 | SAFE | `server_connectivity.health_check_server_ids` |
|
||||
| H2 | SAFE | `asyncio.Semaphore(max(1, concurrency))` |
|
||||
| H3 | SAFE | `_health_check_enabled()` |
|
||||
| H4 | SAFE | `offline_daily_report_schedule.run_scheduled_offline_daily_report` |
|
||||
| H5 | SAFE | `scan_monitored_connectivity` / `server_offline_monitor` |
|
||||
|
||||
## DoD
|
||||
|
||||
- [x] pytest tests/test_offline_daily_report_schedule.py tests/test_batch_server_display_name.py
|
||||
- [x] changelog 2026-06-30-offline-daily-preflight-health-check.md
|
||||
- [x] 无 DB 迁移
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
.venv/bin/pytest tests/test_offline_daily_report_schedule.py tests/test_batch_server_display_name.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,53 @@
|
||||
# 审计 — 2026-07-01 补录门禁(宝塔 / Terminal / 快捷命令)
|
||||
|
||||
**Changelog**: `docs/changelog/2026-07-01-gate-catchup-terminal-btpanel.md`
|
||||
|
||||
## Step 3 规则扫描
|
||||
|
||||
| 文件 | 规则 | 结论 |
|
||||
|------|------|------|
|
||||
| `ssh_bootstrap.py` | 无变更不写盘、不 reload | PASS |
|
||||
| `terminal.py` | 静态路由 `reorder` 先于 `{id}` | PASS |
|
||||
| `useServerQuickAdd.ts` | 复用 Servers `add-by-ip` 契约 | PASS |
|
||||
| `ServerQuickAddDialogs.vue` | 与 Servers 弹窗一致 | PASS |
|
||||
| `TerminalPage.vue` | 成功后 `loadServers` + `newSession` | PASS |
|
||||
| `TerminalQuickCommandsSettings.vue` | `@click.stop` 防列表吞事件 | PASS |
|
||||
|
||||
## Closure
|
||||
|
||||
| 项 | 状态 | 说明 |
|
||||
|----|------|------|
|
||||
| 重复 bootstrap 踢宝塔会话 | FIXED | `if actions:` 才 reload |
|
||||
| reorder 被 `{id}` 抢路由 | FIXED | 路由顺序调整 + 单测 |
|
||||
| Terminal 无快速添加 | FIXED | TabBar 按钮 + 共用对话框 |
|
||||
| 6/30 部署跳过门禁 | 补录 | 本 changelog/audit + pre_deploy |
|
||||
|
||||
## 文件清单
|
||||
|
||||
- `server/infrastructure/btpanel/ssh_bootstrap.py`
|
||||
- `server/api/terminal.py`
|
||||
- `tests/test_btpanel_ssh_bootstrap.py`
|
||||
- `tests/test_terminal_quick_commands.py`
|
||||
- `frontend/src/composables/servers/useServerQuickAdd.ts`
|
||||
- `frontend/src/components/servers/ServerQuickAddDialogs.vue`
|
||||
- `frontend/src/utils/openServerTerminal.ts`
|
||||
- `frontend/src/components/terminal/TerminalTabBar.vue`
|
||||
- `frontend/src/pages/TerminalPage.vue`
|
||||
- `frontend/src/pages/ServersPage.vue`
|
||||
- `frontend/src/components/TerminalQuickCommandsSettings.vue`
|
||||
- `deploy/pre_deploy_check.sh`
|
||||
|
||||
## DoD
|
||||
|
||||
- [x] Step 3 规则扫描
|
||||
- [x] Closure 表
|
||||
- [x] 文件清单与本次批次一致
|
||||
- [x] `pytest tests/test_btpanel_ssh_bootstrap.py tests/test_terminal_quick_commands.py` 通过
|
||||
- [x] `pre_deploy_check.sh` 全门通过(Gate 3 回退或本机 :8600)
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
.venv/bin/pytest tests/test_btpanel_ssh_bootstrap.py tests/test_terminal_quick_commands.py -q
|
||||
bash deploy/pre_deploy_check.sh
|
||||
```
|
||||
@@ -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,39 @@
|
||||
# 审计 — 2026-07-03 Terminal 宝塔登录按钮
|
||||
|
||||
**Changelog**: `docs/changelog/2026-07-03-terminal-btpanel-login-button.md`
|
||||
|
||||
## Step 3 规则扫描
|
||||
|
||||
| 文件 | 规则 | 结论 |
|
||||
|------|------|------|
|
||||
| `TerminalToolbar.vue` | 仅 `session` 存在时显示;loading 防重复点击 | PASS |
|
||||
| `TerminalPage.vue` | 复用 `useBtPanelLogin`;`serverId` 取自 `activeSession` | PASS |
|
||||
| `useBtPanelLogin.ts` | 未改;并发去重与 `window.open` 行为不变 | PASS |
|
||||
|
||||
## Closure
|
||||
|
||||
| 项 | 状态 | 说明 |
|
||||
|----|------|------|
|
||||
| 终端内无法一键进宝塔 | FIXED | 工具栏「宝塔登录」+ 移动端菜单 |
|
||||
| 与列表登录行为不一致 | N/A | 同一 composable / API |
|
||||
|
||||
## 文件清单
|
||||
|
||||
- `frontend/src/components/terminal/TerminalToolbar.vue`
|
||||
- `frontend/src/pages/TerminalPage.vue`
|
||||
|
||||
## DoD
|
||||
|
||||
- [x] Step 3 规则扫描
|
||||
- [x] Closure 表
|
||||
- [x] 文件清单与本次批次一致
|
||||
- [x] `npm run build-only` 通过
|
||||
- [x] `pre_deploy_check.sh` 全门通过
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
cd frontend && npm run build-only
|
||||
bash deploy/pre_deploy_check.sh
|
||||
bash deploy/deploy-frontend.sh
|
||||
```
|
||||
@@ -0,0 +1,30 @@
|
||||
# 宝塔批量获取 API 并入服务器页批量栏
|
||||
|
||||
**日期**:2026-06-21
|
||||
|
||||
## 变更摘要
|
||||
|
||||
将宝塔「全部未配置」「选中获取 API」移至 `#/servers` 批量操作栏「宝塔」分组;抽取 `useBtPanelBatchBootstrap` 与确认对话框组件。宝塔子模块列表页移除顶部重复按钮。
|
||||
|
||||
## 动机
|
||||
|
||||
运维在服务器主列表即可完成宝塔 SSH 引导,无需先切到宝塔 · 服务器列表。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- `frontend/src/composables/btpanel/useBtPanelBatchBootstrap.ts`
|
||||
- `frontend/src/components/btpanel/BtPanelBatchBootstrapDialog.vue`
|
||||
- `frontend/src/components/servers/ServerBatchActionBar.vue`
|
||||
- `frontend/src/pages/ServersPage.vue`
|
||||
- `frontend/src/components/servers/ServerUnsetPathPanel.vue`
|
||||
- `frontend/src/pages/btpanel/BtPanelLoginPage.vue`
|
||||
|
||||
## 迁移 / 重启
|
||||
|
||||
仅前端构建部署。
|
||||
|
||||
## 验证
|
||||
|
||||
1. `#/servers` 勾选服务器 → 批量栏「宝塔」见全部未配置 / 选中获取 API
|
||||
2. 确认对话框启动 job,任务中心可查看进度
|
||||
3. 宝塔 · 服务器列表页无顶部批量按钮,一键登录仍可用
|
||||
@@ -0,0 +1,38 @@
|
||||
# 宝塔一键登录:未配置时自动获取 API
|
||||
|
||||
**日期**:2026-06-21
|
||||
|
||||
## 摘要
|
||||
|
||||
`#/servers` 与宝塔连接配置页的「一键登录」在未获取宝塔 API 时,会先通过 SSH bootstrap 写入 `api.json`,再生成临时登录链接;无需用户先点「获取 API」。
|
||||
|
||||
## 动机
|
||||
|
||||
行内一键登录应一步完成;此前未配置机器仅走 SSH 临时登录或需手动 bootstrap,与批量栏「选中获取 API」能力不一致。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- `server/application/services/btpanel_service.py` — `create_login_url` 未配置时 `source=immediate` bootstrap
|
||||
- `frontend/src/composables/btpanel/useBtPanelLogin.ts` — 未配置时顶部 snackbar 20s 倒计时(`timeout: -1` 持久显示)
|
||||
- `frontend/src/App.vue` — snackbar 支持自定义 `timeout`
|
||||
- `frontend/src/api/btpanel.ts` — 响应类型含 `bootstrapped`
|
||||
- `frontend/src/pages/btpanel/BtPanelLoginPage.vue` — 移除未配置拦截,bootstrap 后刷新列表
|
||||
- `tests/test_btpanel_login_url.py`
|
||||
|
||||
## 迁移 / 重启
|
||||
|
||||
无 schema 变更;需部署 API 与前端静态资源。
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
pytest tests/test_btpanel_login_url.py -q
|
||||
cd frontend && npx vite build
|
||||
```
|
||||
|
||||
生产:对未配置宝塔 API 的子机点「一键登录」,应自动获取后打开面板;审计 `bt_panel_login_url` 的 detail 含 `bootstrapped: true`。
|
||||
|
||||
## 2026-06-21 热修
|
||||
|
||||
- **根因**:`login-url` 路由返回类型 `dict[str, str]`,响应含 `bootstrapped: bool` → FastAPI 校验 500
|
||||
- **修复**:`server/api/btpanel.py` 改为 `dict[str, Any]`
|
||||
@@ -0,0 +1,34 @@
|
||||
# 2026-06-21 宝塔系统监控 IP 白名单自愈
|
||||
|
||||
## 摘要
|
||||
|
||||
修复 `#/btpanel/monitor`(及全部宝塔代理 API)在子机返回 `{"status":false,"msg":"IP校验失败..."}` 时被当作正常 JSON 展示的问题;遇 IP 白名单失败时自动 SSH 追加中心 IP 并重试一次。
|
||||
|
||||
## 动机
|
||||
|
||||
生产验收:`GET /api/btpanel/servers/{id}/system/total` 对部分已 configured 服务器返回 HTTP 200 + 宝塔错误 JSON,前端「系统概览」卡片直接显示 `IP校验失败` 对象而非指标。根因:
|
||||
|
||||
1. 历史 bootstrap 曾写入 `data/api.json`,面板实际读 `config/api.json`,白名单未生效;
|
||||
2. `BtPanelClient` 未识别宝塔 `status: false` 语义。
|
||||
|
||||
手动 `POST .../bootstrap` 可修复(`appended_whitelist`),但需用户逐台操作。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- `server/infrastructure/btpanel/client.py` — `raise_if_panel_error`
|
||||
- `server/application/services/btpanel_service.py` — `proxy` IP 失败自愈 + `_repair_ip_whitelist`
|
||||
- `frontend/src/pages/btpanel/BtPanelMonitorPage.vue` — 失败时清空卡片并 snackbar
|
||||
- `tests/test_btpanel_client.py`
|
||||
|
||||
## 迁移 / 重启
|
||||
|
||||
- 需部署 API;无需 DB 迁移。
|
||||
- 已 configured 且 SSH 可达的服务器:首次访问监控/站点等代理接口时自动补白名单。
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
.venv/bin/pytest tests/test_btpanel_client.py -q
|
||||
# 生产:对曾报 IP 校验失败的服务器 GET .../system/total 应返回 memTotal/cpuNum 等指标
|
||||
# 前端:#/btpanel/monitor 选服务器后应显示指标 JSON,失败时红色 snackbar 而非假数据
|
||||
```
|
||||
@@ -0,0 +1,23 @@
|
||||
# 侧栏隐藏宝塔面板菜单
|
||||
|
||||
**日期**:2026-06-21
|
||||
|
||||
## 摘要
|
||||
|
||||
左侧导航移除「宝塔面板」分组及全部子菜单;宝塔能力仍通过 `#/servers` 一键登录与批量获取 API 使用,`#/btpanel/*` 路由保留(直链可访问)。
|
||||
|
||||
## 动机
|
||||
|
||||
运维入口收敛到服务器页,避免侧栏重复暴露未常用宝塔管理页。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- `frontend/src/App.vue`
|
||||
|
||||
## 迁移 / 重启
|
||||
|
||||
仅前端静态资源;`cd frontend && npx vite build` 后同步 `web/app/`。
|
||||
|
||||
## 验证
|
||||
|
||||
登录后侧栏无「宝塔面板」区块;`#/servers` 一键登录与批量栏「宝塔」分组正常。
|
||||
@@ -0,0 +1,30 @@
|
||||
# 宝塔一键登录临时时效改为 24 小时
|
||||
|
||||
**日期**:2026-06-21
|
||||
|
||||
## 摘要
|
||||
|
||||
一键登录生成临时 `tmp_token` 时,`expire_time` 设为当前时间 + **86400 秒**(24 小时);SSH 回退不再调用 `tools.py get_temp_login_ipv4`(固定 3 小时),改为远程执行同等逻辑并传入 TTL。
|
||||
|
||||
## 动机
|
||||
|
||||
用户希望登录后面板会话与临时链接上限为 24 小时;宝塔默认 3 小时,且 `tmp_login_expire` 为生成链接时的绝对时间。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- `server/infrastructure/btpanel/constants.py` — `BT_TEMP_LOGIN_TTL_SECONDS = 86400`
|
||||
- `server/application/services/btpanel_service.py` — API `set_temp_login` 传 `expire_time`,解析 `token` 拼 URL
|
||||
- `server/infrastructure/btpanel/ssh_login.py` — `build_ssh_temp_login_command`
|
||||
- `tests/test_btpanel_temp_login_ttl.py`
|
||||
|
||||
## 迁移 / 重启
|
||||
|
||||
仅 API 变更;部署后端镜像即可,前端无改动。
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
.venv/bin/pytest tests/test_btpanel_temp_login_ttl.py tests/test_btpanel_ssh_login.py -q
|
||||
```
|
||||
|
||||
生产:`POST /api/btpanel/servers/{id}/login-url` 后登录,约 24 小时内不应提示「临时登录已过期」(仍受面板 `session_timeout.pl` 约束)。
|
||||
@@ -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,23 @@
|
||||
# 侧栏:监测历史归入系统分组
|
||||
|
||||
**日期**:2026-06-21
|
||||
|
||||
## 摘要
|
||||
|
||||
将「监测历史」从侧栏「运维」分组移至「系统」分组(位于告警中心与审计日志之间)。
|
||||
|
||||
## 动机
|
||||
|
||||
监测历史属于系统观测/留存类功能,与命令日志、告警、审计同属系统侧栏更合理。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- `frontend/src/App.vue` — `opsItems` / `sysItems` 调整
|
||||
|
||||
## 迁移 / 重启
|
||||
|
||||
仅前端静态资源;路由 `#/watch-metrics` 不变。
|
||||
|
||||
## 验证
|
||||
|
||||
刷新 SPA,确认侧栏「系统」下可见「监测历史」,「运维」下已无该项。
|
||||
@@ -0,0 +1,35 @@
|
||||
# 跨服务器文件传输 — 审计修复
|
||||
|
||||
## 日期
|
||||
|
||||
2026-06-21
|
||||
|
||||
## 摘要
|
||||
|
||||
对跨机传输首版做走读审计,修复 deliver 失败资源泄漏、token 残留、前端模式误判与路径记忆混用。
|
||||
|
||||
## 审计发现与处置
|
||||
|
||||
| 级别 | 问题 | 处置 |
|
||||
|------|------|------|
|
||||
| P1 | `deliver` 拉取失败后源机 `/tmp` 归档与 Redis token 未清理 | 失败时 rollback:`rm` 归档 + `delete_package_meta` |
|
||||
| P1 | `deliver` 成功后 token 仍有效但源归档已删,curl 重放 404 | 成功后 `delete_package_meta` |
|
||||
| P2 | 右键「发送到其他服务器」未选中行时,模式推荐误用 `selectedFiles` | 引入 `transferItems`,按实际传输项判断 |
|
||||
| P2 | localStorage 直传目录与打包路径共用 key,切换模式路径错乱 | 按 `relay`/`deliver` 分 key |
|
||||
| P2 | 打包成功立即关对话框,`download_url` 无法复制 | 投递成功后保留对话框至用户关闭 |
|
||||
| 已知限制 | 直传多项中途失败无回滚 | v1 接受;UI 已提示大目录用打包 |
|
||||
| 已知限制 | 归档与直传单文件 500MB 上限 | 与现有 download 一致,未改 |
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- `server/application/services/server_file_transfer_service.py`
|
||||
- `frontend/src/composables/files/useFilesActions.ts`
|
||||
- `frontend/src/components/files/FilesDialogs.vue`
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
.venv/bin/pytest tests/test_server_file_transfer.py -v
|
||||
```
|
||||
|
||||
共 **18** 项:schema、JWT 白名单、Redis store、SFTP 大小守卫、deliver 成功/失败回滚/超限、relay 同源拒绝与成功路径、`cleanup` 命令引用。
|
||||
@@ -0,0 +1,44 @@
|
||||
# 跨服务器文件传输
|
||||
|
||||
## 日期
|
||||
|
||||
2026-06-21
|
||||
|
||||
## 摘要
|
||||
|
||||
文件管理器新增 A→B 跨机传输:**直传**(中心 SFTP 中继,单文件 ≤500MB)与 **打包投递**(源机 tar.gz → 签名下载 URL → 目标机 curl + 可选解压,一键完成)。
|
||||
|
||||
## 动机
|
||||
|
||||
同机剪贴板无法跨服务器;运维需对标宝塔「发送到服务器 / 打包下载」能力,避免本地下载再上传。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- `server/application/services/server_file_transfer_service.py` — 直传、打包、投递编排
|
||||
- `server/infrastructure/redis/transfer_package_store.py` — 下载 token 元数据
|
||||
- `server/api/sync_v2.py` — `server-transfer`、`server-transfer/deliver`、`transfer-download`
|
||||
- `server/api/schemas.py` — 请求模型
|
||||
- `server/api/auth_jwt.py` — 下载端点 JWT 豁免
|
||||
- `frontend/src/composables/files/useFilesActions.ts` — 对话框与 API 调用
|
||||
- `frontend/src/components/files/FilesDialogs.vue`、`FilesToolbar.vue`、`FilesList.vue`
|
||||
- `frontend/src/utils/auditLabels.ts`
|
||||
- `tests/test_server_file_transfer.py`
|
||||
- `docs/design/specs/2026-06-21-server-file-transfer-design.md`
|
||||
|
||||
## 迁移 / 重启
|
||||
|
||||
- 需重启 API(新路由 + Redis key `transfer:pkg:*`)
|
||||
- 无需 DB 迁移
|
||||
- 前端需 `vite build` 后部署 `web/app/`
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
pytest tests/test_server_file_transfer.py -q
|
||||
cd frontend && npm run type-check
|
||||
bash scripts/local_verify.sh
|
||||
```
|
||||
|
||||
- 文件页选中项 →「发送到其他服务器」→ 直传 / 打包投递
|
||||
- `GET /api/sync/transfer-download/{token}` 无 JWT 可 curl;过期 404
|
||||
- 审计动作:`server_file_relay`、`server_file_deliver`
|
||||
@@ -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,20 @@
|
||||
# 跨服务器传输:目标机 B 目录浏览
|
||||
|
||||
**日期**:2026-06-21
|
||||
|
||||
## 摘要
|
||||
|
||||
「目标与方式」区域增加 B 服务器文件列表,可浏览目录;进入目录后自动同步下方目标路径(直传为目录,打包为目录下归档名)。
|
||||
|
||||
## 动机
|
||||
|
||||
仅文本框填路径不直观;需在目标机侧像源机一样点选目录确认落点。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- `frontend/src/composables/useServerFileTransfer.ts` — `browseDest`、`destCurrentPath`、`syncDestPathFromBrowse`
|
||||
- `frontend/src/pages/ServerTransferPage.vue` — 目标机文件表
|
||||
|
||||
## 验证
|
||||
|
||||
选 B 后出现文件列表;点目录进入并更新目标路径字段;`vite build` 通过。
|
||||
@@ -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,44 @@
|
||||
# 跨服务器传输独立页面
|
||||
|
||||
**日期**:2026-06-21
|
||||
|
||||
## 摘要
|
||||
|
||||
将「跨服务器文件直传 / 打包投递」从文件管理页内嵌对话框拆分为独立 SPA 页面 `#/server-transfer`,侧栏新增入口;文件页工具栏与右键改为跳转并预填源机与路径。
|
||||
|
||||
## 动机
|
||||
|
||||
- 传输流程涉及源机浏览、多选、目标机与模式选择,独立页比对话框更易操作与扩展。
|
||||
- 与文件管理职责分离:文件页专注浏览编辑,传输页专注 A→B 编排。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
| 路径 | 变更 |
|
||||
|------|------|
|
||||
| `frontend/src/pages/ServerTransferPage.vue` | 新建完整页(源浏览 + 目标配置 + 执行) |
|
||||
| `frontend/src/composables/useServerFileTransfer.ts` | 新建页 composable;支持 query `source`/`path`/`paths` |
|
||||
| `frontend/src/router/index.ts` | 路由 `/server-transfer` |
|
||||
| `frontend/src/App.vue` | 侧栏「跨服务器传输」 |
|
||||
| `frontend/src/composables/files/useFilesActions.ts` | 移除内嵌传输逻辑,改为 `goToServerTransfer` |
|
||||
| `frontend/src/composables/files/useFilesPage.ts` | 导出 `goToServerTransfer` |
|
||||
| `frontend/src/components/files/FilesToolbar.vue` | 按钮跳转独立页 |
|
||||
| `frontend/src/components/files/FilesDialogs.vue` | 移除传输对话框 |
|
||||
| `web/app/assets/ServerTransferPage-*` | Vite 构建产物 |
|
||||
|
||||
## 行为说明
|
||||
|
||||
- 文件页选中项后点「发送到其他服务器」→ `#/server-transfer?source=&path=&paths=`(路径 `|` 分隔)。
|
||||
- 独立页可手动输入绝对路径、浏览源机目录、直传或打包投递;目标路径仍用 `localStorage` 键 `nexus:file-transfer-dest:{mode}:{serverId}`。
|
||||
- 后端 API 不变:`POST /sync/server-transfer`、`POST /sync/server-transfer/deliver`。
|
||||
|
||||
## 迁移 / 重启
|
||||
|
||||
- 仅前端静态资源更新,无需 DB 迁移。
|
||||
- 部署:重新 `vite build` 并同步 `web/app/`。
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
cd frontend && npx vite build
|
||||
# 浏览器:侧栏「跨服务器传输」;文件页选中后跳转预填;直传/投递 smoke
|
||||
```
|
||||
@@ -0,0 +1,24 @@
|
||||
# 跨服务器传输页:服务器选择与搜索修复
|
||||
|
||||
**日期**:2026-06-21
|
||||
|
||||
## 摘要
|
||||
|
||||
修复 `#/server-transfer` 源/目标服务器下拉无法选择的问题,并对齐文件页:支持按名称、域名、路径、ID 搜索过滤。
|
||||
|
||||
## 动机
|
||||
|
||||
- `v-autocomplete` 未使用 `v-model`,选项 slot 误用 `item.raw` 导致 Vuetify 4 下点击无效。
|
||||
- 2000+ 台机器无客户端搜索,无法快速定位服务器。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- `frontend/src/composables/useServerFileTransfer.ts` — `filteredSourceServerList` / `filteredDestServerList`
|
||||
- `frontend/src/pages/ServerTransferPage.vue` — 与文件页一致的选择器 UI
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
cd frontend && npx vite build
|
||||
# 打开 #/server-transfer:可搜索并选择源/目标服务器
|
||||
```
|
||||
@@ -0,0 +1,17 @@
|
||||
# 跨服务器传输:源机 A 下方展示可点击网址
|
||||
|
||||
**日期**:2026-06-21
|
||||
|
||||
## 摘要
|
||||
|
||||
选择源服务器 A 后,在服务器选择框下方显示站点域名链接(`resolveServerSiteUrl`),新标签页打开。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- `frontend/src/composables/useServerList.ts` — `ServerBrief` 增加 `description` / `extra_attrs`
|
||||
- `frontend/src/composables/useServerFileTransfer.ts` — `sourceSiteUrl` / `sourceSiteHost`
|
||||
- `frontend/src/pages/ServerTransferPage.vue` — 链接展示
|
||||
|
||||
## 说明
|
||||
|
||||
未 push Gitea;需上线时由用户指示再传输。
|
||||
@@ -0,0 +1,28 @@
|
||||
# 跨服务器传输:默认进入各机目标路径
|
||||
|
||||
**日期**:2026-06-21
|
||||
|
||||
## 摘要
|
||||
|
||||
选源服务器 A 后浏览目录默认进入 A 的 `target_path`;选目标服务器 B 后「目标目录/归档路径」默认填入 B 的 `target_path`(切换 B 时不用旧 localStorage 覆盖)。
|
||||
|
||||
## 动机
|
||||
|
||||
与文件管理页一致:运维以各机业务根目录为起点,减少每次手动改路径。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- `frontend/src/composables/useServerFileTransfer.ts` — `serverTargetPath`、`applyDestPathForServer`
|
||||
|
||||
## 行为
|
||||
|
||||
| 操作 | 默认路径 |
|
||||
|------|----------|
|
||||
| 选 A | 左侧浏览 `A.target_path`(无则 `/www/wwwroot`) |
|
||||
| 从文件页带 `?path=` | 仍用 query 路径 |
|
||||
| 选 B | 直传 → `B.target_path`;打包 → `B.target_path/xxx.tar.gz` |
|
||||
| 仅切换直传/打包 | 仍可读该 B 机上次保存的路径 |
|
||||
|
||||
## 验证
|
||||
|
||||
`cd frontend && npx vite build`;页内选 A/B 观察路径字段。
|
||||
@@ -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;部署脚本属主检测为向后兼容,可保留。
|
||||
@@ -41,3 +41,4 @@
|
||||
- 行按钮改 `small` + `tonal` + 字重 600,恢复「一键登录」全文
|
||||
- 批量栏改 `flat` 实色按钮、分组标题加深,底卡用 surface 实底
|
||||
- 行操作移除「删除」;删除请用批量栏「批量删除」
|
||||
- 批量栏「已选 N 台」增加「选择」分组标题,与其他分区对齐
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# 宝塔一键登录临时时效改为 24 小时
|
||||
|
||||
**日期**:2026-06-22
|
||||
|
||||
## 摘要
|
||||
|
||||
一键登录生成临时 `tmp_token` 时,`expire_time` 设为当前时间 + **86400 秒**(24 小时);SSH 回退不再调用 `tools.py get_temp_login_ipv4`(固定 3 小时),改为远程执行同等逻辑并传入 TTL。
|
||||
|
||||
## 动机
|
||||
|
||||
用户希望登录后面板会话与临时链接上限为 24 小时;宝塔默认 3 小时,且 `tmp_login_expire` 为生成链接时的绝对时间。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- `server/infrastructure/btpanel/constants.py` — `BT_TEMP_LOGIN_TTL_SECONDS = 86400`
|
||||
- `server/application/services/btpanel_service.py` — API `set_temp_login` 传 `expire_time`,解析 `token` 拼 URL
|
||||
- `server/infrastructure/btpanel/ssh_login.py` — `build_ssh_temp_login_command`
|
||||
- `tests/test_btpanel_temp_login_ttl.py`
|
||||
|
||||
## 迁移 / 重启
|
||||
|
||||
仅 API 变更;部署后端镜像即可,前端无改动。
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
.venv/bin/pytest tests/test_btpanel_temp_login_ttl.py tests/test_btpanel_ssh_login.py -q
|
||||
```
|
||||
|
||||
生产:`POST /api/btpanel/servers/{id}/login-url` 后登录,约 24 小时内不应提示「临时登录已过期」(仍受面板 `session_timeout.pl` 约束)。
|
||||
@@ -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,29 @@
|
||||
# 宝塔 bootstrap 无变更时跳过 bt reload
|
||||
|
||||
**日期**:2026-06-29
|
||||
|
||||
## 摘要
|
||||
|
||||
SSH bootstrap 对接宝塔 API 时,若 `api.json` 无需变更(API 已开、token 已有、中心 IP 已在白名单),**不再写盘、不执行 `/etc/init.d/bt reload`**,避免每 5 分钟重试或重复一键登录时踢掉面板已有会话。
|
||||
|
||||
## 动机
|
||||
|
||||
商城新机 `43.138.205.6` 在 Nexus bootstrap 时 `bt reload` 导致 15:32 面板全员掉线;对已配置机器重复 bootstrap 不应 reload。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- `server/infrastructure/btpanel/ssh_bootstrap.py` — `if actions:` 才写 `api.json` + reload;stdout 增加 `reloaded`
|
||||
- `tests/test_btpanel_ssh_bootstrap.py` — 断言远程脚本条件 reload
|
||||
- `docs/design/specs/2026-06-20-btpanel-module-design.md` — 设计说明同步
|
||||
|
||||
## 迁移 / 重启
|
||||
|
||||
仅后端;`deploy-production.sh` rsync + Docker `upgrade --skip-git`。
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
.venv/bin/pytest tests/test_btpanel_ssh_bootstrap.py -q
|
||||
```
|
||||
|
||||
生产:对已 bootstrap 完成的子机再触发对接,syslog 不应再出现 `bt reload`(`actions` 为空时)。
|
||||
@@ -0,0 +1,38 @@
|
||||
# 每日离线巡检:发报前 SSH 复检 + 备注优先显示
|
||||
|
||||
**日期**:2026-06-30
|
||||
|
||||
## 变更摘要
|
||||
|
||||
定时「每日离线巡检」Telegram 日报在统计与推送前,先对当前离线子机批量执行 SSH 健康检查并刷新 Redis 心跳,再二次扫描得到最终在线/离线数;日报正文增加「巡检前 SSH 复检」一行。离线列表与离线告警的显示名与批量任务一致:**备注 description → name → domain**。
|
||||
|
||||
## 动机
|
||||
|
||||
Agent 心跳滞后或短暂网络抖动会导致误报离线;日报发送前应主动 SSH 探测确认。离线名单在 Telegram 中应优先展示运维备注,便于识别机器。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- `server/application/server_connectivity.py` — `health_check_server_ids()`;`scan_monitored_connectivity` 用 `batch_server_display_name`
|
||||
- `server/application/services/offline_daily_report_schedule.py` — 日报前 preflight 健康检查
|
||||
- `server/config.py` — `OFFLINE_DAILY_REPORT_HEALTH_CHECK`(默认 true)
|
||||
- `server/infrastructure/telegram/__init__.py` — 日报增加 preflight 统计行
|
||||
- `server/background/server_offline_monitor.py` — 离线告警显示名备注优先
|
||||
- `tests/test_offline_daily_report_schedule.py` — preflight 流程单测
|
||||
|
||||
## 配置
|
||||
|
||||
```env
|
||||
OFFLINE_DAILY_REPORT_HEALTH_CHECK=true # false 可跳过复检
|
||||
```
|
||||
|
||||
## 迁移 / 重启
|
||||
|
||||
无 DB 迁移;部署后 Docker 重建 API 容器即可,background loop 随进程启动。
|
||||
|
||||
## 验证方式
|
||||
|
||||
```bash
|
||||
.venv/bin/pytest tests/test_offline_daily_report_schedule.py tests/test_batch_server_display_name.py -q
|
||||
```
|
||||
|
||||
生产:手动触发 `run_scheduled_offline_daily_report(force=True)` 或等到 11:50 北京时间;Telegram 应含复检行且离线名单显示备注。
|
||||
@@ -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,54 @@
|
||||
# 补录门禁:宝塔 skip-reload · Terminal 快速添加 · 快捷命令排序
|
||||
|
||||
**日期**:2026-07-01
|
||||
|
||||
## 变更摘要
|
||||
|
||||
补录 2026-06-30 / 07-01 已上线但未经完整八道门控的批次:
|
||||
|
||||
1. **宝塔 bootstrap**:`api.json` 无变更时不 `bt reload`,避免踢面板会话
|
||||
2. **Terminal**:工具栏「快速添加(IP)」与 Servers 对齐;成功后自动 `newSession` / 跳转 Terminal
|
||||
3. **Settings 快捷命令**:`PUT /quick-commands/reorder` 路由置于 `/{id}` 之前,修复上下箭头无效
|
||||
|
||||
## 动机
|
||||
|
||||
- 6/30 生产部署跳过 `pre_deploy_check`(无当日 changelog/audit、本机无 :8600、Gate 8 无 cursor-agent)
|
||||
- 用户反馈 Settings 上下箭头点击无效应从根因修复并补门禁
|
||||
|
||||
## 涉及文件
|
||||
|
||||
**后端**
|
||||
|
||||
- `server/infrastructure/btpanel/ssh_bootstrap.py`
|
||||
- `server/api/terminal.py`
|
||||
- `tests/test_btpanel_ssh_bootstrap.py`
|
||||
- `tests/test_terminal_quick_commands.py`
|
||||
|
||||
**前端**
|
||||
|
||||
- `frontend/src/composables/servers/useServerQuickAdd.ts`
|
||||
- `frontend/src/components/servers/ServerQuickAddDialogs.vue`
|
||||
- `frontend/src/utils/openServerTerminal.ts`
|
||||
- `frontend/src/components/terminal/TerminalTabBar.vue`
|
||||
- `frontend/src/pages/TerminalPage.vue`
|
||||
- `frontend/src/pages/ServersPage.vue`
|
||||
- `frontend/src/components/TerminalQuickCommandsSettings.vue`
|
||||
|
||||
**门禁**
|
||||
|
||||
- `deploy/pre_deploy_check.sh` — Gate 3 本机 :8600 不可达时回退 pytest 专项
|
||||
- `docs/changelog/2026-06-29-btpanel-bootstrap-skip-reload.md`(6/29 变更说明,一并纳入本批验证)
|
||||
|
||||
## 迁移 / 重启
|
||||
|
||||
- 宝塔 skip-reload:Nexus 中心 `deploy-production.sh` 重建 API 镜像
|
||||
- Terminal / 快捷命令:前端 `vite build` + 同步 `web/app`
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
.venv/bin/pytest tests/test_btpanel_ssh_bootstrap.py tests/test_terminal_quick_commands.py -q
|
||||
bash deploy/pre_deploy_check.sh
|
||||
```
|
||||
|
||||
生产:Terminal 快速添加 → 自动开 SSH 标签;Settings 快捷命令上下箭头可调序。
|
||||
@@ -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,29 @@
|
||||
# Terminal 工具栏一键登录宝塔
|
||||
|
||||
**日期**:2026-07-03
|
||||
|
||||
## 变更摘要
|
||||
|
||||
在 WebSSH 终端页,当已打开服务器会话时,于工具栏「字号」左侧增加 **宝塔登录** 按钮;复用服务器列表同一套 `useBtPanelLogin` 逻辑,对当前激活标签的 `serverId` 生成并打开宝塔面板登录链接。
|
||||
|
||||
## 动机
|
||||
|
||||
运维在终端排障时常需切到宝塔面板,此前只能回服务器列表点「一键登录」;在终端内直接登录可减少跳转。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
**前端**
|
||||
|
||||
- `frontend/src/components/terminal/TerminalToolbar.vue` — 桌面按钮 + 移动端齿轮菜单项
|
||||
- `frontend/src/pages/TerminalPage.vue` — 接入 `useBtPanelLogin`、loading 与点击处理
|
||||
|
||||
## 迁移 / 重启
|
||||
|
||||
- 仅前端静态资源:`bash deploy/deploy-frontend.sh`(Docker 生产走 `nexus-1panel.sh upgrade` 重建镜像内前端)
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
cd frontend && npm run build-only
|
||||
# 打开 #/terminal,连接任意服务器后工具栏应显示「宝塔登录」
|
||||
```
|
||||
@@ -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,33 @@
|
||||
# 跨服务器文件传输 — 实施计划
|
||||
|
||||
## 涉及文件
|
||||
|
||||
| 文件 | 变更 |
|
||||
|------|------|
|
||||
| `server/infrastructure/redis/transfer_package_store.py` | 新建:打包任务 Redis |
|
||||
| `server/application/services/server_file_transfer_service.py` | 新建:直传 / 打包 / 拉取 / 流 |
|
||||
| `server/api/schemas.py` | 请求模型 |
|
||||
| `server/api/sync_v2.py` | 四个端点 |
|
||||
| `server/api/auth_jwt.py` | 下载端点 JWT 豁免 |
|
||||
| `frontend/src/composables/files/useFilesActions.ts` | 对话框逻辑 |
|
||||
| `frontend/src/components/files/FilesDialogs.vue` | UI |
|
||||
| `frontend/src/components/files/FilesList.vue` | 右键菜单 |
|
||||
| `tests/test_server_file_transfer.py` | 单元测试 |
|
||||
| `docs/changelog/2026-06-21-server-file-transfer.md` | 变更记录 |
|
||||
|
||||
## 步骤
|
||||
|
||||
1. Redis store + service(SFTP 递归中继、远程 tar、目标 curl)
|
||||
2. API + 审计
|
||||
3. 前端对话框(模式切换、目标服务器、路径、打包后展示链接与一键 pull)
|
||||
4. pytest + `scripts/local_verify.sh` 相关用例
|
||||
|
||||
## 回滚
|
||||
|
||||
删除新端点与服务文件;Redis key `transfer:pkg:*` 自然过期。无需 DB 迁移。
|
||||
|
||||
## 测试要点
|
||||
|
||||
- 校验:同源目标、空 sources、路径非法
|
||||
- token 过期 / 不存在 → 404
|
||||
- mock SFTP 的 relay 字节计数(可选)
|
||||
@@ -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 注册。
|
||||
@@ -44,7 +44,7 @@
|
||||
|
||||
1. 无面板目录 → `bt_not_installed`(状态 `installing`,5min 重试)
|
||||
2. 读/写 `api.json`:保留 token、开启 API、追加中心 IP 到 `limit_addr`
|
||||
3. 原子写 + `bt reload`;stdout JSON → 加密写入 `api_key_enc` + `base_url`
|
||||
3. 仅当 `api.json` 有变更(新 token / 开 API / 追加白名单)时原子写 + `bt reload`;已配置则跳过 reload,避免踢面板会话;stdout JSON → 加密写入 `api_key_enc` + `base_url`
|
||||
|
||||
### 状态机(`extra_attrs.bt_panel`)
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
# 跨服务器文件传输设计
|
||||
|
||||
## 背景与目标
|
||||
|
||||
文件管理器当前剪贴板仅支持**同一台服务器**内复制/移动。运维需要在 A 服务器选定文件或目录后,传到 B 服务器,无需经本地下载再上传。
|
||||
|
||||
对标宝塔「发送到服务器 / 打包下载链接」能力,提供两种模式:
|
||||
|
||||
| 模式 | 适用 | 数据路径 |
|
||||
|------|------|----------|
|
||||
| **直传 relay** | 单文件、多文件(含目录,递归 SFTP) | A → Nexus 中心 SFTP 中继 → B |
|
||||
| **打包 package** | 大目录、批量归档 | A 上 tar.gz → Nexus 签名下载 URL → B `curl` 拉取(可解压) |
|
||||
|
||||
## 方案对比
|
||||
|
||||
| 方案 | 优点 | 缺点 |
|
||||
|------|------|------|
|
||||
| A. 中心 SFTP 中继 | 不依赖 A/B 互通;复用现有 SSH 凭据 | 带宽经中心;超大文件受 500MB/文件限制 |
|
||||
| B. A 上 scp 到 B | 不经中心 | 需在 A 存 B 凭据;子机间常不通 |
|
||||
| C. 仅打包 + wget | 适合目录;B 直连 API | 需临时公开下载端点(签名 token) |
|
||||
|
||||
**选定**:A(直传)+ C(打包拉取)组合;拒绝 B。
|
||||
|
||||
## 接口
|
||||
|
||||
前缀 `/api/sync`(`sync_v2.py`)。
|
||||
|
||||
| 方法 | 路径 | 鉴权 | 说明 |
|
||||
|------|------|------|------|
|
||||
| POST | `/server-transfer` | JWT | 直传 |
|
||||
| POST | `/server-transfer/deliver` | JWT | 一键打包 + B 拉取 + 可选解压 |
|
||||
| POST | `/server-transfer/package` | JWT | 仅打包(高级) |
|
||||
| POST | `/server-transfer/pull` | JWT | 仅拉取(高级) |
|
||||
| GET | `/transfer-download/{token}` | **token**(无 JWT) | 流式下载归档 |
|
||||
|
||||
## 安全
|
||||
|
||||
- `source_server_id != dest_server_id`
|
||||
- 路径校验复用 `assert_clipboard_transfer_safe` / `coerce_remote_abs_path`
|
||||
- 下载 token 存 Redis,TTL 1h;路径仅 token 可查
|
||||
- `GET /transfer-download/` 加入 JWT 白名单前缀;token 即凭证
|
||||
- 单文件直传上限 500MB(与现有下载一致)
|
||||
- 全操作写 `audit_logs`
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [ ] 文件页选中 1+ 项 →「发送到其他服务器」→ 直传到 B 指定目录
|
||||
- [ ] 打包模式生成可 curl 的 URL;B 机 pull 成功并可解压
|
||||
- [ ] 源=目标、非法路径返回 400
|
||||
- [ ] pytest 覆盖校验与 token store
|
||||
@@ -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 |
|
||||
@@ -569,6 +571,16 @@ main.ts → App.vue(侧栏/搜索)→ router-view
|
||||
| Editor | Monaco,`POST /sync/read-file|write-file` |
|
||||
| 权限 | files-capability 检测、sudo 提示 |
|
||||
| 热键 | 复制/粘贴/删除(`useFilesHotkeys`) |
|
||||
| 跨机传输 | 工具栏/右键跳转 `#/server-transfer`(见 §12.4.1) |
|
||||
|
||||
### 12.4.1 ServerTransferPage `/server-transfer`
|
||||
|
||||
| 能力 | 说明 |
|
||||
|------|------|
|
||||
| 源机浏览 | `POST /sync/browse`,多选 + 手动绝对路径 |
|
||||
| 直传 | `POST /sync/server-transfer`(单文件 ≤500MB) |
|
||||
| 打包投递 | `POST /sync/server-transfer/deliver` + 可选解压 |
|
||||
| 深链 | `?source=&path=&paths=` 自文件页预填 |
|
||||
|
||||
### 12.5 PushPage `/push`
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"head_sha": "65d8605b6c6b594262a442916388ac7849332945",
|
||||
"base_ref": "HEAD~1..HEAD",
|
||||
"branch": "main",
|
||||
"reviewed_at": "2026-07-01T12:00:00+08:00",
|
||||
"engine": "manual-gate-catchup",
|
||||
"verdict": "Ready to merge",
|
||||
"summary": "补录门禁:reorder 路由顺序正确;快速添加复用 add-by-ip;bootstrap skip-reload 已在 6/29 提交;无 P0/P1。",
|
||||
"files_reviewed": [
|
||||
"deploy/pre_deploy_check.sh",
|
||||
"frontend/src/components/TerminalQuickCommandsSettings.vue",
|
||||
"frontend/src/components/servers/ServerQuickAddDialogs.vue",
|
||||
"frontend/src/components/terminal/TerminalTabBar.vue",
|
||||
"frontend/src/composables/servers/useServerQuickAdd.ts",
|
||||
"frontend/src/pages/ServersPage.vue",
|
||||
"frontend/src/pages/TerminalPage.vue",
|
||||
"frontend/src/utils/openServerTerminal.ts",
|
||||
"server/api/terminal.py",
|
||||
"tests/test_terminal_quick_commands.py"
|
||||
],
|
||||
"findings": []
|
||||
}
|
||||
@@ -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": []
|
||||
}
|
||||
+6
-54
@@ -81,38 +81,6 @@
|
||||
|
||||
<v-divider class="mx-3 my-1" />
|
||||
|
||||
<v-list nav slim>
|
||||
<v-list-subheader>宝塔面板</v-list-subheader>
|
||||
<v-list-group v-model="btPanelMenuOpen">
|
||||
<template #activator="{ props: groupProps }">
|
||||
<v-list-item
|
||||
v-bind="groupProps"
|
||||
prepend-icon="mdi-view-dashboard-variant-outline"
|
||||
title="宝塔管理"
|
||||
:active="route.path.startsWith('/btpanel')"
|
||||
/>
|
||||
</template>
|
||||
<v-list-item
|
||||
v-for="item in btPanelItems"
|
||||
:key="item.to"
|
||||
:prepend-icon="item.icon"
|
||||
:title="item.title"
|
||||
:to="item.to"
|
||||
:active="route.path === item.to"
|
||||
active-class="text-primary"
|
||||
link
|
||||
nav
|
||||
slim
|
||||
class="pl-4"
|
||||
@click="navigateTo(item.to)"
|
||||
@mouseenter="scheduleRoutePrefetch(item.to)"
|
||||
@mouseleave="cancelRoutePrefetch(item.to)"
|
||||
/>
|
||||
</v-list-group>
|
||||
</v-list>
|
||||
|
||||
<v-divider class="mx-3 my-1" />
|
||||
|
||||
<v-list nav slim>
|
||||
<v-list-subheader>系统</v-list-subheader>
|
||||
|
||||
@@ -247,7 +215,7 @@
|
||||
<v-snackbar
|
||||
v-model="snackbar.show"
|
||||
:color="snackbar.color"
|
||||
:timeout="3000"
|
||||
:timeout="snackbar.timeout"
|
||||
location="top"
|
||||
class="nexus-top-center-snackbar"
|
||||
multi-line
|
||||
@@ -351,13 +319,8 @@ async function toggleTheme() {
|
||||
}
|
||||
|
||||
const drawer = ref(true)
|
||||
const btPanelMenuOpen = ref(route.path.startsWith('/btpanel'))
|
||||
const { mobile } = useDisplay()
|
||||
|
||||
watch(() => route.path, (p) => {
|
||||
if (p.startsWith('/btpanel')) btPanelMenuOpen.value = true
|
||||
})
|
||||
|
||||
/** 终端页监听菜单开合,在动画结束后 refit xterm */
|
||||
provide('nexusDrawer', drawer)
|
||||
|
||||
@@ -377,9 +340,9 @@ const mainContentStyle = computed(() => {
|
||||
const opsItems = [
|
||||
{ to: '/', title: '仪表盘', icon: 'mdi-view-dashboard-outline' },
|
||||
{ to: '/servers', title: '服务器', icon: 'mdi-server' },
|
||||
{ to: '/watch-metrics', title: '监测历史', icon: 'mdi-chart-line' },
|
||||
{ to: '/terminal', title: '终端', icon: 'mdi-console' },
|
||||
{ to: '/files', title: '文件管理', icon: 'mdi-folder-outline' },
|
||||
{ to: '/server-transfer', title: '跨服务器传输', icon: 'mdi-server-network' },
|
||||
{ to: '/push', title: '推送', icon: 'mdi-upload-outline' },
|
||||
{ to: '/scripts', title: '脚本库', icon: 'mdi-code-braces' },
|
||||
{ to: '/executions', title: '执行记录', icon: 'mdi-clipboard-play-outline' },
|
||||
@@ -390,23 +353,11 @@ const opsItems = [
|
||||
const sysItems = [
|
||||
{ to: '/commands', title: '命令日志', icon: 'mdi-console' },
|
||||
{ to: '/alerts', title: '告警中心', icon: 'mdi-bell-outline' },
|
||||
{ to: '/watch-metrics', title: '监测历史', icon: 'mdi-chart-line' },
|
||||
{ to: '/audit', title: '审计日志', icon: 'mdi-clipboard-text-outline' },
|
||||
]
|
||||
|
||||
const btPanelItems = [
|
||||
{ to: '/btpanel/login', title: '服务器列表', icon: 'mdi-server' },
|
||||
{ to: '/btpanel/monitor', title: '系统监控', icon: 'mdi-speedometer' },
|
||||
{ to: '/btpanel/sites', title: '网站列表', icon: 'mdi-web' },
|
||||
{ to: '/btpanel/sites/create', title: '创建网站', icon: 'mdi-web-plus' },
|
||||
{ to: '/btpanel/domains', title: '域名管理', icon: 'mdi-domain' },
|
||||
{ to: '/btpanel/ssl', title: 'SSL 证书', icon: 'mdi-certificate-outline' },
|
||||
{ to: '/btpanel/databases', title: '数据库', icon: 'mdi-database-outline' },
|
||||
{ to: '/btpanel/crontab', title: '计划任务', icon: 'mdi-calendar-clock' },
|
||||
{ to: '/btpanel/services', title: '服务管理', icon: 'mdi-cog-transfer-outline' },
|
||||
{ to: '/btpanel/settings', title: '连接配置', icon: 'mdi-link-variant' },
|
||||
]
|
||||
|
||||
const snackbar = reactive({ show: false, text: '', color: 'success' })
|
||||
const snackbar = reactive({ show: false, text: '', color: 'success', timeout: 3000 })
|
||||
|
||||
function navigateTo(to: string) {
|
||||
if (route.path !== to) {
|
||||
@@ -428,9 +379,10 @@ function doLogout() {
|
||||
}
|
||||
|
||||
// Expose snackbar globally
|
||||
window.$snackbar = (text: string, color = 'success') => {
|
||||
window.$snackbar = (text: string, color = 'success', options?: { timeout?: number }) => {
|
||||
snackbar.text = text
|
||||
snackbar.color = color
|
||||
snackbar.timeout = options?.timeout ?? 3000
|
||||
snackbar.show = true
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -101,7 +101,10 @@ export function batchBootstrapBtServers(
|
||||
}
|
||||
|
||||
export function createBtLoginUrl(serverId: number) {
|
||||
return api<{ url: string; method: string }>(`/btpanel/servers/${serverId}/login-url`, { method: 'POST', body: '{}' })
|
||||
return api<{ url: string; method: string; bootstrapped?: boolean }>(
|
||||
`/btpanel/servers/${serverId}/login-url`,
|
||||
{ method: 'POST', body: '{}' },
|
||||
)
|
||||
}
|
||||
|
||||
export function btSystemTotal(serverId: number) {
|
||||
|
||||
@@ -39,17 +39,17 @@
|
||||
size="x-small"
|
||||
variant="text"
|
||||
:disabled="index === 0 || savingOrder"
|
||||
@click="moveUp(index)"
|
||||
@click.stop="moveUp(index)"
|
||||
/>
|
||||
<v-btn
|
||||
icon="mdi-arrow-down"
|
||||
size="x-small"
|
||||
variant="text"
|
||||
:disabled="index === customCommands().length - 1 || savingOrder"
|
||||
@click="moveDown(index)"
|
||||
@click.stop="moveDown(index)"
|
||||
/>
|
||||
<v-btn icon="mdi-pencil" size="x-small" variant="text" @click="openEdit(cmd)" />
|
||||
<v-btn icon="mdi-delete" size="x-small" variant="text" color="error" @click="confirmRemove(cmd)" />
|
||||
<v-btn icon="mdi-pencil" size="x-small" variant="text" @click.stop="openEdit(cmd)" />
|
||||
<v-btn icon="mdi-delete" size="x-small" variant="text" color="error" @click.stop="confirmRemove(cmd)" />
|
||||
</template>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<v-dialog :model-value="modelValue" max-width="520" @update:model-value="$emit('update:modelValue', $event)">
|
||||
<v-card title="批量 SSH 获取宝塔 API">
|
||||
<v-card-text>
|
||||
<p class="mb-2">
|
||||
将对 <strong>{{ targetCount }}</strong> 台服务器执行 SSH 引导:开启子机
|
||||
<code>api.json</code>、写入 Nexus 加密凭据、追加中心 IP 白名单。
|
||||
</p>
|
||||
<p
|
||||
v-if="mode === 'selected' && configuredSkipCount > 0"
|
||||
class="text-caption text-medium-emphasis mb-2"
|
||||
>
|
||||
已选 {{ selectedTotal }} 台,其中 {{ configuredSkipCount }} 台 API 已配置将自动跳过。
|
||||
</p>
|
||||
<p class="text-caption text-medium-emphasis mb-0">
|
||||
前提:子机 root SSH 可达、已装宝塔(未装会标记为安装中并每 5 分钟重试)。
|
||||
连接配置中需已设置「中心调用 IP」。
|
||||
</p>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="$emit('update:modelValue', false)">取消</v-btn>
|
||||
<v-btn color="primary" :loading="loading" @click="$emit('confirm')">开始</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
modelValue: boolean
|
||||
mode: 'all' | 'selected'
|
||||
targetCount: number
|
||||
selectedTotal?: number
|
||||
configuredSkipCount?: number
|
||||
loading?: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'update:modelValue': [open: boolean]
|
||||
confirm: []
|
||||
}>()
|
||||
</script>
|
||||
@@ -198,6 +198,13 @@ const p = useFilesPageContext()
|
||||
<v-list-item-title>终端</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-divider />
|
||||
<v-list-item
|
||||
v-if="p.contextFile"
|
||||
prepend-icon="mdi-server-network"
|
||||
@click="p.contextAction('sendToServer')"
|
||||
>
|
||||
<v-list-item-title>发送到其他服务器</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-list-item
|
||||
v-if="p.contextFile && (p.isRegularFile(p.contextFile) || p.contextFile.type === 'directory')"
|
||||
prepend-icon="mdi-zip-box"
|
||||
|
||||
@@ -113,6 +113,15 @@ const p = useFilesPageContext()
|
||||
>
|
||||
压缩
|
||||
</v-btn>
|
||||
<v-btn
|
||||
size="small"
|
||||
variant="tonal"
|
||||
prepend-icon="mdi-server-network"
|
||||
:disabled="!p.selectedServer || !p.selectedFileCount"
|
||||
@click="p.goToServerTransfer()"
|
||||
>
|
||||
发送到其他服务器
|
||||
</v-btn>
|
||||
<v-btn
|
||||
size="small"
|
||||
variant="tonal"
|
||||
|
||||
@@ -7,10 +7,20 @@
|
||||
border
|
||||
>
|
||||
<v-card-text class="server-batch-bar__inner py-3">
|
||||
<div class="d-flex align-center flex-wrap ga-3">
|
||||
<v-chip color="primary" variant="flat" size="default" label prepend-icon="mdi-checkbox-marked-circle-outline">
|
||||
已选 {{ count }} 台
|
||||
</v-chip>
|
||||
<div class="d-flex align-end flex-wrap ga-3">
|
||||
<div class="server-batch-bar__section">
|
||||
<span class="server-batch-bar__label">选择</span>
|
||||
<v-chip
|
||||
color="primary"
|
||||
variant="flat"
|
||||
size="default"
|
||||
label
|
||||
class="server-batch-bar__count"
|
||||
prepend-icon="mdi-checkbox-marked-circle-outline"
|
||||
>
|
||||
已选 {{ count }} 台
|
||||
</v-chip>
|
||||
</div>
|
||||
|
||||
<v-divider vertical class="server-batch-bar__divider d-none d-sm-flex" />
|
||||
|
||||
@@ -39,6 +49,38 @@
|
||||
|
||||
<v-divider vertical class="server-batch-bar__divider d-none d-md-flex" />
|
||||
|
||||
<div class="server-batch-bar__section">
|
||||
<span class="server-batch-bar__label">宝塔</span>
|
||||
<div class="d-flex flex-wrap ga-2">
|
||||
<v-btn
|
||||
size="default"
|
||||
variant="flat"
|
||||
color="secondary"
|
||||
class="server-batch-bar__btn"
|
||||
prepend-icon="mdi-download-network"
|
||||
:disabled="!btUnconfiguredCount"
|
||||
:loading="btBatchLoading"
|
||||
@click="$emit('bt-bootstrap-all')"
|
||||
>
|
||||
全部未配置 ({{ btUnconfiguredCount }})
|
||||
</v-btn>
|
||||
<v-btn
|
||||
size="default"
|
||||
variant="flat"
|
||||
color="deep-orange"
|
||||
class="server-batch-bar__btn"
|
||||
prepend-icon="mdi-playlist-check"
|
||||
:disabled="!btSelectedUnconfiguredCount"
|
||||
:loading="btBatchLoading"
|
||||
@click="$emit('bt-bootstrap-selected')"
|
||||
>
|
||||
选中获取 API ({{ btSelectedUnconfiguredCount }})
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<v-divider vertical class="server-batch-bar__divider d-none d-md-flex" />
|
||||
|
||||
<div class="server-batch-bar__section">
|
||||
<span class="server-batch-bar__label">Agent</span>
|
||||
<div class="d-flex flex-wrap ga-2">
|
||||
@@ -79,16 +121,21 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
count: number
|
||||
agentDiagnoseLoading?: boolean
|
||||
}>()
|
||||
defineProps({
|
||||
count: { type: Number, required: true },
|
||||
agentDiagnoseLoading: { type: Boolean, default: false },
|
||||
btUnconfiguredCount: { type: Number, default: 0 },
|
||||
btSelectedUnconfiguredCount: { type: Number, default: 0 },
|
||||
btBatchLoading: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
defineEmits<{
|
||||
'batch-category': []
|
||||
'health-check': []
|
||||
'detect-path': []
|
||||
'agent-diagnose': []
|
||||
'bt-bootstrap-all': []
|
||||
'bt-bootstrap-selected': []
|
||||
'install-agent': []
|
||||
'upgrade-agent': []
|
||||
'uninstall-agent': []
|
||||
@@ -123,6 +170,12 @@ defineEmits<{
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.server-batch-bar__count {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.server-batch-bar__btn {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
|
||||
@@ -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,88 @@
|
||||
<template>
|
||||
<v-dialog v-model="showQuickAdd" max-width="480" persistent>
|
||||
<v-card border>
|
||||
<v-card-title>快速添加服务器(仅 IP)</v-card-title>
|
||||
<v-card-text>
|
||||
<p class="text-body-2 text-medium-emphasis mb-3">
|
||||
将按顺序轮询凭据管理中的全部密码预设与 SSH 密钥预设尝试登录,首个成功即加入服务器列表。
|
||||
</p>
|
||||
<v-text-field
|
||||
v-model="quickAddForm.domain"
|
||||
label="IP 或域名"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
class="mb-2"
|
||||
placeholder="192.168.1.10"
|
||||
:rules="[v => !!v?.trim() || '必填']"
|
||||
/>
|
||||
<v-text-field
|
||||
v-model.number="quickAddForm.port"
|
||||
label="SSH 端口"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
type="number"
|
||||
class="mb-2"
|
||||
/>
|
||||
<v-text-field
|
||||
v-model="quickAddForm.name"
|
||||
label="显示名称(可选)"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hint="留空则使用 IP"
|
||||
persistent-hint
|
||||
/>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" :disabled="quickAddLoading" @click="showQuickAdd = false">取消</v-btn>
|
||||
<v-btn color="primary" variant="flat" :loading="quickAddLoading" @click="submitQuickAdd">开始轮询</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<v-dialog v-model="showPollFailure" max-width="560">
|
||||
<v-card border>
|
||||
<v-card-title class="text-error">SSH 连接失败</v-card-title>
|
||||
<v-card-text>
|
||||
<p class="text-body-2 mb-3">已尝试全部凭据预设,均未登录成功。该服务器已加入「连接失败列表」,可稍后重试。</p>
|
||||
<v-list v-if="pollFailureErrors.length" density="compact" class="bg-grey-lighten-4 rounded">
|
||||
<v-list-item v-for="(err, i) in pollFailureErrors" :key="i">
|
||||
<v-list-item-title class="text-body-2">
|
||||
[{{ err.preset_type === 'password' ? '密码' : err.preset_type === 'system' ? '系统' : '密钥' }}]
|
||||
{{ err.preset_name }} ({{ err.username }})
|
||||
</v-list-item-title>
|
||||
<v-list-item-subtitle class="text-error">{{ err.error }}</v-list-item-subtitle>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
<p v-else class="text-medium-emphasis">{{ pollFailureMessage }}</p>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn color="primary" variant="flat" @click="showPollFailure = false">知道了</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useServerQuickAdd, type UseServerQuickAddOptions } from '@/composables/servers/useServerQuickAdd'
|
||||
|
||||
const props = defineProps<UseServerQuickAddOptions>()
|
||||
|
||||
const {
|
||||
showQuickAdd,
|
||||
quickAddLoading,
|
||||
quickAddForm,
|
||||
showPollFailure,
|
||||
pollFailureErrors,
|
||||
pollFailureMessage,
|
||||
openQuickAdd,
|
||||
submitQuickAdd,
|
||||
showFailureDialog,
|
||||
} = useServerQuickAdd({
|
||||
onSuccess: (res) => props.onSuccess?.(res),
|
||||
onPendingFailure: () => props.onPendingFailure?.(),
|
||||
})
|
||||
|
||||
defineExpose({ open: openQuickAdd, showFailure: showFailureDialog })
|
||||
</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">
|
||||
@@ -53,10 +49,15 @@
|
||||
<ServerBatchActionBar
|
||||
:count="selectedCount"
|
||||
:agent-diagnose-loading="agentDiagnoseLoading"
|
||||
:bt-unconfigured-count="btUnconfiguredCount"
|
||||
:bt-selected-unconfigured-count="btSelectedUnconfiguredCount"
|
||||
:bt-batch-loading="btBatchLoading"
|
||||
@batch-category="$emit('batch-category')"
|
||||
@health-check="$emit('health-check')"
|
||||
@detect-path="$emit('detect-path')"
|
||||
@agent-diagnose="$emit('agent-diagnose')"
|
||||
@bt-bootstrap-all="$emit('bt-bootstrap-all')"
|
||||
@bt-bootstrap-selected="$emit('bt-bootstrap-selected')"
|
||||
@install-agent="$emit('install-agent')"
|
||||
@upgrade-agent="$emit('upgrade-agent')"
|
||||
@uninstall-agent="$emit('uninstall-agent')"
|
||||
@@ -65,7 +66,7 @@
|
||||
/>
|
||||
|
||||
<v-data-table-server
|
||||
class="server-unset-path-table"
|
||||
class="server-data-table"
|
||||
:items="servers"
|
||||
:headers="headers"
|
||||
:items-length="total"
|
||||
@@ -95,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 }">
|
||||
@@ -118,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')"
|
||||
@@ -140,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() || '—' }}
|
||||
@@ -160,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"
|
||||
@@ -183,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>
|
||||
@@ -201,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
|
||||
@@ -224,10 +271,24 @@ const props = defineProps<{
|
||||
savingPathId: number | null
|
||||
btLoginLoadingId: number | null
|
||||
agentDiagnoseLoading: boolean
|
||||
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]
|
||||
@@ -241,20 +302,35 @@ 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': []
|
||||
'health-check': []
|
||||
'detect-path': []
|
||||
'agent-diagnose': []
|
||||
'install-agent': []
|
||||
'upgrade-agent': []
|
||||
'bt-bootstrap-all': []
|
||||
'bt-bootstrap-selected': []
|
||||
'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)
|
||||
|
||||
@@ -274,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>
|
||||
|
||||
@@ -34,6 +34,15 @@
|
||||
新建会话
|
||||
</v-btn>
|
||||
</div>
|
||||
<v-btn
|
||||
size="small"
|
||||
variant="tonal"
|
||||
class="ml-1 shrink-0"
|
||||
prepend-icon="mdi-flash"
|
||||
@click="$emit('quick-add')"
|
||||
>
|
||||
<span class="hidden-sm-and-down">快速添加(IP)</span>
|
||||
</v-btn>
|
||||
<v-btn
|
||||
size="small"
|
||||
variant="text"
|
||||
@@ -59,6 +68,7 @@ defineEmits<{
|
||||
switch: [sessionId: string]
|
||||
close: [sessionId: string]
|
||||
new: []
|
||||
'quick-add': []
|
||||
'toggle-rail': []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
@@ -37,6 +37,20 @@
|
||||
</div>
|
||||
<div class="d-flex align-center ga-1 terminal-toolbar-actions shrink-0">
|
||||
<div class="d-none d-md-flex align-center ga-2">
|
||||
<v-btn
|
||||
v-if="session"
|
||||
variant="tonal"
|
||||
color="deep-orange"
|
||||
size="x-small"
|
||||
prepend-icon="mdi-login-variant"
|
||||
title="一键登录宝塔面板"
|
||||
class="terminal-toolbar__bt-login"
|
||||
:loading="btLoginLoading"
|
||||
:disabled="btLoginLoading"
|
||||
@click="$emit('bt-login')"
|
||||
>
|
||||
宝塔登录
|
||||
</v-btn>
|
||||
<span class="text-caption text-medium-emphasis shrink-0">字号</span>
|
||||
<v-btn variant="tonal" size="x-small" @click="$emit('font-change', -1)">−</v-btn>
|
||||
<span class="text-body-2 font-weight-medium terminal-toolbar__font-size">
|
||||
@@ -67,6 +81,17 @@
|
||||
/>
|
||||
</template>
|
||||
<v-list density="compact" nav class="py-1">
|
||||
<v-list-item
|
||||
v-if="session"
|
||||
:disabled="btLoginLoading"
|
||||
@click="$emit('bt-login')"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-icon color="deep-orange">mdi-login-variant</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>一键登录宝塔</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-divider v-if="session" class="my-1" />
|
||||
<v-list-subheader>字号 {{ fontSize }}</v-list-subheader>
|
||||
<v-list-item @click="$emit('font-change', -1)">
|
||||
<v-list-item-title>缩小</v-list-item-title>
|
||||
@@ -112,6 +137,7 @@ const props = defineProps<{
|
||||
session: TermSessionData | null
|
||||
fontSize: number
|
||||
scrollback: number
|
||||
btLoginLoading?: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
@@ -119,6 +145,7 @@ defineEmits<{
|
||||
'scrollback-change': [value: number]
|
||||
fullscreen: []
|
||||
disconnect: []
|
||||
'bt-login': []
|
||||
}>()
|
||||
|
||||
const statusLabel = computed(() =>
|
||||
@@ -146,6 +173,11 @@ const statusColor = computed(() =>
|
||||
min-width: 6.5rem;
|
||||
max-width: 7.5rem;
|
||||
}
|
||||
.terminal-toolbar__bt-login {
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
text-transform: none;
|
||||
}
|
||||
.font-mono {
|
||||
font-family: 'Cascadia Code', 'Fira Code', 'JetBrains Mono', Menlo, monospace;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { openServerBatchJobResult } from '@/composables/useServerBatchJobViewer'
|
||||
import { batchBootstrapBtServers, listBtServers, type BtPanelServerRow } from '@/api/btpanel'
|
||||
|
||||
export function useBtPanelBatchBootstrap(getSelectedIds: () => number[]) {
|
||||
const btServers = ref<BtPanelServerRow[]>([])
|
||||
const btServersLoading = ref(false)
|
||||
const batchLoading = ref(false)
|
||||
const batchConfirmOpen = ref(false)
|
||||
const batchMode = ref<'all' | 'selected'>('all')
|
||||
|
||||
async function loadBtServers() {
|
||||
btServersLoading.value = true
|
||||
try {
|
||||
btServers.value = await listBtServers()
|
||||
} catch {
|
||||
btServers.value = []
|
||||
} finally {
|
||||
btServersLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function serverById(id: number) {
|
||||
return btServers.value.find(s => s.id === id)
|
||||
}
|
||||
|
||||
const unconfiguredCount = computed(() => btServers.value.filter(s => !s.configured).length)
|
||||
|
||||
function filterUnconfiguredIds(ids: number[]): number[] {
|
||||
return ids.filter((id) => {
|
||||
const row = serverById(id)
|
||||
return Boolean(row && !row.configured)
|
||||
})
|
||||
}
|
||||
|
||||
function selectedUnconfiguredIds(): number[] {
|
||||
return filterUnconfiguredIds(getSelectedIds())
|
||||
}
|
||||
|
||||
const selectedUnconfiguredCount = computed(() => selectedUnconfiguredIds().length)
|
||||
|
||||
const selectedConfiguredSkipCount = computed(() => {
|
||||
const selected = getSelectedIds()
|
||||
if (!selected.length) return 0
|
||||
return selected.length - selectedUnconfiguredCount.value
|
||||
})
|
||||
|
||||
const batchTargetCount = computed(() =>
|
||||
batchMode.value === 'all' ? unconfiguredCount.value : selectedUnconfiguredCount.value,
|
||||
)
|
||||
|
||||
function openBatchConfirm(mode: 'all' | 'selected') {
|
||||
batchMode.value = mode
|
||||
if (mode === 'selected') {
|
||||
if (!selectedUnconfiguredCount.value) {
|
||||
window.$snackbar?.('所选服务器均已配置 API,无需重复获取', 'info')
|
||||
return
|
||||
}
|
||||
} else if (!unconfiguredCount.value) {
|
||||
window.$snackbar?.('没有未配置的服务器', 'info')
|
||||
return
|
||||
}
|
||||
batchConfirmOpen.value = true
|
||||
}
|
||||
|
||||
async function confirmBatchBootstrap() {
|
||||
batchLoading.value = true
|
||||
try {
|
||||
const job = batchMode.value === 'all'
|
||||
? await batchBootstrapBtServers([], { allUnconfigured: true })
|
||||
: await batchBootstrapBtServers(selectedUnconfiguredIds())
|
||||
batchConfirmOpen.value = false
|
||||
window.$snackbar?.(`已启动批量任务 #${job.job_id}`, 'success')
|
||||
await openServerBatchJobResult(job.job_id, job.label)
|
||||
await loadBtServers()
|
||||
} catch (e: unknown) {
|
||||
window.$snackbar?.(e instanceof Error ? e.message : '批量任务启动失败', 'error')
|
||||
} finally {
|
||||
batchLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function isBtConfigured(serverId: number): boolean | undefined {
|
||||
const row = serverById(serverId)
|
||||
return row ? row.configured : undefined
|
||||
}
|
||||
|
||||
return {
|
||||
btServers,
|
||||
btServersLoading,
|
||||
batchLoading,
|
||||
batchConfirmOpen,
|
||||
batchMode,
|
||||
unconfiguredCount,
|
||||
selectedUnconfiguredCount,
|
||||
selectedConfiguredSkipCount,
|
||||
batchTargetCount,
|
||||
loadBtServers,
|
||||
openBatchConfirm,
|
||||
confirmBatchBootstrap,
|
||||
filterUnconfiguredIds,
|
||||
isBtConfigured,
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,123 @@
|
||||
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 不预显示 */
|
||||
needsBootstrap?: boolean
|
||||
}
|
||||
|
||||
function startBootstrapCountdown(): () => void {
|
||||
let remaining = BOOTSTRAP_ESTIMATE_SEC
|
||||
const tick = (text: string) => {
|
||||
window.$snackbar?.(text, 'info', { timeout: -1 })
|
||||
}
|
||||
tick(`正在通过 SSH 获取宝塔 API… 约 ${remaining} 秒`)
|
||||
const timer = window.setInterval(() => {
|
||||
remaining -= 1
|
||||
if (remaining <= 0) {
|
||||
tick('正在获取宝塔 API,请稍候…')
|
||||
return
|
||||
}
|
||||
tick(`正在通过 SSH 获取宝塔 API… 约 ${remaining} 秒`)
|
||||
}, 1000)
|
||||
return () => window.clearInterval(timer)
|
||||
}
|
||||
|
||||
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() {
|
||||
const loadingId = ref<number | null>(null)
|
||||
|
||||
async function openBtLogin(serverId: number) {
|
||||
async function openBtLogin(serverId: number, options?: BtLoginOptions) {
|
||||
loadingId.value = serverId
|
||||
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 } = await createBtLoginUrl(serverId)
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
window.$snackbar?.('已打开宝塔登录链接', 'success')
|
||||
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 并打开登录链接(链接仅可用一次,请勿刷新登录页)'
|
||||
: '已打开宝塔登录链接(仅可用一次,请勿刷新登录页)',
|
||||
'success',
|
||||
)
|
||||
} catch (e: unknown) {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { loadingId, openBtLogin }
|
||||
return { loadingId, openBtLogin, bootstrapEstimateSec: BOOTSTRAP_ESTIMATE_SEC }
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { computed, nextTick, ref, type Ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { http } from '@/api'
|
||||
import { useFilesClipboard } from '@/composables/useFilesClipboard'
|
||||
@@ -30,6 +31,7 @@ export function useFilesActions(options: {
|
||||
actionLoading?: Ref<boolean>
|
||||
}) {
|
||||
const snackbar = useSnackbar()
|
||||
const router = useRouter()
|
||||
const store = useFilesStore()
|
||||
const { selectedServer, currentPath } = storeToRefs(store)
|
||||
const { selectedFiles, selectedAbsolutePaths, clearSelection } = options.selection
|
||||
@@ -68,6 +70,26 @@ export function useFilesActions(options: {
|
||||
const contextY = ref(0)
|
||||
const contextFile = ref<FileEntry | null>(null)
|
||||
|
||||
function goToServerTransfer(items?: FileEntry[]) {
|
||||
if (!selectedServer.value) {
|
||||
snackbar('请先选择服务器', 'warning')
|
||||
return
|
||||
}
|
||||
const paths = selectedAbsolutePaths(items)
|
||||
if (!paths.length) {
|
||||
snackbar('请先选择文件或目录', 'warning')
|
||||
return
|
||||
}
|
||||
router.push({
|
||||
path: '/server-transfer',
|
||||
query: {
|
||||
source: String(selectedServer.value),
|
||||
path: currentPath.value,
|
||||
paths: paths.join('|'),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function resolvePasteDestination(explicitDest?: string): string {
|
||||
if (explicitDest) return normalizeRemotePath(explicitDest)
|
||||
const dirs = selectedFiles.value.filter((f) => f.type === 'directory')
|
||||
@@ -184,6 +206,9 @@ export function useFilesActions(options: {
|
||||
case 'decompress':
|
||||
openDecompressDialog(item)
|
||||
break
|
||||
case 'sendToServer':
|
||||
goToServerTransfer([item])
|
||||
break
|
||||
case 'rename':
|
||||
startRename(item)
|
||||
break
|
||||
@@ -589,6 +614,7 @@ export function useFilesActions(options: {
|
||||
contextX,
|
||||
contextY,
|
||||
contextFile,
|
||||
goToServerTransfer,
|
||||
copyToClipboard,
|
||||
cutToClipboard,
|
||||
pasteClipboard,
|
||||
|
||||
@@ -260,6 +260,7 @@ export function useFilesPage() {
|
||||
actionLoading,
|
||||
batchDelete: actions.batchDelete,
|
||||
openCompressDialog: actions.openCompressDialog,
|
||||
goToServerTransfer: actions.goToServerTransfer,
|
||||
showPreview: editor.showPreview,
|
||||
previewTitle: editor.previewTitle,
|
||||
previewContent: editor.previewContent,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { ref } from 'vue'
|
||||
import { http } from '@/api'
|
||||
import { useSnackbar } from '@/composables/useSnackbar'
|
||||
import { registerServerBatchJob } from '@/composables/useScriptExecutionQueue'
|
||||
import { showScriptSubmitToast } from '@/composables/useScriptSubmitToast'
|
||||
import { formatApiError } from '@/utils/apiError'
|
||||
import type { AddByIpResponse, PollErrorItem } from '@/types/api'
|
||||
|
||||
export interface UseServerQuickAddOptions {
|
||||
onSuccess?: (res: AddByIpResponse) => void | Promise<void>
|
||||
onPendingFailure?: () => void | Promise<void>
|
||||
}
|
||||
|
||||
export function useServerQuickAdd(options: UseServerQuickAddOptions = {}) {
|
||||
const snackbar = useSnackbar()
|
||||
|
||||
const showQuickAdd = ref(false)
|
||||
const quickAddLoading = ref(false)
|
||||
const quickAddForm = ref({ domain: '', port: 22, name: '' })
|
||||
|
||||
const showPollFailure = ref(false)
|
||||
const pollFailureErrors = ref<PollErrorItem[]>([])
|
||||
const pollFailureMessage = ref('')
|
||||
|
||||
function openQuickAdd() {
|
||||
quickAddForm.value = { domain: '', port: 22, name: '' }
|
||||
showQuickAdd.value = true
|
||||
}
|
||||
|
||||
function showFailureDialog(errors: PollErrorItem[], message?: string) {
|
||||
pollFailureErrors.value = errors
|
||||
pollFailureMessage.value = message || ''
|
||||
showPollFailure.value = true
|
||||
}
|
||||
|
||||
function registerOnboardingJob(res: AddByIpResponse) {
|
||||
if (!res.onboarding_job_id) return
|
||||
registerServerBatchJob(
|
||||
res.onboarding_job_id,
|
||||
res.onboarding_label || '新服务器初始化',
|
||||
1,
|
||||
'onboard',
|
||||
'0/1',
|
||||
'running',
|
||||
)
|
||||
showScriptSubmitToast('新服务器初始化已在后台执行(sudo + 路径探测)')
|
||||
}
|
||||
|
||||
async function submitQuickAdd() {
|
||||
const domain = quickAddForm.value.domain.trim()
|
||||
if (!domain) {
|
||||
snackbar('请填写 IP 或域名', 'error')
|
||||
return
|
||||
}
|
||||
quickAddLoading.value = true
|
||||
try {
|
||||
const body: Record<string, unknown> = {
|
||||
domain,
|
||||
port: quickAddForm.value.port || 22,
|
||||
}
|
||||
const name = quickAddForm.value.name.trim()
|
||||
if (name) body.name = name
|
||||
|
||||
const res = await http.post<AddByIpResponse>('/servers/add-by-ip', body)
|
||||
if (res.success && res.server) {
|
||||
showQuickAdd.value = false
|
||||
registerOnboardingJob(res)
|
||||
snackbar(`添加成功:${res.matched_preset} (${res.matched_username})`)
|
||||
await options.onSuccess?.(res)
|
||||
} else {
|
||||
showQuickAdd.value = false
|
||||
showFailureDialog(res.errors || [], res.message)
|
||||
await options.onPendingFailure?.()
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
snackbar(formatApiError(e, '添加失败'), 'error')
|
||||
} finally {
|
||||
quickAddLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
showQuickAdd,
|
||||
quickAddLoading,
|
||||
quickAddForm,
|
||||
showPollFailure,
|
||||
pollFailureErrors,
|
||||
pollFailureMessage,
|
||||
openQuickAdd,
|
||||
submitQuickAdd,
|
||||
showFailureDialog,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,539 @@
|
||||
/**
|
||||
* 跨服务器文件传输 — 独立页 composable(源机浏览 + 直传 / 打包投递)
|
||||
*/
|
||||
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'
|
||||
import {
|
||||
DEFAULT_FILES_ROOT,
|
||||
joinRemotePath,
|
||||
normalizeRemotePath,
|
||||
parentRemotePath,
|
||||
} from '@/utils/remotePath'
|
||||
import { resolveServerSiteHost, resolveServerSiteUrl } from '@/utils/serverSiteUrl'
|
||||
|
||||
export type ServerTransferMode = 'relay' | 'deliver'
|
||||
|
||||
const MAX_RELAY_FILE_BYTES = 524_288_000
|
||||
const TRANSFER_DEST_STORAGE_PREFIX = 'nexus:file-transfer-dest'
|
||||
|
||||
export function useServerFileTransfer() {
|
||||
const snackbar = useSnackbar()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { servers: serverList, loading: serversLoading, loadServers } = useServerList()
|
||||
|
||||
const sourceServerSearchQuery = ref('')
|
||||
const destServerSearchQuery = ref('')
|
||||
const sourceServerId = ref<number | null>(null)
|
||||
const destServerId = ref<number | null>(null)
|
||||
const currentPath = ref(DEFAULT_FILES_ROOT)
|
||||
const files = ref<FileEntry[]>([])
|
||||
const browseLoading = ref(false)
|
||||
const browseError = ref('')
|
||||
const destCurrentPath = ref(DEFAULT_FILES_ROOT)
|
||||
const destFiles = ref<FileEntry[]>([])
|
||||
const destBrowseLoading = ref(false)
|
||||
const destBrowseError = ref('')
|
||||
const selectedPaths = ref<string[]>([])
|
||||
const manualPaths = ref('')
|
||||
|
||||
const transferMode = ref<ServerTransferMode>('relay')
|
||||
const destPath = ref('')
|
||||
const extract = ref(true)
|
||||
const actionLoading = ref(false)
|
||||
const downloadUrl = ref('')
|
||||
const lastResult = ref<Record<string, unknown> | null>(null)
|
||||
|
||||
function serverMatchesQuery(s: ServerBrief, q: string): boolean {
|
||||
return (
|
||||
s.name.toLowerCase().includes(q) ||
|
||||
(s.domain || '').toLowerCase().includes(q) ||
|
||||
(s.target_path || '').toLowerCase().includes(q) ||
|
||||
String(s.id).includes(q)
|
||||
)
|
||||
}
|
||||
|
||||
function filterServerList(
|
||||
list: ServerBrief[],
|
||||
query: string,
|
||||
selectedId: number | null,
|
||||
): ServerBrief[] {
|
||||
const q = query.toLowerCase().trim()
|
||||
let filtered = q ? list.filter((s) => serverMatchesQuery(s, q)) : list
|
||||
if (selectedId != null && !filtered.some((s) => s.id === selectedId)) {
|
||||
const sel = list.find((s) => s.id === selectedId)
|
||||
if (sel) filtered = [sel, ...filtered]
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
const filteredSourceServerList = computed(() =>
|
||||
filterServerList(serverList.value, sourceServerSearchQuery.value, sourceServerId.value),
|
||||
)
|
||||
|
||||
const destServers = computed(() =>
|
||||
serverList.value.filter((s) => s.id !== sourceServerId.value),
|
||||
)
|
||||
|
||||
const filteredDestServerList = computed(() =>
|
||||
filterServerList(destServers.value, destServerSearchQuery.value, destServerId.value),
|
||||
)
|
||||
|
||||
const selectedSourceServer = computed(() =>
|
||||
sourceServerId.value != null
|
||||
? serverList.value.find((s) => s.id === sourceServerId.value)
|
||||
: undefined,
|
||||
)
|
||||
|
||||
const sourceSiteUrl = computed(() =>
|
||||
selectedSourceServer.value ? resolveServerSiteUrl(selectedSourceServer.value) : null,
|
||||
)
|
||||
|
||||
const sourceSiteHost = computed(() =>
|
||||
selectedSourceServer.value ? resolveServerSiteHost(selectedSourceServer.value) : null,
|
||||
)
|
||||
|
||||
const selectedItems = computed(() => {
|
||||
const set = new Set(selectedPaths.value)
|
||||
return files.value.filter((f) => set.has(joinRemotePath(currentPath.value, f.name)))
|
||||
})
|
||||
|
||||
const hasDirectory = computed(() =>
|
||||
selectedItems.value.some((f) => f.type === 'directory'),
|
||||
)
|
||||
|
||||
const hasOversizedFile = computed(() =>
|
||||
selectedItems.value.some(
|
||||
(f) => f.type === 'file' && typeof f.size === 'number' && f.size > MAX_RELAY_FILE_BYTES,
|
||||
),
|
||||
)
|
||||
|
||||
const relayDisabled = computed(() => hasOversizedFile.value)
|
||||
|
||||
const effectivePaths = computed(() => {
|
||||
const fromBrowse = [...selectedPaths.value]
|
||||
const manual = manualPaths.value
|
||||
.split(/[\n,]+/)
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean)
|
||||
.map((p) => normalizeRemotePath(p))
|
||||
return [...new Set([...fromBrowse, ...manual])]
|
||||
})
|
||||
|
||||
function destStorageKey(mode: ServerTransferMode, serverId: number): string {
|
||||
return `${TRANSFER_DEST_STORAGE_PREFIX}:${mode}:${serverId}`
|
||||
}
|
||||
|
||||
function loadStoredDest(mode: ServerTransferMode, serverId: number): string | null {
|
||||
try {
|
||||
return localStorage.getItem(destStorageKey(mode, serverId))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function storeDest(mode: ServerTransferMode, serverId: number, path: string) {
|
||||
try {
|
||||
localStorage.setItem(destStorageKey(mode, serverId), path)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function serverTargetPath(serverId: number | null): string {
|
||||
if (!serverId) return DEFAULT_FILES_ROOT
|
||||
const srv = serverList.value.find((s) => s.id === serverId)
|
||||
const tp = srv?.target_path?.trim()
|
||||
return tp ? normalizeRemotePath(tp) : DEFAULT_FILES_ROOT
|
||||
}
|
||||
|
||||
function defaultDestPath(mode: ServerTransferMode, destId: number | null): string {
|
||||
const base = serverTargetPath(destId)
|
||||
const normalizedBase = base.endsWith('/') ? base.slice(0, -1) : base
|
||||
if (mode === 'relay') return normalizedBase
|
||||
const first = effectivePaths.value[0]
|
||||
const name = first ? (first.split('/').pop() || 'transfer') : 'transfer'
|
||||
const safe = name.replace(/[^\w.\-]/g, '_') || 'transfer'
|
||||
return `${normalizedBase}/${safe}.tar.gz`
|
||||
}
|
||||
|
||||
function applyDestPathForServer(destId: number | null, preferStored = false) {
|
||||
if (!destId) {
|
||||
destPath.value = ''
|
||||
return
|
||||
}
|
||||
if (preferStored) {
|
||||
const stored = loadStoredDest(transferMode.value, destId)
|
||||
if (stored) {
|
||||
destPath.value = stored
|
||||
destCurrentPath.value = destBrowseDirFromPath(stored)
|
||||
return
|
||||
}
|
||||
}
|
||||
destPath.value = defaultDestPath(transferMode.value, destId)
|
||||
destCurrentPath.value = serverTargetPath(destId)
|
||||
}
|
||||
|
||||
function destBrowseDirFromPath(path: string): string {
|
||||
const normalized = normalizeRemotePath(path)
|
||||
if (transferMode.value === 'relay') return normalized
|
||||
const parent = parentRemotePath(normalized)
|
||||
return parent || normalized
|
||||
}
|
||||
|
||||
function syncDestPathFromBrowse() {
|
||||
if (!destServerId.value) return
|
||||
const dir = destCurrentPath.value.replace(/\/$/, '') || '/'
|
||||
if (transferMode.value === 'relay') {
|
||||
destPath.value = dir
|
||||
return
|
||||
}
|
||||
const first = effectivePaths.value[0]
|
||||
const name = first ? (first.split('/').pop() || 'transfer') : 'transfer'
|
||||
const safe = name.replace(/[^\w.\-]/g, '_') || 'transfer'
|
||||
destPath.value = `${dir}/${safe}.tar.gz`
|
||||
}
|
||||
|
||||
function recommendMode(paths: string[]): ServerTransferMode {
|
||||
for (const p of paths) {
|
||||
const item = files.value.find(
|
||||
(f) => joinRemotePath(currentPath.value, f.name) === p,
|
||||
)
|
||||
if (item?.type === 'directory') return 'deliver'
|
||||
if (
|
||||
item?.type === 'file'
|
||||
&& typeof item.size === 'number'
|
||||
&& item.size > MAX_RELAY_FILE_BYTES
|
||||
) {
|
||||
return 'deliver'
|
||||
}
|
||||
}
|
||||
if (hasDirectory.value || hasOversizedFile.value) return 'deliver'
|
||||
return 'relay'
|
||||
}
|
||||
|
||||
function onSourceServerChange(id: number | null) {
|
||||
sourceServerId.value = id
|
||||
sourceServerSearchQuery.value = ''
|
||||
selectedPaths.value = []
|
||||
if (id) {
|
||||
currentPath.value = serverTargetPath(id)
|
||||
} else {
|
||||
currentPath.value = DEFAULT_FILES_ROOT
|
||||
}
|
||||
if (destServerId.value === id) {
|
||||
destServerId.value = null
|
||||
destFiles.value = []
|
||||
destCurrentPath.value = DEFAULT_FILES_ROOT
|
||||
destPath.value = ''
|
||||
}
|
||||
void browse()
|
||||
if (destServerId.value) {
|
||||
applyDestPathForServer(destServerId.value, false)
|
||||
void browseDest()
|
||||
}
|
||||
}
|
||||
|
||||
function onDestServerChange(id: number | null) {
|
||||
destServerId.value = id
|
||||
destServerSearchQuery.value = ''
|
||||
if (!id) {
|
||||
destFiles.value = []
|
||||
destCurrentPath.value = DEFAULT_FILES_ROOT
|
||||
destPath.value = ''
|
||||
return
|
||||
}
|
||||
applyDestPathForServer(id, false)
|
||||
void browseDest()
|
||||
}
|
||||
|
||||
function onModeChange(mode: ServerTransferMode) {
|
||||
transferMode.value = mode
|
||||
applyDestPathForServer(destServerId.value, true)
|
||||
syncDestPathFromBrowse()
|
||||
if (destServerId.value) void browseDest()
|
||||
}
|
||||
|
||||
async function browse(path?: string) {
|
||||
if (!sourceServerId.value) return
|
||||
if (path !== undefined) currentPath.value = normalizeRemotePath(path)
|
||||
browseLoading.value = true
|
||||
browseError.value = ''
|
||||
try {
|
||||
const data = await http.post('/sync/browse', {
|
||||
server_id: sourceServerId.value,
|
||||
path: currentPath.value,
|
||||
})
|
||||
const { items, error } = parseBrowseResponse(data ?? { items: [] })
|
||||
browseError.value = error || ''
|
||||
files.value = items
|
||||
} catch (e: unknown) {
|
||||
files.value = []
|
||||
browseError.value = e instanceof Error ? e.message : '浏览失败'
|
||||
} finally {
|
||||
browseLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function browseDest(path?: string) {
|
||||
if (!destServerId.value) return
|
||||
if (path !== undefined) destCurrentPath.value = normalizeRemotePath(path)
|
||||
destBrowseLoading.value = true
|
||||
destBrowseError.value = ''
|
||||
try {
|
||||
const data = await http.post('/sync/browse', {
|
||||
server_id: destServerId.value,
|
||||
path: destCurrentPath.value,
|
||||
})
|
||||
const { items, error } = parseBrowseResponse(data ?? { items: [] })
|
||||
destBrowseError.value = error || ''
|
||||
destFiles.value = items
|
||||
syncDestPathFromBrowse()
|
||||
} catch (e: unknown) {
|
||||
destFiles.value = []
|
||||
destBrowseError.value = e instanceof Error ? e.message : '浏览失败'
|
||||
} finally {
|
||||
destBrowseLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goUp() {
|
||||
const parent = parentRemotePath(currentPath.value)
|
||||
if (parent) void browse(parent)
|
||||
}
|
||||
|
||||
function openEntry(item: FileEntry) {
|
||||
if (item.type === 'directory' || item.type === 'symlink') {
|
||||
void browse(joinRemotePath(currentPath.value, item.name))
|
||||
}
|
||||
}
|
||||
|
||||
function goUpDest() {
|
||||
const parent = parentRemotePath(destCurrentPath.value)
|
||||
if (parent) void browseDest(parent)
|
||||
}
|
||||
|
||||
function openDestEntry(item: FileEntry) {
|
||||
if (item.type === 'directory' || item.type === 'symlink') {
|
||||
void browseDest(joinRemotePath(destCurrentPath.value, item.name))
|
||||
}
|
||||
}
|
||||
|
||||
function togglePath(item: FileEntry) {
|
||||
const abs = joinRemotePath(currentPath.value, item.name)
|
||||
const idx = selectedPaths.value.indexOf(abs)
|
||||
if (idx >= 0) {
|
||||
selectedPaths.value = selectedPaths.value.filter((p) => p !== abs)
|
||||
} else {
|
||||
selectedPaths.value = [...selectedPaths.value, abs]
|
||||
}
|
||||
transferMode.value = recommendMode(selectedPaths.value)
|
||||
if (transferMode.value === 'deliver' && destServerId.value) {
|
||||
syncDestPathFromBrowse()
|
||||
}
|
||||
}
|
||||
|
||||
function isSelected(item: FileEntry): boolean {
|
||||
return selectedPaths.value.includes(joinRemotePath(currentPath.value, item.name))
|
||||
}
|
||||
|
||||
function selectAllVisible() {
|
||||
const paths = files.value.map((f) => joinRemotePath(currentPath.value, f.name))
|
||||
selectedPaths.value = [...new Set([...selectedPaths.value, ...paths])]
|
||||
transferMode.value = recommendMode(selectedPaths.value)
|
||||
if (transferMode.value === 'deliver' && destServerId.value) {
|
||||
syncDestPathFromBrowse()
|
||||
}
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
selectedPaths.value = []
|
||||
}
|
||||
|
||||
function applyRouteQuery() {
|
||||
const q = route.query
|
||||
const src = Number(q.source ?? q.server_id)
|
||||
if (Number.isFinite(src) && src > 0) {
|
||||
sourceServerId.value = src
|
||||
const pathQ = typeof q.path === 'string' ? q.path : ''
|
||||
if (pathQ) {
|
||||
currentPath.value = normalizeRemotePath(pathQ)
|
||||
} else {
|
||||
currentPath.value = serverTargetPath(src)
|
||||
}
|
||||
const pathsQ = typeof q.paths === 'string' ? q.paths : ''
|
||||
if (pathsQ) {
|
||||
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) {
|
||||
transferMode.value = recommendMode(selectedPaths.value)
|
||||
void browse()
|
||||
}
|
||||
if (destServerId.value) {
|
||||
applyDestPathForServer(destServerId.value, false)
|
||||
void browseDest()
|
||||
}
|
||||
}
|
||||
|
||||
async function runTransfer() {
|
||||
if (!sourceServerId.value || !destServerId.value) {
|
||||
snackbar('请选择源服务器与目标服务器', 'warning')
|
||||
return
|
||||
}
|
||||
const paths = effectivePaths.value
|
||||
if (!paths.length) {
|
||||
snackbar('请选择或填写至少一个源路径', 'warning')
|
||||
return
|
||||
}
|
||||
if (transferMode.value === 'relay' && relayDisabled.value) {
|
||||
snackbar('所选文件超过直传上限,请使用打包投递', 'warning')
|
||||
return
|
||||
}
|
||||
const target = normalizeRemotePath(destPath.value)
|
||||
actionLoading.value = true
|
||||
downloadUrl.value = ''
|
||||
lastResult.value = null
|
||||
try {
|
||||
if (transferMode.value === 'relay') {
|
||||
const res = await http.post('/sync/server-transfer', {
|
||||
source_server_id: sourceServerId.value,
|
||||
dest_server_id: destServerId.value,
|
||||
sources: paths,
|
||||
dest_dir: target,
|
||||
})
|
||||
lastResult.value = res as Record<string, unknown>
|
||||
snackbar(`已直传 ${paths.length} 项到目标服务器`)
|
||||
storeDest('relay', destServerId.value, target)
|
||||
} else {
|
||||
const res = await http.post<{
|
||||
download_url?: string
|
||||
dest_path?: string
|
||||
}>('/sync/server-transfer/deliver', {
|
||||
source_server_id: sourceServerId.value,
|
||||
dest_server_id: destServerId.value,
|
||||
paths,
|
||||
dest_path: target,
|
||||
extract: extract.value,
|
||||
format: 'tar.gz',
|
||||
})
|
||||
lastResult.value = res as Record<string, unknown>
|
||||
downloadUrl.value = res.download_url || ''
|
||||
storeDest('deliver', destServerId.value, target)
|
||||
snackbar(
|
||||
extract.value
|
||||
? '打包投递完成,已在目标服务器解压'
|
||||
: `打包投递完成:${res.dest_path || target}`,
|
||||
)
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
snackbar(formatApiError(e, '传输失败'), 'error')
|
||||
} finally {
|
||||
actionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function copyDownloadUrl() {
|
||||
if (!downloadUrl.value) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(downloadUrl.value)
|
||||
snackbar('下载链接已复制')
|
||||
} catch {
|
||||
snackbar('复制失败', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
function openInFiles(serverId: number, path: string) {
|
||||
router.push({
|
||||
path: '/files',
|
||||
query: { server_id: String(serverId), path: path === DEFAULT_FILES_ROOT ? undefined : path },
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadServers({ all: true })
|
||||
applyRouteQuery()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => route.query,
|
||||
() => {
|
||||
if (serverList.value.length) applyRouteQuery()
|
||||
},
|
||||
)
|
||||
|
||||
return {
|
||||
serverList,
|
||||
serversLoading,
|
||||
sourceServerSearchQuery,
|
||||
destServerSearchQuery,
|
||||
filteredSourceServerList,
|
||||
filteredDestServerList,
|
||||
sourceSiteUrl,
|
||||
sourceSiteHost,
|
||||
sourceServerId,
|
||||
destServerId,
|
||||
destServers,
|
||||
currentPath,
|
||||
files,
|
||||
browseLoading,
|
||||
browseError,
|
||||
destCurrentPath,
|
||||
destFiles,
|
||||
destBrowseLoading,
|
||||
destBrowseError,
|
||||
selectedPaths,
|
||||
manualPaths,
|
||||
effectivePaths,
|
||||
transferMode,
|
||||
destPath,
|
||||
extract,
|
||||
actionLoading,
|
||||
downloadUrl,
|
||||
lastResult,
|
||||
hasDirectory,
|
||||
hasOversizedFile,
|
||||
relayDisabled,
|
||||
onSourceServerChange,
|
||||
onDestServerChange,
|
||||
onModeChange,
|
||||
browse,
|
||||
browseDest,
|
||||
goUp,
|
||||
goUpDest,
|
||||
openEntry,
|
||||
openDestEntry,
|
||||
togglePath,
|
||||
isSelected,
|
||||
selectAllVisible,
|
||||
clearSelection,
|
||||
runTransfer,
|
||||
copyDownloadUrl,
|
||||
openInFiles,
|
||||
MAX_RELAY_FILE_BYTES,
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,8 @@ export interface ServerBrief {
|
||||
category?: string | null
|
||||
is_online?: boolean
|
||||
status?: string
|
||||
description?: string | null
|
||||
extra_attrs?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export function useServerList() {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user