Compare commits

...

20 Commits

Author SHA1 Message Date
r 1c4c5af0e4 feat(btpanel): 入口改为服务器列表
Nexus CI/CD / test (push) Waiting to run
Nexus CI/CD / e2e (push) Blocked by required conditions
Nexus CI/CD / deploy (push) Blocked by required conditions
Nexus Pre-commit Checks / quick-check (push) Waiting to run
面板登录页改为全量表格与行内一键登录,侧栏首项更名为服务器列表。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-21 09:23:26 +08:00
r d00294c86d docs(btpanel): 部署审计与 changelog
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-21 09:16:54 +08:00
r 82426b1941 feat(btpanel): 独立宝塔模块与 SSH 自动获取 API
新增侧栏「宝塔面板」菜单、代理 API 与连接配置页;通过 SSH 读写子机
api.json 自动写入凭据,5 分钟后台重试,含审计修复、检测缓存与并发锁。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-21 09:13:14 +08:00
r ebab836554 docs: 审计文件清单补全 btpanel client
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-21 08:14:26 +08:00
r ccc5419369 chore: 部署门控 — 今日 changelog/audit、bandit nosec、shell CRLF
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-21 08:14:05 +08:00
r d8ec4f1af4 fix: 登录白名单校验改用 LOGIN_ALLOWED_IPS 单一数据源
多 worker 下订阅刷新后 UI 已更新但登录仍被拒;另修复刷新失败时前端误清空节点列表。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-21 08:12:52 +08:00
r 9c9a51263c chore: 本地快照 — 终端 UI、服务器 site_url、后台任务与 Agent 导航
汇总未提交工作区:Servers 列表 site_url 链接、终端页与选机器 UI、SSH 连通性与 Telegram 通知、agent/offline/unset_path 后台调度与 gate_ai_review 门控;更新 AGENTS 与 agent-exploration 规则(仅本机 commit);同步 web/app 构建产物与相关文档。
2026-06-18 06:01:50 +08:00
Nexus Agent 1a79c0fbfb feat(watch): Baota-style server detail drawer
Expose load/process counts, per-core CPU, memory MB breakdown, and
separate CPU/MEM top-5 processes from SSH probes in the detail drawer.
2026-06-14 06:23:45 +08:00
Nexus Agent 86a45612c8 fix(ops): writable Layer3 state dir for cron user
Fall back to ~/.nexus/layer3-health when deploy/var is root-owned.
2026-06-14 04:23:20 +08:00
Nexus Agent 05b0fde197 fix(ops): Layer3 health_monitor state dir and Telegram heredocs
Move fail counters under deploy/var/layer3-health, use docker_cmd with
sudo fallback, and refactor alert messages to avoid quote parse errors.

Also fix watch Sparkline rgba gradients, hide empty probe pagination,
and ship pending slot pause/menu UI polish.
2026-06-14 04:20:55 +08:00
Nexus Agent c91cb1cd9e fix(watch): polish paused slot UI and header action spacing
Replace the large dashed pause panel with dimmed rings and a resume CTA,
and add gap between the monitoring switch and remove button.
2026-06-14 03:34:17 +08:00
Nexus Agent 3faa7a4f1c fix(watch): align four metric rings in one row
Move load ring beside CPU/mem/disk so the slot card matches Baota layout.
2026-06-14 03:25:10 +08:00
Nexus Agent b5419cd2c0 feat(watch): SSH-first probe, drop IO metrics, Baota ring UI
Align live monitoring with Baota: always SSH psutil for CPU/mem/load,
remove network/disk IO rates, and show metrics as circular rings in slots.
2026-06-14 03:19:43 +08:00
Nexus Agent 5c508ed14c fix(watch): eager probe on resume so metrics appear immediately
After pausing clears Redis snapshots, resuming or adding pins now triggers
an immediate probe before list_slots returns, with frontend refresh fallback.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-14 02:53:02 +08:00
Nexus Agent 825d0297c4 feat(watch): show centered enable switch when slot monitoring is paused
Paused slots now prompt with a large middle switch before showing CPU and memory metrics; header switch only appears while monitoring is on.
2026-06-14 00:38:44 +08:00
Nexus Agent 4bbc47faab feat(watch): polish monitoring history UI with themed charts and empty states
Align watch metrics pages with dashboard fleet trends: theme-aware ECharts,
skeleton loaders, metric color thresholds, and compact I/O layout on slot cards.
2026-06-13 23:29:58 +08:00
Nexus Agent ba7c790f38 feat(watch): show probe history in Beijing time and retire remote browser
Monitoring history charts and tables now format UTC timestamps as Asia/Shanghai.
Remove Playwright worker, global browser panel, and related API routes; open site URLs in a new tab instead. Fix chain test Redis pipeline mock.
2026-06-13 16:32:02 +08:00
Nexus Agent adee25deb6 fix(watch): repair SSH probe script broken by embedded quote in os_release parse
Use chr(34) inside the bash-wrapped python -c probe so monitor slots work again after hardware detail rollout.
2026-06-13 16:10:32 +08:00
Nexus Agent 4acbf04322 feat(watch): show detailed CPU, memory, and disk specs on monitor slots
Expose used vs total capacity, CPU model, swap, OS uptime, and 1/5/15 load from SSH probes and agent heartbeats.
2026-06-13 14:12:09 +08:00
Nexus Agent feb776616f docs: add 2026-06-13 audit/changelog for files new-file fix deploy gate. 2026-06-13 09:09:01 +08:00
5561 changed files with 103681 additions and 3219 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
}
}
}
+48
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`
@@ -42,3 +50,43 @@ bash deploy/deploy-production.sh # 需 SSH nexus + 用户批准
## 归档文档
历史审计合并卷:`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 个文件 → 停止**。
-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()
+291
View File
@@ -2087,3 +2087,294 @@
{"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"}
+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
+32 -11
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}"
@@ -145,7 +146,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 +173,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 +208,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 +235,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 +274,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
@@ -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,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,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,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、探针表分页、空态与暗色主题。
@@ -0,0 +1,42 @@
# Changelog — 门控 Gate 8 AI Review
**日期**: 2026-06-14
## 变更摘要
部署前门控从 7 道增至 **8 道**,新增 **AI Review**:对 `HEAD~1..HEAD` 源码变更自动调用 `cursor-agent`ask 只读模式)审查,结果缓存到 `docs/reviews/`,同 `head_sha` 复用。
## 动机
用户要求部署前自动 AI review,避免仅靠人工审计交叉校验;与 `/ce-code-review` 互补(门控为轻量 diff 审查 + 可机读 JSON 阻断 P0/P1)。
## 涉及文件
- `scripts/gate_ai_review.py` — 审查、缓存、阻断逻辑
- `scripts/gate_ai_review.sh` — 包装入口
- `deploy/pre_deploy_check.sh` — Gate 8/8
- `docs/reviews/` — 审查产物目录
- `docs/design/specs/2026-06-14-gate-ai-review-design.md`
- `docs/design/plans/2026-06-14-gate-ai-review.md`
## 环境变量
| 变量 | 默认 | 说明 |
|------|------|------|
| `NEXUS_AI_REVIEW_MODE` | `auto` | 无缓存时自动跑 agent`cache` 仅校验缓存 |
| `NEXUS_SKIP_AI_REVIEW` | — | `1` 时 WARN 跳过(应急) |
| `NEXUS_AI_REVIEW_TIMEOUT` | `600` | agent 超时秒数 |
| `CURSOR_AGENT_BIN` | — | 自定义 cursor-agent 路径 |
## 迁移 / 重启
无需 DB 迁移;仅改门控脚本。
## 验证方式
```bash
python3 scripts/gate_ai_review.py --run # 生成 docs/reviews/*.json
bash deploy/pre_deploy_check.sh # 应 8/8 PASS
```
P0/P1confidence≥75)或 `verdict=Not ready` 时 Gate 8 BLOCK。
@@ -0,0 +1,35 @@
# 2026-06-14 — Layer3 巡检脚本与监测 UI 小修
## 摘要
修复 `health_monitor.sh` 状态文件权限与 Telegram 长串引号问题;Sparkline 渐变兼容 rgb 主题;探针表无数据时隐藏分页。
## 动机
巡检 P2cron 日志大量 `unexpected EOF``/tmp` 状态文件 Permission deniedLayer3 自愈可能失效。P3Sparkline `${color}33` 在非 hex 主题失效;探针表 total=0 仍显示分页。
## 变更
- `deploy/health_monitor.sh`:状态迁至 `${DEPLOY_DIR}/var/layer3-health`;不可写时回退 `~/.nexus/layer3-health` 或 sudo chown`docker_cmd` 回退 sudoTelegram 文案改 heredoc
- `frontend/src/utils/echartsColor.ts` + `WatchSparkline.vue``colorWithAlpha`
- `WatchProbeRecordsTable.vue``total>0` 才显示分页
- `WatchSlotCard.vue` / `WatchMetricRing.vue`:监测槽 UI(菜单、暂停态,同批)
- `tests/test_ops_patrol_sync.py``bash -n` 与状态路径断言
## 涉及文件
见上。
## 迁移 / 重启
部署后 cron 自动使用新脚本;首次运行创建 `var/layer3-health/`。可选清理 `/tmp/nexus_health_*`(脚本会尝试迁移 fail_count)。
## 验证
```bash
bash -n deploy/health_monitor.sh
pytest tests/test_ops_patrol_sync.py -q
cd frontend && npx vite build
```
生产:观察 5 分钟 `/var/log/nexus_health.log` 无新 EOF/Permission denied。
@@ -0,0 +1,22 @@
# 服务器列表:IP 下显示可点击站点域名
**日期**: 2026-06-14
**类型**: feat(frontend)
## 背景
运维批量写入 `extra_attrs.site_url` 后,列表「地址」列仍只显示 SSH IP(`domain` 字段)。需在 IP 下方展示真实站点链接,供一键打开商城/三方站点。
## 改动
- 新增 `frontend/src/utils/serverSiteUrl.ts``resolveServerSiteUrl()` 优先读 `extra_attrs.site_url|site_domain|website`,其次 `description`,最后从 `target_path` 推断域名。
- `ServersPage.vue`:地址列 IP 下渲染可点击链接;「站点」按钮改用真实 URL 而非 IP。
## 验证
- 本地 `cd frontend && npm run build-only` 通过。
- 生产:部署后打开 `#/servers`id=371 等已写 `site_url` 的机器应显示 `whzecw.com` 链接。
## 关联
- 运维脚本 `商城运维专属/scripts/nexus_update_site_urls.py` 写入元数据。
@@ -0,0 +1,25 @@
# 2026-06-14 — 监测服务器详情(对齐宝塔)
## 摘要
探针新增负载进程数、分核 CPU、内存 MB 明细;进程探针分 CPU/MEM Top5;槽位「详情」抽屉按宝塔布局展示。
## 动机
用户提供的宝塔面板含 1/5/15 负载、活动/总进程、分核占用、内存分项与 CPU/MEM Top5,Nexus 原先仅圆环 + 单一进程列表。
## 变更
- `remote_probe.py`metrics 增 `process_running/total``per_cpu_pct`、内存 MB;进程返回 `top_cpu` + `top_mem`
- `watch_metrics.py``sanitize_process_bundle`、live 推送新字段
- `WatchProcessDrawer.vue`:宝塔式详情抽屉
- `watchFormat.ts``formatLoadTriple``normalizeProcessBundle`
## 验证
```bash
pytest tests/test_watch_metrics.py -q -k "not ssh_watch_metrics_cmd_runs_locally"
cd frontend && npx vite build
```
开启监测 → 点「详情」应见与宝塔同结构的负载/进程/内存/Top5。
@@ -0,0 +1,39 @@
# 2026-06-14 — 监测开启后立即探针
## 摘要
开启实时监测或新加监测槽后,立即执行一次 5s 探针并写入 Redis,避免「等待服务器回传信息」长时间空白。
## 动机
用户反馈开启监测后「获取不到信息」。根因:暂停监测会 `clear_watch_redis_snapshot`,恢复后 API 返回 `metrics=null`,前端进入等待态,需等后台 5s 探针循环(最坏约 5–15s);若 WebSocket 未及时推送,体验更差。
## 变更
- `server/background/watch_probe_runner.py`:抽取 `_probe_server_with_pins`;新增 `trigger_immediate_watch_probe(server_id)`
- `server/application/services/watch_service.py`:加槽 / 替换槽 / 恢复监测后调用立即探针
- `frontend/src/composables/useWatchPins.ts`:恢复监测后 3s、8s 兜底 `refreshPins`(仍 pending 时)
- `frontend/src/components/watch/WatchSlotCard.vue`:暂停底栏文案「已暂停」替代误显示「已到期」
- `tests/test_watch_pins.py`:恢复监测立即带回 metrics 用例
## 涉及文件
- `server/background/watch_probe_runner.py`
- `server/application/services/watch_service.py`
- `frontend/src/composables/useWatchPins.ts`
- `frontend/src/components/watch/WatchSlotCard.vue`
- `tests/test_watch_pins.py`
## 迁移 / 重启
- 需重启 API(后端逻辑变更)
- 前端需 `vite build` 后部署静态资源
## 验证
```bash
pytest tests/test_watch_pins.py -q
bash scripts/local_verify.sh
```
生产:加槽 → 开启实时监测 → 应在数秒内看到 CPU/内存或明确探针错误态,而非长期空白。
@@ -0,0 +1,20 @@
# 2026-06-14 — 监测槽圆环指标(宝塔式)
## 摘要
CPU / 内存 / 硬盘 / 负载四个圆环同一行并排(环内百分比或负载值,下方标签);悬停可看规格。
## 动机
用户反馈宝塔用的是圆圈而非横条进度条。
## 变更
- 新增 `WatchMetricRing.vue``v-progress-circular` + 宝塔色阶)
- `WatchSlotCard.vue`:三圆环一行 + 负载圆环
- 删除 `WatchMetricBar.vue`
- `watchFormat.ts``metricRingColor`
## 验证
`cd frontend && npx vite build`
@@ -0,0 +1,34 @@
# 2026-06-14 — 实时监测探针:SSH 优先,IO 可选
## 摘要
取消「Agent 有心跳 IO 就跳过 SSH」逻辑;5s 监测统一走 SSH psutil,CPU/内存/负载与宝塔同源。IO 速率可选,SSH 无则 Agent 补位,都没有则显示「—」。
## 动机
用户反馈监测数据与宝塔偏差,且负载常缺失。根因:Agent 带 IO 时跳过 SSH,而 Agent 心跳不含 `load_avg`,CPU/内存还是 60s 智能上报的 stale 值。
## 变更
- `watch_probe_runner.probe_server_metrics`:始终 SSH 探针;仅 SSH 全失败时回退 Agent 心跳
- `merge_redis_and_ssh`:CPU/内存/磁盘/负载优先 SSHIO 字段 SSH 优先、Agent 补位
- `redis_sample_has_io`:改为纯信息标记,不再触发跳过 SSH
## 涉及文件
- `server/background/watch_probe_runner.py`
- `server/utils/watch_metrics.py`
- `server/infrastructure/ssh/remote_probe.py`(轻量 load 补位,SSH 全失败时用)
- `tests/test_watch_metrics.py`
## 迁移 / 重启
需重启 API。有 SSH 凭据的子机 5s 探针会多一次 SSH(与宝塔一致);无 SSH 仍用 Agent。
## 验证
```bash
pytest tests/test_watch_metrics.py tests/test_watch_pins.py -q
```
与宝塔对比:CPU/内存/负载 1/5/15 应接近(采样时刻差 ±5s 正常)。
@@ -0,0 +1,35 @@
# 2026-06-14 — 实时监测移除 IO 速率
## 摘要
监测槽、历史趋势、探针记录表不再采集或展示网络/磁盘 IO 速率,探针专注 CPU、内存、磁盘、负载(与宝塔对齐)。
## 动机
用户明确 IO 速率不需要;此前 IO 逻辑还导致 Agent 路径跳过 SSH、数据与宝塔偏差。
## 变更
- SSH 探针命令去掉 `net_io` / `disk_io` 采集
- 探针循环去掉速率差分(`watch:last` / bps 计算)
- 实时推送 `to_live_dict` 不再含 bps 字段
- 前端槽卡片、趋势图、探针表移除 IO 列
## 涉及文件
- `server/infrastructure/ssh/remote_probe.py`
- `server/utils/watch_metrics.py`
- `server/background/watch_probe_runner.py`
- `frontend/src/components/watch/*.vue`
- `frontend/src/composables/useWatchPins.ts`
- `frontend/src/pages/WatchMetricsPage.vue`
## 迁移 / 重启
需重启 API + 前端构建。DB 历史列保留,新记录 IO 字段为 NULL。
## 验证
```bash
pytest tests/test_watch_metrics.py tests/test_watch_pins.py -q
```
@@ -0,0 +1,23 @@
# 监测槽暂停态居中开关
**日期**2026-06-14
## 变更摘要
监测槽关闭实时监测时,卡片中部显示大号「开启实时监测」开关;仅开启后才展示 CPU/内存/硬盘等指标。顶栏开关仅在监测开启时显示(用于关闭)。
## 动机
暂停态原先仅一行小字提示,开关在顶栏易被忽略;用户期望未开启时中间明确引导打开。
## 涉及文件
- `frontend/src/components/watch/WatchSlotCard.vue`
## 迁移 / 重启
仅前端;`vite build` 后生效。
## 验证方式
服务器页添加监测槽 → 关闭监测 → 中部应见大号开关;打开后显示 CPU/内存/硬盘指标。
@@ -0,0 +1,27 @@
# 2026-06-14 — 监测槽暂停态 UI 优化
## 摘要
暂停态仅保留顶栏(服务器名 + 槽位 + ⋮ 菜单)与居中「恢复实时监测」;隐藏时长选择、状态横幅、圆环占位与底栏。
## 动机
用户反馈暂停态「开启实时监测 + 槽位保留…」居中区域观感差,与开启后圆环布局割裂。
## 变更
- `WatchSlotCard.vue`:顶栏 `⋮` 菜单替代开关与 ✕;底栏「暂停」;暂停横幅 + 四圆环占位 + 恢复按钮
- `WatchMetricRing.vue`:新增 `dimmed` 占位态(灰色细环、不可交互)
## 涉及文件
- `frontend/src/components/watch/WatchSlotCard.vue`
- `frontend/src/components/watch/WatchMetricRing.vue`
## 迁移 / 重启
仅前端构建。
## 验证
`cd frontend && npx vite build`;顶栏仅槽位芯片与 ⋮ 菜单;暂停/移除需点开菜单;底栏可点「暂停」。
@@ -0,0 +1,34 @@
# 2026-06-17 — 终端 UI 优化与右键菜单加速
## 摘要
终端页深色界面统一、字符重叠修复、可折叠服务器侧栏、轻量右键菜单;脚本进度条不再遮挡终端顶栏。
## 动机
生产终端页浅色工具栏与黑底 xterm 割裂;`lineHeight: 1.2` 导致 Linux 下行重叠;Vuetify `v-menu` 右键菜单弹出慢;全局脚本进度条与终端页 `padding-top !important` 冲突导致标签栏被挡。
## 变更
- `frontend/src/pages/TerminalPage.vue`:深色 chrome、侧栏折叠、自定义右键菜单
- `frontend/src/components/terminal/TerminalContextMenu.vue`:Teleport 固定菜单,无过渡动画
- `frontend/src/composables/terminal/useTerminalSessions.ts``rescaleOverlappingGlyphs`、Linux lineHeight 1.1、fit 重算
- `frontend/src/components/ShellHighlightField.vue`:多行高亮层 scroll 同步
- `frontend/src/App.vue``--nexus-exec-bar-offset` 修复终端顶栏留白
## 涉及文件
见上;纯前端静态资源,无需重启 API。
## 迁移 / 重启
`cd frontend && npx vite build``deploy/deploy-production.sh` 同步 `web/app/`
## 验证
```bash
cd frontend && npm run type-check && npx vite build
bash scripts/local_verify.sh
```
浏览器:`#/terminal` 强刷 — 右键菜单即时弹出、SSH 输出无行重叠、侧栏可折叠。
@@ -0,0 +1,46 @@
# Changelog — 登录白名单校验改用 LOGIN_ALLOWED_IPS 单一数据源
**日期**2026-06-19
## 摘要
修复多 uvicorn worker 下「订阅 IP 已在设置页更新,但登录白名单实际未生效」的问题。登录校验改为优先读取经订阅刷新广播的 `LOGIN_ALLOWED_IPS`,不再在各 worker 本地重算 `subscription + manual`
## 动机
用户反馈保存/刷新订阅后 UI 显示新节点,登录仍被拒绝。2026-06-11 已修复保存竞态与 Redis 广播,但 `login_allowlist.get_allowed_login_ips()` 仍忽略 `LOGIN_ALLOWED_IPS`,从 `LOGIN_SUBSCRIPTION_IPS``LOGIN_MANUAL_IPS` 拼接。多 worker 下若两键 Pub/Sub 到达顺序不一致或仅部分成功,会出现:
- 处理 `GET /ip-allowlist` 的 worker 已显示新 `subscription_ips`
- 处理 `POST /login` 的 worker 用陈旧拼接列表校验 → 登录时好时坏
另:保存时订阅拉取失败会把前端节点 chip 清空,而 DB 仍保留旧订阅 IP,造成「看起来被清空/未更新」的误导;失败时改回拉取当前白名单状态。
## 涉及文件
- `server/utils/login_allowlist.py``get_allowed_login_ips()` 优先 `LOGIN_ALLOWED_IPS`
- `frontend/src/pages/SettingsPage.vue` — 刷新失败时不覆盖 chip,改为 `loadAllowlist`
- `tests/test_login_access.py` — 新增 SSOT 回归用例
## 行为变更
- 登录与 `check_login_ip`:有 `login_allowed_ips` 时以其为准(与 DB / 刷新任务写入一致)。
-`login_allowed_ips`(历史数据):仍回退 subscription + manual 拼接。
- 设置页:订阅刷新失败时保留/重载已有节点列表,并 warning 提示。
## 迁移 / 重启
- 无 DB 迁移。
- 部署后端 + 前端构建后生效。
## 验证
```bash
pytest tests/test_login_access.py tests/test_ip_allowlist_sync.py -q
```
生产排查(无需改代码):
1. `GET /api/settings/ip-allowlist` — 对比 `subscription_ips` 与 DB `settings``login_subscription_ips``login_allowed_ips`
2. 登录被拒时看响应/审计中的客户端 IP(经 `X-Real-IP` / `X-Forwarded-For` 末段,仅信任内网代理 hop
3. 订阅节点若为**域名**而非字面 IP,白名单不会 DNS 解析,登录 IP 无法匹配域名条目——需改用手动 IP 或仅含 IP 节点的订阅
4. 多 worker`last_refresh` 仍为进程内变量,非 primary worker 可能显示空;以 DB `login_subscription_ips` 更新时间为准
@@ -0,0 +1,80 @@
# Changelog — 宝塔 SSH 自动获取 API
**日期**2026-06-20
## 摘要
通过 SSH 在子机自动读取/开启宝塔 API(`api.json`),加密写入 Nexus `extra_attrs.bt_panel`;未成功时每 5 分钟后台重试,适配「先 SSH、后装宝塔」场景。
## 动机
- 2000+ 子机手工填 `base_url` + API Key 不可运维
- 装机窗口内宝塔可能尚未安装,需后台轮询而非绑 WebSSH
- 中心 IP 白名单需自动追加到子机 `limit_addr`
## 涉及文件
| 层 | 路径 |
|----|------|
| 远程脚本 | `server/infrastructure/btpanel/ssh_bootstrap.py` |
| 状态机 | `server/infrastructure/btpanel/bootstrap_state.py` |
| 并发锁 | `server/infrastructure/btpanel/bootstrap_lock.py` |
| 中心 IP | `server/infrastructure/btpanel/source_ip.py` |
| SSH 检测 | `server/infrastructure/btpanel/ssh_login.py` |
| 调度 | `server/application/services/btpanel_bootstrap_schedule.py` |
| 后台 | `server/background/bt_panel_bootstrap_loop.py` |
| 服务/API | `server/application/services/btpanel_service.py`, `server/api/btpanel.py` |
| 触发 | `server/api/servers.py`(创建/凭据轮询成功) |
| 批量 | `server/application/services/server_batch_service.py``bt-panel-bootstrap` |
| 配置 | `server/config.py``BT_PANEL_*` |
| 前端 | `frontend/src/pages/btpanel/BtPanelSettingsPage.vue`, `frontend/src/api/btpanel.ts` |
| 测试 | `tests/test_btpanel_ssh_bootstrap.py`, `tests/test_btpanel_bootstrap_loop.py`, `tests/test_btpanel_ssh_login.py`, `tests/test_btpanel_get_config.py` |
## 配置项(settings 表 / `.env` `NEXUS_` 前缀)
- `bt_panel_source_ip` — 中心调用 IP(优先于 DNS)
- `bt_panel_auto_bootstrap_enabled` — 全局自动获取(默认 `true`
- `bt_panel_bootstrap_batch` — 每轮最多处理台数(默认 20
- `bt_panel_bootstrap_interval` — 轮询间隔秒(默认 300
## 迁移 / 重启
- 无 DB 迁移(状态存 `servers.extra_attrs`,含 `bt_installed` 缓存)
- 需重启 API 以注册 `bt_panel_bootstrap_loop`primary worker
## 验证
```bash
.venv/bin/pytest tests/test_btpanel_*.py -q
cd frontend && npx vite build
```
- 新服务器创建 → `bootstrap_state=pending`,立即试 1 次
- 无宝塔目录 → `installing`5min 后再试
- 成功 → `ready`loop 跳过
- SSH 认证失败 → `failed`,停止直至手动「立即 SSH 获取」
---
## 审计修复(同日增补)
### 严重
1. **后台 loop 误扫全库**:未入库引导的服务器因默认 `pending` 被 eligible → 仅已有 `bt_panel` 块且显式 `pending`/`installing` + `next_attempt_at` 到期才进 loop。
2. **`Permission denied` 误判永久失败**:区分 SSH 认证 vs 远程写 `api.json` 权限错误。
3. **`ssh_login` 使用 `result.success`**`exec_ssh_command` 返回 `status` 字段,导致检测/一键登录异常 → 已改 `status == "success"`
### 中等
4. **手动获取覆盖 `disabled`**:手动「立即获取」不再强制 `auto_bootstrap=true`
5. **空凭据标 ready**:远程 JSON 空 `base_url`/`api_key``bootstrap_empty_credentials` 可重试。
6. **loop 时间解析**`run_bootstrap_tick` 改用 `_parse_iso`,避免畸形时间打挂整轮。
7. **中心 IP 校验**:全局设置非空须合法 IPv4/IPv6。
8. **UI 状态**`bootstrap_state=null` 显示「未启用」;`auto_bootstrap``true` 为开。
### 体验 / 性能(本轮)
9. **`get_config` SSH 节流**:仅 `pending`/`installing``?refresh_bt_installed=true` 时 SSH 检测;结果缓存 `extra_attrs.bt_panel.bt_installed`
10. **批量初始化确认**:连接配置页批量操作前 `v-dialog` 二次确认。
11. **per-server 锁**`bootstrap_lock` 防止即时触发与 5min loop 并发双 SSH。
12. **loop 日志**`run_bootstrap_tick` 仅统计 `ok=true` 成功台数。
@@ -0,0 +1,50 @@
# 2026-06-20 — 宝塔面板独立模块
## 摘要
新增与「运维」菜单分离的 **宝塔面板** 分组及 10 个子页,后端 `/api/btpanel/*` 代理宝塔 API(签名 + Redis Cookie)并支持 SSH 生成 `tmp_token` 一键登录。
## 动机
用户需在 Nexus 内统一管理子机宝塔:一键登录、监控、网站/域名/SSL/数据库/计划任务/服务启停,且**不并入**现有服务器、文件、调度页面。
## 功能范围
| 编号 | 子菜单 | 说明 |
|------|--------|------|
| A1/A5 | 面板登录 | API `set_temp_login` 或 SSH `tools.py` |
| B1B3 | 系统监控 | GetSystemTotal / Disk / NetWork |
| C1,C6 | 网站列表 | 列表 + 启停 |
| C4 | 创建网站 | AddSite |
| C10 | 域名管理 | AddDomain / DelDomain |
| C15 | SSL 证书 | 列表 + SetSSL + apply_cert |
| D1–D4 | 数据库 | 列表 / 创建 / 改密 / 备份 |
| F1 | 计划任务 | GetCrontab 只读 |
| H1 | 服务管理 | ServiceAdmin |
| — | 连接配置 | `extra_attrs.bt_panel` 加密存 API Key |
## 涉及文件
- `server/infrastructure/btpanel/*`
- `server/application/services/btpanel_service.py`
- `server/api/btpanel.py`, `btpanel_schemas.py`
- `server/main.py`
- `frontend/src/pages/btpanel/*`, `components/btpanel/*`, `api/btpanel.ts`
- `frontend/src/App.vue`, `router/index.ts`
- `docs/design/specs/2026-06-20-btpanel-module-design.md`
- `tests/test_btpanel_client.py`
## 迁移 / 重启
- 无 DB 迁移(凭据在 `servers.extra_attrs`
- 需重启 API 加载新路由
- 前端需 `vite build`
## 验证
```bash
pytest tests/test_btpanel_client.py -q
cd frontend && npx vite build
```
侧栏「宝塔面板」→ 各子页;先在「连接配置」填写面板地址与 API Key,并在子机白名单加入中心 IP。
@@ -0,0 +1,32 @@
# Changelog — 宝塔模块生产部署(2026-06-21
**日期**2026-06-21
## 摘要
`82426b19` + `d00294c8`(独立宝塔模块 + SSH 自动获取 API)部署至生产 `api.synaglobal.vip`
**部署结果(2026-06-21**Gitea push → 生产 `git reset --hard origin/main``nexus-1panel.sh upgrade` 成功;容器 `nexus-prod-nexus-1` healthy`/health` ok;容器内注册 28 条 `/api/btpanel/*` 路由。
## 变更范围
- 后端:`/api/btpanel/*``bt_panel_bootstrap_loop`300s
- 前端:侧栏「宝塔面板」10 子页 + 连接配置(批量确认、检测缓存)
- 配置:`bt_panel_source_ip``bt_panel_auto_bootstrap_enabled`
## 涉及文件
`docs/audit/2026-06-21-btpanel-ssh-bootstrap.md` 文件清单。
## 迁移 / 重启
- 无 DB 迁移
- **必须**重启 API 容器/进程(primary worker 后台任务)
## 验证
```bash
bash deploy/pre_deploy_check.sh
curl -sS https://api.synaglobal.vip/health
# 登录 → 宝塔面板 → 连接配置 → 立即 SSH 获取 / 批量初始化
```
@@ -0,0 +1,29 @@
# Changelog — 宝塔入口改为服务器列表(2026-06-21)
**日期**2026-06-21
## 摘要
将「宝塔 · 面板登录」从单台下拉选择改为主入口**服务器列表**,侧栏首项更名为「服务器列表」。
## 动机
- 2000+ 子机需一览配置状态与一键登录,单台 picker 不符合运维习惯
- 与 Nexus 服务器列表心智一致:先选机再操作
## 涉及文件
- `frontend/src/pages/btpanel/BtPanelLoginPage.vue``v-data-table` 全量列表 + 搜索 + 行内「一键登录」
- `frontend/src/App.vue` — 侧栏文案与图标
- `frontend/src/router/index.ts``/btpanel/servers` 重定向至 `/btpanel/login`
## 迁移 / 重启
- 仅前端;`vite build` 或 Docker upgrade 后生效
## 验证
```bash
cd frontend && npx vite build
# 登录 → 宝塔面板 → 服务器列表 → 表格展示全部服务器 → 行内一键登录
```
@@ -0,0 +1,29 @@
# Changelog — 部署登录白名单 SSOT 修复
**日期**2026-06-21
## 摘要
生产部署 `d8ec4f1`:登录白名单校验改用 `LOGIN_ALLOWED_IPS` 单一数据源,修复多 worker 下订阅 IP 已更新但登录仍被拒;设置页刷新失败时不再误清空节点列表。
## 动机
用户反馈订阅节点在设置页已更新,登录白名单实际未生效。根因见 `docs/changelog/2026-06-19-login-allowlist-auth-ssot.md`
## 涉及文件
- `server/utils/login_allowlist.py`
- `frontend/src/pages/SettingsPage.vue`
- `tests/test_login_access.py`
## 迁移 / 重启
- 无 DB 迁移;Docker 镜像重建 + 前端 vite build 同步。
## 验证
```bash
bash deploy/pre_deploy_check.sh
bash deploy/deploy-production.sh
curl -s https://api.synaglobal.vip/health
```
@@ -0,0 +1,29 @@
# 实施计划 — 门控 AI Review
## 涉及文件
| 文件 | 动作 |
|------|------|
| `scripts/gate_ai_review.py` | 新增:审查逻辑、缓存、cursor-agent 调用 |
| `scripts/gate_ai_review.sh` | 新增:包装脚本 |
| `deploy/pre_deploy_check.sh` | Gate 8、GATES_TOTAL=8 |
| `docs/reviews/.gitkeep` | 审查产物目录 |
| `docs/changelog/2026-06-14-gate-ai-review.md` | changelog |
| `docs/audit/2026-06-14-gate-ai-review.md` | 审计 |
## 步骤
1. 实现 `gate_ai_review.py`git diff、JSON schema、阻断规则)
2. 接入 `pre_deploy_check.sh` Gate 8
3. 本地跑门控验证
## 回滚
- 删除 Gate 8 调用或将 `NEXUS_SKIP_AI_REVIEW=1` 写入 deploy 脚本(临时)
-`git revert` 本提交
## 测试要点
- 无源码变更 commit → skip
- 有缓存 JSON → pass
- 伪造 P0 finding → block
@@ -0,0 +1,34 @@
# 宝塔面板独立模块 — 实施计划
## 涉及文件
### 后端(新增)
- `server/infrastructure/btpanel/credentials.py` — extra_attrs 读写与解密
- `server/infrastructure/btpanel/client.py` — 签名、Cookie、POST 代理
- `server/infrastructure/btpanel/ssh_login.py` — SSH 临时登录链接
- `server/application/services/btpanel_service.py` — 业务编排
- `server/api/btpanel.py` — HTTP 路由
- `server/api/btpanel_schemas.py` — Pydantic 模型
- `tests/test_btpanel_client.py` — 签名与凭据单测
### 后端(修改)
- `server/main.py` — 挂载 router
### 前端(新增)
- `frontend/src/pages/btpanel/*.vue` — 9 个子页
- `frontend/src/components/btpanel/BtPanelServerPicker.vue`
- `frontend/src/composables/btpanel/useBtPanelServer.ts`
- `frontend/src/api/btpanel.ts`
### 前端(修改)
- `frontend/src/router/index.ts`
- `frontend/src/App.vue` — 独立导航分组
- `frontend/src/constants/cachedPages.ts`
## 回滚
删除 `server/api/btpanel.py``server/infrastructure/btpanel/`,移除路由与菜单即可;`extra_attrs.bt_panel` 可保留无害。
@@ -0,0 +1,68 @@
# 远程内置浏览器方案 — 下线经验(2026-06-12
> **状态**:已整体移除(代码 + API + Worker 目录)。下次换方案前必读本文。
> 历史设计归档见 `docs/design/specs/2026-06-11-server-browser-chromium-design.md` 等。
## 演进简史
| 阶段 | 方案 | 结果 |
|------|------|------|
| 1 | 全局浮动窗 + **iframe** 嵌站点 | 大量站点 `X-Frame-Options` 拒嵌 → 已移除 |
| 2 | **独立 Worker**Playwright + JPEG screencast+ Nexus 桥接 WS + 前端 canvas | 2026-06-12 下线 |
## 曾验证可行的部分
- Worker `GET /health`、主站 `NEXUS_BROWSER_*` 门控、会话 `X-Nexus-Worker-Key` 鉴权链路可通。
- `Page.startScreencast` + `cdp.on("Page.screencastFrame")` 能推 JPEG 到前端 canvas(冒烟 ~4KB/帧)。
- 显式 `NAVIGATE` 双端 `validate_navigation_url` 可挡明显 SSRF。
- 服务器页「站点」用域名拼 `https://` 打开(`guessSiteUrlFromDomain`)简单可靠。
## 未达标的根因(勿重复踩坑)
### 1. 产品 / UX
- 浮动窗 + 最小化条 + 顶栏按钮 + 默认收起/展开状态机过于复杂,易出现「看不见窗」「展开黑屏」。
- 用户真实诉求是「在运维后台旁路打开站点」,**新标签 `window.open`** 往往足够;内嵌 canvas 收益不成比例。
### 2. 技术
- **黑屏**:最小化/切标签/重连时 canvas 与 WS 会话生命周期不同步;`disconnect` 后未稳定 `connect`+`navigate`;容器尺寸为 0 时 viewport 异常。
- **验证码**:阿里云等需 headed + 反自动化 + 人工拖动;Worker 侧成本高且仍不稳定。
- **SSRFP1**:仅校验显式 `NAVIGATE`;页内点击/302 **未**逐跳校验(设计文档要求但未完)。
- **多 worker**`session_registry` 进程内字典,uvicorn 多进程时 WS 可能 4403。
- **运维**Worker 8443 防火墙、TLS 自签、会话泄漏(reservation idle)需持续值守。
### 3. 工程
- 前后端 + Worker 三仓逻辑 + `admin_ui_preferences` 状态同步,变更面大、门控/审计成本高。
- 与监测槽/文件/终端等主线并行时,合并与部署易拖慢主线。
## 下线后保留
| 保留 | 说明 |
|------|------|
| `frontend/src/utils/browserUrl.ts` | 服务器列表「站点」→ 新标签打开 |
| 设计/审计/changelog 历史 | 仅供复盘,勿当 SSOT |
| MySQL `admin_ui_preferences``embedded_browser` 行 | 可留可清,不再读写 |
## 下次可选方向(未选型)
1. **维持新标签**(当前):零维护,无 SSRF 面在 Nexus 内。
2. **RDP / noVNC 到子机桌面浏览器**:已有 RDP 栈时可复用,但资源重。
3. **SSH 隧道 + 本地 Chrome**:仅高级用户,不适合默认产品路径。
4. **第三方堡垒机式 Web 终端浏览器**:参考 JumpServer,独立产品级投入。
5. 若再做内嵌:必须先闭环 **framenavigated SSRF**、**单进程会话注册表/Redis**、**验证码策略**、**E2E 黑屏回归**,再动 UI。
## 验收清单(换方案时)
- [ ] 明确是否必须内嵌(vs 新标签/远程桌面)
- [ ] 威胁模型:SSRF、Worker 暴露面、会话归属
- [ ] 最小 UI:一种入口、一种展开态,避免三套入口(地球/顶栏/最小化条)
- [ ] 门控:定向 pytest + 生产 `/api/browser/*` 不存在或 404
- [ ] 下线时:删路由、删前端、删 Worker 部署、文档一篇 lessons
## 相关文档
- Changelog`docs/changelog/2026-06-12-remove-remote-browser.md`
- 审计(历史):`docs/audit/2026-06-12-remote-browser-worker.md`
- 巡查(历史):`docs/reports/2026-06-12-remote-browser-bug-patrol.md`
@@ -0,0 +1,36 @@
# 门控 AI Review 设计
## 背景与目标
部署前门控已有 changelog、人工审计交叉校验、测试、lint、bandit,但缺少对**本次 commit diff** 的自动化代码审查。用户要求将 AI review 纳入 `pre_deploy_check.sh`,与现有流程一致、可重复、可审计。
## 方案对比
| 方案 | 优点 | 缺点 |
|------|------|------|
| A. 仅检查 `docs/reviews/*.json` 存在 | 快、无 API 成本 | 不自动审查,易遗漏 |
| B. 门控内每次调用 `cursor-agent` | 真正自动 | 慢、需 API、重复跑浪费 |
| C. **缓存 + 自动补跑(选定)** | 同 commit 可复用;无缓存时自动 review | 首次部署前需网络与 Cursor 凭据 |
## 选定方案
**Gate 8AI Review**
1.`HEAD~1..HEAD` 无待审源码 → **SKIP PASS**
2.`docs/reviews/*.json``head_sha` 匹配当前 `HEAD` 且无 P0/P1 阻断 → **PASS**
3. 否则在 `NEXUS_AI_REVIEW_MODE=auto`(默认)下调用 `cursor-agent --mode ask --print --output-format json` 审查 diff
4. 写入 `docs/reviews/YYYY-MM-DD-<slug>.json`;存在 P0/P1confidence≥75)或 `verdict=Not ready`**BLOCK**
5. 逃生:`NEXUS_SKIP_AI_REVIEW=1` 记 WARN 通过;`NEXUS_AI_REVIEW_MODE=cache` 仅校验缓存
## 安全与性能
- Review 使用 `ask` 模式(只读),不写仓库
- Diff 超 80KB 截断并注明,避免 token 爆炸
- 产物入 git,部署可追溯
## 验收标准
- [ ] `pre_deploy_check.sh` 显示 Gate 8/8
- [ ] 有源码变更且无 review 缓存时自动跑或明确 BLOCK
- [ ]`head_sha` 缓存命中时秒过
- [ ] P0/P1 阻断部署
@@ -0,0 +1,78 @@
# 宝塔面板独立模块 — 设计文档
> 日期:2026-06-20
> 状态:已批准实施
## 背景与目标
用户在 Nexus 管理 2000+ 台装宝塔的子机,需要**独立菜单**操作宝塔能力(一键登录、监控、网站/库/任务/服务),**不与**现有服务器页、文件、调度等功能合并 UI。
## 方案选定
| 层 | 方案 |
|----|------|
| 凭据 | 每台 `servers.extra_attrs.bt_panel``base_url``api_key_enc`Fernet)、`verify_ssl` |
| 登录 A1/A5 | 优先 `POST /config?action=set_temp_login`;失败则 SSH `tools.py get_temp_login_ipv4` |
| 其它能力 | 中心 HTTP 代理宝塔 API(签名 + Redis Cookie 复用) |
| 前端 | 新导航分组「宝塔面板」+ 9 个子路由,共享服务器选择器 |
## 功能映射
| 编号 | Nexus 路由 | 宝塔 API / 方式 |
|------|-----------|----------------|
| A1/A5 | `/btpanel/login` | `set_temp_login` / SSH tmp_token |
| B1B3 | `/btpanel/monitor` | `GetSystemTotal` / `GetDiskInfo` / `GetNetWork` |
| C1,C6 | `/btpanel/sites` | `getData sites` / `SiteStart` `SiteStop` |
| C4 | `/btpanel/sites/create` | `AddSite` + `GetPHPVersion` |
| C10 | `/btpanel/domains` | `getData domain` / `AddDomain` / `DelDomain` |
| C15 | `/btpanel/ssl` | 站点 SSL 字段 + `SetSSL` / `apply_cert` |
| D1D4 | `/btpanel/databases` | `getData databases` / `AddDatabase` / `ResDatabasePassword` / `ToBackup` |
| F1 | `/btpanel/crontab` | `GetCrontab`(只读) |
| H1 | `/btpanel/services` | `ServiceAdmin` |
| 配置 | `/btpanel/settings` | 读写 `extra_attrs.bt_panel` |
## 安全
- 所有路由 `Depends(get_current_admin)`
- API Key 仅写入加密字段;响应 `api_key_set: bool`
- 生成登录链接、建站、删库等写操作记 `audit_logs`
- 中心机 IP 须在各子机宝塔 API 白名单;SSH Bootstrap 可自动追加 `limit_addr`(见下节)
## SSH Bootstrap2026-06-20 增补)
子机 Linux 宝塔 API 配置位于 `/www/server/panel/data/api.json`。中心通过 root SSH 执行 `ssh_bootstrap.py`
1. 无面板目录 → `bt_not_installed`(状态 `installing`5min 重试)
2. 读/写 `api.json`:保留 token、开启 API、追加中心 IP 到 `limit_addr`
3. 原子写 + `bt reload`stdout JSON → 加密写入 `api_key_enc` + `base_url`
### 状态机(`extra_attrs.bt_panel`
| `bootstrap_state` | 含义 | 5min 重试 |
|-------------------|------|-----------|
| `pending` | 等待首次/下次 | 是 |
| `installing` | 宝塔尚未装好 | 是 |
| `ready` | 已写入凭据 | 否 |
| `failed` | SSH 认证失败等 | 否 |
| `disabled` | 用户关闭自动 | 否 |
### Background Loop
- `server/background/bt_panel_bootstrap_loop.py`primary worker,默认 300s
- 每轮最多 `BT_PANEL_BOOTSTRAP_BATCH`20)台
- 触发:创建服务器、凭据轮询成功、UI「立即 SSH 获取」、批量 `bt-panel-bootstrap`
### API
| 方法 | 路径 |
|------|------|
| POST | `/api/btpanel/servers/{id}/bootstrap` |
| POST | `/api/btpanel/servers/batch-bootstrap` |
| GET/PUT | `/api/btpanel/settings`(中心 IP、全局开关) |
## 验收
1. 侧栏见独立「宝塔面板」分组及子项,与「运维」分组分离
2. 配置 API 后可一键打开 tmp_token 登录页
3. 各子页选定服务器后能拉取对应宝塔数据或执行操作
4. `pytest tests/test_btpanel*.py` + `vite build` 通过
+7 -22
View File
@@ -68,29 +68,14 @@ bash scripts/local_verify.sh # Docker + API + smoke + test_api + ruff
---
## 待办 — 服务端远程浏览器(Worker 分离
## 已下线 — 远程内置浏览器(2026-06-12
**用户确认**:任意公网;Chromium**独立 Worker 服务器**(用户去购机)。
Playwright Worker + canvas 浮动窗 **已整体移除**。服务器页「站点」改为 **新标签打开**`browserUrl.ts`)。
| 文档 | 路径 |
| 用途 | 路径 |
|------|------|
| 设计 | `docs/design/specs/2026-06-11-server-browser-chromium-design.md` |
| 代码实施 | `docs/design/plans/2026-06-11-server-browser-chromium.md` |
| **购机/装机/联调** | **`docs/design/plans/2026-06-11-browser-worker-server-procurement.md`** |
| **经验 SSOT(换方案前必读)** | `docs/design/specs/2026-06-12-remote-browser-retired-lessons.md` |
| 下线 changelog | `docs/changelog/2026-06-12-remove-remote-browser.md` |
| 历史设计(归档) | `docs/design/specs/2026-06-11-server-browser-chromium-design.md` |
**进度**iframe 内置浏览器 **已移除**changelog `2026-06-11-remove-embedded-browser`);待购 Worker 后按 W1W4 重建 canvas 浏览器
**购机规格速记**2C4G 起(推荐 4C8G)· Ubuntu 22.04/24.04 · Worker 8443 仅允许 Nexus 主站 `20.24.218.235` 访问。
---
## 待办 — 内置浏览器网站导航(收藏)
**用户原话**:浏览器内置网站导航,我自己可以增加,类似收藏功能。
| 文档 | 路径 |
|------|------|
| 设计 | `docs/design/specs/2026-06-11-browser-nav-bookmarks-design.md` |
| 实施计划 | `docs/design/plans/2026-06-11-browser-nav-bookmarks.md` |
**进度**:随 iframe 浏览器一并移除;Worker 方案上线时重做。
**勿**在未新设计前恢复 `browser-worker/``/api/browser/*`
+1 -1
View File
@@ -93,7 +93,7 @@ npm run test:e2e
| comment-analyzer | 大段注释/文档变更后核对是否与代码一致 |
| type-design-analyzer | 新 Pydantic 模型 / TS 类型设计 |
Nexus **强制**`deploy/pre_deploy_check.sh` 七道门(ruff、bandit、test_api…)— Skills 不替代门控,只补审查维度。
Nexus **强制**`deploy/pre_deploy_check.sh` **八道门**(含 Gate 8 AI Review`scripts/gate_ai_review.py`)— Skills 不替代门控,只补审查维度。
---
@@ -0,0 +1,46 @@
# RDP 浏览器黑屏 / 连接超时 — 根因定位 (2026-06-10)
## 结论(确定)
**Nexus 侧(前端 + 后端 + guacd 链路)全部正常;黑屏来自 RDP 目标主机本身。**
guacd 直连解码证明:RDP 目标 `20.189.75.225`(用户 `azureuserwindwos`)在 NLA 认证成功后,
回传的 **1024×768 主画面 PNG 是纯黑**`nonblack=0, maxsum=0`),只附带鼠标指针等极小白色缓存图块。
这是 Windows/Azure 典型的「RDP 登录后黑屏(仅有鼠标指针)」现象。
## 证据链
| 检查项 | 方法 | 结果 |
|--------|------|------|
| guacd 握手 | `security=any` / `security=nla` | 均 `ready`(握手成功) |
| **凭据有效性** | `security=nla`(错误凭据必失败) | **握手成功 → 凭据正确**`azureuserwindwos` 非拼写错误 |
| 帧转发 | WS 长连接探针 100s | `frames=108 sync=5` |
| **ping 回显(防超时)** | 内部空操作码 `0.,4.ping,<ts>` 每秒 | `ping_echo=100, max_gap=1.0s` → 不会触发 90s 超时 |
| **主画面像素** | 直连 guacd 解码 img/blob PNG | `1024x768 nonblack=0 maxsum=0`(纯黑) |
| 小图块 | 同上 | `41x39`/`11x16` 纯白(指针/字形缓存) |
| `resize-method=display-update` | 变体探测等 18s | 仍纯黑,无桌面重绘 |
| 浏览器端 | Playwright 生产 E2E | 到达「已连接」+ 1024×768 画布,但像素全黑 |
## 「连接超时」说明
前端 `tunnelErrorMessage``server timeout` → 浏览器 90s 内未收到任何帧。
实测 ping 回显使 `max_gap=1.0s`,当前线上 bundle 的 E2E 直接到达「已连接」而非超时。
故该提示是**更早旧缓存版本**的现象;当前版本能连上,仅表现为黑屏。
## 修复方向(在 RDP 目标 Windows VM 上,Nexus 无法代为修改)
按命中概率排序:
1. **关闭 WDDM 远程显示驱动(最常见修复)**
`gpedit.msc` → 计算机配置 → 管理模板 → Windows 组件 → 远程桌面服务 →
远程桌面会话主机 → 远程会话环境 →
**「将 WDDM 图形显示驱动程序用于远程桌面连接」= 已禁用** → 重启。
2. **重启该 VM**(Azure 门户:重启 / 重新部署 redeploy)。
3. 检查是否有残留断开会话占用 console;或登录脚本/应用全屏绘黑。
4. Azure 门户「运行命令」重启 `explorer.exe` 或图形栈。
## 复现 / 诊断脚本
- `scripts/guacd_rdp_probe.py` — 直连 guacd,解码主画面 PNG 判黑屏
- `scripts/guacd_rdp_variant.py``VARIANT=nla|resize|plain` 切换参数变体
- `scripts/rdp_ws_sync_check.py` — 经 WS 长连接,统计 ping 回显与帧间隔
@@ -0,0 +1,33 @@
# RDP 浏览器 E2E 验收 — 2026-06-10
## 结论
**通过** — 生产环境 Playwright 在约 **7.7s** 内完成 `host_id=1` 连接,出现「已连接」与 1024×768 画布。
## 根因
`useRdpSession.ts` 使用 `Guacamole.Client.STATE_CONNECTED`(运行时 **undefined**),`onstatechange` 从未将 UI 设为 `connected`
WS 层与 guacd 实际正常(sync 已到达浏览器)。
## 修复
- 改用 `Guacamole.Client.State.CONNECTED` / `Tunnel.State.OPEN`
- `RdpPage``mounted` 守卫 + 避免重复 connect
- `useRdpSession`connect 序号防竞态
## 验证命令
```bash
# 生产 mint JWT 后
NEXUS_E2E_ACCESS_TOKEN=<jwt> npx playwright test e2e/pages/rdp-connect.spec.mjs
```
## 用户终验
1. 强刷 `Ctrl+Shift+R`(入口 bundle`index-DWNCTCE6.js`
2. 3389远程 → azurewin11 → 应数秒内显示桌面
## 探针(可选)
- `scripts/guacd_rdp_probe.py` — 直连 guacd,约 11 条指令内收到 sync
- `scripts/rdp_ws_sync_check.py` — 经 WS 第 4 帧收到 `4.sync,...`
@@ -0,0 +1,36 @@
# 生产验证 — 监测槽开关 / TTL / 到期竞态 / 探针修复
**日期**2026-06-12
**Commit**`32a1885` — feat(watch): 监测槽开关、8h/24h、到期暂停与探针修复
**环境**https://api.synaglobal.vip
## 部署
| 项 | 结果 |
|----|------|
| 门控 | 7/7 PASS |
| push | `7bb85aa..32a1885 main → main` |
| deploy-production | ✓ Docker 重建 + web/app 同步 |
| `/health` | ok |
| `/app/` | 200 |
## API 终验(生产 JWT
| 场景 | 结果 |
|------|------|
| POST pin server_id=1 ttl=8 | monitoring=true, ttl_hours=8 |
| PATCH pause | monitoring=false, remaining_sec=null |
| PATCH resume | monitoring=true, remaining≈8h |
| PATCH ttl 24h | ttl_hours=24, remaining≈24h |
| DELETE remove | slot empty=true |
## 前端 bundle
| 检查 | 结果 |
|------|------|
| `ServersPage-DZf56qSd.js` | 200 |
| 含 `setSlotMonitoring` / `monitoring_enabled` | ✓ |
## 结论
**部署成功**;监测槽开关、8h/24h、暂停/恢复、移除 API 均正常。请在浏览器打开服务器页终验 UI(开关、倒计时、中文错误)。
+104
View File
@@ -0,0 +1,104 @@
# Bug 巡查 — 2026-06-13
**日期**2026-06-13
**方法**:全量 pytest + L2b + 前端 build/type-check + 代码走读 + 工作区 diff 审查
**生产**https://api.synaglobal.vip(本机外网/SSH 不可达,见 §5)
## 进度条
| 步骤 | 状态 |
|------|------|
| `local_verify.sh` | ✅ 26/26 |
| `pytest tests/` | ✅ 581/581(修 mock 后) |
| `pytest tests/chain` | ✅ 9/9 |
| `vite build` | ✅ |
| `npm run type-check` | ❌ 工作区未提交删除浏览器文件 |
| 生产探针 | ⚠️ 跳过 |
---
## 发现与处理
| # | 严重度 | 问题 | 根因 | 状态 |
|---|--------|------|------|------|
| 1 | **P2** | `CH-AGT-001` 链条测试失败 | `count_monitored_connectivity` 改用 `redis.pipeline().hgetall`,测试仍 mock 顶层 `hgetall` | **已修**`test_agent_dashboard_flow.py` 补 pipeline mock |
| 2 | P1 | 监测历史仍显示 UTC | 北京时间 UI 未提交/未部署 | **未修** — 见 `docs/reports/2026-06-13-watch-session-patrol.md` |
| 3 | P1 | 远程浏览器「半删」工作区 | `browser-*` 已从磁盘删除但未 `git commit`HEAD/生产仍有 `/browser` 路由 | **未提交** — 需整包提交或 `git restore` |
| 4 | P2 | `npm run type-check` 失败 | 浏览器相关 `.vue/.ts` 已删但未入 index`vue-tsc --build` 报 TS6053 | 随 #3 一并 `git add` 删除后恢复 |
| 5 | P3 | 仪表盘 `online=0` 难察觉 Redis 故障 | `server_stats` catch 后静默归零 | 设计取舍(有 warning 日志) |
| 6 | — | SSH 探针全线失败 | `.strip('"')` 破坏 bash | **已修已部署** `adee25d` |
| 7 | — | TTL 到期 ghost pin | API 未同步 `_finalize_due_pins` | **已修** `2026-06-12` |
| 8 | — | `onRemove` 无错误提示 | — | **已修**`WatchSlotRow` 已有 try/catch |
---
## P2 — 链条测试回归(已修)
```
FAILED tests/chain/test_agent_dashboard_flow.py::test_heartbeat_then_stats_online_count
WARNING: Failed to count monitored connectivity: 'coroutine' object has no attribute 'hgetall'
```
引入提交:`7209f53`(仪表盘 `count_monitored_connectivity` + Redis pipeline)。
门控 Gate 3 只跑 `tests/test_api.py`**不跑 chain**;但 Gitea CI 有独立 `pytest tests/chain` job,会持续红。
修复:mock `redis.pipeline()``test_server_connectivity.py` 同模式。
---
## P1 — 监测历史北京时间(用户可见)
工作区已改 `formatChartAxisBeijing` / `formatDateTimeBeijing`,生产 `web/app/` 仍为旧 bundle → **时间列比北京时间少 8 小时**
---
## P1 — 远程浏览器删除未提交
| 层 | HEAD / 生产 | 工作区 |
|----|-------------|--------|
| 路由 `#/browser` | ✅ 有 | ❌ 已删 |
| `GlobalBrowserPanel` | ✅ 有 | ❌ 已删 |
| `/api/browser/*` | ✅ 有 | ❌ 已删 |
| `vite build` | — | ✅ 通过 |
| `type-check` | — | ❌ 失败 |
**风险**:只部署前端 build(工作区)而不部署后端删除 → 404;只提交后端不提交前端 → 导航仍指向已删页面。
**建议**:按 `docs/changelog/2026-06-12-remove-remote-browser.md` **整包提交** 后一次部署。
---
## P3 — 仪表盘统计静默降级
```192:197:server/api/servers.py
except Exception as e:
logger.warning(f"Failed to count monitored connectivity in server_stats: {e}")
fleet = {"monitored": 0, "online": 0, "offline": 0}
main_scope = {"monitored": 0, "online": 0, "offline": 0}
```
Redis 异常时 UI 显示 0 在线而非错误态;运维需查日志。非本次引入,记录备查。
---
## 本地验证命令
```bash
bash scripts/local_verify.sh # 26/26
.venv/bin/pytest tests/ -q # 581 passed
.venv/bin/pytest tests/chain -q # 9 passed
cd frontend && npx vite build # OK
cd frontend && npm run type-check # 浏览器删除提交前 FAIL
```
---
## 待办优先级
| 优先级 | 项 |
|--------|-----|
| P0 | 提交链条测试修复(`test_agent_dashboard_flow.py` |
| P1 | 部署监测历史北京时间(含 audit + changelog |
| P1 | 远程浏览器删除:整包 commit + 部署,或 `git restore` 回滚工作区 |
| P2 | 生产复验:监测插槽数据、北京时间、CI chain job 绿 |
@@ -0,0 +1,35 @@
# 生产部署验证 — 监测北京时间 + 下线远程浏览器
**日期**2026-06-13
**Commit**`ba7c790` — feat(watch): show probe history in Beijing time and retire remote browser
## 门控
`deploy/pre_deploy_check.sh`**7/7 PASS**
## 部署
```bash
bash scripts/git-push.sh main
bash deploy/deploy-production.sh
```
- 运行时:Docker `@ /opt/nexus`
- 镜像重建 + `web/app` 同步至容器
- Ops cron 已安装
## 生产探针(源站 SSH
| 检查项 | 结果 |
|--------|------|
| `curl http://127.0.0.1:8600/health` | `ok` |
| `curl http://127.0.0.1:8600/app/` | `200` |
| `git log -1` | `ba7c790` |
| 容器内 browser 路由 | `no browser routes` |
| 前端 bundle 含 `Asia/Shanghai` | ✅ |
## 建议浏览器终验
1. 打开 `https://api.synaglobal.vip/app/#/watch-metrics` → 探针记录时间为北京时间,页眉有「北京时间」标签
2. 服务器列表 →「站点」在新标签打开(无全局浏览器面板)
3. 侧栏无「远程浏览器」入口
@@ -0,0 +1,120 @@
# 巡检 — 监测 SSH 探针 / 硬件详情 / 历史北京时间
**日期**2026-06-13
**范围**:本会话监测相关修复与 UI 改动 + 本地门控 + 生产可达性
**生产**https://api.synaglobal.vip
## 进度条
| 步骤 | 状态 |
|------|------|
| SSH 探针引号修复 (`adee25d`) | ✅ 已提交;前序会话已部署 |
| 插槽硬件详情 (`4acbf04`) | ✅ 已提交;前序会话已部署 |
| 监测历史北京时间 | ✅ 工作区实现 + changelog;❌ 未提交、未部署 |
| 本地 L2b | ✅ 26/26 + ruff |
| 功能单测 | ✅ 29/29watch 22 + ops_patrol 7 |
| 前端 build | ✅ `vite build` 6.5s |
| 门控 7/7 | ✅(针对当前 index 已跟踪变更) |
| 生产健康 | ⚠️ 本机外网 `curl` 失败;SSH 无私钥 |
| 浏览器终验 | ⚠️ 北京时间需部署后终验 |
---
## 1. 已上线功能(代码库 HEAD)
### 1.1 SSH 探针引号破坏 bash`adee25d`
| 检查项 | 结果 |
|--------|------|
| `remote_probe.py` 使用 `.strip(chr(34))` 而非 `.strip('"')` | ✅ |
| `test_ssh_watch_metrics_cmd_runs_locally` | ✅ |
| `test_parse_ssh_watch_payload*` | ✅ |
| 审计 `docs/audit/2026-06-13-ssh-watch-probe-quote-fix.md` | ✅ |
**根因回顾**`.strip('"')` 会剥掉脚本内嵌 bash 双引号,导致远端探针语法错误、插槽全线 `ssh_probe_failed`
### 1.2 监测插槽硬件详情(`4acbf04`
| 检查项 | 结果 |
|--------|------|
| `WatchProbeSample``cpu_cores` / `mem_total_gb` 等 | ✅ |
| `fill_hardware_gaps_from_percent` 单测 | ✅ |
| `test_parse_ssh_watch_payload_includes_hardware_specs` | ✅ |
| Agent `_hardware_specs_dict()` | ✅ 代码存在 |
### 1.3 Telegram 群聊 Chat ID`40cba6a` / `e95e522`
| 检查项 | 结果 |
|--------|------|
| `_extract_chats_from_telegram_updates``my_chat_member` | ✅(前序已验) |
| `test_settings_telegram.py` | ✅ 纳入 29 passed |
---
## 2. 工作区待部署:监测历史北京时间
| 检查项 | 结果 |
|--------|------|
| `formatChartAxisBeijing` / 各 watch 组件引用 | ✅ |
| `WatchMetricsPage` 「北京时间」标签 | ✅ |
| Changelog `2026-06-13-watch-history-beijing-time.md` | ✅ 27 行 |
| 审计 `docs/audit/2026-06-13-watch-history-beijing-time.md` | ❌ **缺失**(提交前建议补,避免 Gate 2/7 在含该 diff 时失败) |
| `git commit` / 生产 `web/app/` | ❌ 未做 |
**逻辑**API `recorded_at` 为 UTC naive / ISO-Z`parseApiDateTime` 转 instant 后 `Asia/Shanghai` 格式化。示例:`2026-06-13 04:30:00` UTC → 展示 `2026-06-13 12:30:00`
---
## 3. 本地验证
```
bash scripts/local_verify.sh → 26/26 passed, ruff OK
pytest test_watch_metrics.py → 22 passed
pytest test_ops_patrol_sync.py → 7 passed
cd frontend && npx vite build → OK
deploy/pre_deploy_check.sh → 7/7 gates passed
curl http://127.0.0.1:8600/health → ok
```
---
## 4. 生产可达性(本环境)
```
curl -sk https://api.synaglobal.vip/health → 连接重置 / TLS 失败 (exit 35)
ssh nexus → 主机名不可解析
ssh azureuser@20.24.218.235 → Permission denied (publickey)
(本机无 ~/.ssh/id_rsa_nexus
```
**结论**:无法在本 Agent 环境复验生产 `/health` 与监测插槽实时数据;**不能据此判定生产宕机**(与前序巡检一致,疑为本机网络/DNS/密钥限制)。请在可 SSH 生产的环境复验。
---
## 5. 工作区噪音(勿混入北京时间提交)
以下改动与本次监测巡检**无关**,提交北京时间时不应 `git add`
- `browser-worker/` 整目录删除
- `GlobalBrowserPanel.vue` / `BrowserPage.vue` / `useGlobalBrowser.ts` 等远程浏览器移除
- `deploy/gate_log.jsonl``AI-HANDOFF` 等杂项
---
## 6. 风险与待办
| 优先级 | 项 | 建议 |
|--------|-----|------|
| P0 | 北京时间上线 | 补 audit → 仅 add watch+datetime+changelog → commit → `vite build` → 部署前端 |
| P1 | 生产复验 | 有可 SSH 密钥环境:`curl -sk https://127.0.0.1:8600/health`;打开 `#/watch-metrics` 看时间列 |
| P2 | 插槽数据 | 确认无 `ssh_probe_failed`;卡片显示核数/内存/磁盘 |
| P3 | Layer3 巡检 | 见 `docs/reports/2026-06-13-session-features-patrol.md`(与监测 UI 独立) |
---
## 7. 部署后终验清单
1. `#/watch-metrics` → 探针记录「时间」列为北京时间(与系统时钟差 ≤1 分钟)
2. 趋势图横轴 `MM-DD HH:mm` 与 tooltip 全文时间为北京时间
3. 监测插槽有 CPU/内存/磁盘用量与规格(非全线失败)
4. `https://api.synaglobal.vip/health``ok`
+86
View File
@@ -0,0 +1,86 @@
# Bug 巡查 — 2026-06-14
**日期**2026-06-14
**范围**`825d029` 暂停居中开关 + `4bbc47f` UI 美化 + 监测链路
**方法**L2b + pytest + 代码走读 + 生产探针
## 进度条
| 步骤 | 状态 |
|------|------|
| `local_verify.sh` | ✅ 26/26 |
| `pytest`(非 chain | ✅ 571 passed |
| `pytest tests/chain` | ✅ 9/9 |
| `test_watch_*` | ✅ 23/23 |
| 生产 `/health` | ✅ `ok` @ `825d029` |
| 浏览器终验 | ⚠️ 待用户确认暂停开关交互 |
---
## 发现与处理
| # | 严重度 | 问题 | 根因 | 状态 |
|---|--------|------|------|------|
| 1 | **P2** | 暂停槽底部显示「剩余 **已到期**」 | 暂停时 API `remaining_sec=null``formatRemaining(null)`→「已到期」 | **已修** — 暂停底栏改「已暂停 · 时长 · Agent 60s」 |
| 2 | P3 | Sparkline 渐变 `${color}33` 在非 hex 主题失效 | Vuetify 可能返回 `rgb()` | 未修(线条仍正常) |
| 3 | P3 | 探针表 `total=0` 仍显示 1 页分页 | `pageCount` 下限为 1 | 未修 |
| 4 | — | SSH 探针引号 | `adee25d` | 已部署 |
| 5 | — | TTL ghost pin | `_finalize_due_pins` | 已修 |
| 6 | — | CH-AGT-001 pipeline mock | `825d029` 前已修 | ✅ |
---
## P2 — 暂停态「已到期」误文案(已修)
后端 `watch_service._slot_payload`:暂停时 `remaining_sec=None`TTL 倒计时冻结)。
```138:139:frontend/src/utils/watchFormat.ts
export function formatRemaining(sec: number | null | undefined): string {
if (sec == null || sec <= 0) return '已到期'
```
暂停底栏若用 `formatRemaining(slot.remaining_sec)` 会误显示「已到期」,与用户「槽位保留」语义冲突。
**修复**`WatchSlotCard.vue` 暂停底栏改为 `已暂停 · {ttl} · Agent 60s 上报`。
---
## 暂停居中开关(`825d029`)走读
| 检查项 | 结果 |
|--------|------|
| 暂停仅中部开关可开启 | ✅ 顶栏开关 `v-if="monitoringOn"` |
| 开启后显示指标 | ✅ `v-else` 包裹 metrics |
| 暂停不跳转历史 | ✅ `@click` 仅 `monitoringOn` 时 |
| API 失败提示 | ✅ `WatchSlotRow.onMonitoring` try/catch |
| 暂停不返回 metrics | ✅ 后端 `monitoring_enabled` 分支 |
---
## 本地验证
```text
bash scripts/local_verify.sh → 26/26
pytest tests/ -m "not chain and not slow" → 571 passed
pytest tests/chain -q → 9 passed
pytest tests/test_watch_pins.py tests/test_watch_metrics.py → 23 passed
```
---
## 生产(源站 SSH
| 项 | 结果 |
|----|------|
| `curl http://127.0.0.1:8600/health` | `ok` |
| `git log -1` | `825d029` |
---
## 待办
| 优先级 | 项 |
|--------|-----|
| P0 | 部署 P2 底栏文案修复(工作区未提交) |
| P2 | 浏览器终验:暂停→中部开→指标→顶栏关 |
| P3 | Sparkline 渐变 / 空表分页 |
+107
View File
@@ -0,0 +1,107 @@
# 巡检 — 2026-06-14(监测 UI + 生产健康)
**日期**2026-06-14
**范围**:监测槽 UI 迭代(`c91cb1c` 已部署 + 工作区待部署)· L2b · pytest · 生产源站
**生产**https://api.synaglobal.vip
## 进度条
| 步骤 | 状态 |
|------|------|
| `local_verify.sh` | ✅ 26/26 + ruff |
| `pytest`(非 chain | ✅ 570 passed1 环境性失败见下) |
| `pytest tests/chain` | ✅ 9/9 |
| `test_watch_*` + `test_ops_patrol_sync` | ✅ 29/30SSH 本地探针 1 失败) |
| 门控 7/7 | ✅(针对已跟踪变更) |
| `vite build` | ✅ |
| 生产 `/health` + `/app/` | ✅ `ok` / `200` @ `c91cb1c` |
| 工作区待部署 | ⚠️ 见 §2 |
---
## 1. 生产(源站 SSH
| 项 | 结果 |
|----|------|
| `curl http://127.0.0.1:8600/health` | `ok` |
| `curl /app/` | `200` |
| `git log -1``/opt/nexus` | `c91cb1c` — polish paused UI + header spacing |
| `bash -n deploy/health_monitor.sh` | ✅ 通过 |
| Docker nexus | 运行中(巡检时刻容器 Up) |
**Layer 3 日志**`/var/log/nexus_health.log` 尾部曾出现 `health_monitor.sh: unexpected EOF`(历史 cron 执行);当前仓库脚本 `bash -n` 正常,疑为旧版残留日志,**不影响当前 `/health`**。建议观察下一小时 cron 是否仍报错。
---
## 2. 工作区未部署(监测槽 UI)
相对生产 `c91cb1c`,本地尚有未提交改动:
| 变更 | 说明 |
|------|------|
| 顶栏 ⋮ 菜单 | 暂停/恢复/移除,去掉开关与 ✕,防误触 |
| 暂停态精简 | 仅保留「恢复实时监测」;隐藏时长行、横幅、圆环占位、底栏 |
| 开启态底栏 | 增加「暂停」文字链 |
| `watch_probe_runner.py` | 删除未使用 `import time`ruff F401 |
**建议**:commit → 门控 → 部署前端(纯 UI + 无害 lint 可随下一次一起上)。
---
## 3. 已部署功能走读(`b5419cd`…`c91cb1c`
| 检查项 | 结果 |
|--------|------|
| SSH 优先 5s 探针 | ✅ 代码在产 |
| 移除 IO 速率 | ✅ 前后端 |
| 四圆环一行(CPU/内存/硬盘/负载) | ✅ `3faa7a4` |
| 恢复监测立即探针 | ✅ `5c508ed` |
| 暂停态圆环占位 + 顶栏间距 | ✅ `c91cb1c`(将被下一版精简 UI 替代) |
---
## 4. 本地验证
```text
bash scripts/local_verify.sh → 26/26, ruff OK
pytest tests/ -m "not chain and not slow" -q → 570 passed, 1 failed
pytest tests/chain -q → 9 passed
pytest tests/test_watch_pins.py test_watch_metrics.py test_ops_patrol_sync.py → 29 passed, 1 failed
cd frontend && npx vite build → OK
deploy/pre_deploy_check.sh → 7/7
```
**环境性失败**`test_ssh_watch_metrics_cmd_runs_locally` — 本机无 `psutil`,远端脚本返回 `error`;CI/生产有 psutil 时通过。非回归。
---
## 5. 发现与处理
| # | 严重度 | 问题 | 状态 |
|---|--------|------|------|
| 1 | P2 | 顶栏开关易误触 | **工作区已改** — ⋮ 菜单 + 底栏「暂停」 |
| 2 | P2 | 暂停态信息过载 | **工作区已改** — 仅恢复按钮区 |
| 3 | P3 | `watch_probe_runner` 未使用 `import time` | **工作区已修** — ruff F401 |
| 4 | P3 | L3 日志历史 syntax 报错 | 观察;当前脚本语法 OK |
| 5 | P3 | Sparkline 渐变 hex 假设 | 未修(线条正常) |
| 6 | P3 | 探针表 total=0 仍 1 页 | 未修 |
---
## 6. 部署后终验清单(下一版 UI)
1. 暂停槽:无时长行/圆环/底栏,仅「恢复实时监测」
2. 开启槽:顶栏仅 ⋮,无贴脸开关;底栏可点「暂停」
3. 四圆环数值与宝塔接近(±5s
4. 暂停 → 恢复后 5–15s 内出现指标
---
## 7. 待办
| 优先级 | 项 |
|--------|-----|
| P0 | 提交并部署 §2 工作区 UI + lint 修复 |
| P1 | 用户浏览器终验暂停/恢复/菜单 |
| P2 | 观察 L3 cron 日志是否仍报 EOF |
| P3 | Sparkline / 空表分页 |
View File
+59 -25
View File
@@ -1,5 +1,5 @@
<template>
<v-app :style="appChromeVars">
<v-app>
<!-- App Bar (隐藏于登录页) -->
<v-app-bar v-if="auth.isLoggedIn" color="primary" elevation="0" flat class="ps-4">
<v-app-bar-nav-icon class="mr-3" @click="drawer = !drawer" />
@@ -81,6 +81,38 @@
<v-divider class="mx-3 my-1" />
<v-list nav slim>
<v-list-subheader>宝塔面板</v-list-subheader>
<v-list-group v-model="btPanelMenuOpen">
<template #activator="{ props: groupProps }">
<v-list-item
v-bind="groupProps"
prepend-icon="mdi-view-dashboard-variant-outline"
title="宝塔管理"
:active="route.path.startsWith('/btpanel')"
/>
</template>
<v-list-item
v-for="item in btPanelItems"
:key="item.to"
:prepend-icon="item.icon"
:title="item.title"
:to="item.to"
:active="route.path === item.to"
active-class="text-primary"
link
nav
slim
class="pl-4"
@click="navigateTo(item.to)"
@mouseenter="scheduleRoutePrefetch(item.to)"
@mouseleave="cancelRoutePrefetch(item.to)"
/>
</v-list-group>
</v-list>
<v-divider class="mx-3 my-1" />
<v-list nav slim>
<v-list-subheader>系统</v-list-subheader>
@@ -202,10 +234,8 @@
</v-alert>
</div>
<GlobalBrowserPanel v-if="auth.isLoggedIn" />
<!-- Main Content -->
<v-main :class="mainLayoutClass" :style="mainContentOffset">
<v-main :class="mainLayoutClass" :style="mainContentStyle">
<router-view v-slot="{ Component, route: activeRoute }">
<keep-alive :include="cachedPageNames" :max="12">
<component :is="Component" :key="activeRoute.name ?? activeRoute.path" />
@@ -251,8 +281,6 @@ import { clearCachedQueries } from '@/composables/useCachedQuery'
import { scheduleRoutePrefetch, cancelRoutePrefetch } from '@/composables/useRoutePrefetch'
import { CACHED_PAGE_NAMES } from '@/constants/cachedPages'
import { api } from '@/api'
import GlobalBrowserPanel from '@/components/GlobalBrowserPanel.vue'
const cachedPageNames = [...CACHED_PAGE_NAMES]
const route = useRoute()
@@ -323,8 +351,13 @@ async function toggleTheme() {
}
const drawer = ref(true)
const btPanelMenuOpen = ref(route.path.startsWith('/btpanel'))
const { mobile } = useDisplay()
watch(() => route.path, (p) => {
if (p.startsWith('/btpanel')) btPanelMenuOpen.value = true
})
/** 终端页监听菜单开合,在动画结束后 refit xterm */
provide('nexusDrawer', drawer)
@@ -333,25 +366,12 @@ const mainLayoutClass = computed(() => ({
'nexus-page-main': route.path !== '/terminal' && route.path !== '/login',
}))
const BROWSER_PANEL_HEIGHT = 420
const appChromeVars = computed(() => {
const dock = auth.isLoggedIn ? `${BROWSER_PANEL_HEIGHT}px` : '0px'
return {
'--nexus-browser-dock-height': dock,
'--nexus-browser-dock-offset': dock,
}
})
const mainContentOffset = computed(() => {
const mainContentStyle = computed(() => {
if (!auth.isLoggedIn) return undefined
const execRows = runningItems.value.length
const execExtra = execRows ? execRows * 52 : 0
const browserExtra = BROWSER_PANEL_HEIGHT
const total = execExtra + browserExtra
return {
paddingTop: `calc(var(--v-layout-top, 64px) + ${total}px)`,
}
if (!execExtra) return undefined
return { '--nexus-exec-bar-offset': `${execExtra}px` }
})
const opsItems = [
@@ -373,6 +393,19 @@ const sysItems = [
{ to: '/audit', title: '审计日志', icon: 'mdi-clipboard-text-outline' },
]
const btPanelItems = [
{ to: '/btpanel/login', title: '服务器列表', icon: 'mdi-server' },
{ to: '/btpanel/monitor', title: '系统监控', icon: 'mdi-speedometer' },
{ to: '/btpanel/sites', title: '网站列表', icon: 'mdi-web' },
{ to: '/btpanel/sites/create', title: '创建网站', icon: 'mdi-web-plus' },
{ to: '/btpanel/domains', title: '域名管理', icon: 'mdi-domain' },
{ to: '/btpanel/ssl', title: 'SSL 证书', icon: 'mdi-certificate-outline' },
{ to: '/btpanel/databases', title: '数据库', icon: 'mdi-database-outline' },
{ to: '/btpanel/crontab', title: '计划任务', icon: 'mdi-calendar-clock' },
{ to: '/btpanel/services', title: '服务管理', icon: 'mdi-cog-transfer-outline' },
{ to: '/btpanel/settings', title: '连接配置', icon: 'mdi-link-variant' },
]
const snackbar = reactive({ show: false, text: '', color: 'success' })
function navigateTo(to: string) {
@@ -416,7 +449,7 @@ window.$snackbar = (text: string, color = 'success') => {
min-height: 0;
max-width: 100%;
overflow: hidden;
padding-top: var(--v-layout-top, 64px) !important;
padding-top: calc(var(--v-layout-top, 64px) + var(--nexus-exec-bar-offset, 0px)) !important;
padding-bottom: 0 !important;
transition:
padding 0.2s cubic-bezier(0.4, 0, 0.2, 1),
@@ -434,7 +467,8 @@ window.$snackbar = (text: string, color = 'success') => {
/* 仪表盘等业务页:随侧栏 --v-layout-left 缩进,表格可横向滚动 */
.v-application .v-main.nexus-page-main {
box-sizing: border-box;
min-height: calc(100dvh - var(--v-layout-top, 64px));
padding-top: calc(var(--v-layout-top, 64px) + var(--nexus-exec-bar-offset, 0px));
min-height: calc(100dvh - var(--v-layout-top, 64px) - var(--nexus-exec-bar-offset, 0px));
min-width: 0;
max-width: 100%;
overflow-x: hidden;
@@ -452,7 +486,7 @@ window.$snackbar = (text: string, color = 'success') => {
.nexus-script-exec-bar {
position: fixed;
top: calc(var(--v-layout-top, 64px) + var(--nexus-browser-dock-offset, 0px));
top: var(--v-layout-top, 64px);
left: var(--v-layout-left, 0);
right: 0;
z-index: 1005;
+189
View File
@@ -0,0 +1,189 @@
import { api } from '@/api'
export interface BtPanelServerRow {
id: number
name: string
domain: string
category: string | null
base_url: string | null
api_key_set: boolean
verify_ssl: boolean
configured: boolean
auto_bootstrap?: boolean
bootstrap_state?: string
bootstrap_last_attempt_at?: string | null
bootstrap_next_attempt_at?: string | null
bootstrap_attempts?: number
bootstrap_last_error?: string | null
}
export interface BtPanelConfig {
base_url: string | null
api_key_set: boolean
verify_ssl: boolean
configured: boolean
bt_installed: boolean | null
auto_bootstrap?: boolean
bootstrap_state?: string
bootstrap_last_attempt_at?: string | null
bootstrap_next_attempt_at?: string | null
bootstrap_attempts?: number
bootstrap_last_error?: string | null
bootstrap_last_actions?: string[] | null
ok?: boolean
error?: string
}
export interface BtPanelGlobalSettings {
bt_panel_source_ip: string
bt_panel_auto_bootstrap_enabled: boolean
resolved_center_ip: string | null
}
export interface BatchJobResponse {
job_id: number
status: string
label: string
}
export function listBtServers() {
return api<BtPanelServerRow[]>('/btpanel/servers')
}
export function getBtConfig(serverId: number, options?: { refreshBtInstalled?: boolean }) {
const qs = options?.refreshBtInstalled ? '?refresh_bt_installed=true' : ''
return api<BtPanelConfig>(`/btpanel/servers/${serverId}/config${qs}`)
}
export function updateBtConfig(serverId: number, body: {
base_url?: string
api_key?: string
verify_ssl?: boolean
auto_bootstrap?: boolean
}) {
return api<BtPanelConfig>(`/btpanel/servers/${serverId}/config`, {
method: 'PUT',
body: JSON.stringify(body),
})
}
export function getBtGlobalSettings() {
return api<BtPanelGlobalSettings>('/btpanel/settings')
}
export function updateBtGlobalSettings(body: {
bt_panel_source_ip?: string
bt_panel_auto_bootstrap_enabled?: boolean
}) {
return api<BtPanelGlobalSettings>('/btpanel/settings', {
method: 'PUT',
body: JSON.stringify(body),
})
}
export function bootstrapBtServer(serverId: number) {
return api<BtPanelConfig>(`/btpanel/servers/${serverId}/bootstrap`, { method: 'POST', body: '{}' })
}
export function batchBootstrapBtServers(serverIds: number[]) {
return api<BatchJobResponse>('/btpanel/servers/batch-bootstrap', {
method: 'POST',
body: JSON.stringify({ server_ids: serverIds }),
})
}
export function createBtLoginUrl(serverId: number) {
return api<{ url: string; method: string }>(`/btpanel/servers/${serverId}/login-url`, { method: 'POST', body: '{}' })
}
export function btSystemTotal(serverId: number) {
return api<Record<string, unknown>>(`/btpanel/servers/${serverId}/system/total`)
}
export function btSystemDisk(serverId: number) {
return api<unknown[]>(`/btpanel/servers/${serverId}/system/disk`)
}
export function btSystemNetwork(serverId: number) {
return api<Record<string, unknown>>(`/btpanel/servers/${serverId}/system/network`)
}
export function btListSites(serverId: number) {
return api<{ data?: unknown[] }>(`/btpanel/servers/${serverId}/sites`)
}
export function btSiteStart(serverId: number, site_id: number, name: string) {
return api(`/btpanel/servers/${serverId}/sites/start`, {
method: 'POST',
body: JSON.stringify({ site_id, name }),
})
}
export function btSiteStop(serverId: number, site_id: number, name: string) {
return api(`/btpanel/servers/${serverId}/sites/stop`, {
method: 'POST',
body: JSON.stringify({ site_id, name }),
})
}
export function btPhpVersions(serverId: number) {
return api<unknown[]>(`/btpanel/servers/${serverId}/sites/php-versions`)
}
export function btCreateSite(serverId: number, body: Record<string, unknown>) {
return api(`/btpanel/servers/${serverId}/sites`, {
method: 'POST',
body: JSON.stringify(body),
})
}
export function btListDomains(serverId: number, siteId: number) {
return api<{ data?: unknown[] }>(`/btpanel/servers/${serverId}/sites/${siteId}/domains`)
}
export function btAddDomain(serverId: number, body: Record<string, unknown>) {
return api(`/btpanel/servers/${serverId}/domains`, { method: 'POST', body: JSON.stringify(body) })
}
export function btDeleteDomain(serverId: number, body: Record<string, unknown>) {
return api(`/btpanel/servers/${serverId}/domains/delete`, { method: 'POST', body: JSON.stringify(body) })
}
export function btSslSites(serverId: number) {
return api<{ data?: unknown[] }>(`/btpanel/servers/${serverId}/ssl/sites`)
}
export function btSetSsl(serverId: number, body: { siteName: string; key: string; csr: string }) {
return api(`/btpanel/servers/${serverId}/ssl/set`, { method: 'POST', body: JSON.stringify(body) })
}
export function btApplySsl(serverId: number, body: { domains: string; id: number; auth_type?: string }) {
return api(`/btpanel/servers/${serverId}/ssl/apply`, { method: 'POST', body: JSON.stringify(body) })
}
export function btListDatabases(serverId: number) {
return api<{ data?: unknown[] }>(`/btpanel/servers/${serverId}/databases`)
}
export function btCreateDatabase(serverId: number, body: Record<string, unknown>) {
return api(`/btpanel/servers/${serverId}/databases`, { method: 'POST', body: JSON.stringify(body) })
}
export function btDbPassword(serverId: number, body: { id: number; name: string; password: string }) {
return api(`/btpanel/servers/${serverId}/databases/password`, { method: 'POST', body: JSON.stringify(body) })
}
export function btDbBackup(serverId: number, id: number) {
return api(`/btpanel/servers/${serverId}/databases/backup`, { method: 'POST', body: JSON.stringify({ id }) })
}
export function btCrontab(serverId: number) {
return api<unknown[] | { data?: unknown[] }>(`/btpanel/servers/${serverId}/crontab`)
}
export function btServiceAdmin(serverId: number, name: string, type: string) {
return api(`/btpanel/servers/${serverId}/services`, {
method: 'POST',
body: JSON.stringify({ name, type }),
})
}
@@ -1,328 +0,0 @@
<template>
<div
v-if="tabs.length"
class="global-browser-fixed"
:class="{ 'global-browser-fixed--maximized': maximized }"
>
<v-card border rounded="0" class="global-browser-panel d-flex flex-column h-100">
<div class="global-browser-toolbar d-flex align-center flex-shrink-0">
<v-tabs
:model-value="activeTabId ?? undefined"
density="compact"
show-arrows
class="global-browser-tabs flex-grow-1 min-w-0"
@update:model-value="onTabSelected"
>
<v-tab v-for="tab in tabs" :key="tab.id" :value="tab.id" class="text-none px-2">
<span class="text-truncate" style="max-width: 120px">{{ tabLabel(tab) }}</span>
<v-btn
v-if="tabs.length > 1"
icon="mdi-close"
size="x-small"
variant="text"
class="ml-1 opacity-60"
density="compact"
@click.stop="closeTab(tab.id)"
/>
</v-tab>
</v-tabs>
<v-btn size="small" variant="text" icon="mdi-plus" title="新标签" density="compact" @click="newEmptyTab" />
<v-divider vertical class="mx-1" style="height: 20px; align-self: center" />
<v-btn size="small" variant="text" icon="mdi-history" title="浏览记录" density="compact" @click="showHistory = !showHistory" />
<v-btn size="small" variant="text" icon="mdi-restart" title="重启浏览器(保留历史记录)" density="compact" @click="onRestart" />
<v-btn
size="small"
variant="text"
:icon="maximized ? 'mdi-fullscreen-exit' : 'mdi-fullscreen'"
density="compact"
title="全屏"
class="mr-1"
@click="maximized = !maximized"
/>
</div>
<div class="global-browser-nav d-flex align-center px-2 py-1 flex-shrink-0 border-b">
<v-btn icon="mdi-arrow-left" variant="text" size="small" :disabled="!canGoBack" @click="onGoBack" />
<v-btn icon="mdi-arrow-right" variant="text" size="small" :disabled="!canGoForward" @click="onGoForward" />
<v-btn icon="mdi-refresh" variant="text" size="small" :disabled="!activeTab?.url" @click="onRefresh" />
<v-text-field
v-model="addressDraft"
density="compact"
variant="outlined"
hide-details
placeholder="https://example.com"
prepend-inner-icon="mdi-web"
class="global-browser-url mx-2"
@keydown.enter.prevent="onGo"
/>
<v-btn color="primary" variant="flat" size="small" class="text-none" @click="onGo">打开</v-btn>
<v-btn icon="mdi-open-in-new" variant="text" size="small" :disabled="!activeTab?.url" @click="openExternal" />
</div>
<v-alert
v-if="displayError"
type="warning"
density="compact"
variant="tonal"
class="ma-2 mb-0 flex-shrink-0"
closable
@click:close="clearErrors"
>
{{ displayError }}
</v-alert>
<div class="global-browser-main d-flex flex-grow-1 min-h-0">
<aside v-if="showHistory" class="global-browser-history flex-shrink-0 border-e">
<div class="text-caption font-weight-medium px-3 py-2">浏览记录已同步账号</div>
<v-list density="compact" nav class="py-0 global-browser-history-list">
<v-list-item
v-for="(visit, idx) in visits"
:key="`${visit.at}-${idx}`"
:title="visit.title"
:subtitle="visit.url"
@click="openFromHistory(visit.url)"
/>
<v-list-item v-if="!visits.length" title="暂无记录" disabled />
</v-list>
</aside>
<div class="global-browser-content flex-grow-1 d-flex flex-column min-w-0 min-h-0">
<div
v-if="!activeTab?.url"
class="flex-grow-1 d-flex flex-column align-center justify-center text-medium-emphasis pa-4"
>
<v-icon icon="mdi-web" size="48" class="mb-3 opacity-50" />
<p class="text-body-2 text-center">输入地址后按回车或点打开</p>
<p class="text-caption mt-2 text-center">
Worker 上的 Chromium 远程渲染不受 iframe 嵌入限制
</p>
</div>
<div v-else class="global-browser-canvas-wrap flex-grow-1 d-flex flex-column min-h-0">
<div v-if="connecting" class="d-flex align-center justify-center flex-grow-1">
<v-progress-circular indeterminate color="primary" />
<span class="ml-3 text-body-2">正在连接远程浏览器</span>
</div>
<canvas
v-show="connected"
ref="canvasRef"
class="global-browser-canvas flex-grow-1"
tabindex="0"
@mousemove="onCanvasMouseMove"
@mousedown="onCanvasMouseDown"
@mouseup="onCanvasMouseUp"
@wheel.prevent="onCanvasWheel"
@keydown="onCanvasKeyDown"
@contextmenu.prevent
/>
</div>
</div>
</div>
</v-card>
</div>
<v-dialog v-model="showRestartConfirm" max-width="400">
<v-card border>
<v-card-title>重启浏览器</v-card-title>
<v-card-text>将关闭所有标签并打开空白页浏览历史记录会保留</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="showRestartConfirm = false">取消</v-btn>
<v-btn color="primary" variant="flat" @click="confirmRestart">重启</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { useGlobalBrowser } from '@/composables/useGlobalBrowser'
import { useRemoteBrowserSession } from '@/composables/useRemoteBrowserSession'
const showHistory = ref(false)
const showRestartConfirm = ref(false)
const maximized = ref(false)
const canvasRef = ref<HTMLCanvasElement | null>(null)
const {
lastError,
addressDraft,
visits,
tabs,
activeTabId,
activeTab,
canGoBack,
canGoForward,
tabLabel,
openPanel,
restartBrowser,
navigate,
updateActiveFromRemote,
goBack,
goForward,
openExternal,
switchTab,
closeTab,
newEmptyTab,
openFromHistory,
bootstrap,
} = useGlobalBrowser()
const {
connected,
connecting,
remoteError,
setCanvas,
setPageInfoHandler,
connect,
disconnect,
navigate: remoteNavigate,
reload,
goBack: remoteGoBack,
goForward: remoteGoForward,
onCanvasMouseMove,
onCanvasMouseDown,
onCanvasMouseUp,
onCanvasWheel,
onCanvasKeyDown,
} = useRemoteBrowserSession()
const displayError = computed(() => remoteError.value || lastError.value)
function clearErrors() {
lastError.value = null
if (remoteError.value) remoteError.value = null
}
setPageInfoHandler((url, title) => updateActiveFromRemote(url, title))
async function ensureRemoteSession() {
if (!activeTab.value?.url || connecting.value || connected.value) return
await nextTick()
if (canvasRef.value) setCanvas(canvasRef.value)
const w = Math.max(640, Math.floor(canvasRef.value?.clientWidth || 1280))
const h = Math.max(480, Math.floor(canvasRef.value?.clientHeight || 360))
await connect(w, h)
if (connected.value && activeTab.value?.url) {
remoteNavigate(activeTab.value.url)
}
}
watch(activeTab, () => {
if (activeTab.value?.url) {
openPanel()
void ensureRemoteSession()
}
}, { flush: 'post' })
watch(activeTabId, () => {
disconnect()
if (activeTab.value?.url) {
void ensureRemoteSession()
}
})
onMounted(async () => {
await bootstrap()
if (!tabs.value.length) {
newEmptyTab()
}
openPanel()
if (activeTab.value?.url) {
void ensureRemoteSession()
}
})
function onTabSelected(id: unknown) {
if (typeof id === 'string') switchTab(id)
}
function onGo() {
if (navigate(addressDraft.value)) {
void ensureRemoteSession().then(() => {
if (activeTab.value?.url) remoteNavigate(activeTab.value.url)
})
}
}
function onRestart() {
showRestartConfirm.value = true
}
function confirmRestart() {
showRestartConfirm.value = false
disconnect()
restartBrowser()
}
function onRefresh() {
if (connected.value) reload()
}
function onGoBack() {
goBack()
if (connected.value) remoteGoBack()
}
function onGoForward() {
goForward()
if (connected.value) remoteGoForward()
}
</script>
<style scoped>
.global-browser-fixed {
position: fixed;
top: var(--v-layout-top, 64px);
left: var(--v-layout-left, 0);
right: 0;
z-index: 1004;
height: var(--nexus-browser-dock-height, 420px);
display: flex;
flex-direction: column;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.12);
}
.global-browser-fixed--maximized {
height: calc(100dvh - var(--v-layout-top, 64px)) !important;
}
.global-browser-panel {
overflow: hidden;
height: 100%;
background: rgb(var(--v-theme-surface));
border-radius: 0 !important;
}
.global-browser-toolbar {
height: 40px;
min-height: 40px;
border-bottom: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
}
.global-browser-nav {
background: rgb(var(--v-theme-surface));
}
.global-browser-url {
flex: 1 1 auto;
min-width: 100px;
}
.global-browser-main {
min-height: 0;
}
.global-browser-history {
width: 280px;
max-width: 40%;
background: rgb(var(--v-theme-surface));
}
.global-browser-history-list {
max-height: 100%;
overflow-y: auto;
}
.global-browser-canvas-wrap {
min-height: 0;
background: #1a1a1a;
}
.global-browser-canvas {
width: 100%;
min-height: 0;
cursor: default;
outline: none;
}
</style>
@@ -9,6 +9,7 @@
>
<div
v-if="modelValue"
ref="highlightRef"
class="sh-field__highlight font-mono"
:class="multiline ? 'sh-field__highlight--multi' : 'sh-field__highlight--single'"
aria-hidden="true"
@@ -27,15 +28,17 @@
autocomplete="off"
@input="onInput"
@keydown="onKeydown"
@scroll="syncHighlightScroll"
/>
</div>
</template>
<script setup lang="ts">
import { computed, ref, nextTick } from 'vue'
import { computed, ref, nextTick, watch } from 'vue'
import { highlightShellToHtml } from '@/utils/shellHighlight'
const inputRef = ref<HTMLInputElement | HTMLTextAreaElement | null>(null)
const highlightRef = ref<HTMLElement | null>(null)
const props = withDefaults(
defineProps<{
@@ -62,9 +65,25 @@ const emit = defineEmits<{
const highlighted = computed(() => highlightShellToHtml(props.modelValue))
function syncHighlightScroll() {
const el = inputRef.value
const highlight = highlightRef.value
if (!el || !highlight || !props.multiline) return
highlight.scrollTop = el.scrollTop
highlight.scrollLeft = el.scrollLeft
}
watch(
() => props.modelValue,
() => {
nextTick(syncHighlightScroll)
},
)
function onInput(e: Event) {
const el = e.target as HTMLInputElement | HTMLTextAreaElement
emit('update:modelValue', el.value)
nextTick(syncHighlightScroll)
}
function onKeydown(e: KeyboardEvent) {
@@ -135,6 +154,7 @@ defineExpose({
.sh-field__input--multi {
min-height: 72px;
resize: vertical;
overflow: auto;
}
.sh-field__highlight {
@@ -148,6 +168,10 @@ defineExpose({
border: 1px solid transparent;
}
.sh-field__highlight--multi {
overflow: hidden;
}
.sh-field__input {
position: relative;
z-index: 2;
@@ -0,0 +1,42 @@
<template>
<v-container fluid class="pa-6">
<div class="d-flex flex-wrap align-center ga-3 mb-4">
<div>
<h1 class="text-h6 font-weight-bold">{{ title }}</h1>
<p v-if="subtitle" class="text-caption text-medium-emphasis mb-0">{{ subtitle }}</p>
</div>
<v-spacer />
<div style="min-width: 280px; max-width: 420px; flex: 1">
<BtPanelServerPicker
:model-value="serverId"
:servers="servers"
:loading="loading"
@update:model-value="setServerId"
/>
</div>
</div>
<v-alert v-if="!serverId" type="info" variant="tonal" class="mb-4" text="请选择服务器" />
<slot v-else :server-id="serverId" :server="selected()" />
</v-container>
</template>
<script setup lang="ts">
import { onMounted, provide } from 'vue'
import BtPanelServerPicker from '@/components/btpanel/BtPanelServerPicker.vue'
import { btPanelContextKey } from '@/composables/btpanel/btPanelContext'
import { useBtPanelServer } from '@/composables/btpanel/useBtPanelServer'
defineProps<{
title: string
subtitle?: string
}>()
const ctx = useBtPanelServer()
const { servers, serverId, loading, loadServers, setServerId, selected } = ctx
provide(btPanelContextKey, ctx)
onMounted(() => { void loadServers() })
</script>
@@ -0,0 +1,40 @@
<template>
<v-select
:model-value="modelValue"
:items="items"
item-title="label"
item-value="id"
label="目标服务器"
density="compact"
variant="outlined"
hide-details
class="bt-panel-server-picker"
:loading="loading"
@update:model-value="$emit('update:modelValue', $event)"
>
<template #item="{ props, item }">
<v-list-item v-bind="props" :subtitle="item.raw.hint" />
</template>
</v-select>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { BtPanelServerRow } from '@/api/btpanel'
const props = defineProps<{
modelValue: number | null
servers: BtPanelServerRow[]
loading?: boolean
}>()
defineEmits<{ 'update:modelValue': [number | null] }>()
const items = computed(() =>
props.servers.map(s => ({
id: s.id,
label: `${s.name} (${s.domain})`,
hint: s.configured ? '已配置宝塔 API' : '未配置 API(一键登录可走 SSH',
})),
)
</script>
@@ -14,11 +14,11 @@
<div
v-if="showEmpty"
class="d-flex flex-column align-center justify-center fill-height pa-6"
class="d-flex flex-column align-center justify-center fill-height pa-6 terminal-area__empty"
>
<v-icon size="56" color="primary" class="mb-3">mdi-console-network</v-icon>
<div class="text-h6 mb-1 text-center">WebSSH 终端</div>
<div class="text-body-2 text-medium-emphasis mb-4 text-center">
<div class="text-h6 mb-1 text-center terminal-area__empty-title">WebSSH 终端</div>
<div class="text-body-2 mb-4 text-center terminal-area__empty-hint">
选择一台服务器开始会话或从服务器页点击终端
</div>
<slot name="empty-picker" />
@@ -100,9 +100,19 @@ defineExpose({ containerRef })
.terminal-area__pane {
position: absolute;
inset: 0;
overflow: hidden;
}
.fill-height {
flex: 1 1 auto;
min-height: 200px;
}
.terminal-area__empty {
color: #e8e8e8;
}
.terminal-area__empty-title {
color: #e8e8e8;
}
.terminal-area__empty-hint {
color: #9aa0a6;
}
</style>
@@ -27,7 +27,6 @@ import ShellHighlightField from '@/components/ShellHighlightField.vue'
defineProps<{
modelValue: string
/** 无活动会话时禁用输入 */
disabled: boolean
}>()
@@ -86,7 +85,6 @@ defineExpose({
font-family: 'Cascadia Code', 'Fira Code', 'JetBrains Mono', Menlo, monospace;
}
/* 命令栏统一单色;ANSI 彩色仅由上方 xterm SSH 输出区呈现 */
:deep(.sh-keyword),
:deep(.sh-builtin),
:deep(.sh-sudo),
@@ -0,0 +1,119 @@
<template>
<Teleport to="body">
<div v-show="open" class="terminal-ctx-host">
<div
class="terminal-ctx-backdrop"
@mousedown.prevent="emit('close')"
@contextmenu.prevent="emit('close')"
/>
<div
class="terminal-ctx-menu"
role="menu"
:style="style"
@mousedown.stop
>
<button type="button" class="terminal-ctx-menu__item" role="menuitem" @click="emit('copy')">
<v-icon size="20" icon="mdi-content-copy" />
<span>复制</span>
</button>
<button type="button" class="terminal-ctx-menu__item" role="menuitem" @click="emit('paste')">
<v-icon size="20" icon="mdi-content-paste" />
<span>粘贴</span>
</button>
<button type="button" class="terminal-ctx-menu__item" role="menuitem" @click="emit('select-all')">
<v-icon size="20" icon="mdi-select-all" />
<span>全选</span>
</button>
<template v-if="context === 'terminal'">
<div class="terminal-ctx-menu__divider" aria-hidden="true" />
<button type="button" class="terminal-ctx-menu__item" role="menuitem" @click="emit('clear')">
<v-icon size="20" icon="mdi-delete-sweep" />
<span>清屏</span>
</button>
<button
type="button"
class="terminal-ctx-menu__item terminal-ctx-menu__item--danger"
role="menuitem"
:disabled="!canDisconnect"
@click="emit('disconnect')"
>
<v-icon size="20" icon="mdi-logout" />
<span>断开连接</span>
</button>
</template>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
defineProps<{
open: boolean
style: Record<string, string>
context: 'terminal' | 'command'
canDisconnect: boolean
}>()
const emit = defineEmits<{
close: []
copy: []
paste: []
'select-all': []
clear: []
disconnect: []
}>()
</script>
<style scoped>
.terminal-ctx-host {
pointer-events: none;
}
.terminal-ctx-backdrop {
position: fixed;
inset: 0;
z-index: 2400;
pointer-events: auto;
}
.terminal-ctx-menu {
position: fixed;
z-index: 2401;
min-width: 11.5rem;
padding: 4px 0;
pointer-events: auto;
background: #1e1e1e;
color: #e8e8e8;
border: 1px solid #333;
border-radius: 8px;
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.45);
}
.terminal-ctx-menu__item {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 10px 14px;
border: none;
background: transparent;
cursor: pointer;
font-size: 1rem;
font-weight: 500;
color: inherit;
text-align: left;
line-height: 1.25;
}
.terminal-ctx-menu__item:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.08);
}
.terminal-ctx-menu__item:disabled {
opacity: 0.45;
cursor: default;
}
.terminal-ctx-menu__item--danger {
color: #f44336;
}
.terminal-ctx-menu__divider {
height: 1px;
margin: 4px 0;
background: #333;
}
</style>
@@ -1,7 +1,7 @@
<template>
<div class="terminal-quick-bar shrink-0">
<span class="terminal-quick-bar__label">快捷命令</span>
<div class="d-flex align-center ga-1 flex-grow-1 overflow-x-auto">
<div class="d-flex align-center ga-1 flex-grow-1 overflow-x-auto min-w-0">
<v-chip
v-for="cmd in commands"
:key="cmd.id"
@@ -9,7 +9,7 @@
:variant="cmd.is_builtin ? 'outlined' : 'flat'"
:color="cmd.is_builtin ? 'default' : 'primary'"
label
class="cursor-pointer"
class="cursor-pointer terminal-quick-bar__chip"
@click="$emit('exec', cmd.cmd)"
>
{{ cmd.name }}
@@ -33,7 +33,6 @@ import type { QuickCmdItem } from '@/composables/useTerminalQuickCommands'
defineProps<{
commands: QuickCmdItem[]
/** 无活动会话时禁用断开 */
disconnectDisabled?: boolean
}>()
@@ -58,6 +57,9 @@ defineEmits<{
flex-shrink: 0;
color: rgb(var(--v-theme-on-surface));
}
.terminal-quick-bar__chip {
flex-shrink: 0;
}
.cursor-pointer {
cursor: pointer;
}
@@ -4,10 +4,11 @@
服务器
<span class="text-caption text-medium-emphasis ml-1">({{ servers.length }})</span>
</div>
<div class="terminal-server-rail__search px-2 pt-2 pb-1">
<div v-if="showSearch" class="terminal-server-rail__search px-2 pt-2 pb-1">
<PickerSearch
:search="search"
:search-history="searchHistory"
:autofocus="autofocus"
@update:search="$emit('update:search', $event)"
@search-commit="$emit('search-commit')"
/>
@@ -21,15 +22,22 @@
<v-card v-else-if="mode === 'inline'" border rounded="lg" class="empty-server-picker" max-width="520" width="100%">
<v-card-text class="pa-3">
<PickerSearch
v-if="showSearch"
:search="search"
:search-history="searchHistory"
:autofocus="autofocus"
@update:search="$emit('update:search', $event)"
@search-commit="$emit('search-commit')"
/>
<v-skeleton-loader v-if="loading" type="list-item@6" class="mb-2" />
<v-list v-else density="compact" class="empty-server-picker__list" nav>
<PickerListItems :servers="servers" @select="$emit('select', $event)" />
</v-list>
<p v-if="!showList && listHint" class="text-body-2 text-center mb-0 mt-2 text-medium-emphasis">
{{ listHint }}
</p>
<template v-if="showList">
<v-skeleton-loader v-if="loading" type="list-item@6" class="mb-2" :class="{ 'mt-3': showSearch }" />
<v-list v-else density="compact" class="empty-server-picker__list" nav>
<PickerListItems :servers="servers" @select="$emit('select', $event)" />
</v-list>
</template>
</v-card-text>
</v-card>
@@ -60,8 +68,21 @@ withDefaults(
searchHistory?: string[]
loading?: boolean
mode?: 'inline' | 'dialog' | 'sidebar'
showSearch?: boolean
showList?: boolean
listHint?: string
autofocus?: boolean
}>(),
{ mode: 'inline', loading: false, search: '', searchHistory: () => [] },
{
mode: 'inline',
loading: false,
search: '',
searchHistory: () => [],
showSearch: true,
showList: true,
listHint: '',
autofocus: false,
},
)
defineEmits<{
@@ -77,9 +98,10 @@ defineEmits<{
overflow-y: auto;
}
.terminal-server-rail {
width: 300px;
width: 280px;
flex-shrink: 0;
min-height: 0;
height: 100%;
background: rgb(var(--v-theme-surface));
border-left: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
}
@@ -5,17 +5,29 @@
rounded="lg"
density="compact"
class="terminal-server-item"
:class="dark ? 'terminal-server-item--dark' : 'terminal-server-item--light'"
@click="$emit('select', s.id)"
>
<template #prepend>
<v-icon :color="s.is_online ? 'success' : 'grey'" size="8">mdi-circle</v-icon>
</template>
<v-list-item-title class="text-body-2 text-truncate" :title="s.name">{{ s.name }}</v-list-item-title>
<v-list-item-subtitle class="text-caption text-truncate text-medium-emphasis" :title="s.domain">
<v-list-item-title class="text-body-2 text-truncate terminal-server-item__name" :title="s.name">
{{ s.name }}
</v-list-item-title>
<v-list-item-subtitle
v-if="s.domain"
class="text-caption text-truncate terminal-server-item__domain"
:title="s.domain"
>
{{ s.domain }}
</v-list-item-subtitle>
</v-list-item>
<v-list-item v-if="servers.length === 0" density="compact" class="text-medium-emphasis terminal-server-item">
<v-list-item
v-if="servers.length === 0"
density="compact"
class="terminal-server-item terminal-server-item__empty"
:class="dark ? 'terminal-server-item--dark' : 'terminal-server-item--light'"
>
暂无匹配的服务器
</v-list-item>
</template>
@@ -25,6 +37,8 @@ import type { TerminalServerItem } from '@/composables/terminal/types'
defineProps<{
servers: TerminalServerItem[]
/** Sidebar uses dark chrome; inline/dialog use light card. */
dark?: boolean
}>()
defineEmits<{
@@ -40,4 +54,41 @@ defineEmits<{
.terminal-server-item :deep(.v-list-item__prepend) {
align-self: center;
}
.terminal-server-item--light :deep(.v-list-item-title),
.terminal-server-item--light .terminal-server-item__name {
color: #1a1a1a !important;
opacity: 1 !important;
}
.terminal-server-item--light :deep(.v-list-item-subtitle),
.terminal-server-item--light .terminal-server-item__domain,
.terminal-server-item--light.terminal-server-item__empty {
color: #616161 !important;
opacity: 1 !important;
}
.terminal-server-item--light:hover {
background: rgba(0, 0, 0, 0.04);
}
.terminal-server-item--dark :deep(.v-list-item-title),
.terminal-server-item--dark .terminal-server-item__name {
color: #ffffff !important;
opacity: 1 !important;
}
.terminal-server-item--dark :deep(.v-list-item-subtitle),
.terminal-server-item--dark .terminal-server-item__domain,
.terminal-server-item--dark.terminal-server-item__empty {
color: #b0bec5 !important;
opacity: 1 !important;
}
.terminal-server-item--dark:hover {
background: rgba(255, 255, 255, 0.08);
}
.terminal-server-item--dark.v-list-item--active {
background: rgba(144, 202, 249, 0.16) !important;
}
.terminal-server-item--dark.v-list-item--active :deep(.v-list-item-title),
.terminal-server-item--dark.v-list-item--active .terminal-server-item__name {
color: #ffffff !important;
}
</style>
@@ -1,27 +1,134 @@
<template>
<v-combobox
:model-value="search"
:items="searchHistory"
prepend-inner-icon="mdi-magnify"
placeholder="搜索名称/域名…"
variant="outlined"
density="compact"
hide-details
clearable
:auto-select-first="false"
@update:model-value="$emit('update:search', $event)"
@keydown.enter.prevent="$emit('search-commit')"
/>
<v-theme-provider :theme="dark ? 'dark' : 'light'" :with-background="dark" class="terminal-server-search-wrap">
<v-combobox
ref="comboboxRef"
class="terminal-server-search"
:class="{ 'terminal-server-search--dark': dark }"
v-model="draft"
:items="searchHistory"
prepend-inner-icon="mdi-magnify"
placeholder="搜索名称/域名,回车筛选"
variant="outlined"
density="compact"
hide-details
clearable
:autofocus="autofocus"
:auto-select-first="false"
:bg-color="dark ? '#1a1a1a' : '#ffffff'"
:base-color="dark ? '#888888' : '#666666'"
@keydown.enter.prevent="onEnter"
/>
</v-theme-provider>
</template>
<script setup lang="ts">
defineProps<{
import { computed, nextTick, ref, watch } from 'vue'
const props = defineProps<{
search: string
searchHistory: string[]
dark?: boolean
autofocus?: boolean
}>()
defineEmits<{
const emit = defineEmits<{
'update:search': [value: string | null]
'search-commit': []
}>()
const comboboxRef = ref<{ $el?: HTMLElement } | null>(null)
/** Shared parent draft — both combobox instances bind here for instant sync. */
const draft = computed({
get: () => props.search,
set: (value: string | null) => {
emit('update:search', value)
},
})
function onEnter() {
emit('search-commit')
}
watch(
() => props.autofocus,
(focus) => {
if (!focus) return
void nextTick(() => focusInput())
},
{ immediate: true },
)
function focusInput() {
const el = comboboxRef.value?.$el as HTMLElement | undefined
const input = el?.querySelector('input')
input?.focus()
}
defineExpose({ focusInput })
</script>
<style scoped>
.terminal-server-search-wrap {
display: block;
width: 100%;
border-radius: 4px;
}
.terminal-server-search :deep(.v-field) {
opacity: 1;
}
.terminal-server-search :deep(.v-field__input),
.terminal-server-search :deep(.v-combobox__selection-text),
.terminal-server-search :deep(input) {
color: #1a1a1a !important;
-webkit-text-fill-color: #1a1a1a !important;
caret-color: #1a1a1a !important;
}
.terminal-server-search :deep(input::placeholder) {
color: #616161 !important;
opacity: 1 !important;
-webkit-text-fill-color: #616161 !important;
}
.terminal-server-search :deep(.v-field__prepend-inner .v-icon),
.terminal-server-search :deep(.v-field__append-inner .v-icon) {
color: #616161 !important;
opacity: 1 !important;
}
.terminal-server-search--dark :deep(.v-field) {
background: #1a1a1a !important;
}
.terminal-server-search--dark :deep(.v-field__outline) {
--v-field-border-opacity: 1;
color: #666 !important;
}
.terminal-server-search--dark :deep(.v-field--focused .v-field__outline) {
color: #90caf9 !important;
}
.terminal-server-search--dark :deep(.v-field__input),
.terminal-server-search--dark :deep(.v-combobox__selection-text),
.terminal-server-search--dark :deep(input) {
color: #ffffff !important;
-webkit-text-fill-color: #ffffff !important;
caret-color: #90caf9 !important;
}
.terminal-server-search--dark :deep(input::placeholder) {
color: #b0bec5 !important;
opacity: 1 !important;
-webkit-text-fill-color: #b0bec5 !important;
}
.terminal-server-search--dark :deep(.v-field__prepend-inner .v-icon),
.terminal-server-search--dark :deep(.v-field__append-inner .v-icon) {
color: #b0bec5 !important;
opacity: 1 !important;
}
</style>
@@ -1,8 +1,6 @@
<template>
<div
class="d-flex align-center px-2 py-1 shrink-0 terminal-tab-bar"
>
<div class="d-flex align-center ga-1 overflow-x-auto flex-grow-1">
<div class="d-flex align-center px-2 py-1 shrink-0 terminal-tab-bar">
<div class="d-flex align-center ga-1 overflow-x-auto flex-grow-1 min-w-0">
<v-chip
v-for="tab in sessions"
:key="tab.id"
@@ -10,7 +8,7 @@
:color="tab.id === activeSessionId ? 'primary' : 'default'"
size="small"
label
class="cursor-pointer"
class="cursor-pointer terminal-tab-bar__chip"
@click="$emit('switch', tab.id)"
>
<template #prepend>
@@ -36,6 +34,15 @@
新建会话
</v-btn>
</div>
<v-btn
size="small"
variant="text"
class="ml-1 shrink-0"
:prepend-icon="serverRailOpen ? 'mdi-dock-right' : 'mdi-dock-left'"
@click="$emit('toggle-rail')"
>
<span class="hidden-sm-and-down">{{ serverRailOpen ? '隐藏列表' : '显示列表' }}</span>
</v-btn>
</div>
</template>
@@ -45,12 +52,14 @@ import type { TermSessionData } from '@/composables/terminal/types'
defineProps<{
sessions: TermSessionData[]
activeSessionId: string
serverRailOpen: boolean
}>()
defineEmits<{
switch: [sessionId: string]
close: [sessionId: string]
new: []
'toggle-rail': []
}>()
</script>
@@ -58,9 +67,13 @@ defineEmits<{
.terminal-tab-bar {
background: rgb(var(--v-theme-surface));
border-bottom: 1px solid rgb(var(--v-theme-surface-variant));
min-height: 36px;
}
.terminal-tab-bar__title {
max-width: 100px;
max-width: 140px;
}
.terminal-tab-bar__chip {
max-width: 180px;
}
.cursor-pointer {
cursor: pointer;
@@ -1,10 +1,21 @@
<template>
<div class="d-flex align-center justify-space-between px-4 py-1 shrink-0 terminal-toolbar">
<div class="d-flex align-center ga-3">
<span class="text-body-2 font-weight-medium">{{ session?.serverName || '...' }}</span>
<div class="d-flex align-center justify-space-between px-3 py-1 shrink-0 terminal-toolbar">
<div class="d-flex align-center ga-2 min-w-0 terminal-toolbar__meta">
<div class="min-w-0">
<div class="text-body-2 font-weight-medium text-truncate">
{{ session?.serverName || '...' }}
</div>
<div
v-if="session?.serverDomain"
class="text-caption text-truncate text-medium-emphasis font-mono"
>
{{ session.serverDomain }}
</div>
</div>
<span
v-if="session?.cwd"
class="text-caption text-medium-emphasis font-mono terminal-toolbar__cwd"
class="text-caption font-mono terminal-toolbar__cwd text-medium-emphasis hidden-sm-and-down"
:title="session.cwd"
>
{{ session.cwd }}
</span>
@@ -14,43 +25,76 @@
</template>
{{ statusLabel }}
</v-chip>
<span v-if="session?.uptime" class="text-caption text-medium-emphasis">{{ session.uptime }}</span>
<span v-if="session?.uptime" class="text-caption text-medium-emphasis hidden-sm-and-down">
{{ session.uptime }}
</span>
<span
v-if="session?.pingMs !== null && session?.pingMs !== undefined"
class="text-caption text-medium-emphasis"
class="text-caption text-medium-emphasis hidden-sm-and-down"
>
{{ session.pingMs }}ms
</span>
</div>
<div class="d-flex align-center ga-2 terminal-toolbar-actions">
<span class="text-body-2 text-medium-emphasis shrink-0">字号</span>
<v-btn variant="tonal" size="small" @click="$emit('font-change', -1)">缩小</v-btn>
<span class="text-body-2 font-weight-medium terminal-toolbar__font-size">{{ fontSize }}</span>
<v-btn variant="tonal" size="small" @click="$emit('font-change', 1)">放大</v-btn>
<v-select
:model-value="scrollback"
:items="scrollbackOptions"
item-title="title"
item-value="value"
label="回滚行数"
density="comfortable"
hide-details
variant="outlined"
class="terminal-toolbar-scrollback"
@update:model-value="$emit('scrollback-change', $event)"
/>
<v-btn variant="tonal" size="small" prepend-icon="mdi-fullscreen" @click="$emit('fullscreen')">
全屏
</v-btn>
<div class="d-flex align-center ga-1 terminal-toolbar-actions shrink-0">
<div class="d-none d-md-flex align-center ga-2">
<span class="text-caption text-medium-emphasis shrink-0">字号</span>
<v-btn variant="tonal" size="x-small" @click="$emit('font-change', -1)"></v-btn>
<span class="text-body-2 font-weight-medium terminal-toolbar__font-size">
{{ fontSize }}
</span>
<v-btn variant="tonal" size="x-small" @click="$emit('font-change', 1)">+</v-btn>
<v-select
:model-value="scrollback"
:items="scrollbackOptions"
item-title="title"
item-value="value"
label="回滚"
density="compact"
hide-details
variant="outlined"
class="terminal-toolbar-scrollback"
@update:model-value="$emit('scrollback-change', $event)"
/>
</div>
<v-menu location="bottom end">
<template #activator="{ props: menuProps }">
<v-btn
v-bind="menuProps"
class="d-md-none"
size="x-small"
variant="tonal"
icon="mdi-cog-outline"
/>
</template>
<v-list density="compact" nav class="py-1">
<v-list-subheader>字号 {{ fontSize }}</v-list-subheader>
<v-list-item @click="$emit('font-change', -1)">
<v-list-item-title>缩小</v-list-item-title>
</v-list-item>
<v-list-item @click="$emit('font-change', 1)">
<v-list-item-title>放大</v-list-item-title>
</v-list-item>
<v-divider class="my-1" />
<v-list-subheader>回滚行数</v-list-subheader>
<v-list-item
v-for="opt in scrollbackOptions"
:key="opt.value"
:active="scrollback === opt.value"
@click="$emit('scrollback-change', opt.value)"
>
<v-list-item-title>{{ opt.title }}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
<v-btn variant="tonal" size="x-small" icon="mdi-fullscreen" title="全屏" @click="$emit('fullscreen')" />
<v-btn
variant="tonal"
size="small"
size="x-small"
color="error"
prepend-icon="mdi-link-off"
icon="mdi-link-off"
title="断开"
@click="$emit('disconnect')"
>
断开
</v-btn>
/>
</div>
</div>
</template>
@@ -89,18 +133,18 @@ const statusColor = computed(() =>
.terminal-toolbar {
background: rgb(var(--v-theme-surface));
border-bottom: 1px solid rgb(var(--v-theme-surface-variant));
min-height: 32px;
min-height: 36px;
}
.terminal-toolbar__cwd {
max-width: 200px;
max-width: min(28vw, 240px);
}
.terminal-toolbar__font-size {
min-width: 1.5rem;
min-width: 1.25rem;
text-align: center;
}
.terminal-toolbar-scrollback {
min-width: 8.5rem;
max-width: 10rem;
min-width: 6.5rem;
max-width: 7.5rem;
}
.font-mono {
font-family: 'Cascadia Code', 'Fira Code', 'JetBrains Mono', Menlo, monospace;
@@ -1,32 +0,0 @@
<script setup lang="ts">
const props = defineProps<{
label: string
value: number | null | undefined
}>()
function barColor(v: number): string {
if (v >= 90) return 'error'
if (v >= 70) return 'warning'
return 'success'
}
function displayValue() {
return props.value != null ? `${props.value}%` : '—'
}
</script>
<template>
<div class="watch-metric-bar mb-1">
<div class="d-flex justify-space-between text-caption mb-0">
<span class="text-medium-emphasis">{{ label }}</span>
<span class="font-weight-medium">{{ displayValue() }}</span>
</div>
<v-progress-linear
:model-value="value ?? 0"
:color="value != null ? barColor(value) : 'grey'"
bg-opacity="0.2"
height="6"
rounded
/>
</div>
</template>
@@ -0,0 +1,92 @@
<script setup lang="ts">
import { computed } from 'vue'
import { metricRingColor } from '@/utils/watchFormat'
const props = withDefaults(defineProps<{
label: string
accent: 'cpu' | 'mem' | 'disk' | 'load'
value: number | null | undefined
/** 圆心文字,默认 `${round(value)}%` */
center?: string
hint?: string
size?: number
/** 暂停态占位:灰色细环、不可交互 */
dimmed?: boolean
}>(), {
size: 68,
dimmed: false,
})
const ringWidth = computed(() => Math.max(4, Math.round(props.size * (props.dimmed ? 0.08 : 0.1))))
const ringColor = computed(() => {
if (props.dimmed) return 'rgba(var(--v-theme-on-surface), 0.22)'
return metricRingColor(props.accent, props.value)
})
const centerText = computed(() => {
if (props.dimmed) return '—'
if (props.center != null && props.center !== '') return props.center
return props.value != null ? `${Math.round(props.value)}%` : '—'
})
const ringValue = computed(() => {
if (props.dimmed) return 0
if (props.value == null || Number.isNaN(props.value)) return 0
return Math.max(0, Math.min(100, props.value))
})
const showTooltip = computed(() => !props.dimmed && Boolean(props.hint))
const centerFontPx = computed(() => `${Math.max(11, Math.round(props.size * 0.2))}px`)
</script>
<template>
<v-tooltip :text="hint" location="top" :disabled="!showTooltip">
<template #activator="{ props: tipProps }">
<div
class="watch-metric-ring text-center"
:class="{ 'watch-metric-ring--dimmed': dimmed }"
v-bind="tipProps"
>
<v-progress-circular
:model-value="ringValue"
:size="size"
:width="ringWidth"
:color="ringColor"
bg-color="rgba(var(--v-theme-on-surface), 0.08)"
class="watch-metric-ring__circle"
>
<span class="watch-metric-ring__value" :style="{ color: ringColor }">{{ centerText }}</span>
</v-progress-circular>
<div class="watch-metric-ring__label text-caption text-medium-emphasis mt-1">{{ label }}</div>
</div>
</template>
</v-tooltip>
</template>
<style scoped>
.watch-metric-ring {
flex: 1 1 0;
min-width: 0;
}
.watch-metric-ring__circle {
margin: 0 auto;
}
.watch-metric-ring__value {
font-size: v-bind(centerFontPx);
font-weight: 700;
font-variant-numeric: tabular-nums;
line-height: 1;
}
.watch-metric-ring__label {
letter-spacing: 0.02em;
}
.watch-metric-ring--dimmed .watch-metric-ring__label {
opacity: 0.55;
}
.watch-metric-ring--dimmed .watch-metric-ring__value {
color: rgba(var(--v-theme-on-surface), 0.38) !important;
font-weight: 600;
}
</style>
@@ -1,7 +1,16 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { ref, watch, computed } from 'vue'
import { http } from '@/api'
import { formatBytesPerSec, probeStatusColor, probeStatusLabel, formatProbeError, probeSourceLabel, PROBE_STATUS_FILTER_ITEMS } from '@/utils/watchFormat'
import {
probeStatusColor,
probeStatusLabel,
formatProbeError,
probeSourceLabel,
PROBE_STATUS_FILTER_ITEMS,
metricPctClass,
formatProbePct,
} from '@/utils/watchFormat'
import { formatDateTimeBeijing } from '@/utils/datetime'
export interface ProbeRecordRow {
id: number
@@ -14,8 +23,6 @@ export interface ProbeRecordRow {
cpu_pct?: number | null
mem_pct?: number | null
disk_pct?: number | null
net_up_bps?: number | null
net_down_bps?: number | null
error?: string | null
}
@@ -32,19 +39,20 @@ const loading = ref(false)
const statusFilter = ref<string | null>(null)
const headers = [
{ title: '时间', key: 'recorded_at', width: 160 },
{ title: '服务器', key: 'server_name' },
{ title: '状态', key: 'probe_status', width: 100 },
{ title: '时间', key: 'recorded_at', width: 168 },
{ title: '服务器', key: 'server_name', minWidth: 120 },
{ title: '状态', key: 'probe_status', width: 96 },
{ title: '来源', key: 'source', width: 72 },
{ title: 'CPU', key: 'cpu_pct', width: 64 },
{ title: '内存', key: 'mem_pct', width: 64 },
{ title: '硬盘', key: 'disk_pct', width: 64 },
{ title: '', key: 'net_up_bps', width: 88 },
{ title: '', key: 'net_down_bps', width: 88 },
{ title: '耗时', key: 'duration_ms', width: 72 },
{ title: '错误', key: 'error' },
{ title: 'CPU', key: 'cpu_pct', width: 68, align: 'end' as const },
{ title: '内存', key: 'mem_pct', width: 68, align: 'end' as const },
{ title: '硬盘', key: 'disk_pct', width: 68, align: 'end' as const },
{ title: '耗时', key: 'duration_ms', width: 80, align: 'end' as const },
{ title: '错误', key: 'error', minWidth: 160 },
]
const pageCount = computed(() => Math.max(1, Math.ceil(total.value / 50)))
const showPagination = computed(() => total.value > 0)
async function load() {
loading.value = true
try {
@@ -71,8 +79,8 @@ watch(
</script>
<template>
<div>
<div class="d-flex align-center ga-2 mb-3">
<div class="watch-probe-records">
<div class="d-flex align-center flex-wrap ga-2 mb-3">
<v-select
v-model="statusFilter"
:items="PROBE_STATUS_FILTER_ITEMS"
@@ -82,43 +90,105 @@ watch(
density="compact"
hide-details
variant="outlined"
style="max-width: 180px"
prepend-inner-icon="mdi-filter-outline"
class="watch-probe-records__filter"
@update:model-value="page = 1"
/>
<v-chip size="small" variant="tonal" label>
{{ total }}
</v-chip>
<v-spacer />
<v-btn size="small" variant="text" prepend-icon="mdi-refresh" :loading="loading" @click="load">
<v-btn size="small" variant="tonal" prepend-icon="mdi-refresh" :loading="loading" @click="load">
刷新
</v-btn>
</div>
<v-data-table
:headers="headers"
:items="items"
:loading="loading"
density="compact"
item-value="id"
hide-default-footer
>
<template #item.probe_status="{ item }">
<v-chip size="x-small" :color="probeStatusColor(item.probe_status)" variant="tonal" label>
{{ probeStatusLabel(item.probe_status) }}
</v-chip>
</template>
<template #item.source="{ item }">{{ probeSourceLabel(item.source) }}</template>
<template #item.net_up_bps="{ item }">{{ formatBytesPerSec(item.net_up_bps) }}</template>
<template #item.net_down_bps="{ item }">{{ formatBytesPerSec(item.net_down_bps) }}</template>
<template #item.duration_ms="{ item }">{{ item.duration_ms ?? '—' }} ms</template>
<template #item.error="{ item }">
<span
class="text-caption text-truncate d-inline-block"
style="max-width: 200px"
:title="formatProbeError(item.error, item.probe_status)"
>{{ formatProbeError(item.error, item.probe_status) }}</span>
</template>
</v-data-table>
<div class="d-flex justify-center align-center ga-2 mt-3">
<v-btn size="small" variant="text" :disabled="page <= 1" @click="page -= 1">上一页</v-btn>
<span class="text-caption"> {{ page }} · {{ total }} </span>
<v-btn size="small" variant="text" :disabled="page * 50 >= total" @click="page += 1">下一页</v-btn>
<v-sheet border rounded="lg" class="watch-probe-records__table-wrap">
<v-data-table
:headers="headers"
:items="items"
:loading="loading"
density="comfortable"
item-value="id"
hide-default-footer
fixed-header
height="min(520px, 60vh)"
class="watch-probe-table"
>
<template #item.recorded_at="{ item }">
<span class="watch-probe-time">{{ formatDateTimeBeijing(item.recorded_at) }}</span>
</template>
<template #item.server_name="{ item }">
<span class="text-body-2">{{ item.server_name || `ID ${item.server_id}` }}</span>
</template>
<template #item.probe_status="{ item }">
<v-chip size="x-small" :color="probeStatusColor(item.probe_status)" variant="tonal" label>
{{ probeStatusLabel(item.probe_status) }}
</v-chip>
</template>
<template #item.source="{ item }">
<span class="text-caption text-medium-emphasis">{{ probeSourceLabel(item.source) }}</span>
</template>
<template #item.cpu_pct="{ item }">
<span :class="metricPctClass(item.cpu_pct)">{{ formatProbePct(item.cpu_pct) }}</span>
</template>
<template #item.mem_pct="{ item }">
<span :class="metricPctClass(item.mem_pct)">{{ formatProbePct(item.mem_pct) }}</span>
</template>
<template #item.disk_pct="{ item }">
<span :class="metricPctClass(item.disk_pct)">{{ formatProbePct(item.disk_pct) }}</span>
</template>
<template #item.duration_ms="{ item }">
<span class="text-caption text-medium-emphasis">{{ item.duration_ms ?? '—' }} ms</span>
</template>
<template #item.error="{ item }">
<span
class="text-caption text-truncate d-inline-block watch-probe-error"
:title="formatProbeError(item.error, item.probe_status)"
>{{ formatProbeError(item.error, item.probe_status) }}</span>
</template>
<template #no-data>
<div class="text-center py-10">
<v-icon icon="mdi-database-off-outline" size="40" color="disabled" class="mb-2" />
<div class="text-body-2 text-medium-emphasis">当前时间窗口内无探针记录</div>
</div>
</template>
</v-data-table>
</v-sheet>
<div v-if="showPagination" class="d-flex justify-center align-center mt-4">
<v-pagination
v-model="page"
:length="pageCount"
:total-visible="7"
density="comfortable"
rounded="circle"
size="small"
/>
</div>
</div>
</template>
<style scoped>
.watch-probe-records__filter {
max-width: 200px;
}
.watch-probe-records__table-wrap {
overflow: hidden;
}
.watch-probe-table :deep(thead th) {
font-size: 0.75rem !important;
text-transform: none;
letter-spacing: 0;
background: rgba(var(--v-theme-surface-variant), 0.35);
}
.watch-probe-time {
font-variant-numeric: tabular-nums;
font-size: 0.8125rem;
white-space: nowrap;
}
.watch-probe-error {
max-width: 220px;
color: rgba(var(--v-theme-on-surface), 0.72);
}
</style>
@@ -1,7 +1,14 @@
<script setup lang="ts">
import { computed, watch, ref } from 'vue'
import { http } from '@/api'
import type { WatchSlot, WatchProcess } from '@/composables/useWatchPins'
import type { WatchSlot, WatchProcess, WatchMetrics } from '@/composables/useWatchPins'
import {
formatLoadTriple,
formatMemMb,
formatProcessCounts,
normalizeProcessBundle,
formatProbePct,
} from '@/utils/watchFormat'
const props = defineProps<{
modelValue: boolean
@@ -10,7 +17,7 @@ const props = defineProps<{
const emit = defineEmits<{ 'update:modelValue': [v: boolean] }>()
const processes = ref<WatchProcess[]>([])
const processBundle = ref<{ top_cpu: WatchProcess[]; top_mem: WatchProcess[] }>({ top_cpu: [], top_mem: [] })
const loading = ref(false)
const open = computed({
@@ -18,16 +25,52 @@ const open = computed({
set: (v) => emit('update:modelValue', v),
})
const metrics = computed(() => props.slotData?.metrics as WatchMetrics | null | undefined)
const loadLine = computed(() =>
formatLoadTriple(metrics.value?.load_1, metrics.value?.load_5, metrics.value?.load_15),
)
const processCountLine = computed(() =>
formatProcessCounts(metrics.value?.process_running, metrics.value?.process_total),
)
const perCpu = computed(() => metrics.value?.per_cpu_pct || [])
const memLines = computed(() => {
const m = metrics.value
if (!m) return []
const lines: Array<{ label: string; value: string }> = []
if (m.mem_free_mb != null) lines.push({ label: '空闲内存', value: formatMemMb(m.mem_free_mb) })
if (m.memory_used_gb != null) {
lines.push({ label: '已用', value: formatMemMb(Math.round(m.memory_used_gb * 1024)) })
}
if (m.memory_total_gb != null) {
lines.push({ label: '总内存', value: formatMemMb(Math.round(m.memory_total_gb * 1024)) })
}
if (m.mem_shared_mb != null) lines.push({ label: '共享', value: formatMemMb(m.mem_shared_mb) })
if (m.memory_available_gb != null) {
lines.push({ label: '可分配内存', value: formatMemMb(Math.round(m.memory_available_gb * 1024)) })
}
if (m.mem_buffers_mb != null || m.mem_cached_mb != null) {
lines.push({
label: 'buff/cache',
value: `${formatMemMb(m.mem_buffers_mb ?? 0)} / ${formatMemMb(m.mem_cached_mb ?? 0)}`,
})
}
return lines
})
async function loadProcesses() {
if (!props.slotData?.server_id) return
loading.value = true
try {
const data = await http.get<{ processes: WatchProcess[] }>(
const data = await http.get<{ processes: WatchProcess[] | { top_cpu?: WatchProcess[]; top_mem?: WatchProcess[] } }>(
`/watch/processes/${props.slotData.server_id}`,
)
processes.value = data.processes || props.slotData.processes || []
processBundle.value = normalizeProcessBundle(data.processes || props.slotData.processes)
} catch {
processes.value = props.slotData.processes || []
processBundle.value = normalizeProcessBundle(props.slotData.processes)
} finally {
loading.value = false
}
@@ -46,25 +89,100 @@ watch(
v-model="open"
location="right"
temporary
width="360"
width="400"
class="watch-detail-drawer"
>
<v-toolbar density="compact" flat>
<v-toolbar-title class="text-subtitle-1">
进程 Top5 · {{ slotData?.server_name }}
服务器详情 · {{ slotData?.server_name }}
</v-toolbar-title>
<v-spacer />
<v-btn icon="mdi-close" variant="text" @click="open = false" />
</v-toolbar>
<v-divider />
<v-progress-linear v-if="loading" indeterminate />
<v-list v-if="processes.length" density="compact">
<v-list-item v-for="(p, i) in processes" :key="`${p.pid}-${i}`">
<v-list-item-title class="text-body-2">{{ p.name || '—' }}</v-list-item-title>
<v-list-item-subtitle>PID {{ p.pid ?? '—' }} · CPU {{ p.cpu_pct ?? '—' }}% · MEM {{ p.mem_pct ?? '—' }}%</v-list-item-subtitle>
</v-list-item>
</v-list>
<div v-else class="pa-4 text-medium-emphasis text-caption">
暂无进程数据 10 秒刷新一次
<div class="pa-3">
<section class="watch-detail-section mb-3">
<div class="text-caption text-medium-emphasis mb-1">最近 1 / 5 / 15 分钟平均负载</div>
<div class="text-body-2 font-weight-medium">{{ loadLine }}</div>
</section>
<section class="watch-detail-section mb-3">
<div class="text-caption text-medium-emphasis mb-1">活动进程数 / 总进程数</div>
<div class="text-body-2">{{ processCountLine }}</div>
</section>
<section v-if="perCpu.length" class="watch-detail-section mb-3">
<div class="text-caption text-medium-emphasis mb-2">各核心 CPU 占用</div>
<div class="d-flex flex-wrap ga-1">
<v-chip
v-for="(pct, idx) in perCpu"
:key="idx"
size="x-small"
variant="tonal"
:color="pct >= 80 ? 'error' : pct >= 50 ? 'warning' : 'primary'"
label
>
核心 {{ idx + 1 }} {{ formatProbePct(pct) }}
</v-chip>
</div>
</section>
<section v-if="memLines.length" class="watch-detail-section mb-3">
<div class="text-caption text-medium-emphasis mb-2">内存信息</div>
<div
v-for="row in memLines"
:key="row.label"
class="d-flex justify-space-between text-body-2 watch-detail-kv"
>
<span class="text-medium-emphasis">{{ row.label }}</span>
<span class="font-weight-medium">{{ row.value }}</span>
</div>
</section>
<section class="watch-detail-section mb-3">
<div class="text-caption text-medium-emphasis mb-2">CPU 占用率 Top5</div>
<v-list v-if="processBundle.top_cpu.length" density="compact" class="watch-detail-list py-0">
<v-list-item v-for="(p, i) in processBundle.top_cpu" :key="`cpu-${p.pid}-${i}`">
<v-list-item-title class="text-body-2 text-truncate">{{ p.name || '—' }}</v-list-item-title>
<v-list-item-subtitle>
PID {{ p.pid ?? '—' }} · CPU {{ formatProbePct(p.cpu_pct) }} · MEM {{ formatProbePct(p.mem_pct) }}
</v-list-item-subtitle>
</v-list-item>
</v-list>
<div v-else class="text-caption text-medium-emphasis">暂无数据 10 秒刷新</div>
</section>
<section class="watch-detail-section">
<div class="text-caption text-medium-emphasis mb-2">内存占用率 Top5</div>
<v-list v-if="processBundle.top_mem.length" density="compact" class="watch-detail-list py-0">
<v-list-item v-for="(p, i) in processBundle.top_mem" :key="`mem-${p.pid}-${i}`">
<v-list-item-title class="text-body-2 text-truncate">{{ p.name || '—' }}</v-list-item-title>
<v-list-item-subtitle>
PID {{ p.pid ?? '—' }} · MEM {{ formatProbePct(p.mem_pct) }} · CPU {{ formatProbePct(p.cpu_pct) }}
</v-list-item-subtitle>
</v-list-item>
</v-list>
<div v-else class="text-caption text-medium-emphasis">暂无数据 10 秒刷新</div>
</section>
</div>
</v-navigation-drawer>
</template>
<style scoped>
.watch-detail-section {
padding-bottom: 4px;
border-bottom: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
}
.watch-detail-section:last-child {
border-bottom: none;
}
.watch-detail-kv + .watch-detail-kv {
margin-top: 4px;
}
.watch-detail-list :deep(.v-list-item) {
min-height: 44px;
padding-inline: 0;
}
</style>
+183 -62
View File
@@ -4,13 +4,12 @@ import type { WatchSlot } from '@/composables/useWatchPins'
import {
WATCH_TTL_OPTIONS,
normalizeWatchTtlMinutes,
watchTtlLabel,
watchTtlShortLabel,
type WatchTtlMinutes,
} from '@/constants/watchTtl'
import { formatBytesPerSec, formatRemaining, probeStatusColor, probeStatusLabel, formatProbeError, isPsutilMissingError, isWatchMetricsPending, loadAvgColor, formatLoadAvg } from '@/utils/watchFormat'
import { formatRemaining, probeStatusColor, probeStatusLabel, formatProbeError, isPsutilMissingError, isWatchMetricsPending, formatLoadAvg, formatCpuSpec, formatMemSpec, formatMemSub, formatDiskSpec, formatDiskSub, formatWatchSystemBanner, formatUsagePair, loadUsagePct, loadStatusLabel } from '@/utils/watchFormat'
import WatchSparkline from '@/components/watch/WatchSparkline.vue'
import WatchMetricBar from '@/components/watch/WatchMetricBar.vue'
import WatchMetricRing from '@/components/watch/WatchMetricRing.vue'
const props = defineProps<{
slot: WatchSlot
@@ -38,7 +37,26 @@ const showPsutilInstall = computed(() =>
const chartMetric = ref<'cpu_pct' | 'mem_pct' | 'disk_pct'>('cpu_pct')
const load1 = computed(() => props.slot.metrics?.load_1)
const load5 = computed(() => props.slot.metrics?.load_5)
const load15 = computed(() => props.slot.metrics?.load_15)
const loadBarPct = computed(() => loadUsagePct(load1.value, props.slot.metrics?.cpu_cores))
const loadRingCenter = computed(() => {
if (load1.value == null) return undefined
return formatLoadAvg(load1.value)
})
const loadRingHint = computed(() => {
const parts: string[] = []
if (load1.value != null) parts.push(`1 分钟 ${formatLoadAvg(load1.value)}`)
if (load5.value != null && load15.value != null) {
parts.push(`5 / 15 ${formatLoadAvg(load5.value)} / ${formatLoadAvg(load15.value)}`)
}
const status = loadStatusLabel(load1.value, props.slot.metrics?.cpu_cores)
if (status) parts.push(status)
return parts.join(' · ')
})
const systemBanner = computed(() => formatWatchSystemBanner(props.slot.metrics))
const metricsPending = computed(() => monitoringOn.value && isWatchMetricsPending(props.slot.metrics))
const showSwap = computed(() => (props.slot.metrics?.swap_total_gb ?? 0) > 0)
</script>
<template>
@@ -56,29 +74,55 @@ const metricsPending = computed(() => monitoringOn.value && isWatchMetricsPendin
}"
role="button"
tabindex="0"
@click="emit('history', slot)"
@keydown.enter.prevent="emit('history', slot)"
@click="monitoringOn ? emit('history', slot) : undefined"
@keydown.enter.prevent="monitoringOn ? emit('history', slot) : undefined"
>
<div class="d-flex align-center mb-2" @click.stop>
<div class="text-subtitle-2 text-truncate flex-grow-1" :title="slot.server_name">
<div class="watch-slot-header d-flex align-center mb-2" @click.stop>
<div class="text-subtitle-2 text-truncate flex-grow-1 min-width-0" :title="slot.server_name">
{{ slot.server_name || `ID ${slot.server_id}` }}
</div>
<v-chip size="x-small" variant="tonal" color="info" label>
{{ slot.slot_index + 1 }}
</v-chip>
<v-switch
:model-value="monitoringOn"
density="compact"
hide-details
color="primary"
class="watch-slot-switch ml-1"
@click.stop
@update:model-value="emit('monitoring', slot.slot_index, $event ?? false)"
/>
<v-btn icon="mdi-close" size="x-small" variant="text" @click="emit('remove', slot.slot_index)" />
<div class="watch-slot-header-actions d-flex align-center flex-shrink-0">
<v-chip size="x-small" variant="tonal" :color="monitoringOn ? 'info' : 'warning'" label>
{{ slot.slot_index + 1 }}
</v-chip>
<v-menu location="bottom end" :close-on-content-click="true">
<template #activator="{ props: menuProps }">
<v-btn
v-bind="menuProps"
icon="mdi-dots-vertical"
size="x-small"
variant="text"
class="watch-slot-menu-btn"
aria-label="槽位操作"
@click.stop
/>
</template>
<v-list density="compact" class="watch-slot-menu-list" min-width="168">
<v-list-item
v-if="monitoringOn"
prepend-icon="mdi-pause-circle-outline"
title="暂停实时监测"
@click="emit('monitoring', slot.slot_index, false)"
/>
<v-list-item
v-else
prepend-icon="mdi-play-circle-outline"
title="恢复实时监测"
@click="emit('monitoring', slot.slot_index, true)"
/>
<v-divider class="my-1" />
<v-list-item
prepend-icon="mdi-close-circle-outline"
title="移除此槽"
base-color="error"
@click="emit('remove', slot.slot_index)"
/>
</v-list>
</v-menu>
</div>
</div>
<div class="watch-ttl-row mb-2" @click.stop>
<div v-if="monitoringOn" class="watch-ttl-row mb-2" @click.stop>
<span class="text-caption text-medium-emphasis watch-ttl-label">时长</span>
<div class="watch-ttl-chips d-flex flex-wrap ga-1">
<v-chip
@@ -96,11 +140,29 @@ const metricsPending = computed(() => monitoringOn.value && isWatchMetricsPendin
</div>
</div>
<div v-if="!monitoringOn" class="text-caption text-medium-emphasis mb-2" @click.stop>
实时监测已关闭 · 槽位保留 · Agent 恢复 60s 智能上报
<div
v-if="!monitoringOn"
class="watch-slot-paused-body flex-grow-1"
@click.stop
>
<div class="watch-slot-paused-cta text-center">
<v-icon icon="mdi-radar-off" size="40" class="text-medium-emphasis mb-3 watch-slot-paused-icon" />
<v-btn
size="small"
variant="tonal"
color="primary"
prepend-icon="mdi-play-circle-outline"
@click="emit('monitoring', slot.slot_index, true)"
>
恢复实时监测
</v-btn>
<div class="text-caption text-medium-emphasis mt-3 px-4">
槽位与子机绑定保留 · 关闭期间由 Agent 60 秒上报
</div>
</div>
</div>
<template v-if="monitoringOn">
<template v-else>
<div @click.stop>
<div
v-if="metricsPending"
@@ -125,36 +187,56 @@ const metricsPending = computed(() => monitoringOn.value && isWatchMetricsPendin
{{ probeStatusLabel(slot.metrics.probe_status) }}
</v-chip>
<WatchMetricBar label="CPU" :value="slot.metrics?.cpu_pct" />
<WatchMetricBar label="内存" :value="slot.metrics?.mem_pct" />
<WatchMetricBar label="硬盘" :value="slot.metrics?.disk_pct" />
<div class="d-flex align-center justify-space-between text-caption mb-2 mt-1">
<span class="text-medium-emphasis">负载</span>
<v-tooltip location="top" text="1 分钟平均负载,与宝塔面板「负载」相同(非 CPU 百分比)">
<template #activator="{ props: tipProps }">
<v-chip
v-bind="tipProps"
size="x-small"
:color="loadAvgColor(load1)"
variant="tonal"
label
>
{{ formatLoadAvg(load1) }}
</v-chip>
</template>
</v-tooltip>
<div
v-if="systemBanner.short"
class="text-caption text-medium-emphasis mb-2 text-truncate"
:title="systemBanner.full"
>
{{ systemBanner.short }}
</div>
<div class="text-caption text-medium-emphasis mb-1">
{{ formatBytesPerSec(slot.metrics?.net_up_bps) }}
· {{ formatBytesPerSec(slot.metrics?.net_down_bps) }}
<div class="watch-metric-rings d-flex justify-space-between mb-2">
<WatchMetricRing
label="CPU"
accent="cpu"
:value="slot.metrics?.cpu_pct"
:hint="formatCpuSpec(slot.metrics)"
:size="56"
/>
<WatchMetricRing
label="内存"
accent="mem"
:value="slot.metrics?.mem_pct"
:hint="[formatMemSpec(slot.metrics), formatMemSub(slot.metrics)].filter(Boolean).join(' · ')"
:size="56"
/>
<WatchMetricRing
label="硬盘"
accent="disk"
:value="slot.metrics?.disk_pct"
:hint="[formatDiskSpec(slot.metrics), formatDiskSub(slot.metrics)].filter(Boolean).join(' · ')"
:size="56"
/>
<WatchMetricRing
label="负载"
accent="load"
:value="loadBarPct"
:center="loadRingCenter"
:hint="loadRingHint"
:size="56"
/>
</div>
<div class="text-caption text-medium-emphasis mb-2">
{{ formatBytesPerSec(slot.metrics?.disk_read_bps) }}
· {{ formatBytesPerSec(slot.metrics?.disk_write_bps) }}
<div v-if="showSwap" class="text-caption text-medium-emphasis mb-2 text-center">
交换分区
<span v-if="slot.metrics?.swap_used_gb != null && slot.metrics?.swap_total_gb != null">
· 已用 {{ formatUsagePair(slot.metrics.swap_used_gb, slot.metrics.swap_total_gb) }}
</span>
<span v-if="slot.metrics?.swap_pct != null"> · {{ slot.metrics.swap_pct }}%</span>
</div>
<v-divider class="my-2" />
<v-btn-toggle v-model="chartMetric" density="compact" variant="outlined" divided class="mb-1">
<v-btn value="cpu_pct" size="x-small">CPU</v-btn>
<v-btn value="mem_pct" size="x-small">内存</v-btn>
@@ -189,7 +271,16 @@ const metricsPending = computed(() => monitoringOn.value && isWatchMetricsPendin
剩余 {{ formatRemaining(slot.remaining_sec) }} · {{ watchTtlShortLabel(slotTtlMinutes) }}
</span>
<div class="d-flex ga-1">
<v-btn size="x-small" variant="text" @click.stop="emit('processes', slot)">进程</v-btn>
<v-btn
v-if="monitoringOn"
size="x-small"
variant="text"
class="watch-slot-pause-btn"
@click.stop="emit('monitoring', slot.slot_index, false)"
>
暂停
</v-btn>
<v-btn size="x-small" variant="text" @click.stop="emit('processes', slot)">详情</v-btn>
<v-btn size="x-small" variant="text" @click.stop="emit('history', slot)">历史</v-btn>
</div>
</div>
@@ -203,13 +294,6 @@ const metricsPending = computed(() => monitoringOn.value && isWatchMetricsPendin
</div>
</div>
</template>
<div v-else class="d-flex align-center justify-space-between mt-2" @click.stop>
<span class="text-caption text-medium-emphasis">已暂停 · {{ watchTtlLabel(slotTtlMinutes) }}</span>
<div class="d-flex ga-1">
<v-btn size="x-small" variant="text" @click.stop="emit('history', slot)">历史</v-btn>
</div>
</div>
</v-card>
<v-card
@@ -235,7 +319,7 @@ const metricsPending = computed(() => monitoringOn.value && isWatchMetricsPendin
<style scoped>
.watch-slot-card {
min-height: 280px;
min-height: 320px;
}
/* Vuetify v-card--link 会在 hover 时铺 v-card__overlay 灰层,此处仅保留外框高亮 */
.watch-slot-card :deep(.v-card__overlay) {
@@ -253,6 +337,8 @@ const metricsPending = computed(() => monitoringOn.value && isWatchMetricsPendin
.watch-slot-card--filled {
cursor: pointer;
transition: border-color 0.15s ease;
display: flex;
flex-direction: column;
}
.watch-slot-card--filled:hover {
border-color: rgb(var(--v-theme-primary)) !important;
@@ -261,12 +347,40 @@ const metricsPending = computed(() => monitoringOn.value && isWatchMetricsPendin
border-color: rgb(var(--v-theme-error)) !important;
}
.watch-slot-card--paused {
opacity: 0.72;
background: rgba(var(--v-theme-surface-variant), 0.15);
cursor: default;
min-height: 200px;
}
.watch-slot-switch {
.watch-slot-card--paused:hover {
border-color: rgba(var(--v-border-color), var(--v-border-opacity)) !important;
}
.watch-slot-paused-body {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1 1 auto;
min-height: 140px;
}
.watch-slot-paused-cta {
width: 100%;
padding: 8px 0 12px;
}
.watch-slot-paused-icon {
opacity: 0.45;
}
.watch-slot-header-actions {
gap: 4px;
margin-left: 8px;
}
.watch-slot-menu-btn {
flex: 0 0 auto;
transform: scale(0.85);
opacity: 0.72;
}
.watch-slot-menu-btn:hover {
opacity: 1;
}
.watch-slot-pause-btn {
color: rgba(var(--v-theme-warning), 0.95);
}
.watch-ttl-row {
display: flex;
@@ -288,4 +402,11 @@ const metricsPending = computed(() => monitoringOn.value && isWatchMetricsPendin
.watch-slot-waiting {
min-height: 160px;
}
.watch-metric-rings {
gap: 2px;
}
.watch-metric-rings :deep(.watch-metric-ring__label) {
font-size: 11px;
white-space: nowrap;
}
</style>
@@ -113,14 +113,16 @@ async function onPickServer(serverId: number) {
<template>
<div class="watch-slot-row mb-4">
<div class="d-flex align-center mb-2">
<span class="text-subtitle-2">实时监测</span>
<v-chip size="x-small" variant="tonal" class="ml-2" label>最多 4 · 默认 30 · 暂停不占 TTL</v-chip>
<div class="d-flex align-center mb-3 watch-slot-row__header">
<v-icon icon="mdi-pulse" size="20" color="primary" class="mr-2" />
<span class="text-subtitle-1 font-weight-medium">实时监测</span>
<v-chip size="x-small" variant="tonal" class="ml-2" label>最多 4 </v-chip>
<v-chip size="x-small" variant="outlined" class="ml-1" label>默认 30 · 暂停不占 TTL</v-chip>
<v-spacer />
<v-btn size="small" variant="text" prepend-icon="mdi-history" :to="{ name: 'WatchMetrics' }">
<v-btn size="small" variant="tonal" prepend-icon="mdi-history" :to="{ name: 'WatchMetrics' }">
监测历史
</v-btn>
<v-btn icon="mdi-refresh" size="small" variant="text" :loading="loading" @click="refreshPins" />
<v-btn icon="mdi-refresh" size="small" variant="text" :loading="loading" title="刷新" @click="refreshPins" />
</div>
<v-row dense>
<v-col v-for="slot in slots" :key="slot.slot_index" cols="12" sm="6" lg="3">
@@ -1,39 +1,70 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useTheme } from 'vuetify'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { LineChart } from 'echarts/charts'
import { GridComponent, TooltipComponent } from 'echarts/components'
import VChart from 'vue-echarts'
import type { WatchSparkPoint } from '@/composables/useWatchPins'
import { formatDateTimeBeijing } from '@/utils/datetime'
import { colorWithAlpha } from '@/utils/echartsColor'
use([CanvasRenderer, LineChart, GridComponent, TooltipComponent])
const props = defineProps<{
points: WatchSparkPoint[]
metric?: 'cpu_pct' | 'mem_pct' | 'disk_pct' | 'net_down_bps'
metric?: 'cpu_pct' | 'mem_pct' | 'disk_pct'
height?: number
}>()
const theme = useTheme()
const metricKey = computed(() => props.metric || 'cpu_pct')
const lineColor = computed(() => {
const c = theme.current.value.colors.primary
return typeof c === 'string' ? c : '#1976D2'
})
const chartOption = computed(() => {
const key = metricKey.value
const xs = props.points.map((p) => p.ts || '')
const xs = props.points.map((p) => formatDateTimeBeijing(p.ts))
const ys = props.points.map((p) => (p[key] ?? null) as number | null)
const color = lineColor.value
return {
animation: false,
grid: { left: 4, right: 4, top: 4, bottom: 4 },
grid: { left: 4, right: 4, top: 6, bottom: 4 },
xAxis: { type: 'category', show: false, data: xs },
yAxis: { type: 'value', show: false, min: 0 },
tooltip: { trigger: 'axis', confine: true },
tooltip: {
trigger: 'axis',
confine: true,
formatter: (params: Array<{ dataIndex?: number; value?: number | null }>) => {
const idx = params[0]?.dataIndex ?? 0
const when = xs[idx] ?? ''
const val = params[0]?.value
return `${when}<br/>${val != null ? `${val}%` : '—'}`
},
},
series: [{
type: 'line',
data: ys,
showSymbol: false,
smooth: true,
lineStyle: { width: 1.5 },
areaStyle: { opacity: 0.08 },
lineStyle: { width: 1.5, color },
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: colorWithAlpha(color, 0.2) },
{ offset: 1, color: colorWithAlpha(color, 0.02) },
],
},
},
}],
}
})
+101 -18
View File
@@ -1,10 +1,12 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useTheme } from 'vuetify'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { LineChart } from 'echarts/charts'
import { GridComponent, LegendComponent, TooltipComponent, DataZoomComponent } from 'echarts/components'
import VChart from 'vue-echarts'
import { formatChartAxisBeijing, formatDateTimeBeijing } from '@/utils/datetime'
use([CanvasRenderer, LineChart, GridComponent, LegendComponent, TooltipComponent, DataZoomComponent])
@@ -14,46 +16,127 @@ export interface WatchTrendPoint {
cpu_pct?: number | null
mem_pct?: number | null
disk_pct?: number | null
net_up_bps?: number | null
net_down_bps?: number | null
disk_read_bps?: number | null
disk_write_bps?: number | null
}
const props = withDefaults(defineProps<{
points: WatchTrendPoint[]
series: Array<'cpu_pct' | 'mem_pct' | 'disk_pct' | 'net_up_bps' | 'net_down_bps'>
series: Array<'cpu_pct' | 'mem_pct' | 'disk_pct'>
height?: number
}>(), {
height: 360,
})
const theme = useTheme()
const labels: Record<string, string> = {
cpu_pct: 'CPU %',
mem_pct: '内存 %',
disk_pct: '磁盘 %',
net_up_bps: '上行 B/s',
net_down_bps: '下行 B/s',
disk_read_bps: '磁盘读 B/s',
disk_write_bps: '磁盘写 B/s',
}
const seriesColorKeys: Record<string, string> = {
cpu_pct: 'primary',
mem_pct: 'warning',
disk_pct: 'disk',
}
function themeColor(key: string): string {
if (key === 'disk') return '#9C27B0'
const c = theme.current.value.colors[key as 'primary']
return typeof c === 'string' ? c : '#1976D2'
}
const chartOption = computed(() => {
const xs = props.points.map((p) => p.recorded_at || '')
const series = props.series.map((key) => ({
const dark = theme.current.value.dark
const text = dark ? 'rgba(255,255,255,0.78)' : 'rgba(0,0,0,0.65)'
const axisLine = dark ? 'rgba(255,255,255,0.14)' : 'rgba(0,0,0,0.1)'
const splitLine = dark ? 'rgba(255,255,255,0.07)' : 'rgba(0,0,0,0.06)'
const tooltipBg = dark ? 'rgba(30,30,30,0.96)' : 'rgba(255,255,255,0.98)'
const tooltipBorder = dark ? 'rgba(255,255,255,0.12)' : 'rgba(0,0,0,0.08)'
const timeLabels = props.points.map((p) => ({
axis: formatChartAxisBeijing(p.recorded_at),
full: formatDateTimeBeijing(p.recorded_at),
}))
const palette = props.series.map((key) => themeColor(seriesColorKeys[key] || 'primary'))
const series = props.series.map((key, i) => ({
name: labels[key] || key,
type: 'line',
type: 'line' as const,
showSymbol: false,
smooth: true,
lineStyle: { width: 2 },
emphasis: { focus: 'series' as const },
color: palette[i],
data: props.points.map((p) => p[key] ?? null),
}))
return {
tooltip: { trigger: 'axis' },
legend: { type: 'scroll', top: 0 },
grid: { left: 48, right: 16, top: 40, bottom: 56 },
dataZoom: [{ type: 'inside' }, { type: 'slider', height: 18, bottom: 8 }],
xAxis: { type: 'category', data: xs, axisLabel: { showMaxLabel: true } },
yAxis: { type: 'value', scale: true },
animationDuration: 480,
animationEasing: 'cubicOut',
color: palette,
tooltip: {
trigger: 'axis',
backgroundColor: tooltipBg,
borderColor: tooltipBorder,
borderWidth: 1,
textStyle: { color: text, fontSize: 12 },
formatter: (params: Array<{ dataIndex?: number; seriesName?: string; value?: number | null; color?: string }>) => {
if (!params?.length) return ''
const idx = params[0]?.dataIndex ?? 0
const when = timeLabels[idx]?.full ?? ''
const rows = params.map((p) => {
const dot = p.color || '#888'
const val = p.value != null ? `<b>${p.value}</b>` : '—'
return `<tr>
<td style="padding:2px 10px 2px 0;white-space:nowrap">
<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:${dot};margin-right:6px"></span>${p.seriesName}
</td>
<td style="padding:2px 0;text-align:right">${val}</td>
</tr>`
})
return `<div style="min-width:168px;line-height:1.5">
<div style="font-weight:600;font-size:13px;margin-bottom:6px">${when}</div>
<table style="width:100%;border-collapse:collapse;font-size:12px">${rows.join('')}</table>
</div>`
},
},
legend: {
type: 'scroll',
top: 0,
textStyle: { color: text, fontSize: 11 },
icon: 'roundRect',
itemWidth: 12,
itemHeight: 8,
},
grid: { left: 44, right: 12, top: 36, bottom: 52 },
dataZoom: [
{ type: 'inside' },
{
type: 'slider',
height: 20,
bottom: 4,
borderColor: axisLine,
fillerColor: dark ? 'rgba(25, 118, 210, 0.18)' : 'rgba(25, 118, 210, 0.12)',
handleStyle: { color: themeColor('primary') },
textStyle: { color: text, fontSize: 10 },
},
],
xAxis: {
type: 'category',
data: timeLabels.map((t) => t.axis),
axisLabel: { color: text, fontSize: 10, showMaxLabel: true },
axisLine: { lineStyle: { color: axisLine } },
axisTick: { show: false },
},
yAxis: {
type: 'value',
scale: true,
axisLabel: { color: text, fontSize: 10 },
axisLine: { show: false },
splitLine: { lineStyle: { color: splitLine } },
},
series,
}
})
@@ -0,0 +1,13 @@
import type { InjectionKey, Ref } from 'vue'
import type { BtPanelServerRow } from '@/api/btpanel'
export interface BtPanelContext {
servers: Ref<BtPanelServerRow[]>
serverId: Ref<number | null>
loading: Ref<boolean>
loadServers: () => Promise<void>
setServerId: (id: number | null) => void
selected: () => BtPanelServerRow | null
}
export const btPanelContextKey: InjectionKey<BtPanelContext> = Symbol('btPanelContext')
@@ -0,0 +1,18 @@
import { inject, onMounted, watch, type Ref } from 'vue'
import { btPanelContextKey } from '@/composables/btpanel/btPanelContext'
export function useBtPanelPageLoad(loadFn: (serverId: number) => void | Promise<void>) {
const ctx = inject(btPanelContextKey)
if (!ctx) throw new Error('useBtPanelPageLoad must be used inside BtPanelPageShell')
async function run() {
const id = ctx.serverId.value
if (!id) return
await loadFn(id)
}
onMounted(() => { void run() })
watch(ctx.serverId, () => { void run() })
return { serverId: ctx.serverId as Ref<number | null>, selected: ctx.selected, run }
}

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