Compare commits
20 Commits
72042598b0
...
1c4c5af0e4
| Author | SHA1 | Date | |
|---|---|---|---|
| 1c4c5af0e4 | |||
| d00294c86d | |||
| 82426b1941 | |||
| ebab836554 | |||
| ccc5419369 | |||
| d8ec4f1af4 | |||
| 9c9a51263c | |||
| 1a79c0fbfb | |||
| 86a45612c8 | |||
| 05b0fde197 | |||
| c91cb1cd9e | |||
| 3faa7a4f1c | |||
| b5419cd2c0 | |||
| 5c508ed14c | |||
| 825d0297c4 | |||
| 4bbc47faab | |||
| ba7c790f38 | |||
| adee25deb6 | |||
| 4acbf04322 | |||
| feb776616f |
@@ -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`
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"plugins": {
|
||||
"compound-engineering": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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` §9–10 |
|
||||
|
||||
## 探索规则(省 token)
|
||||
|
||||
详见 `.cursor/rules/agent-exploration.mdc`。要点:**先地图再 Grep(带 path)→ Read ≤5 个文件 → 停止**。
|
||||
|
||||
@@ -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"]
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -1,4 +0,0 @@
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.34.0
|
||||
playwright==1.49.1
|
||||
pydantic-settings==2.7.1
|
||||
@@ -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()
|
||||
@@ -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
@@ -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
@@ -1,6 +1,6 @@
|
||||
#!/bin/bash
|
||||
# Nexus Pre-deploy Gate Check (v2 — 7 gates)
|
||||
# 在部署前强制检查 7 道门控,任何一道不过则阻止部署
|
||||
# Nexus Pre-deploy Gate Check (v3 — 8 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 | 判定 | 依据 |
|
||||
|---|------|------|
|
||||
| H1–H2 | 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 | 判定 | 依据 |
|
||||
|---|------|------|
|
||||
| H1–H4 | SAFE | L2b 26/26;chain 9/9;vite 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 | 判定 | 依据 |
|
||||
|---|------|------|
|
||||
| H1–H4 | 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 | 判定 | 依据 |
|
||||
|---|------|------|
|
||||
| H1–H3 | SAFE | 纯前端 `WatchSlotCard.vue` 模板分支 |
|
||||
|
||||
## DoD
|
||||
|
||||
- [x] changelog
|
||||
- [x] `cd frontend && npx vite build`
|
||||
- [ ] 生产浏览器终验:暂停槽中部开关 → 开启后见 CPU/内存
|
||||
@@ -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` L94–112:seriesName 来自本地 `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) |
|
||||
@@ -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 | 判定 |
|
||||
|---|------|
|
||||
| H1–H3 | 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 | 判定 |
|
||||
|---|------|
|
||||
| H1–H3 | 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 | 判定 | 依据 |
|
||||
|---|------|------|
|
||||
| H1–H5 | SAFE | 恢复监测空窗期根因已消除;测试 10/10;local_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 | 判定 | 依据 |
|
||||
|---|------|------|
|
||||
| H1–H3 | 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 | 判定 | 依据 |
|
||||
|---|------|------|
|
||||
| H1–H3 | 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 列保留 NULL;API 不再推送 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 | 判定 |
|
||||
|---|------|
|
||||
| H1–H3 | 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/P1(confidence≥75)或 `verdict=Not ready` 时 Gate 8 BLOCK。
|
||||
@@ -0,0 +1,35 @@
|
||||
# 2026-06-14 — Layer3 巡检脚本与监测 UI 小修
|
||||
|
||||
## 摘要
|
||||
|
||||
修复 `health_monitor.sh` 状态文件权限与 Telegram 长串引号问题;Sparkline 渐变兼容 rgb 主题;探针表无数据时隐藏分页。
|
||||
|
||||
## 动机
|
||||
|
||||
巡检 P2:cron 日志大量 `unexpected EOF` 与 `/tmp` 状态文件 Permission denied,Layer3 自愈可能失效。P3:Sparkline `${color}33` 在非 hex 主题失效;探针表 total=0 仍显示分页。
|
||||
|
||||
## 变更
|
||||
|
||||
- `deploy/health_monitor.sh`:状态迁至 `${DEPLOY_DIR}/var/layer3-health`;不可写时回退 `~/.nexus/layer3-health` 或 sudo chown;`docker_cmd` 回退 sudo;Telegram 文案改 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/内存/磁盘/负载优先 SSH;IO 字段 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` |
|
||||
| B1–B3 | 系统监控 | 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 侧成本高且仍不稳定。
|
||||
- **SSRF(P1)**:仅校验显式 `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 8:AI 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/P1(confidence≥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 |
|
||||
| B1–B3 | `/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` |
|
||||
| D1–D4 | `/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 Bootstrap(2026-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` 通过
|
||||
@@ -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 后按 W1~W4 重建 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/*`。
|
||||
|
||||
@@ -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(开关、倒计时、中文错误)。
|
||||
@@ -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/29(watch 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`
|
||||
@@ -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 渐变 / 空表分页 |
|
||||
@@ -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 passed(1 环境性失败见下) |
|
||||
| `pytest tests/chain` | ✅ 9/9 |
|
||||
| `test_watch_*` + `test_ops_patrol_sync` | ✅ 29/30(SSH 本地探针 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 / 空表分页 |
|
||||
+59
-25
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) },
|
||||
],
|
||||
},
|
||||
},
|
||||
}],
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user