5 Commits

Author SHA1 Message Date
Codex Release Bot 8363746cf6 docs: clarify release bundle hash verification 2026-07-09 05:14:29 +08:00
Codex Release Bot b49dad8bda chore: ignore local codex virtualenvs 2026-07-09 05:14:04 +08:00
Codex Release Bot 279259092d release: publish gitea allowlist and harden bundle deploy 2026-07-09 05:12:54 +08:00
Codex Release Bot 74149e0d90 docs: add nexus release merge guide 2026-07-09 00:14:19 +08:00
Codex Release Bot 1933f0af6e release: nexus btpanel session fix and app-v2 2026-07-08 22:31:31 +08:00
790 changed files with 52201 additions and 6016 deletions
+44
View File
@@ -0,0 +1,44 @@
---
description: 减少盲目搜文件、控制 Read 次数与 token 消耗
alwaysApply: true
---
# Agent 探索约束(省 token
## 开始前
1. 读根目录 `AGENTS.md` 的「代码地图」与「问题 → 文件」表
2. 架构/API 全览只读 `docs/project/nexus-functional-development-guide.md`(或用户指定的 §),禁止为找结构全库 Grep
3. 用户已给出路径/文件名时,只读该路径,禁止扩大范围
## 搜索顺序
```
AGENTS.md / 功能指南 § → Grep(带 path)→ Read(≤3 个文件)→ 仍不够再扩一圈
```
- **禁止**无 `path` 的全仓库 `Grep` / `Glob`(除非用户明确要求全库)
- **Grep 优先于 Read**Glob 只用于定位文件名,不逐个 Read 全部匹配
- 同一任务 **Read 不超过 5 个文件**;找到答案后立即停止
- 不要重复 Read 已在本对话出现过的文件
## 分层定位(禁止跨层乱搜)
| 层 | 路径 |
|----|------|
| HTTP 路由 | `server/api/<module>.py` |
| 业务 | `server/application/services/` |
| 数据访问 | `server/infrastructure/` |
| 模型 | `server/domain/models/` |
| 前端页 | `frontend/src/pages/` |
| 前端路由 | `frontend/src/router/index.ts` |
| 前端 API 客户端 | `frontend/src/api/` |
真实前端源码在 `frontend/src/`,不是 `web/app/*.html`(那是构建产物)。
## 文档索引
- 功能 SSOT`docs/project/nexus-functional-development-guide.md`
- 文档导航:`docs/README.md`
- 本地路径/MCP`docs/project/linux-dev-paths.md`
- 运维(连机/exec):用户 Skill `Nexus平台` 或 `docs/project/nexus-1panel-operations-knowledge.md`
+7
View File
@@ -0,0 +1,7 @@
{
"plugins": {
"compound-engineering": {
"enabled": true
}
}
}
+6 -1
View File
@@ -38,11 +38,16 @@ FastAPI + Async SQLAlchemy + Redis · Vue 3 + Vuetify 4 SPA15 页)· Vite
## 部署
默认 **本机 rsync 直传**(不 push Gitea):
```bash
bash deploy/pre_deploy_check.sh && bash deploy/deploy-production.sh
```
前端: `cd frontend && npx vite build` → tar/scp(见 `deploy/deploy-frontend.sh`
仅同步源码(不重建镜像):`bash deploy/rsync-local-to-server.sh`
仍走 Gitea 拉取:`NEXUS_DEPLOY_VIA_GIT=1 bash deploy/deploy-production.sh`
前端: `cd frontend && npx vite build` → 部署脚本内 tar/scp 到 `web/app/`
## 进度条(改代码时输出)
+40 -29
View File
@@ -1,29 +1,40 @@
.git
.gitignore
.claude
.cursor
**/__pycache__
**/*.pyc
**/.pytest_cache
**/.mypy_cache
**/.ruff_cache
.coverage
htmlcov/
frontend/node_modules
frontend/dist
node_modules
.env
docker/.env
docker/runtime
tests/
docs/
standards/
*.md
!docker/README.md
web/uploads
backups/
deploy/gate_log.jsonl
.git
.gitignore
.claude
.cursor
**/__pycache__
**/*.pyc
**/.pytest_cache
**/.mypy_cache
**/.ruff_cache
.coverage
htmlcov/
frontend/node_modules
frontend-v2/node_modules
frontend/dist
node_modules
.venv
.venv-*
venv
.pytest_cache
.ruff_cache
.playwright-mcp
2025.2
=2025.2
*.bak-
.env
docker/.env
docker/runtime
tmp/
tests/
docs/
standards/
*.md
!docker/README.md
web/uploads
backups/
deploy/gate_log.jsonl
-63
View File
@@ -1,63 +0,0 @@
# Nexus Environment Configuration
# Copy this file to .env and modify values for your deployment.
# Or run the web installer at /app/install.html to auto-generate this file.
#
# Generate secret keys: openssl rand -hex 32
# ── Brand ──
NEXUS_SYSTEM_NAME=Nexus
NEXUS_SYSTEM_TITLE=Nexus — 服务器运维管理平台
# ── Server ──
NEXUS_HOST=0.0.0.0
NEXUS_PORT=8600
# ── Deployment (auto-set by installer) ──
NEXUS_DEPLOY_PATH=/opt/nexus
# Comma-separated frontend origins for CORS
NEXUS_CORS_ORIGINS=http://localhost:8600
# Base URL for Agent heartbeat reporting
NEXUS_API_BASE_URL=http://localhost:8600
# ── Database (MySQL, REQUIRED) ──
NEXUS_DATABASE_URL=mysql+aiomysql://root:password@127.0.0.1:3306/nexus
# Pool params: auto-calculated by installer from MySQL max_connections
# Formula: pool_size = max(20, maxConn*0.4), overflow = max(20, maxConn*0.3)
# Default below matches MySQL max_connections=400 (160+120=280 max)
# install wizard will overwrite these with values tuned to your MySQL instance
NEXUS_DB_POOL_SIZE=160
NEXUS_DB_MAX_OVERFLOW=120
# ── Security (REQUIRED — generate with: openssl rand -hex 32) ──
NEXUS_SECRET_KEY=change-me-use-openssl-rand-hex-32
NEXUS_API_KEY=change-me-use-openssl-rand-hex-32
NEXUS_ENCRYPTION_KEY=
# ── Redis (REQUIRED) ──
NEXUS_REDIS_URL=redis://127.0.0.1:6379/0
# ── SSH ──
NEXUS_SSH_STRICT_HOST_CHECKING=false
# ── Alert Thresholds (%) ──
NEXUS_CPU_ALERT_THRESHOLD=80
NEXUS_MEM_ALERT_THRESHOLD=80
NEXUS_DISK_ALERT_THRESHOLD=80
# ── Health Check ──
NEXUS_HEALTH_CHECK_INTERVAL=60
# ── Telegram Alerts (optional — configure in Settings UI) ──
NEXUS_TELEGRAM_BOT_TOKEN=
NEXUS_TELEGRAM_CHAT_ID=
# ── MySQL MCP (@yclenove/mysql-mcp-server — run: python scripts/sync_mysql_mcp_env.py) ──
# Cursor 从项目根 .env 读取;与 NEXUS_DATABASE_URL 保持一致;默认只读
# MYSQL_HOST=127.0.0.1
# MYSQL_PORT=3306
# MYSQL_USER=root
# MYSQL_PASSWORD=password
# MYSQL_DATABASE=nexus
# MYSQL_READONLY=true # production / MCP read-only
# MYSQL_READONLY=false # local dev: full DML/DDL via MCP
# MYSQL_DATABASE_ALLOWLIST=nexus
+90 -89
View File
@@ -1,89 +1,90 @@
# Nexus — Python
__pycache__/
*.py[cod]
*$py.class
*.egg-info/
build/
.eggs/
# Environment (NEVER commit — contains production credentials)
.env
.install_locked
.install_state.json
SECRETS.md
.git-askpass-nexus.sh
deploy/nexus-1panel.secrets.sh
*.pem
.env.*
.venv/
venv/
ENV/
# Agent memory (local MCP persistence, do not commit)
.claude/mcp-memory.jsonl
# MCP tool data (local development artifacts)
.megamemory/
.playwright-mcp/
frontend/test-results/
frontend/e2e/.auth/
tmp/
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Logs
*.log
nexus.log
# Node offline cache for WSL install (large tarball)
.cache/
# Test
.pytest_cache/
htmlcov/
.coverage
coverage.xml
# Node (for Tailwind/Alpine.js build)
node_modules/
web/css/tailwind-output.css
# Build output (Vite/Vuetify frontend)
# SPA 构建产物纳入 Git,便于服务器 git pull 部署(见 deploy/deploy-frontend.sh
# 仍忽略本地临时文件
web/app/assets/.tmp/
# Legacy dist dir (no longer used)
**/dist/
# Uploads
web/uploads/
# Data (sensitive)
web/data/config.php
web/data/*.db
web/data/1panel-hosts.json
# Backups
backups/
# Docker local secrets
docker/.env
docker/.env.prod
!docker/.env.example
!docker/.env.prod.example
scripts/npm-proxy.env
# Deploy
deploy/*.key
# Backups
backups/
# Nexus — Python
__pycache__/
*.py[cod]
*$py.class
*.egg-info/
build/
.eggs/
# Environment (NEVER commit — contains production credentials)
.env
.install_locked
.install_state.json
SECRETS.md
.git-askpass-nexus.sh
deploy/nexus-1panel.secrets.sh
*.pem
.env.*
.venv/
.venv-*/
venv/
ENV/
# Agent memory (local MCP persistence, do not commit)
.claude/mcp-memory.jsonl
# MCP tool data (local development artifacts)
.megamemory/
.playwright-mcp/
frontend/test-results/
frontend/e2e/.auth/
tmp/
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Logs
*.log
nexus.log
# Node offline cache for WSL install (large tarball)
.cache/
# Test
.pytest_cache/
htmlcov/
.coverage
coverage.xml
# Node (for Tailwind/Alpine.js build)
node_modules/
web/css/tailwind-output.css
# Build output (Vite/Vuetify frontend)
# SPA 构建产物纳入 Git,便于服务器 git pull 部署(见 deploy/deploy-frontend.sh
# 仍忽略本地临时文件
web/app/assets/.tmp/
# Legacy dist dir (no longer used)
**/dist/
# Uploads
web/uploads/
# Data (sensitive)
web/data/config.php
web/data/*.db
web/data/1panel-hosts.json
# Backups
backups/
# Docker local secrets
docker/.env
docker/.env.prod
!docker/.env.example
!docker/.env.prod.example
scripts/npm-proxy.env
# Deploy
deploy/*.key
# Backups
backups/
+31
View File
@@ -0,0 +1,31 @@
---
name: nexus-btpanel-review
description: Use when reviewing or changing Nexus BT/宝塔 panel integration, one-click login, API bootstrap, session keepalive fixes, or BT credential handling.
---
# Nexus BT Panel Review
Use for 宝塔/BT panel integration changes.
## Call chain to map
API one-click login -> btpanel_service -> BT API credential lookup/bootstrap -> SSH bootstrap if needed -> login URL/tmp_token -> panel session behavior.
## Required checks
- Do not log or return BT API keys, session cookies, temporary login tokens, SSH private keys, or passwords.
- One-click login should fail closed for real authentication errors, but best-effort environment repair must not block login unless required for correctness.
- Session keepalive/cleanup repair must be idempotent and throttled; do not restart or rewrite BT panel aggressively without explicit approval.
- Respect existing BT panel services and ports. Do not occupy panel, nginx, redis, or mysql ports.
- Old servers with existing BT API credentials must still receive the session-cleanup TTL check before login.
- Record bootstrap state without storing secrets.
## Regression tests
Cover:
- Existing credentials still trigger throttled TTL check.
- Missing SSH auth does not block login URL generation.
- Failed repair is throttled and retried later.
- Temporary login token TTL remains bounded.
- No secret values appear in returned bootstrap/status payloads.
+40
View File
@@ -0,0 +1,40 @@
---
name: nexus-security-review
description: Use when reviewing or changing Nexus backend/API/security-sensitive code. Focuses on FastAPI admin APIs, SQLAlchemy data access, SSH operations, file manager boundaries, logging redaction, and regression tests.
---
# Nexus Security Review
Use this skill before changing Nexus backend/API code or doing a security pass.
## Required workflow
1. Identify entrypoints: FastAPI router, service method, DB model/session, Redis/lock usage, SSH/BT panel call.
2. Draw the request path in notes: API -> schema -> service -> DB/Redis/SSH/external.
3. Check auth first: admin-only endpoints must depend on the current admin dependency; background/admin tools must still preserve audit logs.
4. Validate every user-controlled value at the schema/API boundary and again before shell/path-sensitive sinks.
5. For every fix, add pytest coverage for the dangerous case and the allowed case.
6. Run focused tests, then full pytest when practical, then git diff --check.
7. Update the Nexus Markdown report with changed files, tests, and residual risks.
## Nexus-specific rules
- Do not print secrets: passwords, SSH keys, tokens, cookies, API keys, .env values, BT panel credentials.
- script_service is an intentional administrator remote shell. Do not report arbitrary shell execution there as a vulnerability. Review only auth, audit trail, timeout, output truncation, and redaction.
- File manager APIs must not unexpectedly operate outside the target path/server chosen by the admin UI.
- Any remote shell command must use shlex.quote; add -- where command options can be confused with filenames.
- Archive extraction must list and validate members before extraction; reject absolute paths, .., backslashes, and special file types.
- Error responses must not expose stack traces or secrets.
- DB access should use SQLAlchemy parameters/query builder, not raw string interpolation.
## Review output
For each finding record:
- Severity: Critical/High/Medium/Low/Hardening
- Call chain
- Evidence file/function
- Exploit or failure boundary
- Fix summary
- Test command/result
- Commit hash if committed
+29
View File
@@ -0,0 +1,29 @@
---
name: nexus-ssh-safety
description: Use when reviewing or editing Nexus code that executes commands on managed servers over SSH, including file manager, transfer, archive, agent install, and BT panel bootstrap flows.
---
# Nexus SSH Safety
Use for any Nexus feature that builds shell commands or transfers files over SSH.
## Command construction rules
- Prefer argument arrays/local library APIs when available; otherwise quote every dynamic shell token with shlex.quote.
- Insert -- before filename/path operands for commands that parse options:
m, mv, cp, ar, zip, chmod, chown, etc.
- Never concatenate unquoted user input into shell command strings.
- Keep timeouts explicit for network/SSH work; avoid unbounded commands.
- Truncate or sanitize command output before returning it to API clients.
- Do not log secrets or full credential-bearing URLs.
## Path and archive rules
- Normalize paths with POSIX semantics for remote Linux paths.
- Reject empty paths where dangerous, root/system paths for recursive destructive operations, and traversal where the feature expects a bounded directory.
- Archive creation must protect member names beginning with -.
- Archive extraction must validate member names and types before extraction.
- Temporary files/directories must be unique and cleaned best-effort.
## script_service exception
+5
View File
@@ -0,0 +1,5 @@
Collecting tzdata
Downloading tzdata-2026.2-py2.py3-none-any.whl.metadata (1.4 kB)
Downloading tzdata-2026.2-py2.py3-none-any.whl (349 kB)
Installing collected packages: tzdata
Successfully installed tzdata-2026.2
+53 -1
View File
@@ -19,6 +19,14 @@ cd frontend && npm run dev # :3000/app/
路径见 [linux-dev-paths.md](docs/project/linux-dev-paths.md)。
## Git(仅本机快照)
- 本地 `git commit` 用于回滚;**不 `git push`**(除非你明确要求)。
- 已移除 `origin` 跟踪,避免误推;原远程:`http://66.154.115.8:3000/admin/Nexus.git`(需恢复时说一声)。
- `.env``SECRETS.md``*.pem` 已在 `.gitignore`**勿提交**。
- 改代码仍写 `docs/changelog/YYYY-MM-DD-*.md`(≥10 行)。
- 用户说「提交快照 / commit」时执行。
## 强制约束
- 完美实现:`.cursor/rules/perfect-implementation.mdc`
@@ -32,13 +40,57 @@ cd frontend && npm run dev # :3000/app/
## 部署
默认 **本机 rsync → SSH 生产机**(不 push Gitea):
```bash
bash deploy/pre_deploy_check.sh
bash deploy/deploy-production.sh # 需 SSH nexus + 用户批准
bash deploy/deploy-production.sh # rsync + Docker upgrade --skip-git + 前端
```
可选:`NEXUS_DEPLOY_VIA_GIT=1` 恢复远程 `git pull`。仅 `git push` 当你明确要求时。
详情见功能指南 §1718。
## 归档文档
历史审计合并卷:`docs/archive/README.md`
## 代码地图(Agent 先读这里,勿全库搜)
```
server/main.py # 路由挂载入口
server/config.py # 配置(.env + settings 表)
server/api/*.py # HTTP/WebSocket 路由(按模块拆分)
server/application/ # 业务 services
server/infrastructure/ # DB / Redis / SSH / Telegram
server/domain/models/ # SQLAlchemy ORM
frontend/src/pages/ # Vue 页面(15 页 SPA
frontend/src/router/ # Hash 路由 #/
frontend/src/api/ # 前端 API 客户端
frontend/e2e/ # Playwright 验收
tests/test_*.py # pytest
docs/project/ # 功能/部署 SSOT
deploy/ # 生产部署脚本
scripts/ # 本地验证、MCP、门控
.cursor/mcp.json # mysql-mcp
```
## 问题 → 文件(常见锚点)
| 问什么 | 先看 |
|--------|------|
| 某 API 怎么实现 | `server/main.py` 找 router → `server/api/<模块>.py` |
| 服务器列表/搜索/exec | `server/api/servers.py`, `server/api/scripts.py` |
| 认证/JWT | `server/api/auth.py`, `server/api/dependencies.py` |
| Agent 心跳/安装 | `server/api/agent.py` |
| WebSSH/终端 | `server/api/webssh.py`, `server/api/terminal.py` |
| 同步/文件/搜索 | `server/api/sync_v2.py`, `files.py`, `search.py` |
| 设置/审计/告警 | `server/api/settings.py` |
| 前端某页 UI | `frontend/src/router/index.ts``frontend/src/pages/` |
| MySQL MCP | `.cursor/mcp.json`, `scripts/linux_mcp_mysql.sh` |
| 本地开发路径 | `docs/project/linux-dev-paths.md` |
| 14 页/API 全览 | `docs/project/nexus-functional-development-guide.md` §910 |
## 探索规则(省 token
详见 `.cursor/rules/agent-exploration.mdc`。要点:**先地图再 Grep(带 path)→ Read ≤5 个文件 → 停止**。
+46 -33
View File
@@ -1,33 +1,46 @@
# Nexus 6.0 API + 静态 SPA (web/app)
FROM python:3.12-slim-bookworm
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
curl \
openssh-client \
rsync \
sshpass \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY server/ ./server/
COPY web/app/ ./web/app/
COPY docker/entrypoint.sh ./docker/entrypoint.sh
RUN chmod +x ./docker/entrypoint.sh
ENV PYTHONPATH=/app \
PYTHONUNBUFFERED=1 \
NEXUS_HOST=0.0.0.0 \
NEXUS_PORT=8600
EXPOSE 8600
HEALTHCHECK --interval=30s --timeout=5s --start-period=90s --retries=3 \
CMD curl -sf http://127.0.0.1:8600/health | grep -q '^ok' || exit 1
ENTRYPOINT ["/app/docker/entrypoint.sh"]
CMD ["uvicorn", "server.main:app", "--host", "0.0.0.0", "--port", "8600"]
# Nexus 6.0 - API + static SPA (web/app)
FROM python:3.12-slim-bookworm
ARG DEBIAN_MIRROR=https://mirrors.aliyun.com/debian
ARG DEBIAN_SECURITY_MIRROR=https://mirrors.aliyun.com/debian-security
ARG PIP_INDEX_URL=https://mirrors.aliyun.com/pypi/simple/
ARG PIP_TRUSTED_HOST=mirrors.aliyun.com
RUN set -eux; \
sed -i \
-e "s|http://deb.debian.org/debian-security|${DEBIAN_SECURITY_MIRROR}|g" \
-e "s|http://deb.debian.org/debian|${DEBIAN_MIRROR}|g" \
/etc/apt/sources.list.d/debian.sources; \
apt-get update; \
apt-get install -y --no-install-recommends \
curl \
openssh-client \
rsync \
sshpass; \
rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --index-url "${PIP_INDEX_URL}" --trusted-host "${PIP_TRUSTED_HOST}" -r requirements.txt
COPY server/ ./server/
COPY web/app/ ./web/app/
COPY web/app-v2/ ./web/app-v2/
COPY docker/entrypoint.sh ./docker/entrypoint.sh
RUN chmod +x ./docker/entrypoint.sh
ENV PYTHONPATH=/app \
PYTHONUNBUFFERED=1 \
NEXUS_HOST=0.0.0.0 \
NEXUS_PORT=8600
EXPOSE 8600
HEALTHCHECK --interval=30s --timeout=5s --start-period=90s --retries=3 \
CMD curl -sf http://127.0.0.1:8600/health | grep -q '^ok' || exit 1
ENTRYPOINT ["/app/docker/entrypoint.sh"]
CMD ["uvicorn", "server.main:app", "--host", "0.0.0.0", "--port", "8600"]
+77 -64
View File
@@ -1,64 +1,77 @@
# Nexus 6.0 production image (frontend build + API)
# Build: docker compose -f docker/docker-compose.prod.yml build
# See: docs/design/plans/2026-06-04-1panel-docker-production.md
# ── Stage 1: Vue/Vite SPA ──
FROM node:20-bookworm-slim AS frontend
WORKDIR /build/frontend
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci --ignore-scripts
COPY frontend/ ./
RUN npx vite build
# ── Stage 2: Python API + built static assets ──
FROM python:3.12-slim-bookworm
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
curl \
openssh-client \
rsync \
sshpass \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY server/ ./server/
COPY web/agent/ ./web/agent/
COPY --from=frontend /build/web/app ./web/app/
# Login wallpaper cache (Vite does not emit this dir; sync also writes here at runtime)
COPY web/app/wallpapers/ ./web/app/wallpapers/
# Install wizard + static SPA assets not emitted by Vite
COPY web/app/install.html ./web/app/install.html
COPY web/app/servers_import_template.csv ./web/app/servers_import_template.csv
RUN mkdir -p ./web/app/vendor
COPY web/app/vendor/tailwindcss-browser.js \
web/app/vendor/theme-init.js \
web/app/vendor/theme.css \
web/app/vendor/alpinejs.min.js \
./web/app/vendor/
COPY docker/entrypoint.sh ./docker/entrypoint.sh
RUN chmod +x ./docker/entrypoint.sh \
&& test -f /app/web/app/install.html \
&& test -f /app/web/app/vendor/alpinejs.min.js \
&& test -f /app/web/app/vendor/tailwindcss-browser.js \
&& test -f /app/web/app/vendor/theme.css \
&& test -f /app/web/app/vendor/theme-init.js
ENV PYTHONPATH=/app \
PYTHONUNBUFFERED=1 \
NEXUS_HOST=0.0.0.0 \
NEXUS_PORT=8600
EXPOSE 8600
HEALTHCHECK --interval=30s --timeout=5s --start-period=90s --retries=3 \
CMD curl -sf http://127.0.0.1:8600/health | grep -q '^ok' || exit 1
ENTRYPOINT ["/app/docker/entrypoint.sh"]
CMD ["uvicorn", "server.main:app", "--host", "0.0.0.0", "--port", "8600"]
# Nexus 6.0 鈥?production image (frontend build + API)
# Build: docker compose -f docker/docker-compose.prod.yml build
# See: docs/design/plans/2026-06-04-1panel-docker-production.md
# 鈹€鈹€ Stage 1: Vue/Vite SPA 鈹€鈹€
FROM node:20-bookworm-slim AS frontend
WORKDIR /build/frontend
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci --ignore-scripts
COPY frontend/ ./
RUN npx vite build
# Stage 1b: React/Vite App V2
FROM node:20-bookworm-slim AS frontend_v2
WORKDIR /build/frontend-v2
COPY frontend-v2/package.json frontend-v2/package-lock.json* ./
RUN if [ -f package-lock.json ]; then npm ci --ignore-scripts; else npm install --ignore-scripts; fi
COPY frontend-v2/ ./
RUN npx vite build
# 鈹€鈹€ Stage 2: Python API + built static assets 鈹€鈹€
FROM python:3.12-slim-bookworm
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
curl \
openssh-client \
rsync \
sshpass \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY server/ ./server/
COPY web/agent/ ./web/agent/
COPY --from=frontend /build/web/app ./web/app/
COPY --from=frontend_v2 /build/web/app-v2 ./web/app-v2/
# Login wallpaper cache (Vite does not emit this dir; sync also writes here at runtime)
COPY web/app/wallpapers/ ./web/app/wallpapers/
# Install wizard + static SPA assets not emitted by Vite
COPY web/app/install.html ./web/app/install.html
COPY web/app/servers_import_template.csv ./web/app/servers_import_template.csv
RUN mkdir -p ./web/app/vendor
COPY web/app/vendor/tailwindcss-browser.js \
web/app/vendor/theme-init.js \
web/app/vendor/theme.css \
web/app/vendor/alpinejs.min.js \
./web/app/vendor/
COPY docker/entrypoint.sh ./docker/entrypoint.sh
RUN chmod +x ./docker/entrypoint.sh \
&& test -f /app/web/app/install.html \
&& test -f /app/web/app/vendor/alpinejs.min.js \
&& test -f /app/web/app/vendor/tailwindcss-browser.js \
&& test -f /app/web/app/vendor/theme.css \
&& test -f /app/web/app/vendor/theme-init.js
ENV PYTHONPATH=/app \
PYTHONUNBUFFERED=1 \
NEXUS_HOST=0.0.0.0 \
NEXUS_PORT=8600
EXPOSE 8600
HEALTHCHECK --interval=30s --timeout=5s --start-period=90s --retries=3 \
CMD curl -sf http://127.0.0.1:8600/health | grep -q '^ok' || exit 1
ENTRYPOINT ["/app/docker/entrypoint.sh"]
CMD ["uvicorn", "server.main:app", "--host", "0.0.0.0", "--port", "8600"]
-19
View File
@@ -1,19 +0,0 @@
FROM mcr.microsoft.com/playwright/python:v1.49.1-jammy
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
RUN apt-get update && apt-get install -y --no-install-recommends xvfb \
&& rm -rf /var/lib/apt/lists/*
COPY browser_url_safe.py browser_stealth.py config.py session_manager.py main.py docker-entrypoint.sh ./
RUN sed -i 's/\r$//' docker-entrypoint.sh && chmod +x docker-entrypoint.sh
ENV WORKER_BIND=0.0.0.0:8443
ENV DISPLAY=:99
EXPOSE 8443
ENTRYPOINT ["/bin/bash", "docker-entrypoint.sh"]
-46
View File
@@ -1,46 +0,0 @@
"""Reduce automation fingerprints for sites with CAPTCHA (e.g. 阿里云)."""
from __future__ import annotations
CHROMIUM_ARGS = [
"--disable-blink-features=AutomationControlled",
"--no-sandbox",
"--disable-dev-shm-usage",
"--disable-infobars",
"--window-position=0,0",
"--lang=zh-CN",
]
# Playwright adds --enable-automation by default; drop it for CAPTCHA sites.
IGNORE_DEFAULT_ARGS = ["--enable-automation"]
STEALTH_INIT_SCRIPT = """
(() => {
Object.defineProperty(navigator, 'webdriver', { get: () => undefined, configurable: true });
Object.defineProperty(navigator, 'languages', { get: () => ['zh-CN', 'zh', 'en-US', 'en'] });
Object.defineProperty(navigator, 'plugins', { get: () => [1, 2, 3, 4, 5] });
const originalQuery = window.navigator.permissions.query;
window.navigator.permissions.query = (parameters) => (
parameters.name === 'notifications'
? Promise.resolve({ state: Notification.permission })
: originalQuery(parameters)
);
window.chrome = window.chrome || { runtime: {} };
})();
"""
DEFAULT_USER_AGENT = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
)
CONTEXT_OPTIONS = {
"user_agent": DEFAULT_USER_AGENT,
"locale": "zh-CN",
"timezone_id": "Asia/Shanghai",
"color_scheme": "light",
"device_scale_factor": 1,
"has_touch": False,
"is_mobile": False,
"java_script_enabled": True,
}
-77
View File
@@ -1,77 +0,0 @@
"""SSRF-safe URL validation (Worker copy — keep in sync with server/utils/browser_url_safe.py)."""
from __future__ import annotations
import ipaddress
import socket
from urllib.parse import urlparse
BLOCKED_HOSTNAMES = frozenset({
"localhost",
"localhost.localdomain",
"metadata",
"metadata.google.internal",
"169.254.169.254",
})
def _blocked_ip(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved or ip.is_multicast:
return True
if isinstance(ip, ipaddress.IPv4Address):
if ip in ipaddress.ip_network("0.0.0.0/8"):
return True
if ip in ipaddress.ip_network("100.64.0.0/10"):
return True
if isinstance(ip, ipaddress.IPv6Address):
if ip == ipaddress.IPv6Address("::1"):
return True
if ip in ipaddress.ip_network("fc00::/7"):
return True
if ip in ipaddress.ip_network("fe80::/10"):
return True
return False
def resolve_host_blocked(hostname: str) -> str | None:
host = hostname.lower().strip(".")
if not host:
return "Empty hostname"
if host in BLOCKED_HOSTNAMES or "metadata" in host:
return f"Blocked hostname: {hostname}"
try:
infos = socket.getaddrinfo(host, None, type=socket.SOCK_STREAM)
except socket.gaierror as exc:
return f"DNS resolution failed: {exc}"
if not infos:
return f"No addresses for hostname: {hostname}"
for info in infos:
ip_str = info[4][0]
try:
ip = ipaddress.ip_address(ip_str)
except ValueError:
return f"Invalid IP: {ip_str}"
if _blocked_ip(ip):
return f"Blocked IP: {ip_str}"
return None
def validate_navigation_url(url: str) -> tuple[str | None, str | None]:
trimmed = url.strip()
if not trimmed:
return None, "Empty URL"
try:
with_proto = trimmed if "://" in trimmed else f"https://{trimmed}"
parsed = urlparse(with_proto)
except Exception:
return None, "Invalid URL"
if parsed.scheme not in ("http", "https"):
return None, f"Unsupported scheme: {parsed.scheme}"
if parsed.username or parsed.password:
return None, "Credentials in URL are not allowed"
if not parsed.hostname:
return None, "Missing hostname"
host_err = resolve_host_blocked(parsed.hostname)
if host_err:
return None, host_err
return parsed.geturl(), None
-22
View File
@@ -1,22 +0,0 @@
"""Browser Worker configuration."""
from pydantic_settings import BaseSettings, SettingsConfigDict
class WorkerSettings(BaseSettings):
WORKER_SECRET: str = ""
WORKER_BIND: str = "0.0.0.0:8443"
WORKER_MAX_SESSIONS: int = 2
WORKER_IDLE_TIMEOUT_SEC: int = 1800
# POST /v1/sessions without WS attach — reclaim zombie reservations
WORKER_RESERVE_TIMEOUT_SEC: int = 120
# false + Xvfb — better for 阿里云等验证码;true 省资源
WORKER_HEADLESS: str = "false"
WORKER_SCREENCAST_QUALITY: int = 90
WORKER_DEFAULT_VIEWPORT_W: int = 1280
WORKER_DEFAULT_VIEWPORT_H: int = 720
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
settings = WorkerSettings()
-11
View File
@@ -1,11 +0,0 @@
services:
browser-worker:
build: .
container_name: nexus-browser-worker
restart: unless-stopped
env_file:
- .env
ports:
- "8443:8443"
shm_size: "1gb"
ipc: host
-11
View File
@@ -1,11 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# Headed Chromium needs a virtual display (CAPTCHA / anti-bot).
if [[ "${WORKER_HEADLESS:-false}" != "true" && "${WORKER_HEADLESS:-false}" != "1" ]]; then
export DISPLAY="${DISPLAY:-:99}"
if ! pgrep -x Xvfb >/dev/null 2>&1; then
Xvfb "${DISPLAY}" -screen 0 1920x1080x24 -ac +extension GLX +render -noreset &
sleep 1
fi
fi
exec uvicorn main:app --host 0.0.0.0 --port 8443 --log-level info
-147
View File
@@ -1,147 +0,0 @@
"""Browser Worker — Playwright Chromium + WebSocket stream."""
from __future__ import annotations
import asyncio
import json
import logging
from contextlib import asynccontextmanager
from typing import Any
from fastapi import FastAPI, Header, HTTPException, WebSocket, WebSocketDisconnect
from pydantic import BaseModel, Field
from config import settings
from session_manager import manager
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("browser_worker")
def _check_key(header: str | None) -> None:
if not settings.WORKER_SECRET:
raise HTTPException(status_code=503, detail="Worker secret not configured")
if not header or header != settings.WORKER_SECRET:
raise HTTPException(status_code=401, detail="Invalid worker key")
class CreateSessionBody(BaseModel):
session_id: str = Field(min_length=8, max_length=64)
viewport_w: int = Field(default=1280, ge=320, le=1920)
viewport_h: int = Field(default=720, ge=240, le=1080)
@asynccontextmanager
async def lifespan(_app: FastAPI):
await manager.start()
logger.info("Playwright Chromium ready")
yield
await manager.stop()
app = FastAPI(title="Nexus Browser Worker", lifespan=lifespan)
@app.get("/health")
async def health():
return {
"status": "ok",
"sessions": manager.session_count(),
"max_sessions": settings.WORKER_MAX_SESSIONS,
}
@app.post("/v1/sessions")
async def create_session(
body: CreateSessionBody,
x_nexus_worker_key: str | None = Header(default=None, alias="X-Nexus-Worker-Key"),
):
_check_key(x_nexus_worker_key)
try:
await manager.reserve_session(body.session_id, body.viewport_w, body.viewport_h)
except ValueError as exc:
code = str(exc)
if code == "max_sessions":
raise HTTPException(status_code=503, detail="Max sessions reached") from exc
if code == "session_exists":
raise HTTPException(status_code=409, detail="Session already exists") from exc
raise HTTPException(status_code=400, detail=code) from exc
return {"session_id": body.session_id, "viewport_w": body.viewport_w, "viewport_h": body.viewport_h}
@app.delete("/v1/sessions/{session_id}")
async def delete_session(
session_id: str,
x_nexus_worker_key: str | None = Header(default=None, alias="X-Nexus-Worker-Key"),
):
_check_key(x_nexus_worker_key)
if not await manager.destroy_session(session_id):
raise HTTPException(status_code=404, detail="Session not found")
return {"ok": True}
@app.websocket("/v1/sessions/{session_id}/stream")
async def session_stream(
websocket: WebSocket,
session_id: str,
x_nexus_worker_key: str | None = Header(default=None, alias="X-Nexus-Worker-Key"),
):
key = x_nexus_worker_key or websocket.headers.get("x-nexus-worker-key")
if not settings.WORKER_SECRET or key != settings.WORKER_SECRET:
await websocket.close(code=4401, reason="Invalid worker key")
return
if not manager.get_session(session_id):
await websocket.close(code=4404, reason="Session not found")
return
await websocket.accept()
outbound: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
async def send(msg: dict[str, Any]) -> None:
await outbound.put(msg)
try:
await manager.attach_stream(session_id, send)
except ValueError:
await websocket.close(code=4404, reason="Session not found")
return
except RuntimeError:
await manager.destroy_session(session_id)
await websocket.close(code=4503, reason="Browser not ready")
return
async def pump_out() -> None:
while True:
msg = await outbound.get()
await websocket.send_json(msg)
pump_task = asyncio.create_task(pump_out(), name=f"ws_out_{session_id}")
try:
while True:
raw = await websocket.receive_text()
try:
msg = json.loads(raw)
except json.JSONDecodeError:
await send({"type": "ERROR", "message": "Invalid JSON", "code": "BAD_JSON"})
continue
if not isinstance(msg, dict):
continue
await manager.handle_message(session_id, msg)
except WebSocketDisconnect:
pass
finally:
pump_task.cancel()
try:
await pump_task
except asyncio.CancelledError:
pass
await manager.destroy_session(session_id)
if __name__ == "__main__":
import uvicorn
host, _, port = settings.WORKER_BIND.partition(":")
uvicorn.run("main:app", host=host or "0.0.0.0", port=int(port or 8443), log_level="info")
-4
View File
@@ -1,4 +0,0 @@
fastapi==0.115.6
uvicorn[standard]==0.34.0
playwright==1.49.1
pydantic-settings==2.7.1
-346
View File
@@ -1,346 +0,0 @@
"""Playwright Chromium session manager with CDP screencast."""
from __future__ import annotations
import asyncio
import logging
import time
from dataclasses import dataclass, field
from typing import Any, Callable, Awaitable
from playwright.async_api import Browser, BrowserContext, Page, async_playwright
from browser_stealth import (
CHROMIUM_ARGS,
CONTEXT_OPTIONS,
IGNORE_DEFAULT_ARGS,
STEALTH_INIT_SCRIPT,
)
from browser_url_safe import validate_navigation_url
from config import settings
def _headless() -> bool:
return settings.WORKER_HEADLESS.lower() in ("1", "true", "yes")
logger = logging.getLogger("browser_worker.session")
SendFn = Callable[[dict[str, Any]], Awaitable[None]]
@dataclass
class BrowserSession:
session_id: str
viewport_w: int
viewport_h: int
send: SendFn
ready: bool = False
context: BrowserContext | None = None
page: Page | None = None
cdp: Any = None
last_activity: float = field(default_factory=time.monotonic)
reserved_at: float = field(default_factory=time.monotonic)
_screencast_task: asyncio.Task | None = None
_title_task: asyncio.Task | None = None
async def touch(self) -> None:
self.last_activity = time.monotonic()
async def close(self) -> None:
for task in (self._screencast_task, self._title_task):
if task and not task.done():
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
self._screencast_task = None
self._title_task = None
if self.cdp:
try:
await self.cdp.send("Page.stopScreencast")
except Exception:
pass
self.cdp = None
if self.context:
try:
await self.context.close()
except Exception as exc:
logger.warning("context close failed session=%s: %s", self.session_id, exc)
self.context = None
self.page = None
self.ready = False
class SessionManager:
def __init__(self) -> None:
self._browser: Browser | None = None
self._playwright = None
self._sessions: dict[str, BrowserSession] = {}
self._lock = asyncio.Lock()
self._idle_task: asyncio.Task | None = None
async def start(self) -> None:
self._playwright = await async_playwright().start()
self._browser = await self._playwright.chromium.launch(
headless=_headless(),
args=CHROMIUM_ARGS,
ignore_default_args=IGNORE_DEFAULT_ARGS,
)
self._idle_task = asyncio.create_task(self._idle_loop(), name="browser_idle")
async def stop(self) -> None:
if self._idle_task:
self._idle_task.cancel()
try:
await self._idle_task
except asyncio.CancelledError:
pass
for sid in list(self._sessions):
await self.destroy_session(sid)
if self._browser:
await self._browser.close()
self._browser = None
if self._playwright:
await self._playwright.stop()
self._playwright = None
async def _idle_loop(self) -> None:
while True:
await asyncio.sleep(60)
now = time.monotonic()
stale: list[str] = []
for sid, sess in self._sessions.items():
if sess.ready:
if now - sess.last_activity > settings.WORKER_IDLE_TIMEOUT_SEC:
stale.append(sid)
elif now - sess.reserved_at > settings.WORKER_RESERVE_TIMEOUT_SEC:
stale.append(sid)
for sid in stale:
logger.info("idle timeout session=%s", sid)
await self.destroy_session(sid)
def session_count(self) -> int:
return len(self._sessions)
async def reserve_session(
self,
session_id: str,
viewport_w: int,
viewport_h: int,
) -> None:
async with self._lock:
if session_id in self._sessions:
raise ValueError("session_exists")
if len(self._sessions) >= settings.WORKER_MAX_SESSIONS:
raise ValueError("max_sessions")
self._sessions[session_id] = BrowserSession(
session_id=session_id,
viewport_w=viewport_w,
viewport_h=viewport_h,
send=self._noop_send,
)
async def attach_stream(self, session_id: str, send: SendFn) -> BrowserSession:
async with self._lock:
session = self._sessions.get(session_id)
if not session:
raise ValueError("session_not_found")
if session.ready:
session.send = send
return session
if not self._browser:
raise RuntimeError("browser_not_ready")
session.send = send
context = await self._browser.new_context(
viewport={"width": session.viewport_w, "height": session.viewport_h},
ignore_https_errors=False,
**CONTEXT_OPTIONS,
)
await context.add_init_script(STEALTH_INIT_SCRIPT)
page = await context.new_page()
session.context = context
session.page = page
cdp = await context.new_cdp_session(page)
session.cdp = cdp
await cdp.send("Page.startScreencast", {
"format": "jpeg",
"quality": max(60, min(settings.WORKER_SCREENCAST_QUALITY, 100)),
"everyNthFrame": 1,
})
session._screencast_task = asyncio.create_task(
self._screencast_loop(session, cdp),
name=f"screencast_{session_id}",
)
session._title_task = asyncio.create_task(
self._title_loop(session),
name=f"title_{session_id}",
)
session.ready = True
await session.send({
"type": "BROWSER_INIT",
"session_id": session_id,
"viewport_w": session.viewport_w,
"viewport_h": session.viewport_h,
})
return session
async def _noop_send(self, _msg: dict[str, Any]) -> None:
return
async def destroy_session(self, session_id: str) -> bool:
async with self._lock:
session = self._sessions.pop(session_id, None)
if not session:
return False
await session.close()
return True
def get_session(self, session_id: str) -> BrowserSession | None:
return self._sessions.get(session_id)
async def _screencast_loop(self, session: BrowserSession, cdp: Any) -> None:
"""CDPSession has no wait_for_event — use on() + asyncio.Queue."""
frame_queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
def _on_screencast_frame(params: dict[str, Any]) -> None:
try:
frame_queue.put_nowait(params)
except asyncio.QueueFull:
pass
cdp.on("Page.screencastFrame", _on_screencast_frame)
try:
while session.session_id in self._sessions and session.ready:
try:
msg = await asyncio.wait_for(frame_queue.get(), timeout=30.0)
except asyncio.TimeoutError:
continue
await session.touch()
await session.send({
"type": "SCREENCAST_FRAME",
"data_b64": msg.get("data", ""),
"metadata": msg.get("metadata") or {},
})
await cdp.send("Page.screencastFrameAck", {"sessionId": msg["sessionId"]})
except asyncio.CancelledError:
raise
except Exception as exc:
logger.warning("screencast ended session=%s: %s", session.session_id, exc)
async def _title_loop(self, session: BrowserSession) -> None:
page = session.page
if not page:
return
last_url = ""
last_title = ""
try:
while session.session_id in self._sessions and session.ready:
await asyncio.sleep(1)
await session.touch()
try:
url = page.url
title = await page.title()
except Exception:
continue
if url != last_url or title != last_title:
last_url = url
last_title = title
await session.send({
"type": "PAGE_INFO",
"url": url,
"title": title,
})
except asyncio.CancelledError:
raise
async def handle_message(self, session_id: str, msg: dict[str, Any]) -> None:
session = self.get_session(session_id)
if not session or not session.page or not session.ready:
return
await session.touch()
page = session.page
mtype = msg.get("type")
if mtype == "PING":
await session.send({"type": "PONG"})
return
if mtype == "NAVIGATE":
url = msg.get("url", "")
normalized, err = validate_navigation_url(str(url))
if err:
await session.send({"type": "ERROR", "message": err, "code": "URL_BLOCKED"})
return
try:
await page.goto(normalized, wait_until="domcontentloaded", timeout=30_000)
await session.send({
"type": "PAGE_INFO",
"url": page.url,
"title": await page.title(),
})
except Exception as exc:
await session.send({
"type": "ERROR",
"message": str(exc)[:500],
"code": "NAVIGATE_FAILED",
})
return
if mtype == "RELOAD":
await page.reload(wait_until="domcontentloaded", timeout=30_000)
return
if mtype == "GO_BACK":
await page.go_back(wait_until="domcontentloaded", timeout=30_000)
return
if mtype == "GO_FORWARD":
await page.go_forward(wait_until="domcontentloaded", timeout=30_000)
return
if mtype == "VIEWPORT":
w = int(msg.get("width") or session.viewport_w)
h = int(msg.get("height") or session.viewport_h)
w = max(320, min(w, 1920))
h = max(240, min(h, 1080))
session.viewport_w = w
session.viewport_h = h
await page.set_viewport_size({"width": w, "height": h})
return
if mtype == "MOUSE":
event = msg.get("event")
x = float(msg.get("x", 0))
y = float(msg.get("y", 0))
button = msg.get("button", "left")
steps = int(msg.get("steps") or 1)
steps = max(1, min(steps, 25))
if event == "move":
await page.mouse.move(x, y, steps=steps)
elif event == "down":
await page.mouse.move(x, y)
await page.mouse.down(button=button)
elif event == "up":
await page.mouse.move(x, y)
await page.mouse.up(button=button)
elif event == "click":
await page.mouse.click(x, y, button=button, delay=50)
elif event == "wheel":
delta_y = float(msg.get("delta_y", 0))
await page.mouse.wheel(0, delta_y)
return
if mtype == "KEY":
event = msg.get("event")
key = msg.get("key", "")
text = msg.get("text", "")
if event == "down" and key:
await page.keyboard.down(key)
elif event == "up" and key:
await page.keyboard.up(key)
elif event == "type" and text:
await page.keyboard.type(text)
return
manager = SessionManager()
+71 -71
View File
@@ -1,71 +1,71 @@
# 1Panel OpenResty — Nexus reverse proxy (paste into site custom config)
# Domain: api.synaglobal.vip
# Upstream: Nexus Docker on 127.0.0.1:8600 (docker-compose.prod.yml)
#
# SSL: configure in 1Panel UI (Let's Encrypt). This snippet assumes HTTPS server block.
upstream nexus_api {
server 127.0.0.1:8600;
keepalive 32;
}
# WebSocket log without query string (JWT must not hit access_log)
log_format nexus_ws '$remote_addr - [$time_local] "$request_method $uri" $status $body_bytes_sent';
client_max_body_size 500m;
client_body_timeout 600s;
location /api/ {
proxy_pass http://nexus_api;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 600s;
proxy_send_timeout 600s;
proxy_connect_timeout 10s;
}
location /ws/ {
access_log /www/sites/api.synaglobal.vip/log/ws.log nexus_ws;
proxy_pass http://nexus_api;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
location /health {
proxy_pass http://nexus_api;
proxy_set_header Host $host;
}
location /app/ {
proxy_pass http://nexus_api;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /agent/ {
proxy_pass http://nexus_api;
proxy_set_header Host $host;
}
# Fallback: install mode or misc routes served by FastAPI
location / {
proxy_pass http://nexus_api;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# 1Panel OpenResty — Nexus reverse proxy (paste into site custom config)
# Domain: api.synaglobal.vip
# Upstream: Nexus Docker on 127.0.0.1:8600 (docker-compose.prod.yml)
#
# SSL: configure in 1Panel UI (Let's Encrypt). This snippet assumes HTTPS server block.
upstream nexus_api {
server 127.0.0.1:8600;
keepalive 32;
}
# WebSocket log without query string (JWT must not hit access_log)
log_format nexus_ws '$remote_addr - [$time_local] "$request_method $uri" $status $body_bytes_sent';
client_max_body_size 500m;
client_body_timeout 600s;
location /api/ {
proxy_pass http://nexus_api;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 600s;
proxy_send_timeout 600s;
proxy_connect_timeout 10s;
}
location /ws/ {
access_log /www/sites/api.synaglobal.vip/log/ws.log nexus_ws;
proxy_pass http://nexus_api;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
location /health {
proxy_pass http://nexus_api;
proxy_set_header Host $host;
}
location /app/ {
proxy_pass http://nexus_api;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /agent/ {
proxy_pass http://nexus_api;
proxy_set_header Host $host;
}
# Fallback: install mode or misc routes served by FastAPI
location / {
proxy_pass http://nexus_api;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
+272
View File
@@ -0,0 +1,272 @@
#!/usr/bin/env bash
# Apply a Nexus release tarball on the production host.
#
# Usage:
# sudo bash deploy/apply-release-bundle.sh \
# --tarball /tmp/nexus-release-20260708.tar.gz \
# --sha256 6474d58fdccde668b4c91d5e7454fa08e01e20660e3bcbb363eeadbb7c2346a1 \
# --deploy-path /opt/nexus
#
# Validate only (no backup, no rsync, no rebuild):
# bash deploy/apply-release-bundle.sh --validate-only \
# --tarball /tmp/nexus-release-20260708.tar.gz \
# --sha256 6474d58fdccde668b4c91d5e7454fa08e01e20660e3bcbb363eeadbb7c2346a1
#
# This script intentionally preserves runtime secrets and data:
# - .env
# - docker/.env.prod
# - web/data
set -euo pipefail
TARBALL=""
EXPECTED_SHA256=""
DEPLOY_PATH="${NEXUS_DEPLOY_PATH:-/opt/nexus}"
BACKUP_DIR="${NEXUS_BACKUP_DIR:-/opt/nexus-backups}"
SKIP_REBUILD=0
DRY_RUN=0
VALIDATE_ONLY=0
APPLY_TMP=""
info() { echo -e "\033[0;32m[INFO]\033[0m $*"; }
warn() { echo -e "\033[1;33m[WARN]\033[0m $*"; }
error() { echo -e "\033[0;31m[ERROR]\033[0m $*" >&2; }
usage() {
sed -n '1,26p' "$0"
}
while [[ $# -gt 0 ]]; do
case "$1" in
--tarball)
TARBALL="${2:-}"
shift 2
;;
--sha256)
EXPECTED_SHA256="${2:-}"
shift 2
;;
--deploy-path)
DEPLOY_PATH="${2:-}"
shift 2
;;
--backup-dir)
BACKUP_DIR="${2:-}"
shift 2
;;
--skip-rebuild)
SKIP_REBUILD=1
shift
;;
--dry-run)
DRY_RUN=1
shift
;;
--validate-only)
VALIDATE_ONLY=1
shift
;;
-h|--help)
usage
exit 0
;;
*)
error "Unknown argument: $1"
usage
exit 2
;;
esac
done
require_cmd() {
command -v "$1" >/dev/null 2>&1 || {
error "Missing required command: $1"
exit 127
}
}
require_runtime_cmds() {
require_cmd tar
require_cmd sha256sum
if [[ "$VALIDATE_ONLY" == 1 ]]; then
return 0
fi
require_cmd rsync
require_cmd curl
}
run() {
echo "+ $*"
if [[ "$DRY_RUN" == 1 ]]; then
return 0
fi
"$@"
}
cleanup() {
if [[ -n "${APPLY_TMP:-}" && -d "$APPLY_TMP" ]]; then
rm -rf "$APPLY_TMP"
fi
}
detect_runtime() {
if [[ -f "${DEPLOY_PATH}/docker/.env.prod" ]] && command -v docker >/dev/null 2>&1; then
echo docker
return
fi
if command -v supervisorctl >/dev/null 2>&1 && supervisorctl status nexus >/dev/null 2>&1; then
echo supervisor
return
fi
echo unknown
}
verify_sha256() {
if [[ -z "$EXPECTED_SHA256" ]]; then
warn "No --sha256 provided; skipping checksum verification"
return 0
fi
local actual
actual="$(sha256sum "$TARBALL" | awk '{print $1}')"
if [[ "$actual" != "$EXPECTED_SHA256" ]]; then
error "SHA256 mismatch"
error "expected: $EXPECTED_SHA256"
error "actual: $actual"
exit 1
fi
info "SHA256 OK: $actual"
}
validate_extracted_tree() {
local src="$1"
for required in server deploy Dockerfile.prod frontend-v2 web/app-v2; do
if [[ ! -e "${src}/${required}" ]]; then
error "Release bundle missing required path: ${required}"
exit 1
fi
done
for forbidden in .env docker/.env.prod web/data .venv-py312-codex frontend-v2/node_modules 2025.2 =2025.2; do
if [[ -e "${src}/${forbidden}" ]]; then
error "Release bundle contains forbidden path: ${forbidden}"
exit 1
fi
done
info "Extracted release tree validation OK"
}
backup_current() {
if [[ ! -d "$DEPLOY_PATH" ]]; then
warn "Deploy path does not exist yet: $DEPLOY_PATH"
return 0
fi
local stamp backup
stamp="$(date +%Y%m%d-%H%M%S)"
backup="${BACKUP_DIR}/nexus-before-${stamp}.tar.gz"
run mkdir -p "$BACKUP_DIR"
info "Backup current deploy path -> $backup"
run tar czf "$backup" \
--exclude='./web/data' \
--exclude='./.venv*' \
--exclude='./venv' \
--exclude='./frontend/node_modules' \
--exclude='./frontend-v2/node_modules' \
-C "$(dirname "$DEPLOY_PATH")" "$(basename "$DEPLOY_PATH")"
}
sync_release() {
local src="$1"
run mkdir -p "$DEPLOY_PATH"
info "Sync release tree -> $DEPLOY_PATH"
run rsync -a --delete \
--exclude='.env' \
--exclude='docker/.env.prod' \
--exclude='web/data' \
"${src}/" "${DEPLOY_PATH}/"
}
rebuild_or_restart() {
if [[ "$SKIP_REBUILD" == 1 ]]; then
warn "--skip-rebuild set; not rebuilding/restarting Nexus"
return 0
fi
local runtime
runtime="$(detect_runtime)"
info "Runtime: $runtime"
case "$runtime" in
docker)
if [[ -x "${DEPLOY_PATH}/deploy/nexus-1panel.sh" || -f "${DEPLOY_PATH}/deploy/nexus-1panel.sh" ]]; then
run env NEXUS_ROOT="$DEPLOY_PATH" bash "${DEPLOY_PATH}/deploy/nexus-1panel.sh" upgrade --skip-git
else
error "Docker runtime detected but deploy/nexus-1panel.sh missing"
exit 1
fi
if [[ -f "${DEPLOY_PATH}/deploy/sync_webapp_to_container.sh" ]]; then
run env NEXUS_ROOT="$DEPLOY_PATH" bash "${DEPLOY_PATH}/deploy/sync_webapp_to_container.sh"
fi
;;
supervisor)
run supervisorctl restart nexus
;;
*)
error "Unknown runtime. Rebuild/restart manually."
exit 1
;;
esac
}
verify_local_endpoints() {
local port health app app_v2
port="$(grep -E '^NEXUS_PUBLISH_PORT=' "${DEPLOY_PATH}/docker/.env.prod" 2>/dev/null | head -1 | cut -d= -f2- | tr -d '\r" ' || true)"
port="${port:-8600}"
health="$(curl -s "http://127.0.0.1:${port}/health" 2>/dev/null || true)"
app="$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:${port}/app/" 2>/dev/null || echo 000)"
app_v2="$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:${port}/app-v2/" 2>/dev/null || echo 000)"
echo " /health -> ${health}"
echo " /app/ -> ${app}"
echo " /app-v2/ -> ${app_v2}"
if [[ "$health" != "ok" || "$app" != "200" || "$app_v2" != "200" ]]; then
error "Local endpoint verification failed"
exit 1
fi
info "Local endpoint verification OK"
}
main() {
if [[ -z "$TARBALL" ]]; then
error "--tarball is required"
usage
exit 2
fi
[[ -f "$TARBALL" ]] || {
error "Tarball not found: $TARBALL"
exit 1
}
require_runtime_cmds
info "Tarball: $TARBALL"
info "Deploy path: $DEPLOY_PATH"
info "Backup dir: $BACKUP_DIR"
verify_sha256
local extracted
APPLY_TMP="$(mktemp -d /tmp/nexus-release-apply.XXXXXX)"
trap cleanup EXIT
run tar xzf "$TARBALL" -C "$APPLY_TMP"
extracted="${APPLY_TMP}/nexus-release"
validate_extracted_tree "$extracted"
if [[ "$VALIDATE_ONLY" == 1 ]]; then
info "Validate-only mode: tarball checksum and content validation passed"
return 0
fi
backup_current
sync_release "$extracted"
rebuild_or_restart
if [[ "$DRY_RUN" != 1 && "$SKIP_REBUILD" != 1 ]]; then
verify_local_endpoints
fi
info "Release applied successfully"
}
main "$@"
+69 -26
View File
@@ -1,16 +1,19 @@
#!/usr/bin/env bash
# Full production deploy: push (optional) + backend upgrade + frontend + health
# Full production deploy: local rsync + backend upgrade + frontend + health
#
# 自动识别远程 Docker/opt/nexus)或 Supervisor/www/wwwroot/...)运行时
# 默认从本机工作区 rsync 到生产机,不 push Gitea、不远程 git pull
#
# Prerequisites on YOUR machine:
# - SSH: ~/.ssh/config Host nexus OR export NEXUS_SSH="azureuser@20.24.218.235"
# - rsync, SSH Host nexus OR export NEXUS_SSH="azureuser@20.24.218.235"
# - Key: export NEXUS_SSH_KEY=~/.ssh/id_rsa_nexus
#
# Usage:
# cd "$NEXUS_ROOT"
# bash deploy/pre_deploy_check.sh
# bash deploy/deploy-production.sh
#
# 仍走 Gitea(仅当显式设置):
# NEXUS_DEPLOY_VIA_GIT=1 bash deploy/deploy-production.sh
set -euo pipefail
@@ -31,8 +34,15 @@ fi
ssh_cmd() { ssh "${SSH_OPTS[@]}" "${NEXUS_SSH_HOST}" "$@"; }
scp_cmd() { scp "${SSH_OPTS[@]}" "$@"; }
DEPLOY_VIA_GIT="${NEXUS_DEPLOY_VIA_GIT:-0}"
case "${DEPLOY_VIA_GIT}" in
1|true|yes|on) DEPLOY_VIA_GIT=1 ;;
*) DEPLOY_VIA_GIT=0 ;;
esac
echo "═══ Nexus production deploy ═══"
echo "SSH target: ${NEXUS_SSH_HOST}"
echo "Mode: $([[ "$DEPLOY_VIA_GIT" == 1 ]] && echo 'Gitea git pull' || echo 'local rsync')"
if ! ssh_cmd "echo SSH OK" >/dev/null 2>&1; then
echo "ERROR: Cannot SSH to ${NEXUS_SSH_HOST}" >&2
@@ -94,38 +104,69 @@ 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"
echo "▶ Sync host web/app into container..."
echo "▶ Frontend V2: local vite build:safe + upload to host web/app-v2..."
cd frontend-v2 && npm run build:safe && cd ..
tar czf /tmp/nexus-frontend-v2.tar.gz -C web/app-v2 index.html assets/
scp_cmd /tmp/nexus-frontend-v2.tar.gz "${NEXUS_SSH_HOST}:/tmp/nexus-frontend-v2.tar.gz"
ssh_cmd "sudo mkdir -p ${DEPLOY_PATH}/web/app-v2 && cd ${DEPLOY_PATH}/web/app-v2 && sudo tar xzf /tmp/nexus-frontend-v2.tar.gz && rm /tmp/nexus-frontend-v2.tar.gz && sudo chown -R ${DEPLOY_CHOWN} ${DEPLOY_PATH}/web/app-v2"
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 and web/app-v2 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
}
@@ -136,9 +177,11 @@ PORT="$(ssh_cmd "grep -E '^NEXUS_PUBLISH_PORT=' ${DEPLOY_PATH}/docker/.env.prod
PORT="${PORT:-8600}"
HEALTH=$(ssh_cmd "curl -s http://127.0.0.1:${PORT}/health" 2>/dev/null || true)
SPA=$(ssh_cmd "curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:${PORT}/app/" 2>/dev/null || echo "000")
SPA_V2=$(ssh_cmd "curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:${PORT}/app-v2/" 2>/dev/null || echo "000")
echo " /health → ${HEALTH}"
echo " /app/ → ${SPA}"
if [[ "${HEALTH}" == "ok" && "${SPA}" == "200" ]]; then
echo " /app-v2/ → ${SPA_V2}"
if [[ "${HEALTH}" == "ok" && "${SPA}" == "200" && "${SPA_V2}" == "200" ]]; then
echo "✓ Deploy successful"
if ssh_cmd "command -v crontab >/dev/null && crontab -l 2>/dev/null | grep -q nexus-health-monitor" 2>/dev/null; then
echo "✓ Ops cron (health_monitor) already installed"
+895
View File
@@ -2087,3 +2087,898 @@
{"ts":"2026-06-13T01:45:17+08:00","date":"2026-06-13","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-13T01:45:17+08:00","date":"2026-06-13","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-13T01:45:17+08:00","date":"2026-06-13","gate":"summary","result":"PASS","detail":"7/7"}
{"ts":"2026-06-13T02:05:03+08:00","date":"2026-06-13","gate":"changelog","result":"PASS","detail":"2026-06-13-telegram-chat-id-group-detect.md 29lines"}
{"ts":"2026-06-13T02:05:03+08:00","date":"2026-06-13","gate":"audit","result":"PASS","detail":"2026-06-13-offline-quick-cmd-telegram.md"}
{"ts":"2026-06-13T02:05:03+08:00","date":"2026-06-13","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-13T02:05:03+08:00","date":"2026-06-13","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-13T02:05:03+08:00","date":"2026-06-13","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-13T02:05:03+08:00","date":"2026-06-13","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-13T02:05:03+08:00","date":"2026-06-13","gate":"review","result":"BLOCK","detail":"audit missing changed files"}
{"ts":"2026-06-13T02:05:03+08:00","date":"2026-06-13","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-13T02:05:03+08:00","date":"2026-06-13","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-13T02:05:03+08:00","date":"2026-06-13","gate":"summary","result":"BLOCK","detail":"6/7"}
{"ts":"2026-06-13T02:05:27+08:00","date":"2026-06-13","gate":"changelog","result":"PASS","detail":"2026-06-13-telegram-chat-id-group-detect.md 29lines"}
{"ts":"2026-06-13T02:05:27+08:00","date":"2026-06-13","gate":"audit","result":"PASS","detail":"2026-06-13-telegram-chat-id-group-detect.md"}
{"ts":"2026-06-13T02:05:27+08:00","date":"2026-06-13","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-13T02:05:27+08:00","date":"2026-06-13","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-13T02:05:27+08:00","date":"2026-06-13","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-13T02:05:27+08:00","date":"2026-06-13","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-13T02:05:27+08:00","date":"2026-06-13","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-06-13T02:05:27+08:00","date":"2026-06-13","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-13T02:05:27+08:00","date":"2026-06-13","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-13T02:05:27+08:00","date":"2026-06-13","gate":"summary","result":"PASS","detail":"7/7"}
{"ts":"2026-06-13T09:08:25+08:00","date":"2026-06-13","gate":"changelog","result":"PASS","detail":"2026-06-13-telegram-chat-id-group-detect.md 29lines"}
{"ts":"2026-06-13T09:08:25+08:00","date":"2026-06-13","gate":"audit","result":"PASS","detail":"2026-06-13-telegram-chat-id-group-detect.md"}
{"ts":"2026-06-13T09:08:25+08:00","date":"2026-06-13","gate":"test","result":"BLOCK","detail":"tests failed"}
{"ts":"2026-06-13T09:08:25+08:00","date":"2026-06-13","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-13T09:08:25+08:00","date":"2026-06-13","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-13T09:08:25+08:00","date":"2026-06-13","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-13T09:08:25+08:00","date":"2026-06-13","gate":"review","result":"BLOCK","detail":"audit missing changed files"}
{"ts":"2026-06-13T09:08:25+08:00","date":"2026-06-13","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-13T09:08:25+08:00","date":"2026-06-13","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-13T09:08:25+08:00","date":"2026-06-13","gate":"summary","result":"BLOCK","detail":"5/7"}
{"ts":"2026-06-13T09:09:16+08:00","date":"2026-06-13","gate":"changelog","result":"PASS","detail":"2026-06-13-files-new-file-fix.md 30lines"}
{"ts":"2026-06-13T09:09:16+08:00","date":"2026-06-13","gate":"audit","result":"PASS","detail":"2026-06-13-files-new-file-fix.md"}
{"ts":"2026-06-13T09:09:16+08:00","date":"2026-06-13","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-13T09:09:16+08:00","date":"2026-06-13","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-13T09:09:16+08:00","date":"2026-06-13","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-13T09:09:16+08:00","date":"2026-06-13","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-13T09:09:16+08:00","date":"2026-06-13","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-06-13T09:09:16+08:00","date":"2026-06-13","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-13T09:09:16+08:00","date":"2026-06-13","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-13T09:09:16+08:00","date":"2026-06-13","gate":"summary","result":"PASS","detail":"7/7"}
{"ts":"2026-06-13T09:49:22+08:00","date":"2026-06-13","gate":"changelog","result":"PASS","detail":"2026-06-13-files-new-file-fix.md 30lines"}
{"ts":"2026-06-13T09:49:22+08:00","date":"2026-06-13","gate":"audit","result":"PASS","detail":"2026-06-13-files-new-file-fix.md"}
{"ts":"2026-06-13T09:49:22+08:00","date":"2026-06-13","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-13T09:49:22+08:00","date":"2026-06-13","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-13T09:49:22+08:00","date":"2026-06-13","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-13T09:49:22+08:00","date":"2026-06-13","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-13T09:49:22+08:00","date":"2026-06-13","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-06-13T09:49:22+08:00","date":"2026-06-13","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-13T09:49:22+08:00","date":"2026-06-13","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-13T09:49:22+08:00","date":"2026-06-13","gate":"summary","result":"PASS","detail":"7/7"}
{"ts":"2026-06-13T14:12:12+08:00","date":"2026-06-13","gate":"changelog","result":"PASS","detail":"2026-06-13-watch-slot-hardware-specs.md 34lines"}
{"ts":"2026-06-13T14:12:12+08:00","date":"2026-06-13","gate":"audit","result":"PASS","detail":"2026-06-13-watch-slot-hardware-specs.md"}
{"ts":"2026-06-13T14:12:12+08:00","date":"2026-06-13","gate":"test","result":"BLOCK","detail":"tests failed"}
{"ts":"2026-06-13T14:12:12+08:00","date":"2026-06-13","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-13T14:12:12+08:00","date":"2026-06-13","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-13T14:12:12+08:00","date":"2026-06-13","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-13T14:12:12+08:00","date":"2026-06-13","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-06-13T14:12:12+08:00","date":"2026-06-13","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-13T14:12:12+08:00","date":"2026-06-13","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-13T14:12:12+08:00","date":"2026-06-13","gate":"summary","result":"BLOCK","detail":"6/7"}
{"ts":"2026-06-13T14:12:37+08:00","date":"2026-06-13","gate":"changelog","result":"PASS","detail":"2026-06-13-watch-slot-hardware-specs.md 34lines"}
{"ts":"2026-06-13T14:12:37+08:00","date":"2026-06-13","gate":"audit","result":"PASS","detail":"2026-06-13-watch-slot-hardware-specs.md"}
{"ts":"2026-06-13T14:12:37+08:00","date":"2026-06-13","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-13T14:12:37+08:00","date":"2026-06-13","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-13T14:12:37+08:00","date":"2026-06-13","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-13T14:12:37+08:00","date":"2026-06-13","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-13T14:12:37+08:00","date":"2026-06-13","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-06-13T14:12:37+08:00","date":"2026-06-13","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-13T14:12:37+08:00","date":"2026-06-13","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-13T14:12:37+08:00","date":"2026-06-13","gate":"summary","result":"PASS","detail":"7/7"}
{"ts":"2026-06-13T16:10:32+08:00","date":"2026-06-13","gate":"changelog","result":"PASS","detail":"2026-06-13-ssh-watch-probe-quote-fix.md 22lines"}
{"ts":"2026-06-13T16:10:32+08:00","date":"2026-06-13","gate":"audit","result":"PASS","detail":"2026-06-13-ssh-watch-probe-quote-fix.md"}
{"ts":"2026-06-13T16:10:32+08:00","date":"2026-06-13","gate":"test","result":"BLOCK","detail":"tests failed"}
{"ts":"2026-06-13T16:10:32+08:00","date":"2026-06-13","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-13T16:10:32+08:00","date":"2026-06-13","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-13T16:10:32+08:00","date":"2026-06-13","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-13T16:10:32+08:00","date":"2026-06-13","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-06-13T16:10:32+08:00","date":"2026-06-13","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-13T16:10:32+08:00","date":"2026-06-13","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-13T16:10:32+08:00","date":"2026-06-13","gate":"summary","result":"BLOCK","detail":"6/7"}
{"ts":"2026-06-13T16:10:53+08:00","date":"2026-06-13","gate":"changelog","result":"PASS","detail":"2026-06-13-ssh-watch-probe-quote-fix.md 22lines"}
{"ts":"2026-06-13T16:10:53+08:00","date":"2026-06-13","gate":"audit","result":"PASS","detail":"2026-06-13-ssh-watch-probe-quote-fix.md"}
{"ts":"2026-06-13T16:10:53+08:00","date":"2026-06-13","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-13T16:10:53+08:00","date":"2026-06-13","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-13T16:10:53+08:00","date":"2026-06-13","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-13T16:10:53+08:00","date":"2026-06-13","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-13T16:10:53+08:00","date":"2026-06-13","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-06-13T16:10:53+08:00","date":"2026-06-13","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-13T16:10:53+08:00","date":"2026-06-13","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-13T16:10:53+08:00","date":"2026-06-13","gate":"summary","result":"PASS","detail":"7/7"}
{"ts":"2026-06-13T16:23:12+08:00","date":"2026-06-13","gate":"changelog","result":"PASS","detail":"2026-06-13-watch-history-beijing-time.md 27lines"}
{"ts":"2026-06-13T16:23:12+08:00","date":"2026-06-13","gate":"audit","result":"PASS","detail":"2026-06-13-ssh-watch-probe-quote-fix.md"}
{"ts":"2026-06-13T16:23:12+08:00","date":"2026-06-13","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-13T16:23:12+08:00","date":"2026-06-13","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-13T16:23:12+08:00","date":"2026-06-13","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-13T16:23:12+08:00","date":"2026-06-13","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-13T16:23:12+08:00","date":"2026-06-13","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-06-13T16:23:12+08:00","date":"2026-06-13","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-13T16:23:12+08:00","date":"2026-06-13","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-13T16:23:12+08:00","date":"2026-06-13","gate":"summary","result":"PASS","detail":"7/7"}
{"ts":"2026-06-13T16:30:50+08:00","date":"2026-06-13","gate":"changelog","result":"PASS","detail":"2026-06-13-watch-history-beijing-time.md 27lines"}
{"ts":"2026-06-13T16:30:50+08:00","date":"2026-06-13","gate":"audit","result":"PASS","detail":"2026-06-13-watch-beijing-remove-browser.md"}
{"ts":"2026-06-13T16:30:50+08:00","date":"2026-06-13","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-13T16:30:50+08:00","date":"2026-06-13","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-13T16:30:50+08:00","date":"2026-06-13","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-13T16:30:50+08:00","date":"2026-06-13","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-13T16:30:50+08:00","date":"2026-06-13","gate":"review","result":"BLOCK","detail":"audit missing changed files"}
{"ts":"2026-06-13T16:30:50+08:00","date":"2026-06-13","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-13T16:30:50+08:00","date":"2026-06-13","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-13T16:30:50+08:00","date":"2026-06-13","gate":"summary","result":"BLOCK","detail":"6/7"}
{"ts":"2026-06-13T16:31:16+08:00","date":"2026-06-13","gate":"changelog","result":"PASS","detail":"2026-06-13-watch-history-beijing-time.md 27lines"}
{"ts":"2026-06-13T16:31:16+08:00","date":"2026-06-13","gate":"audit","result":"PASS","detail":"2026-06-13-watch-beijing-remove-browser.md"}
{"ts":"2026-06-13T16:31:16+08:00","date":"2026-06-13","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-13T16:31:16+08:00","date":"2026-06-13","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-13T16:31:16+08:00","date":"2026-06-13","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-13T16:31:16+08:00","date":"2026-06-13","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-13T16:31:16+08:00","date":"2026-06-13","gate":"review","result":"BLOCK","detail":"audit missing changed files"}
{"ts":"2026-06-13T16:31:16+08:00","date":"2026-06-13","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-13T16:31:16+08:00","date":"2026-06-13","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-13T16:31:16+08:00","date":"2026-06-13","gate":"summary","result":"BLOCK","detail":"6/7"}
{"ts":"2026-06-13T16:31:34+08:00","date":"2026-06-13","gate":"changelog","result":"PASS","detail":"2026-06-13-watch-history-beijing-time.md 27lines"}
{"ts":"2026-06-13T16:31:34+08:00","date":"2026-06-13","gate":"audit","result":"PASS","detail":"2026-06-13-watch-beijing-remove-browser.md"}
{"ts":"2026-06-13T16:31:34+08:00","date":"2026-06-13","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-13T16:31:34+08:00","date":"2026-06-13","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-13T16:31:34+08:00","date":"2026-06-13","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-13T16:31:34+08:00","date":"2026-06-13","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-13T16:31:34+08:00","date":"2026-06-13","gate":"review","result":"BLOCK","detail":"audit missing changed files"}
{"ts":"2026-06-13T16:31:34+08:00","date":"2026-06-13","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-13T16:31:34+08:00","date":"2026-06-13","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-13T16:31:34+08:00","date":"2026-06-13","gate":"summary","result":"BLOCK","detail":"6/7"}
{"ts":"2026-06-13T16:31:45+08:00","date":"2026-06-13","gate":"changelog","result":"PASS","detail":"2026-06-13-watch-history-beijing-time.md 27lines"}
{"ts":"2026-06-13T16:31:45+08:00","date":"2026-06-13","gate":"audit","result":"PASS","detail":"2026-06-13-watch-beijing-remove-browser.md"}
{"ts":"2026-06-13T16:31:45+08:00","date":"2026-06-13","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-13T16:31:45+08:00","date":"2026-06-13","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-13T16:31:45+08:00","date":"2026-06-13","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-13T16:31:45+08:00","date":"2026-06-13","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-13T16:31:45+08:00","date":"2026-06-13","gate":"review","result":"BLOCK","detail":"audit missing changed files"}
{"ts":"2026-06-13T16:31:45+08:00","date":"2026-06-13","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-13T16:31:45+08:00","date":"2026-06-13","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-13T16:31:45+08:00","date":"2026-06-13","gate":"summary","result":"BLOCK","detail":"6/7"}
{"ts":"2026-06-13T16:32:02+08:00","date":"2026-06-13","gate":"changelog","result":"PASS","detail":"2026-06-13-watch-history-beijing-time.md 27lines"}
{"ts":"2026-06-13T16:32:02+08:00","date":"2026-06-13","gate":"audit","result":"PASS","detail":"2026-06-13-watch-beijing-remove-browser.md"}
{"ts":"2026-06-13T16:32:02+08:00","date":"2026-06-13","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-13T16:32:02+08:00","date":"2026-06-13","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-13T16:32:02+08:00","date":"2026-06-13","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-13T16:32:02+08:00","date":"2026-06-13","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-13T16:32:02+08:00","date":"2026-06-13","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-06-13T16:32:02+08:00","date":"2026-06-13","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-13T16:32:02+08:00","date":"2026-06-13","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-13T16:32:02+08:00","date":"2026-06-13","gate":"summary","result":"PASS","detail":"7/7"}
{"ts":"2026-06-13T23:30:02+08:00","date":"2026-06-13","gate":"changelog","result":"PASS","detail":"2026-06-13-watch-ui-polish.md 30lines"}
{"ts":"2026-06-13T23:30:02+08:00","date":"2026-06-13","gate":"audit","result":"PASS","detail":"2026-06-13-watch-ui-polish.md"}
{"ts":"2026-06-13T23:30:02+08:00","date":"2026-06-13","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-13T23:30:02+08:00","date":"2026-06-13","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-13T23:30:02+08:00","date":"2026-06-13","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-13T23:30:02+08:00","date":"2026-06-13","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-13T23:30:02+08:00","date":"2026-06-13","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-06-13T23:30:02+08:00","date":"2026-06-13","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-13T23:30:02+08:00","date":"2026-06-13","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-13T23:30:02+08:00","date":"2026-06-13","gate":"summary","result":"PASS","detail":"7/7"}
{"ts":"2026-06-14T00:38:17+08:00","date":"2026-06-14","gate":"changelog","result":"BLOCK","detail":"file not found"}
{"ts":"2026-06-14T00:38:17+08:00","date":"2026-06-14","gate":"audit","result":"BLOCK","detail":"file not found"}
{"ts":"2026-06-14T00:38:17+08:00","date":"2026-06-14","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-14T00:38:17+08:00","date":"2026-06-14","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-14T00:38:17+08:00","date":"2026-06-14","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-14T00:38:17+08:00","date":"2026-06-14","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-14T00:38:17+08:00","date":"2026-06-14","gate":"review","result":"BLOCK","detail":"no audit file"}
{"ts":"2026-06-14T00:38:17+08:00","date":"2026-06-14","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-14T00:38:17+08:00","date":"2026-06-14","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-14T00:38:17+08:00","date":"2026-06-14","gate":"summary","result":"BLOCK","detail":"4/7"}
{"ts":"2026-06-14T00:38:28+08:00","date":"2026-06-14","gate":"changelog","result":"BLOCK","detail":"file not found"}
{"ts":"2026-06-14T00:38:28+08:00","date":"2026-06-14","gate":"audit","result":"BLOCK","detail":"file not found"}
{"ts":"2026-06-14T00:38:28+08:00","date":"2026-06-14","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-14T00:38:28+08:00","date":"2026-06-14","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-14T00:38:28+08:00","date":"2026-06-14","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-14T00:38:28+08:00","date":"2026-06-14","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-14T00:38:28+08:00","date":"2026-06-14","gate":"review","result":"BLOCK","detail":"no audit file"}
{"ts":"2026-06-14T00:38:28+08:00","date":"2026-06-14","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-14T00:38:28+08:00","date":"2026-06-14","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-14T00:38:44+08:00","date":"2026-06-14","gate":"changelog","result":"PASS","detail":"2026-06-14-watch-slot-paused-center-switch.md 23lines"}
{"ts":"2026-06-14T00:38:44+08:00","date":"2026-06-14","gate":"audit","result":"PASS","detail":"2026-06-14-watch-slot-paused-center-switch.md"}
{"ts":"2026-06-14T00:38:44+08:00","date":"2026-06-14","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-14T00:38:44+08:00","date":"2026-06-14","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-14T00:38:44+08:00","date":"2026-06-14","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-14T00:38:44+08:00","date":"2026-06-14","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-14T00:38:44+08:00","date":"2026-06-14","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-06-14T00:38:44+08:00","date":"2026-06-14","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-14T00:38:44+08:00","date":"2026-06-14","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-14T00:38:44+08:00","date":"2026-06-14","gate":"summary","result":"PASS","detail":"7/7"}
{"ts":"2026-06-14T02:53:05+08:00","date":"2026-06-14","gate":"changelog","result":"PASS","detail":"2026-06-14-watch-eager-probe-on-resume.md 39lines"}
{"ts":"2026-06-14T02:53:05+08:00","date":"2026-06-14","gate":"audit","result":"PASS","detail":"2026-06-14-watch-eager-probe-on-resume.md"}
{"ts":"2026-06-14T02:53:05+08:00","date":"2026-06-14","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-14T02:53:05+08:00","date":"2026-06-14","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-14T02:53:05+08:00","date":"2026-06-14","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-14T02:53:05+08:00","date":"2026-06-14","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-14T02:53:05+08:00","date":"2026-06-14","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-06-14T02:53:05+08:00","date":"2026-06-14","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-14T02:53:05+08:00","date":"2026-06-14","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-14T02:53:05+08:00","date":"2026-06-14","gate":"summary","result":"PASS","detail":"7/7"}
{"ts":"2026-06-14T03:19:48+08:00","date":"2026-06-14","gate":"changelog","result":"PASS","detail":"2026-06-14-watch-metric-ring-baota-style.md 20lines"}
{"ts":"2026-06-14T03:19:48+08:00","date":"2026-06-14","gate":"audit","result":"PASS","detail":"2026-06-14-watch-ssh-ring-baota.md"}
{"ts":"2026-06-14T03:19:48+08:00","date":"2026-06-14","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-14T03:19:48+08:00","date":"2026-06-14","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-14T03:19:48+08:00","date":"2026-06-14","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-14T03:19:48+08:00","date":"2026-06-14","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-14T03:19:48+08:00","date":"2026-06-14","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-06-14T03:19:48+08:00","date":"2026-06-14","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-14T03:19:48+08:00","date":"2026-06-14","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-14T03:19:48+08:00","date":"2026-06-14","gate":"summary","result":"PASS","detail":"7/7"}
{"ts":"2026-06-14T03:25:10+08:00","date":"2026-06-14","gate":"changelog","result":"PASS","detail":"2026-06-14-watch-metric-ring-baota-style.md 20lines"}
{"ts":"2026-06-14T03:25:10+08:00","date":"2026-06-14","gate":"audit","result":"PASS","detail":"2026-06-14-watch-ssh-ring-baota.md"}
{"ts":"2026-06-14T03:25:10+08:00","date":"2026-06-14","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-14T03:25:10+08:00","date":"2026-06-14","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-14T03:25:10+08:00","date":"2026-06-14","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-14T03:25:10+08:00","date":"2026-06-14","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-14T03:25:10+08:00","date":"2026-06-14","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-06-14T03:25:10+08:00","date":"2026-06-14","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-14T03:25:10+08:00","date":"2026-06-14","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-14T03:25:10+08:00","date":"2026-06-14","gate":"summary","result":"PASS","detail":"7/7"}
{"ts":"2026-06-14T03:34:17+08:00","date":"2026-06-14","gate":"changelog","result":"PASS","detail":"2026-06-14-watch-slot-paused-ui-polish.md 27lines"}
{"ts":"2026-06-14T03:34:17+08:00","date":"2026-06-14","gate":"audit","result":"PASS","detail":"2026-06-14-watch-slot-paused-ui-polish.md"}
{"ts":"2026-06-14T03:34:17+08:00","date":"2026-06-14","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-14T03:34:17+08:00","date":"2026-06-14","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-14T03:34:17+08:00","date":"2026-06-14","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-14T03:34:17+08:00","date":"2026-06-14","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-14T03:34:17+08:00","date":"2026-06-14","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-06-14T03:34:17+08:00","date":"2026-06-14","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-14T03:34:17+08:00","date":"2026-06-14","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-14T03:34:17+08:00","date":"2026-06-14","gate":"summary","result":"PASS","detail":"7/7"}
{"ts":"2026-06-14T04:16:26+08:00","date":"2026-06-14","gate":"changelog","result":"PASS","detail":"2026-06-14-watch-slot-paused-ui-polish.md 27lines"}
{"ts":"2026-06-14T04:16:26+08:00","date":"2026-06-14","gate":"audit","result":"PASS","detail":"2026-06-14-watch-slot-paused-ui-polish.md"}
{"ts":"2026-06-14T04:16:26+08:00","date":"2026-06-14","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-14T04:16:26+08:00","date":"2026-06-14","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-14T04:16:26+08:00","date":"2026-06-14","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-14T04:16:26+08:00","date":"2026-06-14","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-14T04:16:26+08:00","date":"2026-06-14","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-06-14T04:16:26+08:00","date":"2026-06-14","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-14T04:16:26+08:00","date":"2026-06-14","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-14T04:16:26+08:00","date":"2026-06-14","gate":"summary","result":"PASS","detail":"7/7"}
{"ts":"2026-06-14T04:20:09+08:00","date":"2026-06-14","gate":"changelog","result":"PASS","detail":"2026-06-14-layer3-health-monitor-p2-p3-ui.md 34lines"}
{"ts":"2026-06-14T04:20:09+08:00","date":"2026-06-14","gate":"audit","result":"PASS","detail":"2026-06-14-layer3-health-monitor-p2-p3-ui.md"}
{"ts":"2026-06-14T04:20:09+08:00","date":"2026-06-14","gate":"test","result":"BLOCK","detail":"tests failed"}
{"ts":"2026-06-14T04:20:09+08:00","date":"2026-06-14","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-14T04:20:09+08:00","date":"2026-06-14","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-14T04:20:09+08:00","date":"2026-06-14","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-14T04:20:09+08:00","date":"2026-06-14","gate":"review","result":"BLOCK","detail":"audit missing changed files"}
{"ts":"2026-06-14T04:20:09+08:00","date":"2026-06-14","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-14T04:20:09+08:00","date":"2026-06-14","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-14T04:20:09+08:00","date":"2026-06-14","gate":"summary","result":"BLOCK","detail":"5/7"}
{"ts":"2026-06-14T04:20:43+08:00","date":"2026-06-14","gate":"changelog","result":"PASS","detail":"2026-06-14-layer3-health-monitor-p2-p3-ui.md 35lines"}
{"ts":"2026-06-14T04:20:43+08:00","date":"2026-06-14","gate":"audit","result":"PASS","detail":"2026-06-14-layer3-health-monitor-p2-p3-ui.md"}
{"ts":"2026-06-14T04:20:43+08:00","date":"2026-06-14","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-14T04:20:43+08:00","date":"2026-06-14","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-14T04:20:43+08:00","date":"2026-06-14","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-14T04:20:43+08:00","date":"2026-06-14","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-14T04:20:43+08:00","date":"2026-06-14","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-06-14T04:20:43+08:00","date":"2026-06-14","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-14T04:20:43+08:00","date":"2026-06-14","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-14T04:20:43+08:00","date":"2026-06-14","gate":"summary","result":"PASS","detail":"7/7"}
{"ts":"2026-06-14T06:23:45+08:00","date":"2026-06-14","gate":"changelog","result":"PASS","detail":"2026-06-14-watch-baota-server-detail.md 25lines"}
{"ts":"2026-06-14T06:23:45+08:00","date":"2026-06-14","gate":"audit","result":"PASS","detail":"2026-06-14-watch-baota-server-detail.md"}
{"ts":"2026-06-14T06:23:45+08:00","date":"2026-06-14","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-14T06:23:45+08:00","date":"2026-06-14","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-14T06:23:45+08:00","date":"2026-06-14","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-14T06:23:45+08:00","date":"2026-06-14","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-14T06:23:45+08:00","date":"2026-06-14","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-06-14T06:23:45+08:00","date":"2026-06-14","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-14T06:23:45+08:00","date":"2026-06-14","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-14T06:23:45+08:00","date":"2026-06-14","gate":"summary","result":"PASS","detail":"7/7"}
{"ts":"2026-06-14T19:03:03+08:00","date":"2026-06-14","gate":"changelog","result":"PASS","detail":"2026-06-14-gate-ai-review.md 42lines"}
{"ts":"2026-06-14T19:03:03+08:00","date":"2026-06-14","gate":"audit","result":"PASS","detail":"2026-06-14-gate-ai-review.md"}
{"ts":"2026-06-14T19:03:03+08:00","date":"2026-06-14","gate":"test","result":"BLOCK","detail":"tests failed"}
{"ts":"2026-06-14T19:03:03+08:00","date":"2026-06-14","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-14T19:03:03+08:00","date":"2026-06-14","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-14T19:03:03+08:00","date":"2026-06-14","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-14T19:03:03+08:00","date":"2026-06-14","gate":"review","result":"BLOCK","detail":"audit missing changed files"}
{"ts":"2026-06-14T19:03:03+08:00","date":"2026-06-14","gate":"ai_review","result":"PASS","detail":"skipped no code"}
{"ts":"2026-06-14T19:03:03+08:00","date":"2026-06-14","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-14T19:03:03+08:00","date":"2026-06-14","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-14T19:03:03+08:00","date":"2026-06-14","gate":"summary","result":"BLOCK","detail":"6/8"}
{"ts":"2026-06-17T09:37:14+08:00","date":"2026-06-17","gate":"changelog","result":"PASS","detail":"2026-06-17-terminal-ui-deploy.md 34lines"}
{"ts":"2026-06-17T09:37:14+08:00","date":"2026-06-17","gate":"audit","result":"PASS","detail":"2026-06-17-terminal-ui-deploy.md"}
{"ts":"2026-06-17T09:37:14+08:00","date":"2026-06-17","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-17T09:37:14+08:00","date":"2026-06-17","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-17T09:37:14+08:00","date":"2026-06-17","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-17T09:37:14+08:00","date":"2026-06-17","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-17T09:37:14+08:00","date":"2026-06-17","gate":"review","result":"BLOCK","detail":"audit missing changed files"}
{"ts":"2026-06-17T09:37:14+08:00","date":"2026-06-17","gate":"ai_review","result":"BLOCK","detail":"see gate_ai_review.py output"}
{"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"}
+145 -16
View File
@@ -9,12 +9,64 @@ set -euo pipefail
DEPLOY_DIR="${NEXUS_DEPLOY_DIR:-/opt/nexus}"
NEXUS_SERVICE="${NEXUS_SERVICE:-nexus}"
FAIL_FILE="/tmp/nexus_health_fail_count"
LAST_RESTART_FILE="/tmp/nexus_health_last_restart"
STATE_DIR=""
FAIL_FILE=""
LAST_RESTART_FILE=""
# Legacy paths (cron user may have created root-owned files under /tmp)
LEGACY_FAIL_FILE="/tmp/nexus_health_fail_count"
LEGACY_LAST_RESTART_FILE="/tmp/nexus_health_last_restart"
MAX_FAIL=3
RESTART_COOLDOWN_SEC="${NEXUS_HEALTH_RESTART_COOLDOWN:-300}"
ENV_PROD="${DEPLOY_DIR}/docker/.env.prod"
ensure_state_paths() {
if [[ -n "$STATE_DIR" ]]; then
return 0
fi
local preferred="${DEPLOY_DIR}/var/layer3-health"
local fallback="${HOME:-/tmp}/.nexus/layer3-health"
if mkdir -p "$preferred" 2>/dev/null && [[ -w "$preferred" ]]; then
STATE_DIR="$preferred"
elif command -v sudo >/dev/null 2>&1 && sudo mkdir -p "$preferred" 2>/dev/null \
&& sudo chown -R "$(id -un):$(id -gn)" "$preferred" 2>/dev/null \
&& [[ -w "$preferred" ]]; then
STATE_DIR="$preferred"
else
mkdir -p "$fallback"
STATE_DIR="$fallback"
fi
FAIL_FILE="${STATE_DIR}/fail_count"
LAST_RESTART_FILE="${STATE_DIR}/last_restart"
}
docker_cmd() {
if docker "$@" 2>/dev/null; then
return 0
fi
if command -v sudo >/dev/null 2>&1; then
sudo docker "$@" 2>/dev/null
else
return 1
fi
}
init_layer3_state() {
ensure_state_paths
chmod 755 "$STATE_DIR" 2>/dev/null || true
if [[ ! -f "$FAIL_FILE" && -f "$LEGACY_FAIL_FILE" ]]; then
cp "$LEGACY_FAIL_FILE" "$FAIL_FILE" 2>/dev/null || true
fi
if [[ ! -f "$LAST_RESTART_FILE" && -f "$LEGACY_LAST_RESTART_FILE" ]]; then
cp "$LEGACY_LAST_RESTART_FILE" "$LAST_RESTART_FILE" 2>/dev/null || true
fi
}
write_state_file() {
local path="$1" value="$2"
init_layer3_state
printf '%s\n' "$value" >"$path" 2>/dev/null || return 1
}
resolve_publish_port() {
local port
port="$(grep -E '^NEXUS_PUBLISH_PORT=' "$ENV_PROD" 2>/dev/null | head -1 | cut -d= -f2- | tr -d '\r" ' || true)"
@@ -25,7 +77,7 @@ PUBLISH_PORT="$(resolve_publish_port)"
HEALTH_URL="http://127.0.0.1:${PUBLISH_PORT}/health"
resolve_nexus_container() {
docker ps --format '{{.Names}}' 2>/dev/null | grep -E 'nexus-prod-nexus|nexus-nexus-1' | head -1 || true
docker_cmd ps --format '{{.Names}}' 2>/dev/null | grep -E 'nexus-prod-nexus|nexus-nexus-1' | head -1 || true
}
read_env_var() {
@@ -50,7 +102,7 @@ load_telegram_from_db() {
[[ -n "$cname" ]] || return 1
[[ -n "${BOT_TOKEN:-}" && -n "${CHAT_ID:-}" ]] && return 0
local out token chat
out="$(docker exec "$cname" python3 -c "
out="$(docker_cmd exec "$cname" python3 -c "
import asyncio
from server.config import settings as s
from server.infrastructure.database.session import AsyncSessionLocal
@@ -94,7 +146,7 @@ load_telegram_config() {
NEXUS_TELEGRAM_BOT_TOKEN=*) BOT_TOKEN="${line#NEXUS_TELEGRAM_BOT_TOKEN=}" ;;
NEXUS_TELEGRAM_CHAT_ID=*) CHAT_ID="${line#NEXUS_TELEGRAM_CHAT_ID=}" ;;
esac
done < <(docker exec "$cname" grep -E '^NEXUS_TELEGRAM_' /app/.env 2>/dev/null || true)
done < <(docker_cmd exec "$cname" grep -E '^NEXUS_TELEGRAM_' /app/.env 2>/dev/null || true)
[[ -n "$BOT_TOKEN" && -n "$CHAT_ID" ]] && return 0
load_telegram_from_db "$cname" || true
fi
@@ -103,7 +155,7 @@ load_telegram_config() {
}
send_telegram() {
local msg="${1//\\n/$'\n'}" resp http log="${NEXUS_HEALTH_LOG:-/var/log/nexus_health.log}"
local msg="${1}" resp http log="${NEXUS_HEALTH_LOG:-/var/log/nexus_health.log}"
BOT_TOKEN="$(normalize_env_value "${BOT_TOKEN:-}")"
CHAT_ID="$(normalize_env_value "${CHAT_ID:-}")"
[[ -n "$BOT_TOKEN" && -n "$CHAT_ID" ]] || return 1
@@ -121,24 +173,25 @@ send_telegram() {
bump_fail_count() {
local fail=0
init_layer3_state
if command -v flock >/dev/null 2>&1; then
(
flock -x 9
fail="$(cat "$FAIL_FILE" 2>/dev/null || echo 0)"
fail=$((fail + 1))
echo "$fail" > "$FAIL_FILE"
write_state_file "$FAIL_FILE" "$fail" || true
) 9>"${FAIL_FILE}.lock"
fail="$(cat "$FAIL_FILE" 2>/dev/null || echo 0)"
else
fail="$(cat "$FAIL_FILE" 2>/dev/null || echo 0)"
fail=$((fail + 1))
echo "$fail" > "$FAIL_FILE"
write_state_file "$FAIL_FILE" "$fail" || true
fi
echo "$fail"
}
reset_fail_count() {
echo "0" > "$FAIL_FILE"
write_state_file "$FAIL_FILE" "0" || true
}
restart_cooldown_active() {
@@ -151,13 +204,13 @@ restart_cooldown_active() {
}
mark_restart_attempt() {
date +%s > "$LAST_RESTART_FILE"
write_state_file "$LAST_RESTART_FILE" "$(date +%s)" || true
}
restart_nexus() {
local cname="$1" via=""
if [[ -n "$cname" ]]; then
docker restart "$cname" >/dev/null 2>&1 && via="Docker 容器 ${cname}"
docker_cmd restart "$cname" >/dev/null 2>&1 && via="Docker 容器 ${cname}"
elif command -v supervisorctl >/dev/null 2>&1 && supervisorctl status "$NEXUS_SERVICE" >/dev/null 2>&1; then
supervisorctl restart "$NEXUS_SERVICE" >/dev/null 2>&1 && via="Supervisor ${NEXUS_SERVICE}"
else
@@ -166,8 +219,84 @@ restart_nexus() {
echo "$via"
}
telegram_health_fail_cooldown() {
local body
body="$(cat <<EOF
🔴 <b>【Layer 3 宿主机巡检】健康检查连续失败(冷却期内)</b>
宿主机:${HOST_LABEL}
检测地址:${HEALTH_URL}
连续失败:已达 ${MAX_FAIL} 次
Docker 容器:${NEXUS_CONTAINER:-未识别}
具体情况:/health 仍不可达,但距上次自动重启不足 ${RESTART_COOLDOWN_SEC} 秒,本次跳过重启以免频繁抖动
建议操作:查看 docker logs ${NEXUS_CONTAINER:-nexus} 与 ${NEXUS_HEALTH_LOG:-/var/log/nexus_health.log}
时间:${TS_LABEL}
EOF
)"
send_telegram "$body" || true
}
telegram_health_fail_restart() {
local body
body="$(cat <<EOF
🔴 <b>【Layer 3 宿主机巡检】Nexus 连续 ${MAX_FAIL} 次无响应</b>
宿主机:${HOST_LABEL}
检测地址:${HEALTH_URL}
连续失败:${FAIL} 次(阈值 ${MAX_FAIL} 次)
Docker 容器:${NEXUS_CONTAINER:-未识别}
具体情况:宿主机 cron 无法访问 API 健康端点,平台可能已宕机或端口异常
正在执行:自动重启 Nexus 服务…
时间:${TS_LABEL}
EOF
)"
send_telegram "$body" || true
}
telegram_health_recovered() {
local via="$1" body
body="$(cat <<EOF
🟢 <b>【Layer 3 宿主机巡检】Nexus 已自动恢复</b>
宿主机:${HOST_LABEL}
检测地址:${HEALTH_URL}
恢复方式:${via}
具体情况:自动重启后 /health 检测通过,服务已恢复在线
时间:${TS_LABEL}
EOF
)"
send_telegram "$body" || true
}
telegram_health_still_down() {
local via="$1" body
body="$(cat <<EOF
🔴 <b>【Layer 3 宿主机巡检】自动重启后仍未恢复</b>
宿主机:${HOST_LABEL}
检测地址:${HEALTH_URL}
已尝试:${via}
具体情况:重启完成后 /health 仍失败,可能存在数据库/Redis 故障或配置错误
建议操作:立即 SSH 登录排查 docker logs、MySQL、Redis,需人工介入
时间:${TS_LABEL}
EOF
)"
send_telegram "$body" || true
}
telegram_health_restart_failed() {
local body
body="$(cat <<EOF
🔴 <b>【Layer 3 宿主机巡检】无法执行自动重启</b>
宿主机:${HOST_LABEL}
检测地址:${HEALTH_URL}
具体情况:未找到可重启的 Docker 容器或 Supervisor 服务(${NEXUS_SERVICE}
建议操作:确认 nexus 容器名称与 docker ps 输出,手动执行 docker restart 或 nx update
时间:${TS_LABEL}
EOF
)"
send_telegram "$body" || true
}
NEXUS_CONTAINER="$(resolve_nexus_container)"
load_telegram_config "$NEXUS_CONTAINER"
init_layer3_state
HOST_LABEL="$(hostname -f 2>/dev/null || hostname 2>/dev/null || echo unknown)"
TS_LABEL="$(date '+%Y-%m-%d %H:%M:%S %Z' 2>/dev/null || date)"
@@ -182,21 +311,21 @@ if [[ "$FAIL" -lt "$MAX_FAIL" ]]; then
fi
if restart_cooldown_active; then
send_telegram "🔴 <b>【Layer 3 宿主机巡检】健康检查连续失败(冷却期内)</b>\n宿主机:${HOST_LABEL}\n检测地址:${HEALTH_URL}\n连续失败:已达 ${MAX_FAIL} 次\nDocker 容器:${NEXUS_CONTAINER:-未识别}\n具体情况:/health 仍不可达,但距上次自动重启不足 ${RESTART_COOLDOWN_SEC} 秒,本次跳过重启以免频繁抖动\n建议操作:查看 docker logs ${NEXUS_CONTAINER:-nexus} 与 /var/log/nexus_health.log\n时间:${TS_LABEL}" || true
telegram_health_fail_cooldown
exit 0
fi
send_telegram "🔴 <b>【Layer 3 宿主机巡检】Nexus 连续 ${MAX_FAIL} 次无响应</b>\n宿主机:${HOST_LABEL}\n检测地址:${HEALTH_URL}\n连续失败:${FAIL} 次(阈值 ${MAX_FAIL} 次)\nDocker 容器:${NEXUS_CONTAINER:-未识别}\n具体情况:宿主机 cron 无法访问 API 健康端点,平台可能已宕机或端口异常\n正在执行:自动重启 Nexus 服务…\n时间:${TS_LABEL}" || true
telegram_health_fail_restart
mark_restart_attempt
if VIA="$(restart_nexus "$NEXUS_CONTAINER")"; then
sleep 5
if curl -sf --max-time 5 "$HEALTH_URL" > /dev/null 2>&1; then
send_telegram "🟢 <b>【Layer 3 宿主机巡检】Nexus 已自动恢复</b>\n宿主机:${HOST_LABEL}\n检测地址:${HEALTH_URL}\n恢复方式:${VIA}\n具体情况:自动重启后 /health 检测通过,服务已恢复在线\n时间:${TS_LABEL}" || true
telegram_health_recovered "$VIA"
reset_fail_count
else
send_telegram "🔴 <b>【Layer 3 宿主机巡检】自动重启后仍未恢复</b>\n宿主机:${HOST_LABEL}\n检测地址:${HEALTH_URL}\n已尝试:${VIA}\n具体情况:重启完成后 /health 仍失败,可能存在数据库/Redis 故障或配置错误\n建议操作:立即 SSH 登录排查 docker logs、MySQL、Redis,需人工介入\n时间:${TS_LABEL}" || true
telegram_health_still_down "$VIA"
fi
else
send_telegram "🔴 <b>【Layer 3 宿主机巡检】无法执行自动重启</b>\n宿主机:${HOST_LABEL}\n检测地址:${HEALTH_URL}\n具体情况:未找到可重启的 Docker 容器或 Supervisor 服务(${NEXUS_SERVICE})\n建议操作:确认 nexus 容器名称与 docker ps 输出,手动执行 docker restart 或 nx update\n时间:${TS_LABEL}" || true
telegram_health_restart_failed
fi
Executable → Regular
View File
Executable → Regular
View File
Executable → Regular
View File
-21
View File
@@ -1,21 +0,0 @@
# 复制: cp deploy/nexus-1panel.secrets.sh.example deploy/nexus-1panel.secrets.sh
# 用于 git pushscripts/git-push.sh);clone/pull 仍可匿名
# NEXUS_GITEA_HOST='66.154.115.8:3000'
# NEXUS_GITEA_REPO='admin/Nexus.git'
# admin 账号密码或 Access Token
# NEXUS_GITEA_TOKEN='你的密码或令牌'
# 新机安装勿填下面三项(install-nexus-fresh / --fresh 会自动按机生成)
# NEXUS_SECRET_KEY=''
# NEXUS_API_KEY=''
# NEXUS_ENCRYPTION_KEY=''
NEXUS_DOMAIN='api.synaglobal.vip'
NEXUS_ROOT='/opt/nexus'
NEXUS_PROFILE='2c8g'
NEXUS_FROM_ENV='/www/wwwroot/api.synaglobal.vip/.env'
# 本机生产 SSHdeploy-production.sh 读取;私钥路径勿提交真实 .pem)
# NEXUS_SSH='azureuser@20.24.218.235'
# NEXUS_SSH_KEY='/path/to/nz-admin.pem'
Executable → Regular
+26 -5
View File
@@ -50,6 +50,7 @@ error() { echo -e "${RED}[ERROR]${NC} $*" >&2; }
step() { echo -e "${CYAN}[STEP]${NC} $*"; }
SKIP_CLONE=false
SKIP_GIT=false
FRESH_INSTALL=false
REUSE_SECRETS=false
FROM_ENV="${NEXUS_FROM_ENV}"
@@ -181,9 +182,13 @@ apply_pool_to_env_prod() {
local root="$1" prof="$2"
local env_file="$root/$ENV_PROD"
[[ -f "$env_file" ]] || return 0
if [[ ! -f "$prof" ]]; then
warn "未找到资源档位文件: $prof,保留 $ENV_PROD 中现有数据库连接池配置"
return 0
fi
local pool overflow
pool="$(grep -E '^NEXUS_DB_POOL_SIZE=' "$prof" | cut -d= -f2-)"
overflow="$(grep -E '^NEXUS_DB_MAX_OVERFLOW=' "$prof" | cut -d= -f2-)"
pool="$(grep -E '^NEXUS_DB_POOL_SIZE=' "$prof" | cut -d= -f2- || true)"
overflow="$(grep -E '^NEXUS_DB_MAX_OVERFLOW=' "$prof" | cut -d= -f2- || true)"
[[ -n "$pool" ]] && sed -i "s|^NEXUS_DB_POOL_SIZE=.*|NEXUS_DB_POOL_SIZE=${pool}|" "$env_file"
[[ -n "$overflow" ]] && sed -i "s|^NEXUS_DB_MAX_OVERFLOW=.*|NEXUS_DB_MAX_OVERFLOW=${overflow}|" "$env_file"
}
@@ -1018,10 +1023,14 @@ verify_health() {
}
ensure_repo() {
if [[ ! -d "$NEXUS_ROOT/.git" ]]; then
if [[ ! -d "$NEXUS_ROOT" ]]; then
error "未找到 $NEXUS_ROOT,请先: bash $0 install"
exit 1
fi
if [[ "$SKIP_GIT" != true && ! -d "$NEXUS_ROOT/.git" ]]; then
error "未找到 $NEXUS_ROOT/.git,请先: bash $0 install;如为发布包/rsync 部署请使用 --skip-git"
exit 1
fi
if [[ ! -f "$NEXUS_ROOT/$ENV_PROD" ]]; then
error "缺少 $NEXUS_ROOT/$ENV_PROD"
exit 1
@@ -1138,8 +1147,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 +1162,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 +1179,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 +1292,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 ;;
Executable → Regular
View File
+64 -16
View File
@@ -1,6 +1,6 @@
#!/bin/bash
# Nexus Pre-deploy Gate Check (v27 gates)
# 在部署前强制检查 7 道门控,任何一道不过则阻止部署
# Nexus Pre-deploy Gate Check (v38 gates)
# 在部署前强制检查 8 道门控,任何一道不过则阻止部署
#
# 门控1: CHANGELOG — docs/changelog/ 下必须有今天的 .md 文件(且行数≥10)
# 门控2: AUDIT — docs/audit/ 下必须有今天的审计记录(且包含关键段落)
@@ -9,6 +9,7 @@
# 门控5: IMPORT — python -c "import server.main" 成功
# 门控6: SECURITY — bandit -r server/ 无 HIGH/MEDIUM
# 门控7: REVIEW — 审计文件包含 Closure表+文件清单+DoD,且文件清单与 git diff 交叉验证
# 门控8: AI_REVIEW — cursor-agent 审查 HEAD~1..HEAD;缓存 docs/reviews/<head_sha>.json
#
# 用法: bash deploy/pre_deploy_check.sh
# 退出码: 0=全部通过, 1=至少一道门未过
@@ -22,7 +23,7 @@ DEPLOY_DIR="${NEXUS_DEPLOY_DIR:-${_REPO_ROOT}}"
TODAY=$(date +%Y-%m-%d)
TIMESTAMP=$(date -Iseconds)
GATES_PASSED=0
GATES_TOTAL=7
GATES_TOTAL=8
BLOCKED=0
GATE_RESULTS=""
@@ -53,14 +54,14 @@ gate_log() {
}
echo "========================================"
echo " Nexus Pre-deploy Gate Check (v2)"
echo " Nexus Pre-deploy Gate Check (v3)"
echo " Date: ${TODAY}"
echo " Gates: ${GATES_TOTAL}"
echo "========================================"
echo ""
# ── Gate 1: Changelog (存在 + 行数≥10) ──
echo -n "Gate 1/7: Changelog ... "
echo -n "Gate 1/8: Changelog ... "
CHANGELOG_DIR="${DEPLOY_DIR}/docs/changelog"
if ls "${CHANGELOG_DIR}/${TODAY}-"*.md 1>/dev/null 2>&1; then
FILE=$(ls -t "${CHANGELOG_DIR}/${TODAY}-"*.md 2>/dev/null | head -1)
@@ -86,7 +87,7 @@ else
fi
# ── Gate 2: Audit (存在 + 关键段落) ──
echo -n "Gate 2/7: Audit ... "
echo -n "Gate 2/8: Audit ... "
AUDIT_DIR="${DEPLOY_DIR}/docs/audit"
if ls "${AUDIT_DIR}/${TODAY}-"*.md 1>/dev/null 2>&1; then
FILE=$(ls -t "${AUDIT_DIR}/${TODAY}-"*.md 2>/dev/null | head -1)
@@ -117,7 +118,7 @@ else
fi
# ── Gate 3: Test (必须存在 + 必须通过) ──
echo -n "Gate 3/7: Test ... "
echo -n "Gate 3/8: Test ... "
TEST_SCRIPT="${DEPLOY_DIR}/tests/test_api.py"
if [ ! -f "${TEST_SCRIPT}" ]; then
echo -e "${RED}BLOCK${NC}"
@@ -130,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"
@@ -145,7 +173,7 @@ else
fi
# ── Gate 4: Lint (ruff — F only: undefined names, unused imports) ──
echo -n "Gate 4/7: Lint ... "
echo -n "Gate 4/8: Lint ... "
RUFF_BIN=$(_pick_bin ruff || true)
if [ -n "${RUFF_BIN}" ]; then
LINT_OUTPUT=$(cd "${DEPLOY_DIR}" && "${RUFF_BIN}" check server/ --select F 2>&1) || true
@@ -172,7 +200,7 @@ else
fi
# ── Gate 5: Import (python -c "import server.main") ──
echo -n "Gate 5/7: Import ... "
echo -n "Gate 5/8: Import ... "
IMPORT_PYTHON=""
for p in "${DEPLOY_DIR}/.venv/bin/python3" "${DEPLOY_DIR}/venv/bin/python3"; do
if [ -x "${p}" ]; then
@@ -207,7 +235,7 @@ else
fi
# ── Gate 6: Security (bandit — HIGH only) ──
echo -n "Gate 6/7: Security ... "
echo -n "Gate 6/8: Security ... "
BANDIT_BIN=$(_pick_bin bandit || true)
if [ -n "${BANDIT_BIN}" ]; then
SEC_OUTPUT=$(cd "${DEPLOY_DIR}" && "${BANDIT_BIN}" -r server/ -f txt -ll 2>&1) || true
@@ -234,7 +262,7 @@ else
fi
# ── Gate 7: Review (审计文件交叉验证) ──
echo -n "Gate 7/7: Review ... "
echo -n "Gate 7/8: Review ... "
if ls "${AUDIT_DIR}/${TODAY}-"*.md 1>/dev/null 2>&1; then
FILE=$(ls -t "${AUDIT_DIR}/${TODAY}-"*.md 2>/dev/null | head -1)
# 检查审计文件中是否列出了实际改动的文件
@@ -273,6 +301,26 @@ else
gate_log "review" "BLOCK" "no audit file"
fi
# ── Gate 8: AI Review (cursor-agent + docs/reviews cache) ──
echo -n "Gate 8/8: AI Review ... "
AI_REVIEW_OUT=$(cd "${DEPLOY_DIR}" && python3 "${DEPLOY_DIR}/scripts/gate_ai_review.py" 2>&1) || AI_REVIEW_RC=$?
AI_REVIEW_RC=${AI_REVIEW_RC:-0}
if [ "${AI_REVIEW_RC}" -eq 0 ]; then
echo -e "${GREEN}PASS${NC}"
echo "${AI_REVIEW_OUT}" | sed 's/^/ └─ /' | head -5
GATES_PASSED=$((GATES_PASSED + 1))
if echo "${AI_REVIEW_OUT}" | grep -qi "skip"; then
gate_log "ai_review" "PASS" "skipped no code"
else
gate_log "ai_review" "PASS" "ok"
fi
else
echo -e "${RED}BLOCK${NC}"
echo "${AI_REVIEW_OUT}" | sed 's/^/ │ /' | head -12
BLOCKED=1
gate_log "ai_review" "BLOCK" "see gate_ai_review.py output"
fi
# ── Pre-flight: shell scripts LF ──
echo -n "Pre-flight: Shell EOL (LF) ... "
if bash "${DEPLOY_DIR}/deploy/check_shell_eol.sh" >/dev/null 2>&1; then
+197
View File
@@ -0,0 +1,197 @@
#!/usr/bin/env bash
# Nexus production host preflight before applying a release tarball.
#
# Usage:
# bash deploy/preflight-release-host.sh \
# --tarball /tmp/nexus-release-20260708.tar.gz \
# --sha256 a4eb8f35f0b98d40c1ad83117bfc652e7686ffc7d55f6bf90fcfd433a2ad6701 \
# --deploy-path /opt/nexus
set -euo pipefail
TARBALL=""
EXPECTED_SHA256=""
DEPLOY_PATH="${NEXUS_DEPLOY_PATH:-/opt/nexus}"
MIN_FREE_MB="${NEXUS_RELEASE_MIN_FREE_MB:-1024}"
info() { echo -e "\033[0;32m[INFO]\033[0m $*"; }
warn() { echo -e "\033[1;33m[WARN]\033[0m $*"; }
error() { echo -e "\033[0;31m[ERROR]\033[0m $*" >&2; }
usage() {
sed -n '1,12p' "$0"
}
while [[ $# -gt 0 ]]; do
case "$1" in
--tarball)
TARBALL="${2:-}"
shift 2
;;
--sha256)
EXPECTED_SHA256="${2:-}"
shift 2
;;
--deploy-path)
DEPLOY_PATH="${2:-}"
shift 2
;;
--min-free-mb)
MIN_FREE_MB="${2:-}"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
error "Unknown argument: $1"
usage
exit 2
;;
esac
done
check_cmd() {
local cmd="$1"
if command -v "$cmd" >/dev/null 2>&1; then
info "command OK: $cmd"
return 0
fi
error "missing command: $cmd"
return 1
}
docker_cmd() {
if docker "$@" 2>/dev/null; then return 0; fi
sudo docker "$@" 2>/dev/null
}
detect_runtime() {
if [[ -f "${DEPLOY_PATH}/docker/.env.prod" ]] && command -v docker >/dev/null 2>&1; then
echo docker
return
fi
if command -v supervisorctl >/dev/null 2>&1 && supervisorctl status nexus >/dev/null 2>&1; then
echo supervisor
return
fi
echo unknown
}
verify_tarball() {
if [[ -z "$TARBALL" ]]; then
warn "--tarball not provided; skipping tarball checksum/content checks"
return 0
fi
[[ -f "$TARBALL" ]] || {
error "tarball not found: $TARBALL"
return 1
}
info "tarball exists: $TARBALL"
if [[ -n "$EXPECTED_SHA256" ]]; then
local actual
actual="$(sha256sum "$TARBALL" | awk '{print $1}')"
if [[ "$actual" != "$EXPECTED_SHA256" ]]; then
error "SHA256 mismatch: expected=$EXPECTED_SHA256 actual=$actual"
return 1
fi
info "SHA256 OK: $actual"
else
warn "--sha256 not provided; checksum not verified"
fi
tar tzf "$TARBALL" nexus-release/server nexus-release/deploy nexus-release/web/app-v2 >/dev/null
info "tarball required paths OK"
}
check_deploy_path() {
if [[ -d "$DEPLOY_PATH" ]]; then
info "deploy path exists: $DEPLOY_PATH"
else
warn "deploy path does not exist yet: $DEPLOY_PATH"
fi
if [[ -f "${DEPLOY_PATH}/.env" ]]; then
info "runtime .env preserved path exists"
else
warn "${DEPLOY_PATH}/.env not found"
fi
if [[ -f "${DEPLOY_PATH}/docker/.env.prod" ]]; then
info "docker/.env.prod exists"
else
warn "${DEPLOY_PATH}/docker/.env.prod not found"
fi
if [[ -d "${DEPLOY_PATH}/web/data" ]]; then
info "web/data exists"
else
warn "${DEPLOY_PATH}/web/data not found"
fi
}
check_disk_space() {
local target free_mb
target="$DEPLOY_PATH"
[[ -e "$target" ]] || target="$(dirname "$DEPLOY_PATH")"
free_mb="$(df -Pm "$target" | awk 'NR==2 {print $4}')"
if [[ -z "$free_mb" ]]; then
warn "unable to determine free disk space for $target"
return 0
fi
if (( free_mb < MIN_FREE_MB )); then
error "low disk space on $target: ${free_mb}MB < ${MIN_FREE_MB}MB"
return 1
fi
info "disk free OK: ${free_mb}MB >= ${MIN_FREE_MB}MB"
}
check_runtime() {
local runtime
runtime="$(detect_runtime)"
info "runtime detected: $runtime"
case "$runtime" in
docker)
docker_cmd ps --format '{{.Names}}' | grep -E 'nexus-prod-nexus|nexus-nexus-1' >/dev/null \
&& info "Nexus docker container found" \
|| warn "Nexus docker container not found by known name pattern"
;;
supervisor)
supervisorctl status nexus || true
;;
*)
warn "runtime unknown; apply script may require manual restart"
;;
esac
}
check_local_endpoint() {
local port health app app_v2
port="$(grep -E '^NEXUS_PUBLISH_PORT=' "${DEPLOY_PATH}/docker/.env.prod" 2>/dev/null | head -1 | cut -d= -f2- | tr -d '\r" ' || true)"
port="${port:-8600}"
health="$(curl -s "http://127.0.0.1:${port}/health" 2>/dev/null || true)"
app="$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:${port}/app/" 2>/dev/null || echo 000)"
app_v2="$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:${port}/app-v2/" 2>/dev/null || echo 000)"
echo " current /health -> ${health:-<empty>}"
echo " current /app/ -> ${app}"
echo " current /app-v2/ -> ${app_v2}"
}
main() {
local failures=0
for cmd in tar sha256sum df awk grep sed curl; do
check_cmd "$cmd" || failures=$((failures + 1))
done
check_cmd rsync || failures=$((failures + 1))
verify_tarball || failures=$((failures + 1))
check_deploy_path
check_disk_space || failures=$((failures + 1))
check_runtime
check_local_endpoint
if (( failures > 0 )); then
error "preflight failed: ${failures} blocking issue(s)"
return 1
fi
info "preflight OK"
}
main "$@"
Executable → Regular
View File
+112
View File
@@ -0,0 +1,112 @@
#!/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='frontend-v2/node_modules'
--exclude='node_modules'
--exclude='.venv'
--exclude='.venv-*'
--exclude='venv'
--exclude='__pycache__'
--exclude='.pytest_cache'
--exclude='.ruff_cache'
--exclude='.cursor'
--exclude='.playwright-mcp'
--exclude='.install_locked'
--exclude='.install_state.json'
--exclude='web/app/assets'
--exclude='tmp'
--exclude='2025.2'
--exclude='=2025.2'
--exclude='*.bak-'
--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})"
View File
+34 -2
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
# Sync Git-tracked web/app into the running Nexus Docker container.
# Sync Git-tracked web/app and web/app-v2 into the running Nexus Docker container.
#
# Root cause: Dockerfile.prod runs vite build inside the image; Docker layer cache
# can produce asset hashes that differ from committed web/app on the host.
@@ -51,8 +51,25 @@ verify_entry_bundle() {
info "入口校验 OK: ${bundle_path} → HTTP 200"
}
verify_app_v2_entry_bundle() {
local html bundle_path code
html="$(curl -sf "http://127.0.0.1:${NEXUS_PUBLISH_PORT}/app-v2/" 2>/dev/null || true)"
bundle_path="$(printf '%s' "$html" | sed -n 's/.*src="\(\/app-v2\/assets\/[^"]*\.js\)".*/\1/p' | head -1)"
if [[ -z "$bundle_path" ]]; then
error "/app-v2/ index.html 未解析到主 bundle"
exit 1
fi
code="$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:${NEXUS_PUBLISH_PORT}${bundle_path}" 2>/dev/null || echo 000)"
if [[ "$code" != "200" ]]; then
error "V2 主 bundle ${bundle_path} → HTTP ${code}"
exit 1
fi
info "V2 入口校验 OK: ${bundle_path} → HTTP 200"
}
main() {
local app="${NEXUS_ROOT}/web/app"
local app_v2="${NEXUS_ROOT}/web/app-v2"
if [[ ! -f "${app}/index.html" ]]; then
error "缺少 ${app}/index.html,请先 git pull"
exit 1
@@ -61,6 +78,14 @@ main() {
error "缺少 ${app}/assets"
exit 1
fi
if [[ ! -f "${app_v2}/index.html" ]]; then
error "缺少 ${app_v2}/index.html,请先构建 frontend-v2"
exit 1
fi
if [[ ! -d "${app_v2}/assets" ]]; then
error "缺少 ${app_v2}/assets"
exit 1
fi
resolve_container
@@ -74,6 +99,12 @@ main() {
docker_cmd cp "${app}/favicon.ico" "${CONTAINER}:/app/web/app/favicon.ico" 2>/dev/null || true
fi
info "同步 web/app-v2 → 容器 /app/web/app-v2 ..."
docker_cmd exec "$CONTAINER" rm -rf /app/web/app-v2/assets
docker_cmd exec "$CONTAINER" mkdir -p /app/web/app-v2
docker_cmd cp "${app_v2}/index.html" "${CONTAINER}:/app/web/app-v2/index.html"
docker_cmd cp "${app_v2}/assets" "${CONTAINER}:/app/web/app-v2/assets"
if [[ -x "${NEXUS_ROOT}/deploy/prune_frontend_assets.py" ]]; then
info "容器内 prune 未引用 assets(可选)..."
docker_cmd exec "$CONTAINER" python3 /app/deploy/prune_frontend_assets.py --dry-run 2>/dev/null || \
@@ -81,7 +112,8 @@ main() {
fi
verify_entry_bundle
info "web/app 同步完成"
verify_app_v2_entry_bundle
info "web/app 与 web/app-v2 同步完成"
}
main "$@"
View File
View File
Executable → Regular
View File
Executable → Regular
View File
+15 -8
View File
@@ -1,8 +1,8 @@
# Nexus 6.0 Docker Compose (dev / staging)
# Nexus 6.0 鈥?Docker Compose (dev / staging)
# Usage:
# cp docker/.env.example docker/.env # or: python docker/generate_env.py
# docker compose up -d --build
# open http://localhost:8600/app/
# docker compose --env-file docker/.env up -d --build
# open http://localhost:18600/app/
name: nexus
@@ -24,7 +24,7 @@ services:
retries: 8
start_period: 40s
ports:
- "${MYSQL_PUBLISH_PORT:-3306}:3306"
- "${MYSQL_PUBLISH_PORT:-13306}:3306"
redis:
image: redis:7-alpine
@@ -38,12 +38,18 @@ services:
timeout: 3s
retries: 8
ports:
- "${REDIS_PUBLISH_PORT:-6379}:6379"
- "${REDIS_PUBLISH_PORT:-16379}:6379"
nexus:
build:
context: .
dockerfile: Dockerfile
network: host
args:
DEBIAN_MIRROR: https://mirrors.aliyun.com/debian
DEBIAN_SECURITY_MIRROR: https://mirrors.aliyun.com/debian-security
PIP_INDEX_URL: https://mirrors.aliyun.com/pypi/simple/
PIP_TRUSTED_HOST: mirrors.aliyun.com
restart: unless-stopped
depends_on:
mysql:
@@ -51,7 +57,7 @@ services:
redis:
condition: service_healthy
ports:
- "${NEXUS_PUBLISH_PORT:-8600}:8600"
- "${NEXUS_PUBLISH_PORT:-18600}:8600"
environment:
MYSQL_HOST: mysql
MYSQL_PORT: "3306"
@@ -64,11 +70,11 @@ services:
NEXUS_DEPLOY_PATH: /app
NEXUS_DATABASE_URL: mysql+aiomysql://nexus:${MYSQL_PASSWORD:-nexus_dev_pass}@mysql:3306/nexus
NEXUS_REDIS_URL: redis://redis:6379/0
NEXUS_CORS_ORIGINS: ${NEXUS_CORS_ORIGINS:-http://localhost:8600,http://127.0.0.1:8600}
NEXUS_CORS_ORIGINS: ${NEXUS_CORS_ORIGINS:-http://localhost:18600,http://127.0.0.1:18600,http://localhost:8600,http://127.0.0.1:8600}
NEXUS_SECRET_KEY: ${NEXUS_SECRET_KEY:-}
NEXUS_API_KEY: ${NEXUS_API_KEY:-}
NEXUS_ENCRYPTION_KEY: ${NEXUS_ENCRYPTION_KEY:-}
NEXUS_API_BASE_URL: ${NEXUS_API_BASE_URL:-http://localhost:8600}
NEXUS_API_BASE_URL: ${NEXUS_API_BASE_URL:-http://localhost:18600}
env_file:
- path: docker/.env
required: false
@@ -87,3 +93,4 @@ volumes:
redis-data:
nexus-state:
nexus-web-data:
-18
View File
@@ -1,18 +0,0 @@
# Copy to docker/.env (or run: python docker/generate_env.py)
# Used by docker compose for nexus service — never commit docker/.env
MYSQL_ROOT_PASSWORD=change_me_root
MYSQL_PASSWORD=change_me_nexus
# Leave empty to use install wizard at http://localhost:8600/app/install.html
# Or run generate_env.py to fill these three:
NEXUS_SECRET_KEY=
NEXUS_API_KEY=
NEXUS_ENCRYPTION_KEY=
NEXUS_CORS_ORIGINS=http://localhost:8600,http://127.0.0.1:8600
NEXUS_API_BASE_URL=http://localhost:8600
# Optional host port overrides
# NEXUS_PUBLISH_PORT=8600
# MYSQL_PUBLISH_PORT=3306
-39
View File
@@ -1,39 +0,0 @@
# Production Compose env — copy to docker/.env.prod (never commit)
# Fresh install: bash deploy/install-nexus-fresh.sh (auto-generates NEXUS keys per host)
# MySQL / Redis 不在 Docker 栈 — 请在宿主机或 1Panel 自建,安装向导步骤 3 填写
# Auto-generated on fresh install (unique per server). Do NOT copy between hosts.
NEXUS_SECRET_KEY=
NEXUS_API_KEY=
NEXUS_ENCRYPTION_KEY=
# 1 = entrypoint must not write /app/.env until install wizard step 3
NEXUS_INSTALL_WIZARD_PENDING=0
# Public URL
NEXUS_CORS_ORIGINS=https://api.synaglobal.vip
NEXUS_API_BASE_URL=https://api.synaglobal.vip
# Nexus container resource profile (docker/profiles/*.env)
NEXUS_DB_POOL_SIZE=30
NEXUS_DB_MAX_OVERFLOW=20
# 1Panelnx update 自动探测并写入(安装向导步骤 3 预填)
# NEXUS_1PANEL_DB_HOST=1Panel-mysql-xxxx
# NEXUS_1PANEL_REDIS_HOST=1Panel-redis-xxxx
# 宿主机 Compose 插值/ nx update 同步用;安装向导完成后以卷内 /app/.env 为准(不经 Compose 注入)
# 1Panel 有密码时: redis://:你的密码@1Panel-redis-xxxx:6379/0
NEXUS_REDIS_URL=redis://host.docker.internal:6379/0
# 宿主机 git 仓库路径(容器内安装向导用于提示 nx cron / nx update
NEXUS_HOST_ROOT=/opt/nexus
# Optional — production probe / E2E only (host .env.prod; NOT for API runtime; never commit values)
# NEXUS_TEST_ADMIN_PASSWORD — must match admin login password
# NEXUS_PROBE_CLIENT_IP — optional; IP in login allowlist for origin probe (auto-read from DB if unset)
# NEXUS_TEST_ADMIN_PASSWORD=
# NEXUS_PROBE_CLIENT_IP=
# Optional
# NEXUS_PUBLISH_PORT=8600
+20 -20
View File
@@ -1,20 +1,20 @@
# MySQL 8.4 — Nexus production tuning for 1 vCPU / 4 GiB host (minimal)
[mysqld]
character-set-server = utf8mb4
collation-server = utf8mb4_unicode_ci
innodb_buffer_pool_size = 512M
innodb_log_file_size = 128M
max_connections = 80
innodb_read_io_threads = 2
innodb_write_io_threads = 2
table_open_cache = 1000
tmp_table_size = 32M
max_heap_table_size = 32M
[client]
default-character-set = utf8mb4
# MySQL 8.4 — Nexus production tuning for 1 vCPU / 4 GiB host (minimal)
[mysqld]
character-set-server = utf8mb4
collation-server = utf8mb4_unicode_ci
innodb_buffer_pool_size = 512M
innodb_log_file_size = 128M
max_connections = 80
innodb_read_io_threads = 2
innodb_write_io_threads = 2
table_open_cache = 1000
tmp_table_size = 32M
max_heap_table_size = 32M
[client]
default-character-set = utf8mb4
+25 -25
View File
@@ -1,25 +1,25 @@
# MySQL 8.4 — Nexus production tuning for 2 vCPU / 8 GiB host
# Mounted by docker/docker-compose.prod.yml (see docs/design/plans/2026-06-04-1panel-docker-production.md)
[mysqld]
character-set-server = utf8mb4
collation-server = utf8mb4_unicode_ci
# ~1 GiB for InnoDB (leave RAM for Nexus, Redis, 1Panel, OS)
innodb_buffer_pool_size = 1024M
innodb_log_file_size = 256M
# Nexus async pool default 30+20; leave headroom for admin/migrations
max_connections = 120
# 2 vCPU: avoid aggressive IO threads
innodb_read_io_threads = 4
innodb_write_io_threads = 4
# Sensible defaults on small VMs
table_open_cache = 2000
tmp_table_size = 64M
max_heap_table_size = 64M
[client]
default-character-set = utf8mb4
# MySQL 8.4 — Nexus production tuning for 2 vCPU / 8 GiB host
# Mounted by docker/docker-compose.prod.yml (see docs/design/plans/2026-06-04-1panel-docker-production.md)
[mysqld]
character-set-server = utf8mb4
collation-server = utf8mb4_unicode_ci
# ~1 GiB for InnoDB (leave RAM for Nexus, Redis, 1Panel, OS)
innodb_buffer_pool_size = 1024M
innodb_log_file_size = 256M
# Nexus async pool default 30+20; leave headroom for admin/migrations
max_connections = 120
# 2 vCPU: avoid aggressive IO threads
innodb_read_io_threads = 4
innodb_write_io_threads = 4
# Sensible defaults on small VMs
table_open_cache = 2000
tmp_table_size = 64M
max_heap_table_size = 64M
[client]
default-character-set = utf8mb4
+20 -20
View File
@@ -1,20 +1,20 @@
# MySQL 8.4 — Nexus production tuning for 4 vCPU / 16 GiB host
[mysqld]
character-set-server = utf8mb4
collation-server = utf8mb4_unicode_ci
innodb_buffer_pool_size = 4096M
innodb_log_file_size = 512M
max_connections = 200
innodb_read_io_threads = 8
innodb_write_io_threads = 8
table_open_cache = 4000
tmp_table_size = 128M
max_heap_table_size = 128M
[client]
default-character-set = utf8mb4
# MySQL 8.4 — Nexus production tuning for 4 vCPU / 16 GiB host
[mysqld]
character-set-server = utf8mb4
collation-server = utf8mb4_unicode_ci
innodb_buffer_pool_size = 4096M
innodb_log_file_size = 512M
max_connections = 200
innodb_read_io_threads = 8
innodb_write_io_threads = 8
table_open_cache = 4000
tmp_table_size = 128M
max_heap_table_size = 128M
[client]
default-character-set = utf8mb4
+5 -5
View File
@@ -1,5 +1,5 @@
# Host profile: 1 vCPU / 4 GiB
NEXUS_MEM_LIMIT=1g
NEXUS_CPUS=1
NEXUS_DB_POOL_SIZE=15
NEXUS_DB_MAX_OVERFLOW=10
# Host profile: 1 vCPU / 4 GiB
NEXUS_MEM_LIMIT=1g
NEXUS_CPUS=1
NEXUS_DB_POOL_SIZE=15
NEXUS_DB_MAX_OVERFLOW=10
+5 -5
View File
@@ -1,5 +1,5 @@
# Host profile: 2 vCPU / 8 GiB — default production
NEXUS_MEM_LIMIT=2g
NEXUS_CPUS=1.5
NEXUS_DB_POOL_SIZE=30
NEXUS_DB_MAX_OVERFLOW=20
# Host profile: 2 vCPU / 8 GiB — default production
NEXUS_MEM_LIMIT=2g
NEXUS_CPUS=1.5
NEXUS_DB_POOL_SIZE=30
NEXUS_DB_MAX_OVERFLOW=20
+5 -5
View File
@@ -1,5 +1,5 @@
# Host profile: 4 vCPU / 16 GiB
NEXUS_MEM_LIMIT=4g
NEXUS_CPUS=3
NEXUS_DB_POOL_SIZE=50
NEXUS_DB_MAX_OVERFLOW=30
# Host profile: 4 vCPU / 16 GiB
NEXUS_MEM_LIMIT=4g
NEXUS_CPUS=3
NEXUS_DB_POOL_SIZE=50
NEXUS_DB_MAX_OVERFLOW=30
-733
View File
@@ -1,733 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>Nexus 6.0 — 功能说明书</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#0f172a;color:#e2e8f0;line-height:1.6}
a{color:#60a5fa;text-decoration:none}
a:hover{text-decoration:underline}
/* Layout */
.wrap{max-width:960px;margin:0 auto;padding:32px 24px 80px}
/* Header */
.hero{text-align:center;padding:48px 0 40px;border-bottom:1px solid #1e293b;margin-bottom:40px}
.hero h1{font-size:2.2rem;font-weight:700;color:#f8fafc;letter-spacing:-0.02em}
.hero .sub{font-size:1rem;color:#64748b;margin-top:8px}
.badge{display:inline-block;margin:4px;padding:3px 10px;border-radius:999px;font-size:0.72rem;font-weight:600}
.badge-blue{background:#1e3a5f;color:#60a5fa;border:1px solid #2563eb55}
.badge-green{background:#14371f;color:#4ade80;border:1px solid #16a34a55}
.badge-amber{background:#3d2a00;color:#fbbf24;border:1px solid #d9770655}
.badge-red{background:#3b1212;color:#f87171;border:1px solid #dc262655}
/* TOC */
.toc{background:#1e293b;border:1px solid #334155;border-radius:12px;padding:20px 24px;margin-bottom:36px}
.toc h2{font-size:0.85rem;font-weight:700;color:#94a3b8;text-transform:uppercase;letter-spacing:.08em;margin-bottom:12px}
.toc ol{padding-left:18px;column-count:2;column-gap:24px}
.toc li{margin:5px 0;font-size:0.88rem}
/* Section */
.section{margin-bottom:48px}
.section-title{font-size:1.4rem;font-weight:700;color:#f1f5f9;padding-bottom:10px;border-bottom:2px solid #334155;margin-bottom:24px;display:flex;align-items:center;gap:10px}
.section-title .icon{font-size:1.2rem}
/* Card */
.card{background:#1e293b;border:1px solid #334155;border-radius:12px;padding:24px;margin-bottom:16px}
.card-title{font-size:1.05rem;font-weight:600;color:#f1f5f9;margin-bottom:6px;display:flex;align-items:center}
.card-title .num{display:inline-flex;align-items:center;justify-content:center;width:22px;height:22px;border-radius:50%;background:#334155;font-size:0.7rem;font-weight:700;color:#94a3b8;margin-right:8px;shrink:0}
.card-desc{color:#94a3b8;font-size:0.88rem;margin-bottom:14px}
/* Info rows */
.info-grid{display:grid;grid-template-columns:1fr;gap:10px}
.info-block{border-radius:8px;padding:12px 14px}
.info-block.usage{background:#0f2d1f;border:1px solid #166534}
.info-block.warn{background:#2a1a00;border:1px solid #92400e}
.info-block.note{background:#1a1f2e;border:1px solid #4338ca}
.info-title{font-size:0.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.08em;margin-bottom:6px}
.info-title.u{color:#4ade80}
.info-title.w{color:#fbbf24}
.info-title.n{color:#818cf8}
.info-block ul{padding-left:16px;font-size:0.86rem;color:#cbd5e1}
.info-block li{margin:4px 0}
.info-block p{font-size:0.86rem;color:#cbd5e1}
/* Sub table */
.feat-table{width:100%;border-collapse:collapse;font-size:0.86rem;margin-top:10px}
.feat-table th{text-align:left;color:#64748b;font-weight:600;font-size:0.75rem;text-transform:uppercase;padding:6px 10px;border-bottom:1px solid #334155}
.feat-table td{padding:7px 10px;border-bottom:1px solid #1e293b;color:#cbd5e1;vertical-align:top}
.feat-table tr:last-child td{border-bottom:none}
/* Footer */
footer{text-align:center;color:#334155;font-size:0.8rem;margin-top:60px;padding-top:24px;border-top:1px solid #1e293b}
code{background:#334155;color:#7dd3fc;padding:2px 6px;border-radius:4px;font-size:0.83em;font-family:monospace}
</style>
</head>
<body>
<div class="wrap">
<!-- HERO -->
<div class="hero">
<h1>⚡ Nexus 6.0</h1>
<p class="sub">服务器运维管理平台 — 全站功能说明书 · 2026-05-23</p>
<p class="sub" style="margin-top:10px;font-size:0.85rem">
Markdown 维护版:<code>docs/project/nexus-full-site-features.md</code> ·
AI 接续:<code>docs/project/AI-HANDOFF-2026-05-23.md</code>
</p>
<div style="margin-top:14px">
<span class="badge badge-blue">FastAPI</span>
<span class="badge badge-blue">SQLAlchemy 2.0</span>
<span class="badge badge-blue">Redis 7</span>
<span class="badge badge-blue">asyncssh</span>
<span class="badge badge-blue">Tailwind v4</span>
<span class="badge badge-green">2000+ 服务器</span>
<span class="badge badge-green">JWT + TOTP</span>
</div>
</div>
<!-- TOC -->
<div class="toc">
<h2>目录</h2>
<ol>
<li><a href="#sitemap">全站页面地图</a></li>
<li><a href="#network">网络与通道</a></li>
<li><a href="#auth">认证与访问控制</a></li>
<li><a href="#dashboard">仪表盘</a></li>
<li><a href="#alerts">告警中心</a></li>
<li><a href="#servers">服务器管理</a></li>
<li><a href="#assets">资产管理</a></li>
<li><a href="#webssh">Web SSH</a></li>
<li><a href="#commands">命令与推送日志</a></li>
<li><a href="#push">批量文件推送</a></li>
<li><a href="#scripts">脚本与执行历史</a></li>
<li><a href="#files">远程文件管理</a></li>
<li><a href="#schedules">定时调度</a></li>
<li><a href="#credentials">凭据管理</a></li>
<li><a href="#retries">重试队列</a></li>
<li><a href="#search">全局搜索</a></li>
<li><a href="#settings">系统设置</a></li>
<li><a href="#audit">审计日志</a></li>
<li><a href="#install">安装向导</a></li>
<li><a href="#guardian">3 层守护</a></li>
<li><a href="#agent">子机 Agent</a></li>
<li><a href="#background">后台任务</a></li>
<li><a href="#acceptance">开发验收标准</a></li>
<li><a href="#notimpl">未实现 / 已否决</a></li>
</ol>
</div>
<!-- 0. SITEMAP -->
<div class="section" id="sitemap">
<div class="section-title"><span class="icon">🗺</span> 全站页面地图(18 页)</div>
<div class="card">
<table class="feat-table">
<tr><th>页面</th><th>路径</th><th>功能</th></tr>
<tr><td>安装向导</td><td><code>/app/install.html</code></td><td>无 .env 时安装中心机</td></tr>
<tr><td>登录</td><td><code>/app/login.html</code></td><td>JWT + TOTP + 防暴破</td></tr>
<tr><td>仪表盘</td><td><code>/app/index.html</code></td><td>统计、WS 刷新统计</td></tr>
<tr><td>服务器</td><td><code>/app/servers.html</code></td><td>CRUD、Agent 安装/升级、漂移徽章</td></tr>
<tr><td>资产管理</td><td><code>/app/assets.html</code></td><td>Platform / Node</td></tr>
<tr><td>文件管理</td><td><code>/app/files.html</code></td><td>SSH 浏览与文件操作</td></tr>
<tr><td>推送</td><td><code>/app/push.html</code></td><td>rsync 批量 + dry-run</td></tr>
<tr><td>脚本库</td><td><code>/app/scripts.html</code></td><td>脚本 CRUD、直接执行</td></tr>
<tr><td>执行历史</td><td><code>/app/script-executions.html</code></td><td>停止/重试/日志</td></tr>
<tr><td>凭据</td><td><code>/app/credentials.html</code></td><td>DB 凭据 + 密码预设</td></tr>
<tr><td>定时调度</td><td><code>/app/schedules.html</code></td><td>Cron 推送或脚本</td></tr>
<tr><td>重试队列</td><td><code>/app/retries.html</code></td><td>推送失败重试</td></tr>
<tr><td>命令日志</td><td><code>/app/commands.html</code></td><td>WebSSH 命令 + 推送日志</td></tr>
<tr><td>告警中心</td><td><code>/app/alerts.html</code></td><td>告警历史与统计</td></tr>
<tr><td>审计</td><td><code>/app/audit.html</code></td><td>操作审计、多维过滤</td></tr>
<tr><td>设置</td><td><code>/app/settings.html</code></td><td>阈值、Telegram、白名单、TOTP</td></tr>
<tr><td>WebSSH</td><td><code>/app/terminal.html</code></td><td>xterm 终端</td></tr>
</table>
<p class="card-desc" style="margin-top:12px">共享:<code>layout.js</code>(侧栏/移动适配)、<code>api.js</code>JWT、toast、全局搜索)</p>
</div>
</div>
<!-- NETWORK -->
<div class="section" id="network">
<div class="section-title"><span class="icon">🔌</span> 网络与通道模型</div>
<div class="card">
<table class="feat-table">
<tr><th>方向</th><th>端口</th><th>用途</th><th>必须</th></tr>
<tr><td>子机 → 中心</td><td><strong>443 出站</strong></td><td>Agent 心跳、长任务 curl 回调</td><td></td></tr>
<tr><td>中心 → 子机</td><td><strong>22 入站</strong></td><td>推送、文件、脚本、健康检查、Agent 安装</td><td></td></tr>
<tr><td>子机本机</td><td><code>127.0.0.1:8601</code></td><td>Agent HTTP(可不暴露公网)</td><td>可选</td></tr>
</table>
<div class="info-block note" style="margin-top:12px"><div class="info-title n">说明</div><p>脚本执行与手动健康检查已改走 <strong>SSH</strong>,不依赖公网 8601。宝塔环境请用系统 Python 3.12 + <code>install.sh</code> venv,勿用面板 Python 项目管理器托管 Agent。</p></div>
</div>
</div>
<!-- 1. AUTH -->
<div class="section" id="auth">
<div class="section-title"><span class="icon">🔒</span> 1. 认证与访问控制</div>
<div class="card">
<div class="card-title"><span class="num">1</span> JWT 登录 + 自动刷新</div>
<p class="card-desc">管理员通过用户名+密码登录,获取 Access Token30 分钟)和 Refresh Token(7 天)。前端到期前 2 分钟自动换新 Token,无感续期。</p>
<div class="info-grid">
<div class="info-block usage"><div class="info-title u">使用说明</div><ul>
<li>访问 <code>/app/login.html</code>,输入管理员账号密码</li>
<li>Token 存于 <code>localStorage</code>,关闭浏览器后 7 天内免登录</li>
<li>8 小时无操作自动退出</li>
</ul></div>
<div class="info-block warn"><div class="info-title w">缺点 / 注意</div><ul>
<li>JWT 存于 localStorageXSS 漏洞可能导致 Token 泄露(已做 CSP 缓解)</li>
<li>无 RBAC:只有一个管理员角色,鉴权即信任</li>
<li>不支持多管理员角色区分</li>
</ul></div>
</div>
</div>
<div class="card">
<div class="card-title"><span class="num">2</span> TOTP 双因素认证</div>
<p class="card-desc">可选启用 Google Authenticator 兼容的 TOTP。登录时先验密码,再验 6 位动态码(30 秒窗口)。</p>
<div class="info-grid">
<div class="info-block usage"><div class="info-title u">使用说明</div><ul>
<li>进入「设置」→「安全设置」→ 启用 / <strong>重新绑定</strong> TOTP,扫描二维码</li>
<li>禁用 TOTP 需同时提供当前密码 + TOTP 码</li>
</ul></div>
<div class="info-block warn"><div class="info-title w">缺点 / 注意</div><ul>
<li>TOTP 秘钥丢失无法恢复,需提前备份</li>
<li>系统时间偏差 &gt;30s 会导致验证失败(接受 ±1 窗口共 90s)</li>
</ul></div>
</div>
</div>
<div class="card">
<div class="card-title"><span class="num">3</span> 登录防暴破</div>
<p class="card-desc">同一 IP + 用户名 15 分钟内失败 5 次,账号自动锁定并返回 HTTP 429。</p>
<div class="info-block note"><div class="info-title n">注意</div><p>锁定基于 IP + 用户名组合,更换 IP 可绕过。适合防自动化爆破,不防分布式攻击。</p></div>
</div>
<div class="card">
<div class="card-title"><span class="num">4</span> 登录 IP 白名单</div>
<p class="card-desc">可在设置中开启「仅允许白名单 IP 登录」。支持手动 IP/CIDR,以及从<strong>代理订阅 URL</strong>解析节点 IP(每 2 小时自动刷新,与手动列表合并)。</p>
<div class="info-block usage"><div class="info-title u">使用说明</div><ul>
<li>「设置」→「登录 IP 白名单」→ 总开关 + 订阅地址 + 手动 IP</li>
<li>非白名单 IP 登录被拒绝并记审计</li>
</ul></div>
</div>
</div>
<!-- 2. DASHBOARD -->
<div class="section" id="dashboard">
<div class="section-title"><span class="icon">📊</span> 2. 仪表盘与实时告警</div>
<div class="card">
<div class="card-title"><span class="num">1</span> 实时统计面板</div>
<p class="card-desc">首页展示服务器总数、在线数、离线数、告警中台数、最近推送记录。数据来源 Redis(实时)+ MySQL(历史),通过 WebSocket 触发刷新。</p>
<div class="info-grid">
<div class="info-block usage"><div class="info-title u">使用说明</div><ul>
<li>在线状态取自 Redis <code>heartbeat:{id}</code>,子机 60s 上报一次</li>
<li>告警计数 = Redis <code>alerts:*</code> key 数量</li>
<li>WS 断线会自动重连(3 次指数退避)</li>
</ul></div>
<div class="info-block warn"><div class="info-title w">缺点 / 注意</div><ul>
<li>在线状态最多滞后 60s(子机心跳间隔)</li>
<li>Redis 不可用时统计退化为 MySQL 数据,可能不准</li>
</ul></div>
</div>
</div>
<div class="card">
<div class="card-title"><span class="num">2</span> 告警通知(Telegram</div>
<p class="card-desc">CPU / 内存 / 磁盘超过阈值(默认 80%)时,通过 Telegram Bot 推送告警;恢复后发送恢复通知。同服务器同指标 5 分钟内不重复推送。</p>
<div class="info-grid">
<div class="info-block usage"><div class="info-title u">使用说明</div><ul>
<li>「设置」配置 <code>TELEGRAM_BOT_TOKEN</code> + <code>TELEGRAM_CHAT_ID</code></li>
<li>阈值可在设置页调整(CPU/内存/磁盘各独立)</li>
<li>浏览器仪表盘<strong>只显示计数,不弹窗、不响铃</strong>(设计决策)</li>
</ul></div>
<div class="info-block warn"><div class="info-title w">缺点 / 注意</div><ul>
<li>浏览器仪表盘不弹告警列表;历史见「告警中心」页</li>
<li>Telegram Bot 被封或网络不通时告警静默失败(有日志)</li>
<li>告警防抖 key 存于进程内存,重启后防抖状态重置</li>
</ul></div>
</div>
</div>
</div>
<!-- ALERTS -->
<div class="section" id="alerts">
<div class="section-title"><span class="icon">🔔</span> 告警中心</div>
<div class="card">
<div class="card-title"><span class="num">1</span> 告警历史(alerts.html</div>
<p class="card-desc">将 CPU/内存/磁盘告警与恢复事件持久化到 <code>alert_logs</code>,支持分页、按服务器/类型/日期筛选,以及近 7 日统计图表数据。</p>
<div class="info-block usage"><div class="info-title u">使用说明</div><ul>
<li>侧栏「告警中心」查看历史列表</li>
<li>API<code>GET /api/alert-history/</code><code>GET /api/alert-history/stats</code></li>
</ul></div>
</div>
<div class="card">
<div class="card-title"><span class="num">2</span> 分项 Telegram 开关</div>
<p class="card-desc">设置页可单独关闭某类告警的 Telegram 推送(告警/恢复/系统/时钟漂移等),便于后续扩展通道。</p>
</div>
</div>
<!-- 3. SERVERS -->
<div class="section" id="servers">
<div class="section-title"><span class="icon">🖥</span> 3. 服务器管理</div>
<div class="card">
<div class="card-title"><span class="num">1</span> 服务器 CRUD</div>
<p class="card-desc">增删改查服务器配置,支持 SSH 密钥 / 密码双认证,可按平台类型(Platform)和节点(Node 树)分组。</p>
<div class="info-grid">
<div class="info-block usage"><div class="info-title u">使用说明</div><ul>
<li>新增服务器:填写名称、域名/IP、SSH 端口、认证方式</li>
<li>认证方式选「密钥」时粘贴私钥内容,自动 Fernet 加密存储</li>
<li>支持 <code>platform_id</code> / <code>node_id</code> 分组归类</li>
<li>服务器列表分页显示(默认 50 条/页)</li>
</ul></div>
<div class="info-block warn"><div class="info-title w">缺点 / 注意</div><ul>
<li>批量导入(CSV)未实现,2000 台需逐一或通过 API 批量添加</li>
<li>删除服务器同时删除关联推送日志(CASCADE)</li>
</ul></div>
</div>
</div>
<div class="card">
<div class="card-title"><span class="num">2</span> 健康检查</div>
<p class="card-desc">手动触发 <code>POST /api/servers/check</code>:经 <strong>SSH</strong> 执行 Python 采集 CPU/内存/磁盘(<code>remote_probe</code>),<strong>不需要</strong>公网 8601。</p>
<div class="info-block note"><div class="info-title n">与在线状态区别</div><p>列表「在线」主要来自 Agent <strong>443 心跳</strong>;健康检查用于即时 SSH 探活与指标快照。</p></div>
</div>
<div class="card">
<div class="card-title"><span class="num">4</span> Agent 安装与升级</div>
<p class="card-desc">服务器详情「Agent 安装」:复制 <code>install.sh</code> 命令或一键 SSH 安装(带日志);安装后轮询在线。升级通过 SSH 拉取最新 <code>agent.py</code> 并 restart。</p>
<div class="info-block warn"><div class="info-title w">注意</div><ul>
<li>安装脚本:Python 3.12 venv、监听 <code>127.0.0.1:8601</code>,无需公网放行 8601</li>
<li>时钟漂移:心跳带 <code>agent_time</code>,列表显示 warn/crit 徽章</li>
</ul></div>
</div>
<div class="card">
<div class="card-title"><span class="num">3</span> 每服务器 API Key</div>
<p class="card-desc">为每台服务器单独生成 Agent API Key(格式 <code>nxs-xxx</code>),用于该服务器子机与中心通信的专属认证。</p>
<div class="info-grid">
<div class="info-block usage"><div class="info-title u">使用说明</div><ul>
<li>服务器详情 → 「生成 Agent Key」,完整 Key 仅本次显示,立即复制到子机 <code>config.json</code></li>
<li>列表页只显示前 8 位用于识别</li>
</ul></div>
<div class="info-block warn"><div class="info-title w">注意</div><ul>
<li>Key 生成后无法再次查看(一次性展示设计)</li>
<li>丢失需重新生成并更新子机配置</li>
</ul></div>
</div>
</div>
</div>
<!-- ASSETS -->
<div class="section" id="assets">
<div class="section-title"><span class="icon">🗂</span> 资产管理</div>
<div class="card">
<div class="card-title"><span class="num">1</span> Platform 与 Node</div>
<p class="card-desc"><code>assets.html</code> 管理资产类型模板(Platform)与树形节点(Node),服务器表单可关联分组。</p>
<p class="card-desc">API<code>/api/assets/platforms</code><code>/api/assets/nodes</code>(含 tree</p>
</div>
<div class="card">
<div class="card-title"><span class="num">2</span> SSH 会话与命令(只读查询)</div>
<p class="card-desc">同页或「命令日志」可查询 <code>ssh_sessions</code><code>command_logs</code>WebSSH 产生)。</p>
</div>
</div>
<!-- 4. WEB SSH -->
<div class="section" id="webssh">
<div class="section-title"><span class="icon">💻</span> 4. Web SSH 终端</div>
<div class="card">
<div class="card-title"><span class="num">1</span> 浏览器终端</div>
<p class="card-desc">基于 xterm.js + asyncssh,在浏览器内打开远程服务器 SSH 会话,支持全屏、自动 resize、彩色输出。</p>
<div class="info-grid">
<div class="info-block usage"><div class="info-title u">使用说明</div><ul>
<li>服务器列表点击「终端」→ 弹出 <code>/app/terminal.html?server_id=X</code></li>
<li>认证:先获取短效 webssh-token(15 分钟),绑定到该服务器,再建 WebSocket</li>
<li>支持窗口 resize 自动通知远程 shell</li>
</ul></div>
<div class="info-block warn"><div class="info-title w">缺点 / 注意</div><ul>
<li>Token 通过 URL 参数传递(?token=...),HTTPS 下安全;HTTP 下有风险</li>
<li>不支持文件上传 / 拖拽(需用文件管理页)</li>
<li>会话不录像(已明确否决)</li>
<li>网络中断 WebSocket 不会自动重连,需刷新页面</li>
</ul></div>
</div>
</div>
</div>
<!-- COMMANDS -->
<div class="section" id="commands">
<div class="section-title"><span class="icon">💻</span> 命令与推送日志</div>
<div class="card">
<div class="card-title"><span class="num">1</span> WebSSH 会话与命令</div>
<p class="card-desc"><code>commands.html</code>:会话列表、命令列表(危险命令高亮)、按服务器过滤。数据来自 WebSSH,直接 SSH 不记录。</p>
</div>
<div class="card">
<div class="card-title"><span class="num">2</span> 推送日志</div>
<p class="card-desc">同页「推送日志」Tab:rsync 批量推送历史,支持服务器/状态/同步模式/触发方式过滤与分页。</p>
</div>
</div>
<!-- 5. PUSH -->
<div class="section" id="push">
<div class="section-title"><span class="icon">📤</span> 5. 批量文件推送</div>
<div class="card">
<div class="card-title"><span class="num">1</span> rsync 批量推送</div>
<p class="card-desc">将源目录通过 rsync-over-SSH 推送到选定的服务器列表,支持 4 种同步模式,内置分批(50 台/批)并发(10 路)控制。</p>
<table class="feat-table">
<tr><th>同步模式</th><th>rsync 参数</th><th>适用场景</th></tr>
<tr><td>增量同步</td><td><code>-az</code></td><td>日常更新,只同步有变动的文件</td></tr>
<tr><td>全量同步</td><td><code>-az --delete</code></td><td>强制目标与源完全一致,会删除目标多余文件</td></tr>
<tr><td>校验和</td><td><code>-az --checksum</code></td><td>按文件内容而非时间戳判断差异</td></tr>
<tr><td>直接覆盖</td><td><code>-az --inplace</code></td><td>不用临时文件,直接覆盖(大文件节省空间)</td></tr>
</table>
<div class="info-grid" style="margin-top:12px">
<div class="info-block warn"><div class="info-title w">缺点 / 注意</div><ul>
<li><strong>全量同步(--delete)会不可逆删除</strong>目标目录多余文件,务必先「预览」</li>
<li>rsync 在中心机运行,中心机需能 SSH 到所有子机</li>
<li>失败的服务器自动加入重试队列(指数退避,最多 100 次)</li>
</ul></div>
</div>
</div>
<div class="card">
<div class="card-title"><span class="num">2</span> 推送前预览(dry-run</div>
<p class="card-desc">选中全量/覆盖模式时,页面显示橙色警告,引导先对首台服务器运行 <code>rsync --dry-run --stats</code>,查看待传输/删除文件数量后再决定是否推送。</p>
<div class="info-grid">
<div class="info-block usage"><div class="info-title u">使用说明</div><ul>
<li>选择「全量同步」→ 出现橙色预览按钮 → 点击 → 展示首台服务器变动摘要</li>
<li>可展开「详细文件列表」(最多 200 行)</li>
<li>预览不阻塞推送,看完数据自行决定是否点「开始推送」</li>
</ul></div>
<div class="info-block warn"><div class="info-title w">注意</div><ul>
<li>仅预览第一台选中的服务器,其余服务器可能略有差异</li>
<li>预览需 SSH 连通,与真实推送消耗相同时间(通常 &lt;5s</li>
</ul></div>
</div>
</div>
<div class="card">
<div class="card-title"><span class="num">3</span> 失败自动重试队列</div>
<p class="card-desc">推送失败的服务器自动进入 <code>push_retry_jobs</code> 队列,后台每 5 分钟按指数退避重试(60s → 120s → 240s...,最大 64 分钟)。</p>
<div class="info-block note"><div class="info-title n">注意</div><p>最多重试 100 次。可在「重试」页手动强制立即重试或删除任务。</p></div>
</div>
</div>
<!-- 6. SCRIPTS -->
<div class="section" id="scripts">
<div class="section-title"><span class="icon">📜</span> 脚本与执行历史</div>
<div class="card">
<div class="card-title"><span class="num">1</span> 脚本库管理</div>
<p class="card-desc">维护可复用 Shell 脚本库(按 ops/deploy/check/cleanup 分类),一键下发到选定的服务器。</p>
<div class="info-block usage"><div class="info-title u">使用说明</div><ul>
<li>「脚本」页 → 新建脚本 → 填写名称/类别/内容</li>
<li>点击脚本名 → 编辑;点击「执行」→ 选择服务器 → 运行</li>
<li>支持直接输入临时命令(无需保存到库)</li>
</ul></div>
</div>
<div class="card">
<div class="card-title"><span class="num">2</span> 批量执行(短任务 / 长任务)</div>
<p class="card-desc">短任务:同步等待结果(默认 30s 超时)。长任务:nohup 后台运行,完成后 curl 回调中心记录结果。</p>
<div class="info-grid">
<div class="info-block usage"><div class="info-title u">使用说明</div><ul>
<li>选择服务器 → 填写命令 → 「执行」</li>
<li>执行走 <strong>SSH 22</strong>(结果含 <code>channel: ssh</code>),无需子机公网 8601</li>
<li>长任务:nohup + 子机 curl 回调中心(需 HTTPS <code>NEXUS_API_BASE_URL</code></li>
<li>「执行历史」页:停止 / 重试 / 标记卡住 / 拉取 nexus-job 日志</li>
</ul></div>
<div class="info-block warn"><div class="info-title w">缺点 / 注意</div><ul>
<li><code>rm -rf /</code> 等危险命令被中心<strong>拦截(400</strong>,但仅匹配预设规则</li>
<li>长任务依赖子机能访问中心 <code>NEXUS_API_BASE_URL</code>(必须配置且使用 HTTPS</li>
<li>执行历史默认保留 2 小时 Redis 热数据,60s 刷入 MySQL</li>
<li>执行命令时密码等敏感 $DB_* 变量存储时已脱敏(替换为 ***)</li>
</ul></div>
</div>
</div>
<div class="card">
<div class="card-title"><span class="num">3</span> $DB_* 数据库凭据替换</div>
<p class="card-desc">脚本中使用 <code>$DB_USER</code> / <code>$DB_PASS</code> / <code>$DB_HOST</code> / <code>$DB_PORT</code> / <code>$DB_NAME</code> 占位符,执行时自动替换为选定的数据库凭据,密码不出现在脚本文本或日志中。</p>
<div class="info-block note"><div class="info-title n">注意</div><p>替换在中心机完成后发往子机,子机接收到的已是明文命令;传输通过 HTTPS,子机侧无加密保护。</p></div>
</div>
</div>
<!-- 7. FILES -->
<div class="section" id="files">
<div class="section-title"><span class="icon">📁</span> 7. 远程文件管理</div>
<div class="card">
<div class="card-title"><span class="num">1</span> 目录浏览</div>
<p class="card-desc">通过 SSH exec<code>ls -la</code>)浏览指定服务器的远程目录,支持面包屑导航和目录点击进入。</p>
<div class="info-grid">
<div class="info-block usage"><div class="info-title u">使用说明</div><ul>
<li>「文件管理」→ 选服务器 → 输入路径 → 「浏览」(或按 Enter)</li>
<li>点击目录名进入子目录,点击「..」返回上级</li>
<li>文件按 目录优先 / 字母顺序 排列</li>
</ul></div>
<div class="info-block warn"><div class="info-title w">缺点 / 注意</div><ul>
<li>无左侧树状面板(每次需手动导航)</li>
<li>显示的文件大小为 <code>ls</code> 输出的字节数,大文件不友好</li>
</ul></div>
</div>
</div>
<div class="card">
<div class="card-title"><span class="num">2</span> 文件操作(删除/重命名/新建目录)</div>
<p class="card-desc">鼠标悬停文件/目录行显示操作按钮,通过 SSH exec 在远程执行操作。</p>
<table class="feat-table">
<tr><th>操作</th><th>命令</th><th>触发方式</th></tr>
<tr><td>删除文件</td><td><code>rm -f path</code></td><td>悬停 → 🗑 → confirm 弹窗</td></tr>
<tr><td>删除目录</td><td><code>rm -rf path</code></td><td>悬停 → 🗑 → 额外警告弹窗</td></tr>
<tr><td>重命名</td><td><code>mv src dst</code></td><td>悬停 → ✏ → prompt 输入新名</td></tr>
<tr><td>新建目录</td><td><code>mkdir -p path</code></td><td>右上角 📁+ → prompt 输入名</td></tr>
</table>
<div class="info-block warn" style="margin-top:10px"><div class="info-title w">缺点 / 注意</div><ul>
<li>删除目录(rm -rf<strong>不可恢复</strong>,浏览器弹窗确认无法替代真正的安全保障</li>
<li>无文件编辑器(已明确否决,用 WebSSH vim/nano 代替)</li>
<li>无文件上传功能(可用 SFTP API 或 WebSSH scp</li>
<li>无文件内容预览</li>
</ul></div>
</div>
</div>
<!-- 8. SCHEDULES -->
<div class="section" id="schedules">
<div class="section-title"><span class="icon"></span> 8. 定时调度</div>
<div class="card">
<div class="card-title"><span class="num">1</span> Cron 调度(推送 + 脚本)</div>
<p class="card-desc">支持两种 <code>schedule_type</code><strong>文件推送</strong><strong>脚本执行</strong>。5 字段 Cron,后台每 60s 检查(仅 Primary Worker)。</p>
<div class="info-grid">
<div class="info-block usage"><div class="info-title u">使用说明</div><ul>
<li>「定时」→ 选类型 → 填 Cron、目标服务器</li>
<li>脚本型:选脚本库 ID 或内联内容,可设超时与长任务</li>
<li>示例:<code>0 2 * * *</code> = 每天凌晨 2 点</li>
</ul></div>
<div class="info-block warn"><div class="info-title w">注意</div><ul>
<li>Cron 精度受 60s 检查间隔限制</li>
<li>脚本调度同样走 SSH 执行通道</li>
</ul></div>
</div>
</div>
</div>
<!-- RETRIES -->
<div class="section" id="retries">
<div class="section-title"><span class="icon">🔄</span> 重试队列</div>
<div class="card">
<p class="card-desc"><code>retries.html</code> 管理推送失败任务:<code>retry_runner</code> 每 5 分钟指数退避;可手动立即重试或删除。</p>
</div>
</div>
<!-- SEARCH -->
<div class="section" id="search">
<div class="section-title"><span class="icon">🔍</span> 全局搜索</div>
<div class="card">
<p class="card-desc">侧栏搜索框调用 <code>GET /api/search/</code>,跨服务器、脚本、凭据、定时任务关键字匹配。</p>
</div>
</div>
<!-- 9. CREDENTIALS -->
<div class="section" id="credentials">
<div class="section-title"><span class="icon">🔑</span> 9. 凭据管理</div>
<div class="card">
<div class="card-title"><span class="num">1</span> 数据库凭据</div>
<p class="card-desc">存储 MySQL/PostgreSQL 连接信息,密码 Fernet 加密,API 响应不含密码(仅元数据)。供脚本执行时通过 $DB_* 变量引用。</p>
<div class="info-block warn"><div class="info-title w">注意</div><p>凭据只加密存储,子机接收到的命令已含明文连接信息(仅在中心→子机的 HTTPS 传输中受保护)。</p></div>
</div>
<div class="card">
<div class="card-title"><span class="num">2</span> 密码预设</div>
<p class="card-desc">存储 SSH 密码模板(Fernet 加密),可在推送配置中引用,避免重复输入常用密码。</p>
<div class="info-block note"><div class="info-title n">注意</div><p>密码预设不能用于脚本 $DB_PASS 替换,只用于推送密码模板。</p></div>
</div>
</div>
<!-- 10. SETTINGS -->
<div class="section" id="settings">
<div class="section-title"><span class="icon">⚙️</span> 10. 系统设置</div>
<div class="card">
<div class="card-title"><span class="num">1</span> 可配置项</div>
<table class="feat-table">
<tr><th>设置项</th><th>说明</th><th>是否可改</th></tr>
<tr><td>系统名称 / 标题</td><td>品牌展示名称</td><td>✅ 可改</td></tr>
<tr><td>告警阈值(CPU/内存/磁盘)</td><td>超过阈值触发 Telegram 告警,默认 80%</td><td>✅ 可改</td></tr>
<tr><td>Redis URL</td><td>Redis 连接地址</td><td>✅ 可改</td></tr>
<tr><td>数据库连接池大小</td><td>pool_size / max_overflow</td><td>✅ 可改</td></tr>
<tr><td>Telegram Bot Token / Chat ID</td><td>告警推送配置</td><td>✅ 可改</td></tr>
<tr><td>SECRET_KEY / API_KEY / ENCRYPTION_KEY</td><td>核心安全密钥</td><td>❌ 不可改(需改 .env 重启)</td></tr>
<tr><td>DATABASE_URL</td><td>数据库连接</td><td>❌ 不可改</td></tr>
</table>
</div>
<div class="card">
<div class="card-title"><span class="num">2</span> Telegram</div>
<p class="card-desc">配置 Bot Token / Chat ID<strong>测试发送</strong>;通过 getUpdates <strong>检测 Chat ID</strong>;各告警类型独立开关。</p>
</div>
<div class="card">
<div class="card-title"><span class="num">3</span> TOTP 与 API Key</div>
<p class="card-desc">TOTP 启用/禁用/<strong>重新绑定</strong>;全局 API Key 需密码二次验证后查看(记审计)。</p>
</div>
<div class="card">
<div class="card-title"><span class="num">4</span> 登录 IP 白名单</div>
<p class="card-desc">总开关、订阅 URL(2h 刷新)、手动 IP、解析预览(见「认证」章节)。</p>
</div>
</div>
<!-- 11. AUDIT -->
<div class="section" id="audit">
<div class="section-title"><span class="icon">📋</span> 11. 审计日志</div>
<div class="card">
<div class="card-title"><span class="num">1</span> 操作审计</div>
<p class="card-desc">所有 CUD 操作(创建/更新/删除服务器、推送、执行脚本、登录、改密码等)自动写入 <code>audit_logs</code>,记录操作人、IP、目标、详情。</p>
<div class="info-grid">
<div class="info-block usage"><div class="info-title u">使用说明</div><ul>
<li>「审计」页:日期起止、管理员用户名、操作类型(分组下拉)</li>
<li>每页 50 条分页</li>
</ul></div>
<div class="info-block warn"><div class="info-title w">缺点 / 注意</div><ul>
<li>无日志导出(已否决 CSV/JSON</li>
<li>审计日志不会自动清理,长期需归档</li>
</ul></div>
</div>
</div>
</div>
<!-- 12. INSTALL -->
<div class="section" id="install">
<div class="section-title"><span class="icon">🚀</span> 12. 安装向导</div>
<div class="card">
<div class="card-title"><span class="num">1</span> 5 步安装向导</div>
<p class="card-desc"><code>.env</code> 文件时系统进入安装模式,访问 <code>/app/install.html</code> 完成 5 步配置:欢迎 → 数据库 → Redis → 管理员账号 → 完成。自动生成 API Key / Secret Key / Encryption Key。</p>
<div class="info-grid">
<div class="info-block usage"><div class="info-title u">使用说明</div><ul>
<li>安装完成后 <code>.env</code> 自动生成,安装 API 除 GET /status 外全部锁定(返回 403</li>
<li>重装需手动删除 <code>.env</code> 并重启</li>
</ul></div>
<div class="info-block warn"><div class="info-title w">注意</div><ul>
<li>安装过程未验证数据库/Redis 连接成功才允许进入下一步(应在步骤中手动验证)</li>
<li>安装模式下除 install API 和静态文件外,其余 API 返回 503</li>
</ul></div>
</div>
</div>
</div>
<!-- 13. GUARDIAN -->
<div class="section" id="guardian">
<div class="section-title"><span class="icon">🛡</span> 13. 三层守护机制</div>
<div class="card">
<div class="card-title"><span class="num">1</span> 三层守护</div>
<table class="feat-table">
<tr><th>层级</th><th>机制</th><th>触发条件</th><th>动作</th></tr>
<tr><td>Layer 1</td><td>Supervisor</td><td>Python 进程崩溃</td><td>自动重启</td></tr>
<tr><td>Layer 2</td><td>Python self_monitor30s</td><td>Redis/MySQL 不可达</td><td>发 Telegram 告警/恢复</td></tr>
<tr><td>Layer 3</td><td>Shell cron1 分钟)</td><td>连续 3 次 /health 失败</td><td>supervisorctl restart + Telegram</td></tr>
</table>
<div class="info-block warn" style="margin-top:10px"><div class="info-title w">注意</div><p>Shell cron 依赖宝塔/系统 crontab 配置,部署时需手动添加定时任务。</p></div>
</div>
</div>
<!-- 14. AGENT -->
<div class="section" id="agent">
<div class="section-title"><span class="icon">🤖</span> 14. 子机 Agent</div>
<div class="card">
<div class="card-title"><span class="num">1</span> Agent 功能</div>
<p class="card-desc">部署在每台被管服务器上的轻量服务:<strong>心跳走 443 出站</strong>HTTP 仅本机 <code>127.0.0.1:8601</code>。中心侧脚本已改 <strong>SSH</strong>/exec 端点保留供兼容。</p>
<table class="feat-table">
<tr><th>能力</th><th>说明</th></tr>
<tr><td>心跳</td><td>POST 中心 <code>/api/agent/heartbeat</code>,写 Redis,智能加急</td></tr>
<tr><td>时钟漂移</td><td><code>agent_time</code> 与中心比对,warn/crit + Telegram</td></tr>
<tr><td>IP 白名单</td><td><code>allowed_ips</code> 限制访问本机 Agent HTTP</td></tr>
<tr><td>长任务回调</td><td>POST <code>/api/agent/script-callback</code></td></tr>
<tr><td>安装</td><td><code>web/agent/install.sh</code> — python3.12 venv + systemd</td></tr>
</table>
<div class="info-block warn" style="margin-top:12px"><div class="info-title w">注意</div><ul>
<li>升级 API 存在,建议与 venv 安装脚本对齐后使用</li>
<li>长任务回调需子机出站访问中心 HTTPS</li>
</ul></div>
</div>
</div>
<!-- BACKGROUND -->
<div class="section" id="background">
<div class="section-title"><span class="icon"></span> 后台任务(Primary Worker</div>
<div class="card">
<table class="feat-table">
<tr><th>任务</th><th>间隔</th><th>说明</th></tr>
<tr><td>heartbeat_flush</td><td>10min</td><td>Redis 心跳 → MySQL</td></tr>
<tr><td>self_monitor</td><td>30s</td><td>Redis/MySQL/WS 自检 + Telegram</td></tr>
<tr><td>schedule_runner</td><td>60s</td><td>Cron 调度</td></tr>
<tr><td>retry_runner</td><td>5min</td><td>推送重试</td></tr>
<tr><td>ip_allowlist_refresh</td><td>2h</td><td>订阅 IP 刷新</td></tr>
<tr><td>script_execution_flush</td><td>60s</td><td>执行记录 Redis→MySQL</td></tr>
</table>
</div>
</div>
<!-- ACCEPTANCE -->
<div class="section" id="acceptance">
<div class="section-title"><span class="icon"></span> 标准与验收(与团队同等严谨)</div>
<div class="card">
<p class="card-desc"><strong>全量标准转接</strong><code>docs/project/standards-transfer-package.md</code> — 含 Cursor 规则、<code>standards/</code> 三件套、逐行八步 DoD、设计/上线清单。下一任 AI <strong>不得降低</strong>执行力度。</p>
<table class="feat-table">
<tr><th>类别</th><th>权威路径</th></tr>
<tr><td>标准转接包</td><td><code>docs/project/standards-transfer-package.md</code></td></tr>
<tr><td>验收 L0L5</td><td><code>development-acceptance-standard.md</code></td></tr>
<tr><td>系统开发</td><td><code>standards/system-development-standard.md</code></td></tr>
<tr><td>逐行审计 v2</td><td><code>standards/line-walk-audit-standard-v2.md</code></td></tr>
<tr><td>审计原则</td><td><code>standards/audit-core-principles.md</code></td></tr>
<tr><td>Cursor</td><td><code>.cursor/rules/*.mdc</code>(含 audit-line-review</td></tr>
</table>
<p class="card-desc" style="margin-top:12px">逐行审查:8 步/文件、Closure 全表含 SAFE、FINDING 必须 <code>文件:行号</code>、单回复 ≤5 文件。AI 接续:HANDOFF + 全站功能 + 标准转接包。</p>
</div>
</div>
<!-- 15. NOT IMPL -->
<div class="section" id="notimpl">
<div class="section-title"><span class="icon">🚫</span> 15. 未实现 / 已否决功能</div>
<div class="card">
<table class="feat-table">
<tr><th>功能</th><th>状态</th><th>原因 / 替代方案</th></tr>
<tr><td>文件编辑器(Monaco/ACE</td><td><span class="badge badge-red">已否决</span></td><td>运维场景用 WebSSH + vim/nano;嵌入编辑器增加复杂度无实质收益</td></tr>
<tr><td>会话录像(asciicast</td><td><span class="badge badge-red">已否决</span></td><td>命令日志已满足审计需求</td></tr>
<tr><td>RBAC 权限体系</td><td><span class="badge badge-red">已否决</span></td><td>鉴权即信任,单管理员模型;增加 RBAC 代价远超收益</td></tr>
<tr><td>移动端适配</td><td><span class="badge badge-red">已否决</span></td><td>2000 台服务器运维不会用手机操作</td></tr>
<tr><td>批量服务器导入(CSV</td><td><span class="badge badge-amber">未实现</span></td><td>可通过 API 批量 POST /api/servers/ 实现</td></tr>
<tr><td>文件上传到远程服务器</td><td><span class="badge badge-amber">未实现</span></td><td>可用 WebSSH scp / 推送功能代替</td></tr>
<tr><td>文件内容预览</td><td><span class="badge badge-amber">未实现</span></td><td>可通过 WebSSH cat 查看</td></tr>
<tr><td>WebSSH 断线自动重连</td><td><span class="badge badge-amber">未实现</span></td><td>当前需刷新页面;计划自动重试+按钮</td></tr>
<tr><td>仪表盘历史趋势图</td><td><span class="badge badge-amber">未实现</span></td><td>仅实时 Redis 指标</td></tr>
<tr><td>宝塔 API 监控集成</td><td><span class="badge badge-amber">未实现</span></td><td>可选;参考 btpanel-skills</td></tr>
<tr><td>Agent mTLS</td><td><span class="badge badge-amber">讨论中</span></td><td>未实现</td></tr>
<tr><td>首次部署 E2ET1T5、T-SE</td><td><span class="badge badge-amber">待验证</span></td><td>见 production-verification-checklist.md</td></tr>
</table>
</div>
</div>
<!-- FOOTER -->
<footer>
<p>Nexus 6.0 全站功能说明书 · 2026-05-23(对齐代码库)</p>
<p style="margin-top:6px">
<a href="project/nexus-full-site-features.md">全站功能</a> ·
<a href="project/standards-transfer-package.md">标准转接</a> ·
<a href="project/development-acceptance-standard.md">验收标准</a> ·
<a href="project/AI-HANDOFF-2026-05-23.md">AI 接续</a>
</p>
<p style="margin-top:4px">Python 3.12 / FastAPI 0.115 / MySQL 8.4 / Redis 7 / asyncssh 2.17 / Tailwind v4</p>
</footer>
</div>
</body>
</html>
@@ -0,0 +1,35 @@
# 审计 — 文件管理「新建文件」修复
**Changelog**: `docs/changelog/2026-06-13-files-new-file-fix.md`
## 变更文件
| 文件 | 说明 |
|------|------|
| `frontend/src/composables/files/useFilesActions.ts` | `openNewFileDialog`、等待编辑器就绪 |
| `frontend/src/composables/files/useFilesEditor.ts` | 导出 `waitForEditorWorkbench` |
| `frontend/src/composables/files/useFilesPage.ts` | 透传 helper |
| `frontend/src/components/files/FilesToolbar.vue` | 未选服务器时提示 |
| `server/infrastructure/ssh/asyncssh_pool.py` | SFTP 写前确保父目录 |
## Step 3(规则扫描)
| 规则 | 结论 | 说明 |
|------|------|------|
| 鉴权 | PASS | 仍走 `/sync/write-file` + admin JWT |
| 路径 | PASS | `validatePathSegment` 不变 |
| 静默失败 | PASS | 编辑器未就绪有 warning snackbar |
| SSH | PASS | `mkdir -p` 仅父目录 |
## Closure
| 文件 | 结论 |
|------|------|
| `useFilesActions.ts` | SAFE — snackbar 错误提示 |
| `asyncssh_pool.py` | SAFE — 复用 `_ensure_remote_parent_dir` |
## DoD
- [x] changelog + 本审计
- [x] frontend type-check
- [ ] 生产验证新建文件
@@ -0,0 +1,28 @@
# 审计 — SSH 监测探针引号修复
**Changelog**: `docs/changelog/2026-06-13-ssh-watch-probe-quote-fix.md`
## 审计范围
| 文件 | 变更 | 状态 |
|------|------|------|
| `server/infrastructure/ssh/remote_probe.py` | `strip(chr(34))` | ☑ |
| `tests/test_watch_metrics.py` | 探针脚本回归 | ☑ |
## Step 3 规则扫描
| H | 规则 | 结论 |
|---|------|------|
| H1 | 无新攻击面 | SAFE |
| H2 | 恢复监测可用性 | SAFE — 热修复 |
## Closure
| H | 判定 | 依据 |
|---|------|------|
| H1H2 | SAFE | 本地 bash 执行探针返回 status ok |
## DoD
- [x] `test_ssh_watch_metrics_cmd_runs_locally` passed
- [x] changelog / audit
@@ -0,0 +1,69 @@
# 审计 — 监测历史北京时间 + 下线远程浏览器
**Changelog**:
- `docs/changelog/2026-06-13-watch-history-beijing-time.md`
- `docs/changelog/2026-06-12-remove-remote-browser.md`
## 审计范围
| 文件 | 变更 | 状态 |
|------|------|------|
| `frontend/src/utils/datetime.ts` | `formatChartAxisBeijing` | ☑ |
| `frontend/src/components/watch/WatchProbeRecordsTable.vue` | 北京时间列 | ☑ |
| `frontend/src/components/watch/WatchSparkline.vue` | tooltip 北京时间 | ☑ |
| `frontend/src/components/watch/WatchTrendChart.vue` | 轴/tooltip 北京时间 | ☑ |
| `frontend/src/pages/WatchMetricsPage.vue` | 「北京时间」标签 | ☑ |
| `frontend/src/App.vue` | 移除 GlobalBrowserPanel | ☑ |
| `frontend/src/router/index.ts` | 移除 `/browser` 路由 | ☑ |
| `frontend/src/pages/ServersPage.vue` | 站点 `window.open` | ☑ |
| `frontend/src/pages/AuditPage.vue` | 移除未用 import | ☑ |
| `frontend/src/components/GlobalBrowserPanel.vue` | 删除 | ☑ |
| `frontend/src/composables/useGlobalBrowser.ts` | 删除 | ☑ |
| `frontend/src/composables/useRemoteBrowserSession.ts` | 删除 | ☑ |
| `frontend/src/pages/BrowserPage.vue` | 删除 | ☑ |
| `server/main.py` | 注销 browser 路由 | ☑ |
| `server/api/browser.py` | 删除 | ☑ |
| `server/api/browser_session.py` | 删除 | ☑ |
| `server/infrastructure/browser/` | 删除 | ☑ |
| `__init__.py` | browser 包删除 | ☑ |
| `server/utils/browser_ui_state.py` | 删除 | ☑ |
| `server/utils/browser_url_safe.py` | 删除 | ☑ |
| `browser-worker/` | 删除 | ☑ |
| `Dockerfile` | browser-worker 删除 | ☑ |
| `browser-worker/main.py` | 删除 | ☑ |
| `browser-worker/browser_url_safe.py` | 删除 | ☑ |
| `browser-worker/browser_stealth.py` | 删除 | ☑ |
| `browser-worker/config.py` | 删除 | ☑ |
| `browser-worker/docker-compose.yml` | 删除 | ☑ |
| `browser-worker/docker-entrypoint.sh` | 删除 | ☑ |
| `browser-worker/requirements.txt` | 删除 | ☑ |
| `browser-worker/session_manager.py` | 删除 | ☑ |
| `server/infrastructure/browser/__init__.py` | 删除 | ☑ |
| `server/infrastructure/browser/session_registry.py` | 删除 | ☑ |
| `server/infrastructure/browser/worker_client.py` | 删除 | ☑ |
| `tests/test_browser_ui_state.py` | 删除 | ☑ |
| `tests/test_browser_url_safe.py` | 删除 | ☑ |
| `tests/chain/test_agent_dashboard_flow.py` | Redis pipeline mock | ☑ |
## Step 3 规则扫描
| H | 规则 | 结论 |
|---|------|------|
| H1 | 北京时间仅前端展示,不改 API 时区 | SAFE |
| H2 | 浏览器下线后无孤立 `/api/browser` 注册 | SAFE |
| H3 | `browserUrl.ts` 保留供站点外链 | SAFE |
| H4 | 无新密钥/明文 | SAFE |
## Closure
| H | 判定 | 依据 |
|---|------|------|
| H1H4 | SAFE | L2b 26/26chain 9/9vite build OK |
## DoD
- [x] `bash scripts/local_verify.sh`
- [x] `pytest tests/chain -q`
- [x] `cd frontend && npx vite build`
- [x] changelog + audit
- [ ] 生产 `/health` + `#/watch-metrics` 北京时间终验
@@ -0,0 +1,39 @@
# 审计 — 监测插槽硬件详情
**Changelog**: `docs/changelog/2026-06-13-watch-slot-hardware-specs.md`
## 审计范围
| 文件 | 变更 | 状态 |
|------|------|------|
| `server/utils/watch_metrics.py` | 硬件字段、fill_hardware_gaps | ☑ |
| `server/infrastructure/ssh/remote_probe.py` | SSH 探针采集详情 | ☑ |
| `server/background/watch_probe_runner.py` | 探针后补齐用量 | ☑ |
| `web/agent/agent.py` | `_hardware_specs_dict` 心跳 | ☑ |
| `frontend/src/components/watch/WatchSlotCard.vue` | 详情 UI | ☑ |
| `frontend/src/components/watch/WatchMetricBar.vue` | sub 副行 | ☑ |
| `frontend/src/utils/watchFormat.ts` | 格式化 | ☑ |
| `frontend/src/composables/useWatchPins.ts` | 类型 | ☑ |
| `tests/test_watch_metrics.py` | 单测 | ☑ |
## Step 3 规则扫描
| H | 规则 | 结论 |
|---|------|------|
| H1 | cpu_model 长度截断 | SAFE — `_short_text` 120 字符 |
| H2 | 无新公开端点 | SAFE — 复用 watch WS |
| H3 | SSH 脚本无 shell 注入 | SAFE — 固定 python -c |
| H4 | 旧 Agent 回退 | SAFE — `fill_hardware_gaps` 按 % 推算 |
## Closure
| H | 判定 | 依据 |
|---|------|------|
| H1H4 | SAFE | `pytest tests/test_watch_metrics.py` 13/13 |
## DoD
- [x] pytest watch_metrics passed
- [x] changelog / audit
- [x] 前端 build 由部署脚本执行
- [x] 无密钥类配置变更
@@ -0,0 +1,29 @@
# 审计 — 监测槽暂停态居中开关
**Changelog**: `docs/changelog/2026-06-13-watch-slot-paused-center-switch.md`
## 审计范围
| 文件 | 变更 | 状态 |
|------|------|------|
| `WatchSlotCard.vue` | 暂停态居中开关、开启后才显示指标 | ☑ |
## Step 3 规则扫描
| H | 规则 | 结论 |
|---|------|------|
| H1 | 无新 API | SAFE — 仍 `emit('monitoring', …)` |
| H2 | 误触历史 | SAFE — 暂停态卡片主体不跳转,仅「历史」按钮 |
| H3 | 双开关冲突 | SAFE — 顶栏开关仅 `monitoringOn` 时显示;暂停仅中部开关开启 |
## Closure
| H | 判定 | 依据 |
|---|------|------|
| H1H3 | SAFE | 纯前端 `WatchSlotCard.vue` 模板分支 |
## DoD
- [x] changelog
- [x] `cd frontend && npx vite build`
- [ ] 生产浏览器终验:暂停槽中部开关 → 开启后见 CPU/内存
+70
View File
@@ -0,0 +1,70 @@
# 审计 — 监测 UI 视觉迭代
**Changelog**: `docs/changelog/2026-06-13-watch-ui-polish.md`
**范围**:监测历史页 / 趋势图 / 探针表 / 实时槽 — 纯前端视觉,无 API 或后端变更
## 审计范围
| 文件 | 变更 | 状态 |
|------|------|------|
| `frontend/src/pages/WatchMetricsPage.vue` | 卡片页头、共用时间窗、骨架屏/空态 | ☑ |
| `frontend/src/components/watch/WatchTrendChart.vue` | 主题色 ECharts、暗色 tooltip/轴 | ☑ |
| `frontend/src/components/watch/WatchProbeRecordsTable.vue` | 表容器、色阶、分页 | ☑ |
| `frontend/src/components/watch/WatchSlotCard.vue` | I/O 网格、分隔线 | ☑ |
| `frontend/src/components/watch/WatchMetricBar.vue` | 进度条高度/过渡 | ☑ |
| `frontend/src/components/watch/WatchSparkline.vue` | 主题色折线、渐变面积 | ☑ |
| `frontend/src/components/watch/WatchSlotRow.vue` | 行首标题区 | ☑ |
| `frontend/src/utils/watchFormat.ts` | `metricPctClass` / `formatProbePct` | ☑ |
## Step 3 规则扫描
| H | 规则 | 结论 |
|---|------|------|
| H1 | XSS / HTML 注入 | SAFE — 趋势 tooltip 仅拼接固定 `labels` 与数值;探针错误列用 Vue 文本插值,非 `v-html` |
| H2 | 新 API / 鉴权面 | SAFE — 无后端改动;仍走既有 `/watch/metrics``/watch/probe-records` |
| H3 | 密钥 / 凭据 | SAFE — 无配置或环境变量变更 |
| H4 | 时区语义 | SAFE — 仅展示层;`formatDateTimeBeijing` / `formatChartAxisBeijing` 逻辑未改 |
| H5 | 分页与筛选 | SAFE — `statusFilter` 变更时 `page = 1``pageCount` 下限为 1 |
| H6 | 主题色一致性 | SAFE — `WatchTrendChart` 对齐 `FleetTrendEChart``useTheme` 模式 |
| H7 | Sparkline 渐变 | P3 — `areaStyle` 使用 `${color}33` 后缀透明度,依赖主题为 `#hex`;若主题为 `rgb()` 渐变可能失效(线条仍可见) |
## Closure
| H | 判定 | 依据 |
|---|------|------|
| H1 | SAFE | `WatchTrendChart.vue` L94112seriesName 来自本地 `labels`;时间经 `formatDateTimeBeijing` |
| H2 | SAFE | diff 仅 `frontend/src/**` |
| H3 | SAFE | 无 `server/``.env` 变更 |
| H4 | SAFE | 未改 `datetime.ts` |
| H5 | SAFE | `WatchProbeRecordsTable.vue` L88、`pageCount` computed |
| H6 | SAFE | 与 `FleetTrendEChart.vue` 同模式 `themeColor()` |
| H7 | P3 接受 | 与 Vuetify 默认 hex 主题兼容;后续可改为 `color-mix` 或固定 alpha 对象 |
## 验证
| 项 | 结果 |
|----|------|
| `cd frontend && npx vite build` | ✅ 6.5s |
| `bash scripts/local_verify.sh` | 未重跑(纯前端 UI,无 API 变更) |
| 门控 Gate 7 文件名 | 上表已列全 8 个源码文件 basename |
## DoD
- [x] changelog `2026-06-13-watch-ui-polish.md`(≥10 行)
- [x] 本审计含 Step 3 / Closure / DoD
- [x] `vite build` 通过
- [x] 提交 `4bbc47f` + 生产部署(2026-06-13
- [x] 生产 `/health` ok、`/app/` 200
- [ ] 浏览器终验:`#/watch-metrics` 暗色/亮色、探针分页、四槽空态
## 部署说明
- **仅前端**`vite build``deploy/deploy-frontend.sh` 或全量 `deploy-production.sh`
- **无需** 重启后端或数据库迁移
## 待办(非阻塞)
| 优先级 | 项 |
|--------|-----|
| P3 | Sparkline `areaStyle``rgb()` 主题下的渐变兼容 |
| P3 | 探针表 `total=0` 时隐藏分页器(UX |
+35
View File
@@ -0,0 +1,35 @@
# 审计 — 2026-06-14 门控 Gate 8 AI Review
**Changelog**: `docs/changelog/2026-06-14-gate-ai-review.md`
## Step 3 规则扫描
| 文件 | 规则 | 结论 |
|------|------|------|
| `scripts/gate_ai_review.py` | 无静默吞错 | PASS — 失败 exit 1 + 明确 stderr |
| `scripts/gate_ai_review.py` | 无明文密钥 | PASS — 仅用 env CURSOR_API_KEY(标准) |
| `scripts/gate_ai_review.py` | subprocess 安全 | PASS — 固定 argv,无 shell 拼接用户输入 |
| `deploy/pre_deploy_check.sh` | 门控计数一致 | PASS — GATES_TOTAL=8 |
| `docs/reviews/` | 产物可追溯 | PASS — JSON 含 head_sha、verdict、findings |
## Closure
| 项 | 状态 | 说明 |
|----|------|------|
| gate_ai_review.py | SAFE | 只读 review + 写 docs/reviews |
| pre_deploy_check.sh Gate 8 | SAFE | 调用 python 脚本 |
| 应急跳过 | SAFE | NEXUS_SKIP_AI_REVIEW=1 显式 opt-out |
## 文件清单
- `scripts/gate_ai_review.py`
- `scripts/gate_ai_review.sh`
- `deploy/pre_deploy_check.sh`
- `docs/reviews/.gitkeep`
## DoD
- [x] Step 3 规则扫描
- [x] Closure 表
- [x] 文件清单与 git diff 对齐
- [x] changelog ≥10 行
@@ -0,0 +1,37 @@
# 审计 — Layer3 health_monitor P2 + 监测 UI P3
**Changelog**: `docs/changelog/2026-06-14-layer3-health-monitor-p2-p3-ui.md`
## 审计范围
| 文件 | 变更 | 状态 |
|------|------|------|
| `deploy/health_monitor.sh` | 状态目录、docker_cmd、heredoc Telegram | ☑ |
| `tests/test_ops_patrol_sync.py` | bash -n + 路径断言 | ☑ |
| `frontend/src/utils/echartsColor.ts` | 新建 | ☑ |
| `WatchSparkline.vue` | rgba 渐变 | ☑ |
| `WatchProbeRecordsTable.vue` | 空表隐藏分页 | ☑ |
| `WatchSlotCard.vue` | ⋮ 菜单、暂停态精简(同批部署) | ☑ |
| `WatchMetricRing.vue` | `dimmed` 占位(同批) | ☑ |
| `server/background/watch_probe_runner.py` | 删除未使用 import | ☑ |
## Step 3
| H | 规则 | 结论 |
|---|------|------|
| H1 | sudo docker | SAFE — 与 `nexus-1panel.sh` 同模式,失败则跳过重启 |
| H2 | 状态目录权限 | SAFE — 在 DEPLOY_DIR 下,cron 用户可写 |
| H3 | Telegram HTML | SAFE — heredoc 真实换行,`--data-urlencode` 不变 |
## Closure
| H | 判定 |
|---|------|
| H1H3 | SAFE |
## DoD
- [x] changelog
- [x] `bash -n` + pytest ops_patrol
- [x] vite build
- [ ] 生产 L3 日志 5min 无新错误
@@ -0,0 +1,36 @@
# 审计 — 监测服务器详情(宝塔对齐)
**Changelog**: `docs/changelog/2026-06-14-watch-baota-server-detail.md`
## 审计范围
| 文件 | 变更 | 状态 |
|------|------|------|
| `remote_probe.py` | 负载进程数、分核 CPU、内存 MB、双 Top5 | ☑ |
| `watch_metrics.py` | `sanitize_process_bundle`、live 字段 | ☑ |
| `watch_service.py` | 进程 API 返回 bundle | ☑ |
| `WatchProcessDrawer.vue` | 宝塔式详情抽屉 | ☑ |
| `watchFormat.ts` / `useWatchPins.ts` | 类型与格式化 | ☑ |
| `WatchSlotCard.vue` | 「详情」入口 | ☑ |
| `tests/test_watch_metrics.py` | 新字段单测 | ☑ |
## Step 3
| H | 规则 | 结论 |
|---|------|------|
| H1 | SSH 脚本注入 | SAFE — 固定 psutil 脚本 |
| H2 | API 契约 | SAFE — `processes` 兼容 list→bundle |
| H3 | 性能 | NOTE — 分核 CPU 每 5s 一次 SSH,与现有探针同频 |
## Closure
| H | 判定 |
|---|------|
| H1H3 | SAFE / 接受 |
## DoD
- [x] changelog
- [x] pytest watch_metrics
- [x] vite build
- [ ] 生产终验详情抽屉
@@ -0,0 +1,36 @@
# 审计 — 监测开启后立即探针
**Changelog**: `docs/changelog/2026-06-14-watch-eager-probe-on-resume.md`
## 审计范围
| 文件 | 变更 | 状态 |
|------|------|------|
| `watch_probe_runner.py` | 抽取 `_probe_server_with_pins``trigger_immediate_watch_probe` | ☑ |
| `watch_service.py` | 加槽/替换/恢复监测后 eager probe | ☑ |
| `useWatchPins.ts` | 恢复监测 3s/8s 兜底 refresh | ☑ |
| `WatchSlotCard.vue` | 暂停底栏「已暂停」文案 | ☑ |
| `test_watch_pins.py` | 恢复监测立即 metrics 用例 + noop mock | ☑ |
## Step 3 规则扫描
| H | 规则 | 结论 |
|---|------|------|
| H1 | 无静默吞错 | SAFE — `trigger_immediate_watch_probe` 异常 `logger.exception` |
| H2 | 探针重复/竞态 | SAFE — 与 5s 循环共用 `_probe_server_with_pins`,仅多一次即时采样 |
| H3 | 暂停清空 Redis | SAFE — 仍仅在 `monitoring_enabled=false``clear_watch_redis_snapshot` |
| H4 | WS 推送 | SAFE — 即时探针同样 `publish_watch_metrics` |
| H5 | 前端轮询 | SAFE — 仅 `isWatchMetricsPending` 时 refresh,不刷屏 |
## Closure
| H | 判定 | 依据 |
|---|------|------|
| H1H5 | SAFE | 恢复监测空窗期根因已消除;测试 10/10local_verify 26/26 |
## DoD
- [x] changelog ≥10 行
- [x] `pytest tests/test_watch_pins.py`
- [x] `bash scripts/local_verify.sh`
- [ ] 生产:开启实时监测后数秒内见指标或探针错误态
@@ -0,0 +1,29 @@
# 审计 — 监测槽暂停态居中开关
**Changelog**: `docs/changelog/2026-06-14-watch-slot-paused-center-switch.md`
## 审计范围
| 文件 | 变更 | 状态 |
|------|------|------|
| `WatchSlotCard.vue` | 暂停态居中开关、开启后才显示指标 | ☑ |
## Step 3 规则扫描
| H | 规则 | 结论 |
|---|------|------|
| H1 | 无新 API | SAFE — 仍 `emit('monitoring', …)` |
| H2 | 误触历史 | SAFE — 暂停态卡片主体不跳转,仅「历史」按钮 |
| H3 | 双开关冲突 | SAFE — 顶栏开关仅 `monitoringOn` 时显示;暂停仅中部开关开启 |
## Closure
| H | 判定 | 依据 |
|---|------|------|
| H1H3 | SAFE | 纯前端 `WatchSlotCard.vue` 模板分支 |
## DoD
- [x] changelog
- [x] `cd frontend && npx vite build`
- [ ] 生产浏览器终验:暂停槽中部开关 → 开启后见 CPU/内存
@@ -0,0 +1,30 @@
# 审计 — 监测槽暂停态 UI 与顶栏间距
**Changelog**: `docs/changelog/2026-06-14-watch-slot-paused-ui-polish.md`
## 审计范围
| 文件 | 变更 | 状态 |
|------|------|------|
| `WatchSlotCard.vue` | 顶栏 ⋮ 菜单(暂停/恢复/移除);底栏暂停;暂停态 UI | ☑ |
| `WatchMetricRing.vue` | `dimmed` 占位态 | ☑ |
## Step 3 规则扫描
| H | 规则 | 结论 |
|---|------|------|
| H1 | 无新 API | SAFE — 仍 `emit('monitoring'/'remove')` |
| H2 | 误触移除/暂停 | SAFE — 顶栏无开关;暂停/移除在菜单内;底栏「暂停」为文字链 |
| H3 | 无障碍 | SAFE — `aria-label` on 开关与移除 |
## Closure
| H | 判定 | 依据 |
|---|------|------|
| H1H3 | SAFE | 纯前端 |
## DoD
- [x] changelog ≥10 行
- [x] `vite build`
- [ ] 生产终验:暂停态无大虚线框;开启后开关与 ✕ 不贴在一起
@@ -0,0 +1,70 @@
# 审计 — 监测探针 SSH 优先 + 移除 IO + 宝塔圆环 UI
**Changelog**:
- `docs/changelog/2026-06-14-watch-probe-ssh-first-no-io-skip.md`
- `docs/changelog/2026-06-14-watch-remove-io-metrics.md`
- `docs/changelog/2026-06-14-watch-metric-ring-baota-style.md`
**范围**:实时监测探针与槽卡片 UI,对齐宝塔 CPU/内存/磁盘/负载展示。
## 审计范围
| 文件 | 变更 | 状态 |
|------|------|------|
| `server/background/watch_probe_runner.py` | 5s 一律 SSH;去掉 IO 速率差分 | ☑ |
| `server/utils/watch_metrics.py` | merge/live 优先 SSH;去掉 bps | ☑ |
| `server/infrastructure/ssh/remote_probe.py` | 精简探针命令;load 补位 | ☑ |
| `tests/test_watch_metrics.py` | 适配无 IO / SSH 优先 | ☑ |
| `frontend/src/components/watch/WatchMetricRing.vue` | 新建圆环组件 | ☑ |
| `frontend/src/components/watch/WatchSlotCard.vue` | 三圆环 + 负载圆环 | ☑ |
| `frontend/src/components/watch/WatchProbeRecordsTable.vue` | 去掉 IO 列 | ☑ |
| `frontend/src/components/watch/WatchTrendChart.vue` | 去掉 IO 曲线 | ☑ |
| `frontend/src/components/watch/WatchSparkline.vue` | 去掉 IO 指标 | ☑ |
| `frontend/src/composables/useWatchPins.ts` | 类型去掉 bps | ☑ |
| `frontend/src/pages/WatchMetricsPage.vue` | 去掉 IO 展示 | ☑ |
| `frontend/src/utils/watchFormat.ts` | `metricRingColor` / `loadUsagePct` | ☑ |
| `frontend/src/components/watch/WatchMetricBar.vue` | 删除(横条弃用) | ☑ |
## Step 3 规则扫描
| H | 规则 | 结论 |
|---|------|------|
| H1 | SSH 凭据与命令注入 | SAFE — 仍走既有 `remote_probe` 封装;无用户输入拼入 shell |
| H2 | 探针频率与负载 | NOTE — 有 SSH 时每 5s 一次 SSH(与宝塔同源,用户明确要求) |
| H3 | 数据合并优先级 | SAFE — SSH 覆盖 Agent stale 心跳;SSH 全失败才回退 Agent |
| H4 | 前端 XSS | SAFE — 圆环仅数值与固定标签;tooltip 文本插值 |
| H5 | IO 字段移除 | SAFE — DB 列保留 NULLAPI 不再推送 bps |
| H6 | 密钥 / 凭据 | SAFE — 无 `.env` 或密钥变更 |
## Closure
| H | 判定 | 依据 |
|---|------|------|
| H1 | SAFE | `remote_probe.py` 固定 psutil 脚本 |
| H2 | 接受 | 用户要求与宝塔一致;仅监测开启槽 |
| H3 | SAFE | `merge_redis_and_ssh` CPU/mem/disk/load 优先 SSH |
| H4 | SAFE | `WatchMetricRing.vue``v-html` |
| H5 | SAFE | `to_live_dict` 无 bps 键 |
| H6 | SAFE | diff 无 secrets |
## 验证
| 项 | 结果 |
|----|------|
| `pytest tests/test_watch_metrics.py tests/test_watch_pins.py -q` | 22 passed(本机无 psutil 的 SSH 本地探针 1 skip/fail 为环境) |
| `cd frontend && npx vite build` | ✅ |
| `bash deploy/pre_deploy_check.sh` | 部署前执行 |
## DoD
- [x] changelog 三份(≥10 行)
- [x] 本审计含 Step 3 / Closure / DoD / 文件清单
- [x] `vite build` 通过
- [ ] 提交并 push
- [ ] 生产 `/health` ok、`/app/` 200
- [ ] 浏览器终验:`#/watch-metrics` 圆环与宝塔数值接近
## 部署说明
- **后端 + 前端**:需 API 重启(Docker upgrade+ `vite build` 同步 `web/app`
- **无** 数据库迁移
@@ -0,0 +1,33 @@
# 审计 — 终端 UI 部署(2026-06-17
**Changelog**: `docs/changelog/2026-06-17-terminal-ui-deploy.md`
## 审计范围
| 模块 | 文件 |
|------|------|
| 终端页 | `frontend/src/pages/TerminalPage.vue` |
| 右键菜单 | `frontend/src/components/terminal/TerminalContextMenu.vue` |
| xterm 会话 | `frontend/src/composables/terminal/useTerminalSessions.ts` |
| 全局布局 | `frontend/src/App.vue` |
## Step 3
| H | 规则 | 结论 |
|---|------|------|
| H1 | 纯前端,无新 API | PASS |
| H2 | 剪贴板仍走浏览器 API | PASS |
| H3 | 无密钥/凭据变更 | PASS |
## Closure
| H | 判定 |
|---|------|
| H1H3 | PASS |
## DoD
- [x] `local_verify.sh` 26/26
- [x] `npx vite build`
- [x] changelog + audit
- [ ] 生产 `/app/` 200 + 浏览器终验
@@ -0,0 +1,34 @@
# 审计 — 2026-06-19 登录白名单 LOGIN_ALLOWED_IPS SSOT
**Changelog**: `docs/changelog/2026-06-19-login-allowlist-auth-ssot.md`
## Step 3 规则扫描
| 文件 | 规则 | 结论 |
|------|------|------|
| `server/utils/login_allowlist.py` | 多 worker 数据一致 | PASS — 优先 `LOGIN_ALLOWED_IPS` |
| `server/utils/login_allowlist.py` | 无静默吞错 | PASS — 空列表回退 sub+manual |
| `frontend/src/pages/SettingsPage.vue` | 刷新失败 UX | PASS — `refresh_ok=false``loadAllowlist` |
| `tests/test_login_access.py` | SSOT 回归 | PASS — 新增用例 |
## Closure
| 项 | 状态 | 说明 |
|----|------|------|
| login_allowlist.py | SAFE | 校验与 DB 广播合并列表一致 |
| SettingsPage.vue | SAFE | 失败不覆盖 chip |
| test_login_access.py | SAFE | 覆盖 SSOT 路径 |
## 文件清单
- `server/utils/login_allowlist.py`
- `frontend/src/pages/SettingsPage.vue`
- `tests/test_login_access.py`
- `docs/changelog/2026-06-19-login-allowlist-auth-ssot.md`
## DoD
- [x] Step 3 规则扫描
- [x] Closure 表
- [x] 文件清单与 git diff 对齐
- [x] changelog ≥10 行
@@ -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,78 @@
# 审计 — 2026-06-21 宝塔 SSH 自动获取 API 部署
**Changelog**: `docs/changelog/2026-06-20-btpanel-ssh-api-bootstrap.md`
**Commit**: `82426b19` feat(btpanel): 独立宝塔模块与 SSH 自动获取 API
## Step 3 规则扫描
| 文件 | 规则 | 结论 |
|------|------|------|
| `ssh_bootstrap.py` | 无静默吞错、远程 JSON 校验 | PASS |
| `bootstrap_state.py` | 仅已入库服务器进 loop | PASS |
| `btpanel_service.py` | CUD 审计 `bt_panel_ssh_bootstrap` | PASS |
| `BtPanelSettingsPage.vue` | 批量操作二次确认 | PASS |
| `bootstrap_lock.py` | 同机并发 SSH 互斥 | PASS |
## Closure
| 项 | 状态 | 说明 |
|----|------|------|
| 后台 loop 误扫全库 | FIXED | `is_eligible` 需显式 `bt_panel` 块 |
| `result.success` SSH | FIXED | 改用 `status == success` |
| Permission denied 误判 | FIXED | 区分 SSH 认证与 api.json 写权限 |
| 空凭据标 ready | FIXED | `bootstrap_empty_credentials` |
| get_config 每次 SSH | FIXED | pending/installing 或 refresh 才探测 |
## 文件清单
- `frontend/src/App.vue`
- `frontend/src/api/btpanel.ts`
- `frontend/src/components/btpanel/BtPanelPageShell.vue`
- `frontend/src/components/btpanel/BtPanelServerPicker.vue`
- `frontend/src/composables/btpanel/btPanelContext.ts`
- `frontend/src/composables/btpanel/useBtPanelPageLoad.ts`
- `frontend/src/composables/btpanel/useBtPanelServer.ts`
- `frontend/src/constants/cachedPages.ts`
- `frontend/src/pages/btpanel/BtPanelCrontabPage.vue`
- `frontend/src/pages/btpanel/BtPanelDatabasesPage.vue`
- `frontend/src/pages/btpanel/BtPanelDomainsPage.vue`
- `frontend/src/pages/btpanel/BtPanelLoginPage.vue`
- `frontend/src/pages/btpanel/BtPanelMonitorPage.vue`
- `frontend/src/pages/btpanel/BtPanelServicesPage.vue`
- `frontend/src/pages/btpanel/BtPanelSettingsPage.vue`
- `frontend/src/pages/btpanel/BtPanelSiteCreatePage.vue`
- `frontend/src/pages/btpanel/BtPanelSitesPage.vue`
- `frontend/src/pages/btpanel/BtPanelSslPage.vue`
- `frontend/src/router/index.ts`
- `server/api/btpanel.py`
- `server/api/btpanel_schemas.py`
- `server/api/servers.py`
- `server/application/services/btpanel_bootstrap_schedule.py`
- `server/application/services/btpanel_service.py`
- `server/application/services/server_batch_service.py`
- `server/background/bt_panel_bootstrap_loop.py`
- `server/config.py`
- `server/infrastructure/btpanel/__init__.py`
- `server/infrastructure/btpanel/bootstrap_lock.py`
- `server/infrastructure/btpanel/bootstrap_state.py`
- `server/infrastructure/btpanel/client.py`
- `server/infrastructure/btpanel/credentials.py`
- `server/infrastructure/btpanel/source_ip.py`
- `server/infrastructure/btpanel/ssh_bootstrap.py`
- `server/infrastructure/btpanel/ssh_login.py`
- `server/infrastructure/ssh/remote_shell.py`
- `server/main.py`
- `tests/test_btpanel_bootstrap_loop.py`
- `tests/test_btpanel_client.py`
- `tests/test_btpanel_get_config.py`
- `tests/test_btpanel_ssh_bootstrap.py`
- `tests/test_btpanel_ssh_login.py`
## DoD
- [x] Step 3 规则扫描
- [x] Closure 表
- [x] 文件清单与 `git diff HEAD~1..HEAD` 对齐
- [x] `local_verify` 通过
- [x] `tests/test_btpanel_*.py` 25 passed
- [x] 需重启 API 注册 `bt_panel_bootstrap_loop`
@@ -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_timetoken 拼 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,34 @@
# 审计 — 2026-06-21 登录白名单 SSOT 生产部署
**Changelog**: `docs/changelog/2026-06-21-login-allowlist-deploy.md`
## Step 3 规则扫描
| 文件 | 规则 | 结论 |
|------|------|------|
| `server/utils/login_allowlist.py` | SSOT / 多 worker | PASS |
| `frontend/src/pages/SettingsPage.vue` | 刷新失败 UX | PASS |
| `tests/test_login_access.py` | 回归覆盖 | PASS |
## Closure
| 项 | 状态 | 说明 |
|----|------|------|
| login_allowlist.py | SAFE | 优先 LOGIN_ALLOWED_IPS |
| SettingsPage.vue | SAFE | refresh_ok=false 重载 |
| test_login_access.py | SAFE | SSOT 用例 |
## 文件清单
- `server/utils/login_allowlist.py`
- `frontend/src/pages/SettingsPage.vue`
- `tests/test_login_access.py`
- `server/infrastructure/btpanel/client.py`bandit nosec,宝塔 API md5 协议)
- `scripts/gate_ai_review.sh`CRLF 修复)
## DoD
- [x] Step 3 规则扫描
- [x] Closure 表
- [x] 文件清单与 git diff 对齐
- [x] changelog ≥10 行
@@ -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 TTLJWT 前缀白名单 | 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_timetoken 拼 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 passedwatch + agent_watch_mode
- [x] 心跳响应含 watch_active
- [x] Agent 2.1.0 watch_mode 字段
## 验证
```bash
.venv/bin/pytest tests/test_agent_watch_mode.py tests/test_watch_metrics.py -q
```
@@ -0,0 +1,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
```
@@ -1,21 +0,0 @@
# 2026-06-06 — Gitea 推送脚本与本地 secrets
## 摘要
新增 `scripts/git-push.sh`,从 `deploy/nexus-1panel.secrets.sh` 读取 admin 凭据推送;更新 `.cursorrules` / `linux-dev-paths.md`
## 动机
公共仓库可匿名 pull,push 需写权限;凭据仅存本机 gitignore 文件,不入仓库。
## 涉及文件
- `scripts/git-push.sh`
- `deploy/nexus-1panel.secrets.sh.example`
- `.cursorrules``docs/project/linux-dev-paths.md`
## 验证
```bash
bash scripts/git-push.sh main # 需已配置 deploy/nexus-1panel.secrets.sh
```
@@ -1,34 +0,0 @@
# 2026-06-06 — 安装脚本按机自动生成唯一密钥
## 摘要
Docker / 1Panel 全新安装路径统一通过 `scripts/generate_nexus_secrets.py` 生成每台服务器独立的 `MYSQL_*``NEXUS_SECRET_KEY` / `NEXUS_API_KEY` / `NEXUS_ENCRYPTION_KEY`,写入 `docker/.env.prod` 并备份到 `/root/.nexus-install-secrets-*.env`。安装向导 `init-db` 复用容器环境内预生成密钥,避免与 Compose 不一致。
## 动机
新机安装不应从 `nexus-1panel.secrets.sh` 复制生产密钥;此前 `--fresh` 留空密钥导致 Compose 校验失败或各机密钥相同。
## 涉及文件
- `scripts/generate_nexus_secrets.py`(新建)
- `deploy/nexus-1panel.sh``write_env_prod` / `--reuse-secrets` / `--fresh` 忽略 secrets 中 NEXUS 项
- `deploy/install-nexus-fresh.sh` / `deploy/quick-install.sh`
- `docker/generate_env.py` — 复用同一生成器
- `docker/entrypoint.sh``NEXUS_INSTALL_WIZARD_PENDING=1` 时不写 `/app/.env`
- `docker/docker-compose.prod.yml` — 传递 `NEXUS_INSTALL_WIZARD_PENDING`
- `server/api/install.py``_install_keys_from_environment()`
- `deploy/README-1panel.md` · `deploy/nexus-1panel.secrets.sh.example`
## 迁移 / 重启
已部署实例无需变更。新机安装后需完成 `/app/install.html` 向导。
## 验证
```bash
python3 scripts/generate_nexus_secrets.py write-prod \
--template docker/.env.prod.example --output /tmp/t.env.prod \
--domain test.example.com --profile-env docker/profiles/2c8g.env --fresh --wizard-pending
grep NEXUS_SECRET_KEY /tmp/t.env.prod
ruff check server/api/install.py scripts/generate_nexus_secrets.py
```
@@ -1,29 +0,0 @@
# Changelog — 生产 SSH 凭据纳入 secrets + deploy 自动加载
**日期**2026-06-07
## 摘要
本机生产 SSH 私钥路径写入 `deploy/nexus-1panel.secrets.sh`gitignore);`deploy-production.sh` 启动时自动 source,与 `git-push.sh` 同一凭据文件。
## 动机
本机 `Host nexus` 未指向生产 VPS;部署脚本需 `NEXUS_SSH` / `NEXUS_SSH_KEY`,避免每次手动 export。
## 涉及文件
- `deploy/deploy-production.sh`
- `deploy/nexus-1panel.secrets.sh.example`
- `docs/project/linux-dev-paths.md`
- `deploy/nexus-1panel.secrets.sh`(本地 only,不入 git
## 迁移 / 重启
无。
## 验证
```bash
source deploy/nexus-1panel.secrets.sh
ssh -i "$NEXUS_SSH_KEY" "$NEXUS_SSH" "echo SSH_OK"
```
@@ -0,0 +1,32 @@
# 2026-06-10 — RDP Guacamole Client.State 常量修复
## 摘要
修复浏览器 RDP 已收到 `sync` 但 UI 永远停在「连接中」:`guacamole-common-js` 1.5 运行时使用 `Client.State.CONNECTED`,而非错误的 `STATE_CONNECTED`undefined)。
## 动机
Playwright E2E 与 WS 抓包显示 sync 已到达,但 Pinia 状态未变为 `connected`;根因是 `onstatechange``undefined` 比较永远不成立。
## 涉及文件
- `frontend/src/composables/rdp/useRdpSession.ts` — 使用 `Client.State` / `Tunnel.State`connect 序号防竞态
- `frontend/src/pages/RdpPage.vue``mounted` 守卫避免重复 connect
- `frontend/src/types/guacamole.d.ts` — 类型与运行时对齐
- `frontend/e2e/pages/rdp-connect.spec.mjs` — JWT 注入登录、nav 侧栏、canvas `.first()`
- `frontend/e2e/helpers.mjs``loginWithAccessToken``/rdp` 导航
- `scripts/rdp_ws_probe.py``scripts/guacd_rdp_probe.py` — 生产 WS/guacd 探针
## 迁移 / 重启
- 仅前端热更新(`web/app` sync 到容器)
## 验证
```bash
cd frontend && npx vite build
# 生产 Playwright(需 NEXUS_E2E_ACCESS_TOKEN 由容器 mint JWT
NEXUS_E2E_ACCESS_TOKEN=... npx playwright test e2e/pages/rdp-connect.spec.mjs
```
生产 E2E:约 12s 内出现「已连接」+ 1024×768 画布。
@@ -0,0 +1,22 @@
# 2026-06-10 — RDP 黑屏:缩放 + 键鼠
## 摘要
修复「已连接但黑屏」:Guacamole 画布默认 1024×768 未缩放,大区域为深色背景;补 `display.scale()` 适配容器、键鼠转发与 `onsync` 后重算缩放。
## 动机
E2E 显示已连接且 canvas 存在,但用户看到全黑;WS 已有 img/sync 帧,属前端渲染/布局问题。
## 涉及文件
- `frontend/src/composables/rdp/useRdpSession.ts` — scale、Mouse/Keyboard、onsync
- `frontend/src/pages/RdpPage.vue` — canvas 容器 flex 居中
- `frontend/src/types/guacamole.d.ts`
- `frontend/e2e/pages/rdp-connect.spec.mjs` — 采样非黑像素
## 验证
`NEXUS_E2E_ACCESS_TOKEN=... npx playwright test e2e/pages/rdp-connect.spec.mjs`
生产入口 bundle`index-DTfFTLS8.js`;强刷后 3389 页应可见桌面且可键鼠操作。
@@ -2,11 +2,11 @@
## 摘要
保留 **原 GlobalBrowserPanel 完整 UI**(标签、地址栏、canvas),固定在 App Bar 正下方全宽展示;移除地球图标、拖动、最小化、缩放角。账号仍在 App Bar 最右侧**不**把地址栏拆进 toolbar
原悬浮窗保留:可拖动、最小化条、缩放;**无地球启动钮**。登录后默认收起;有标签时显示**顶栏右下**最小化条。首次无保存坐标时面板默认在**账号右侧**(顶栏下右对齐)
## 动机
用户要的是原悬浮窗形态固定置顶,而非顶栏内嵌地址栏
用户只要改默认停靠位置,不要砍掉原悬浮窗能力
## 涉及文件
@@ -0,0 +1,31 @@
# 2026-06-12 移除远程内置浏览器方案
## 摘要
整体下线 Playwright Worker + 全局浮动 canvas 浏览器:删前端组件/路由、后端 `/api/browser/*``/ws/browser``browser-worker/` 目录及单测。
## 动机
黑屏与状态机复杂、验证码与 SSRF 未闭环;运维场景下「站点」新标签打开即可。经验沉淀见 `docs/design/specs/2026-06-12-remote-browser-retired-lessons.md`
## 涉及文件(删/改)
- 删:`frontend/src/components/GlobalBrowserPanel.vue``useGlobalBrowser.ts``useRemoteBrowserSession.ts``BrowserPage.vue`
- 删:`server/api/browser.py``browser_session.py``server/infrastructure/browser/*``server/utils/browser_*`
- 删:`browser-worker/``tests/test_browser_*`
- 改:`server/main.py`(已不注册路由)、`server/config.py`(移除 `BROWSER_*`
- 保留:`frontend/src/utils/browserUrl.ts``ServersPage`「站点」→ `window.open`
## 迁移/重启
- 生产可保留 `NEXUS_BROWSER_*``.env``Settings.extra=ignore` 忽略);建议删除 Worker 容器与 8443 规则。
- 需 **重启 Nexus API****前端 build 同步**
## 验证
```bash
venv/bin/pytest tests/test_settings_sound_defaults.py -q
cd frontend && npm run type-check && npx vite build
curl -sf https://api.synaglobal.vip/health
# 404 预期:GET /api/browser/status(需 JWT
```
@@ -0,0 +1,30 @@
# 2026-06-13 — 文件管理「新建文件」修复
## 摘要
修复文件页「新建文件」点击无反馈或创建后编辑器不弹出的问题。
## 动机
- 未选服务器时按钮被禁用,用户以为功能坏了
- 创建成功后未等待 `FileEditorWorkbench` 就绪即 `openFile`,编辑器静默不打开
- SFTP 写入前未确保父目录存在(部分路径下 write-file 失败)
## 涉及文件
- `frontend/src/composables/files/useFilesActions.ts``openNewFileDialog``waitForEditorWorkbench`
- `frontend/src/composables/files/useFilesEditor.ts` — 导出 `waitForEditorWorkbench`
- `frontend/src/composables/files/useFilesPage.ts` — 透传 helper
- `frontend/src/components/files/FilesToolbar.vue` — 未选服务器时提示而非禁用
- `server/infrastructure/ssh/asyncssh_pool.py` — SFTP 写前 `mkdir -p` 父目录
## 迁移/重启
- 需重启 nexus 容器 + 同步前端
## 验证
```bash
cd frontend && npm run type-check
# 生产:文件管理 → 选服务器 → 新建文件 → 应创建并打开编辑器
```
@@ -0,0 +1,22 @@
# 修复 SSH 监测探针脚本语法错误
**日期**2026-06-13
## 变更摘要
修复 `SSH_WATCH_METRICS_CMD``.strip('"')` 破坏 bash 双引号包裹导致探针全部 `ssh_probe_failed`、实时监测不可用的问题。
## 动机
硬件详情上线后监测插槽无数据;根因为嵌入脚本的 `"` 提前结束 `python3 -c "..."` 字符串。
## 涉及文件
- `server/infrastructure/ssh/remote_probe.py` — 改用 `strip(chr(34))`
- `tests/test_watch_metrics.py` — 本地执行探针脚本回归
## 验证
```bash
.venv/bin/pytest tests/test_watch_metrics.py::test_ssh_watch_metrics_cmd_runs_locally -q
```
@@ -0,0 +1,27 @@
# 监测历史时间显示改为北京时间
**日期**2026-06-13
## 变更摘要
监测历史页(探针记录表、资源趋势图、插槽折线 tooltip)将 API 返回的 UTC 时间按 `Asia/Shanghai` 格式化展示,与审计/告警等页一致。
## 动机
此前直接显示 UTC 的 `recorded_at` 原文,比北京时间少 8 小时,易误解。
## 涉及文件
- `frontend/src/utils/datetime.ts``formatChartAxisBeijing`
- `frontend/src/components/watch/WatchProbeRecordsTable.vue`
- `frontend/src/components/watch/WatchTrendChart.vue`
- `frontend/src/components/watch/WatchSparkline.vue`
- `frontend/src/pages/WatchMetricsPage.vue`
## 迁移 / 重启
仅前端;`vite build` 后生效,无需重启后端。
## 验证方式
`cd frontend && npx vite build`;打开 `#/watch-metrics`,探针记录「时间」列应与本地北京时间一致。
@@ -0,0 +1,34 @@
# 监测插槽显示 CPU 核数 / 内存 / 硬盘容量
**日期**2026-06-13
## 变更摘要
实时监测插槽展示完整硬件与用量细节:CPU 型号与物理/逻辑核、内存/磁盘已用与剩余、交换分区、系统版本与运行时长;负载显示 1/5/15 分钟。
## 动机
用户需要在插槽卡片上直观看到子机配置与实时占用,而不只有总量与使用率百分比。
## 涉及文件
- `server/utils/watch_metrics.py` — 扩展探针字段、`fill_hardware_gaps`
- `server/infrastructure/ssh/remote_probe.py` — SSH 探针采集详情
- `server/background/watch_probe_runner.py` — 探针后补齐用量
- `web/agent/agent.py``_hardware_specs_dict()` 心跳上报
- `frontend/src/components/watch/WatchSlotCard.vue``WatchMetricBar.vue`
- `frontend/src/utils/watchFormat.ts``useWatchPins.ts`
- `tests/test_watch_metrics.py`
## 迁移 / 重启
无 DB 变更。SSH 监测即时生效;Agent 需升级后 Redis 通道才有完整字段(旧数据可用百分比推算已用/剩余)。
## 验证方式
```bash
.venv/bin/pytest tests/test_watch_metrics.py -q
cd frontend && npx vite build
```
浏览器:监测槽应显示系统横幅、CPU 型号、已用/总量、可用/剩余、Swap(若有)、负载 1/5/15。
@@ -0,0 +1,23 @@
# 监测槽暂停态居中开关
**日期**2026-06-13
## 变更摘要
监测槽关闭实时监测时,卡片中部显示大号「开启实时监测」开关;仅开启后才展示 CPU/内存/硬盘等指标。顶栏开关仅在监测开启时显示(用于关闭)。
## 动机
暂停态原先仅一行小字提示,开关在顶栏易被忽略;用户期望未开启时中间明确引导打开。
## 涉及文件
- `frontend/src/components/watch/WatchSlotCard.vue`
## 迁移 / 重启
仅前端;`vite build` 后生效。
## 验证方式
服务器页添加监测槽 → 关闭顶栏开关(或添加后默认关)→ 中部应见大号开关;打开后显示指标。
@@ -0,0 +1,30 @@
# 监测 UI 视觉迭代
**日期**2026-06-13
## 变更摘要
监测历史页、趋势图、探针表与实时槽卡片对齐仪表盘舰队趋势的视觉规范:主题色 ECharts、骨架屏/空态、指标色阶与 I/O 网格布局。
## 动机
北京时间上线后,监测页仍偏「工具表」风格,与仪表盘 `FleetTrendEChart` 体验不一致;继续迭代提升可读性与暗色模式适配。
## 涉及文件
- `frontend/src/pages/WatchMetricsPage.vue`
- `frontend/src/components/watch/WatchTrendChart.vue`
- `frontend/src/components/watch/WatchProbeRecordsTable.vue`
- `frontend/src/components/watch/WatchSlotCard.vue`
- `frontend/src/components/watch/WatchMetricBar.vue`
- `frontend/src/components/watch/WatchSparkline.vue`
- `frontend/src/components/watch/WatchSlotRow.vue`
- `frontend/src/utils/watchFormat.ts`
## 迁移 / 重启
仅前端;`vite build` 后生效。
## 验证方式
`cd frontend && npx vite build`;打开 `#/watch-metrics` 与服务器页监测槽,检查趋势 tooltip、探针表分页、空态与暗色主题。

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