Compare commits

...

20 Commits

Author SHA1 Message Date
Nexus Agent 72042598b0 Fix files page new-file flow and SFTP parent directory creation.
Nexus CI/CD / test (push) Waiting to run
Nexus CI/CD / e2e (push) Blocked by required conditions
Nexus CI/CD / deploy (push) Blocked by required conditions
Nexus Pre-commit Checks / quick-check (push) Waiting to run
Show a clear prompt when no server is selected, wait for the editor workbench before openFile, and mkdir -p parent paths before SFTP writes.
2026-06-13 09:08:17 +08:00
Nexus Agent e95e522d4d docs(audit): gate checklist for Telegram group chat ID detection 2026-06-13 02:05:27 +08:00
Nexus Agent 40cba6a567 fix(settings): detect Telegram group chat IDs via my_chat_member
Extend getUpdates parsing for group joins and update settings UI hints so Chat ID detection works for supergroups, not only private DMs.
2026-06-13 02:04:59 +08:00
Nexus Agent 821cf12ade fix(terminal,alerts): quick-cmd enter on send and offline Telegram channel
Append CR when executing quick commands (fixes trim stripping saved \r) and
allow optional dedicated Telegram bot for server offline alerts only.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-13 01:45:55 +08:00
Nexus Agent deafd46a0e docs(reports): 文件管理移除下载生产验证记录
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-12 06:52:47 +08:00
Nexus Agent dbb6bc4a55 fix(browser): 门控 — Worker TLS verify 可配置、审计清单与 shell LF
默认校验 Worker HTTPS 证书;审计文件补全变更清单;docker-entrypoint.sh 改为 LF。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-12 06:50:06 +08:00
Nexus Agent e833b923c8 feat(browser): 远程浏览器 Worker 桥接与顶栏固定悬浮窗
接入 Playwright Worker、会话 WebSocket 与全局浏览器面板(固定于 App Bar 下);
含验证码 stealth、设置项默认音与 URL 安全校验;附带 worker 部署与设计文档。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-12 06:49:20 +08:00
Nexus Agent 2b0413c43e feat(files): 移除文件管理下载入口
去掉行内与右键下载;大文件提示改为使用终端。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-12 06:32:09 +08:00
Nexus Agent a51a5c5341 fix(files): 修复下载流式 SFTP 并收紧列表行高
下载在独立 SFTP 会话中流式传输;文件/终端列表与服务器页一致的紧凑操作与行高。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-12 05:50:09 +08:00
Nexus Agent 80aaf855a3 feat(terminal): 右侧服务器列表增加搜索
侧栏复用 PickerSearch,按名称/域名筛选并支持搜索历史。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-12 05:46:34 +08:00
Nexus Agent e42612e75f feat(watch): 监测历史四槽同屏展示趋势
资源趋势 2×2 四图;探针记录默认全部槽位;表头内存/硬盘中文。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-12 05:44:28 +08:00
Nexus Agent 7a0001d8f4 fix(watch): 恢复指标进度条原三档配色
≥90% 红、70–89% 黄、<70% 绿。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-12 05:29:54 +08:00
Nexus Agent 3598d38b8e fix(watch): 指标进度条 ≥80% 变红,到期倒计时归零加固
进度条阈值由 70/90 改为 80% 红色;remaining_sec≤0 时立即 refreshPins。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-12 05:17:38 +08:00
Nexus Agent d5ecb54d6e feat(watch): 监测槽等待探针回传占位 UI
开启监测后无 probe_status 时显示等待文案与加载动画,回传后自动展示指标。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-12 05:08:03 +08:00
Nexus Agent ac98dc496c fix(watch): 移除监测槽 hover 时 Vuetify 灰遮罩
v-card 关闭 link/ripple 并隐藏 overlay,仅保留外框主题色高亮。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-12 04:54:24 +08:00
Nexus Agent 49b471eff9 test(e2e): 监测槽时长与指标 UI 生产冒烟
Playwright 验证默认 30 分 chip、内存/硬盘/负载与进度条;附部署验证报告。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-12 04:40:40 +08:00
Nexus Agent 647ebcb673 feat(watch): 监测槽分钟时长档、指标进度条与负载显示
默认 30 分钟,支持 30分/1时/2时/8时/24时;槽卡展示 CPU/内存/硬盘进度条与 1 分钟负载;API 改用 ttl_minutes。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-12 04:34:52 +08:00
Nexus Agent 4c38bc05d3 docs(audit): 监测槽 hover 外框高亮 2026-06-12 04:21:28 +08:00
Nexus Agent 907607e30b fix(watch): 监测槽 hover 改为外框高亮
悬停时仅边框变主题色,不再整卡铺灰底。
2026-06-12 04:21:16 +08:00
Nexus Agent bdbfef7110 fix(audit): 目标列显示服务器名称而非 #id
审计列表对 server 类型批量解析 target_name,前端展示为「服务器 {name}」。
2026-06-12 04:16:46 +08:00
112 changed files with 6308 additions and 342 deletions
+19
View File
@@ -0,0 +1,19 @@
FROM mcr.microsoft.com/playwright/python:v1.49.1-jammy
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
RUN apt-get update && apt-get install -y --no-install-recommends xvfb \
&& rm -rf /var/lib/apt/lists/*
COPY browser_url_safe.py browser_stealth.py config.py session_manager.py main.py docker-entrypoint.sh ./
RUN sed -i 's/\r$//' docker-entrypoint.sh && chmod +x docker-entrypoint.sh
ENV WORKER_BIND=0.0.0.0:8443
ENV DISPLAY=:99
EXPOSE 8443
ENTRYPOINT ["/bin/bash", "docker-entrypoint.sh"]
+46
View File
@@ -0,0 +1,46 @@
"""Reduce automation fingerprints for sites with CAPTCHA (e.g. 阿里云)."""
from __future__ import annotations
CHROMIUM_ARGS = [
"--disable-blink-features=AutomationControlled",
"--no-sandbox",
"--disable-dev-shm-usage",
"--disable-infobars",
"--window-position=0,0",
"--lang=zh-CN",
]
# Playwright adds --enable-automation by default; drop it for CAPTCHA sites.
IGNORE_DEFAULT_ARGS = ["--enable-automation"]
STEALTH_INIT_SCRIPT = """
(() => {
Object.defineProperty(navigator, 'webdriver', { get: () => undefined, configurable: true });
Object.defineProperty(navigator, 'languages', { get: () => ['zh-CN', 'zh', 'en-US', 'en'] });
Object.defineProperty(navigator, 'plugins', { get: () => [1, 2, 3, 4, 5] });
const originalQuery = window.navigator.permissions.query;
window.navigator.permissions.query = (parameters) => (
parameters.name === 'notifications'
? Promise.resolve({ state: Notification.permission })
: originalQuery(parameters)
);
window.chrome = window.chrome || { runtime: {} };
})();
"""
DEFAULT_USER_AGENT = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
)
CONTEXT_OPTIONS = {
"user_agent": DEFAULT_USER_AGENT,
"locale": "zh-CN",
"timezone_id": "Asia/Shanghai",
"color_scheme": "light",
"device_scale_factor": 1,
"has_touch": False,
"is_mobile": False,
"java_script_enabled": True,
}
+77
View File
@@ -0,0 +1,77 @@
"""SSRF-safe URL validation (Worker copy — keep in sync with server/utils/browser_url_safe.py)."""
from __future__ import annotations
import ipaddress
import socket
from urllib.parse import urlparse
BLOCKED_HOSTNAMES = frozenset({
"localhost",
"localhost.localdomain",
"metadata",
"metadata.google.internal",
"169.254.169.254",
})
def _blocked_ip(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved or ip.is_multicast:
return True
if isinstance(ip, ipaddress.IPv4Address):
if ip in ipaddress.ip_network("0.0.0.0/8"):
return True
if ip in ipaddress.ip_network("100.64.0.0/10"):
return True
if isinstance(ip, ipaddress.IPv6Address):
if ip == ipaddress.IPv6Address("::1"):
return True
if ip in ipaddress.ip_network("fc00::/7"):
return True
if ip in ipaddress.ip_network("fe80::/10"):
return True
return False
def resolve_host_blocked(hostname: str) -> str | None:
host = hostname.lower().strip(".")
if not host:
return "Empty hostname"
if host in BLOCKED_HOSTNAMES or "metadata" in host:
return f"Blocked hostname: {hostname}"
try:
infos = socket.getaddrinfo(host, None, type=socket.SOCK_STREAM)
except socket.gaierror as exc:
return f"DNS resolution failed: {exc}"
if not infos:
return f"No addresses for hostname: {hostname}"
for info in infos:
ip_str = info[4][0]
try:
ip = ipaddress.ip_address(ip_str)
except ValueError:
return f"Invalid IP: {ip_str}"
if _blocked_ip(ip):
return f"Blocked IP: {ip_str}"
return None
def validate_navigation_url(url: str) -> tuple[str | None, str | None]:
trimmed = url.strip()
if not trimmed:
return None, "Empty URL"
try:
with_proto = trimmed if "://" in trimmed else f"https://{trimmed}"
parsed = urlparse(with_proto)
except Exception:
return None, "Invalid URL"
if parsed.scheme not in ("http", "https"):
return None, f"Unsupported scheme: {parsed.scheme}"
if parsed.username or parsed.password:
return None, "Credentials in URL are not allowed"
if not parsed.hostname:
return None, "Missing hostname"
host_err = resolve_host_blocked(parsed.hostname)
if host_err:
return None, host_err
return parsed.geturl(), None
+22
View File
@@ -0,0 +1,22 @@
"""Browser Worker configuration."""
from pydantic_settings import BaseSettings, SettingsConfigDict
class WorkerSettings(BaseSettings):
WORKER_SECRET: str = ""
WORKER_BIND: str = "0.0.0.0:8443"
WORKER_MAX_SESSIONS: int = 2
WORKER_IDLE_TIMEOUT_SEC: int = 1800
# POST /v1/sessions without WS attach — reclaim zombie reservations
WORKER_RESERVE_TIMEOUT_SEC: int = 120
# false + Xvfb — better for 阿里云等验证码;true 省资源
WORKER_HEADLESS: str = "false"
WORKER_SCREENCAST_QUALITY: int = 90
WORKER_DEFAULT_VIEWPORT_W: int = 1280
WORKER_DEFAULT_VIEWPORT_H: int = 720
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
settings = WorkerSettings()
+11
View File
@@ -0,0 +1,11 @@
services:
browser-worker:
build: .
container_name: nexus-browser-worker
restart: unless-stopped
env_file:
- .env
ports:
- "8443:8443"
shm_size: "1gb"
ipc: host
+11
View File
@@ -0,0 +1,11 @@
#!/usr/bin/env bash
set -euo pipefail
# Headed Chromium needs a virtual display (CAPTCHA / anti-bot).
if [[ "${WORKER_HEADLESS:-false}" != "true" && "${WORKER_HEADLESS:-false}" != "1" ]]; then
export DISPLAY="${DISPLAY:-:99}"
if ! pgrep -x Xvfb >/dev/null 2>&1; then
Xvfb "${DISPLAY}" -screen 0 1920x1080x24 -ac +extension GLX +render -noreset &
sleep 1
fi
fi
exec uvicorn main:app --host 0.0.0.0 --port 8443 --log-level info
+147
View File
@@ -0,0 +1,147 @@
"""Browser Worker — Playwright Chromium + WebSocket stream."""
from __future__ import annotations
import asyncio
import json
import logging
from contextlib import asynccontextmanager
from typing import Any
from fastapi import FastAPI, Header, HTTPException, WebSocket, WebSocketDisconnect
from pydantic import BaseModel, Field
from config import settings
from session_manager import manager
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("browser_worker")
def _check_key(header: str | None) -> None:
if not settings.WORKER_SECRET:
raise HTTPException(status_code=503, detail="Worker secret not configured")
if not header or header != settings.WORKER_SECRET:
raise HTTPException(status_code=401, detail="Invalid worker key")
class CreateSessionBody(BaseModel):
session_id: str = Field(min_length=8, max_length=64)
viewport_w: int = Field(default=1280, ge=320, le=1920)
viewport_h: int = Field(default=720, ge=240, le=1080)
@asynccontextmanager
async def lifespan(_app: FastAPI):
await manager.start()
logger.info("Playwright Chromium ready")
yield
await manager.stop()
app = FastAPI(title="Nexus Browser Worker", lifespan=lifespan)
@app.get("/health")
async def health():
return {
"status": "ok",
"sessions": manager.session_count(),
"max_sessions": settings.WORKER_MAX_SESSIONS,
}
@app.post("/v1/sessions")
async def create_session(
body: CreateSessionBody,
x_nexus_worker_key: str | None = Header(default=None, alias="X-Nexus-Worker-Key"),
):
_check_key(x_nexus_worker_key)
try:
await manager.reserve_session(body.session_id, body.viewport_w, body.viewport_h)
except ValueError as exc:
code = str(exc)
if code == "max_sessions":
raise HTTPException(status_code=503, detail="Max sessions reached") from exc
if code == "session_exists":
raise HTTPException(status_code=409, detail="Session already exists") from exc
raise HTTPException(status_code=400, detail=code) from exc
return {"session_id": body.session_id, "viewport_w": body.viewport_w, "viewport_h": body.viewport_h}
@app.delete("/v1/sessions/{session_id}")
async def delete_session(
session_id: str,
x_nexus_worker_key: str | None = Header(default=None, alias="X-Nexus-Worker-Key"),
):
_check_key(x_nexus_worker_key)
if not await manager.destroy_session(session_id):
raise HTTPException(status_code=404, detail="Session not found")
return {"ok": True}
@app.websocket("/v1/sessions/{session_id}/stream")
async def session_stream(
websocket: WebSocket,
session_id: str,
x_nexus_worker_key: str | None = Header(default=None, alias="X-Nexus-Worker-Key"),
):
key = x_nexus_worker_key or websocket.headers.get("x-nexus-worker-key")
if not settings.WORKER_SECRET or key != settings.WORKER_SECRET:
await websocket.close(code=4401, reason="Invalid worker key")
return
if not manager.get_session(session_id):
await websocket.close(code=4404, reason="Session not found")
return
await websocket.accept()
outbound: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
async def send(msg: dict[str, Any]) -> None:
await outbound.put(msg)
try:
await manager.attach_stream(session_id, send)
except ValueError:
await websocket.close(code=4404, reason="Session not found")
return
except RuntimeError:
await manager.destroy_session(session_id)
await websocket.close(code=4503, reason="Browser not ready")
return
async def pump_out() -> None:
while True:
msg = await outbound.get()
await websocket.send_json(msg)
pump_task = asyncio.create_task(pump_out(), name=f"ws_out_{session_id}")
try:
while True:
raw = await websocket.receive_text()
try:
msg = json.loads(raw)
except json.JSONDecodeError:
await send({"type": "ERROR", "message": "Invalid JSON", "code": "BAD_JSON"})
continue
if not isinstance(msg, dict):
continue
await manager.handle_message(session_id, msg)
except WebSocketDisconnect:
pass
finally:
pump_task.cancel()
try:
await pump_task
except asyncio.CancelledError:
pass
await manager.destroy_session(session_id)
if __name__ == "__main__":
import uvicorn
host, _, port = settings.WORKER_BIND.partition(":")
uvicorn.run("main:app", host=host or "0.0.0.0", port=int(port or 8443), log_level="info")
+4
View File
@@ -0,0 +1,4 @@
fastapi==0.115.6
uvicorn[standard]==0.34.0
playwright==1.49.1
pydantic-settings==2.7.1
+346
View File
@@ -0,0 +1,346 @@
"""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()
+430
View File
@@ -1657,3 +1657,433 @@
{"ts":"2026-06-11T17:47:40+08:00","date":"2026-06-11","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"} {"ts":"2026-06-11T17:47:40+08:00","date":"2026-06-11","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-11T17:47:40+08:00","date":"2026-06-11","gate":"text_eol","result":"PASS","detail":"git index LF"} {"ts":"2026-06-11T17:47:40+08:00","date":"2026-06-11","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-11T17:47:40+08:00","date":"2026-06-11","gate":"summary","result":"PASS","detail":"7/7"} {"ts":"2026-06-11T17:47:40+08:00","date":"2026-06-11","gate":"summary","result":"PASS","detail":"7/7"}
{"ts":"2026-06-11T17:54:51+08:00","date":"2026-06-11","gate":"changelog","result":"PASS","detail":"2026-06-11-servers-search-manual-trigger.md 28lines"}
{"ts":"2026-06-11T17:54:51+08:00","date":"2026-06-11","gate":"audit","result":"PASS","detail":"2026-06-11-servers-search-manual-trigger.md"}
{"ts":"2026-06-11T17:54:51+08:00","date":"2026-06-11","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-11T17:54:51+08:00","date":"2026-06-11","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-11T17:54:51+08:00","date":"2026-06-11","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-11T17:54:51+08:00","date":"2026-06-11","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-11T17:54:51+08:00","date":"2026-06-11","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-06-11T17:54:51+08:00","date":"2026-06-11","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-11T17:54:51+08:00","date":"2026-06-11","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-11T17:54:51+08:00","date":"2026-06-11","gate":"summary","result":"PASS","detail":"7/7"}
{"ts":"2026-06-11T18:12:57+08:00","date":"2026-06-11","gate":"changelog","result":"PASS","detail":"2026-06-11-files-chmod-not-effective-fix.md 32lines"}
{"ts":"2026-06-11T18:12:57+08:00","date":"2026-06-11","gate":"audit","result":"PASS","detail":"2026-06-11-files-chmod-not-effective-fix.md"}
{"ts":"2026-06-11T18:12:57+08:00","date":"2026-06-11","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-11T18:12:57+08:00","date":"2026-06-11","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-11T18:12:57+08:00","date":"2026-06-11","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-11T18:12:57+08:00","date":"2026-06-11","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-11T18:12:57+08:00","date":"2026-06-11","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-06-11T18:12:57+08:00","date":"2026-06-11","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-11T18:12:57+08:00","date":"2026-06-11","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-11T18:12:57+08:00","date":"2026-06-11","gate":"summary","result":"PASS","detail":"7/7"}
{"ts":"2026-06-11T18:23:41+08:00","date":"2026-06-11","gate":"changelog","result":"PASS","detail":"2026-06-11-files-upload-www-permissions.md 39lines"}
{"ts":"2026-06-11T18:23:41+08:00","date":"2026-06-11","gate":"audit","result":"PASS","detail":"2026-06-11-files-chmod-not-effective-fix.md"}
{"ts":"2026-06-11T18:23:41+08:00","date":"2026-06-11","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-11T18:23:41+08:00","date":"2026-06-11","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-11T18:23:41+08:00","date":"2026-06-11","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-11T18:23:41+08:00","date":"2026-06-11","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-11T18:23:41+08:00","date":"2026-06-11","gate":"review","result":"BLOCK","detail":"audit missing changed files"}
{"ts":"2026-06-11T18:23:41+08:00","date":"2026-06-11","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-11T18:23:41+08:00","date":"2026-06-11","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-11T18:23:41+08:00","date":"2026-06-11","gate":"summary","result":"BLOCK","detail":"6/7"}
{"ts":"2026-06-11T18:24:13+08:00","date":"2026-06-11","gate":"changelog","result":"PASS","detail":"2026-06-11-files-upload-www-permissions.md 39lines"}
{"ts":"2026-06-11T18:24:13+08:00","date":"2026-06-11","gate":"audit","result":"PASS","detail":"2026-06-11-files-upload-www-permissions.md"}
{"ts":"2026-06-11T18:24:13+08:00","date":"2026-06-11","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-11T18:24:13+08:00","date":"2026-06-11","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-11T18:24:13+08:00","date":"2026-06-11","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-11T18:24:13+08:00","date":"2026-06-11","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-11T18:24:13+08:00","date":"2026-06-11","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-06-11T18:24:13+08:00","date":"2026-06-11","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-11T18:24:13+08:00","date":"2026-06-11","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-11T18:24:13+08:00","date":"2026-06-11","gate":"summary","result":"PASS","detail":"7/7"}
{"ts":"2026-06-11T18:30:51+08:00","date":"2026-06-11","gate":"changelog","result":"PASS","detail":"2026-06-11-embedded-browser.md 38lines"}
{"ts":"2026-06-11T18:30:51+08:00","date":"2026-06-11","gate":"audit","result":"PASS","detail":"2026-06-11-embedded-browser.md"}
{"ts":"2026-06-11T18:30:51+08:00","date":"2026-06-11","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-11T18:30:51+08:00","date":"2026-06-11","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-11T18:30:51+08:00","date":"2026-06-11","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-11T18:30:51+08:00","date":"2026-06-11","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-11T18:30:51+08:00","date":"2026-06-11","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-06-11T18:30:51+08:00","date":"2026-06-11","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-11T18:30:51+08:00","date":"2026-06-11","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-11T18:30:51+08:00","date":"2026-06-11","gate":"summary","result":"PASS","detail":"7/7"}
{"ts":"2026-06-11T20:11:04+08:00","date":"2026-06-11","gate":"changelog","result":"PASS","detail":"2026-06-11-global-floating-browser.md 41lines"}
{"ts":"2026-06-11T20:11:04+08:00","date":"2026-06-11","gate":"audit","result":"BLOCK","detail":"missing sections"}
{"ts":"2026-06-11T20:11:04+08:00","date":"2026-06-11","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-11T20:11:04+08:00","date":"2026-06-11","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-11T20:11:04+08:00","date":"2026-06-11","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-11T20:11:04+08:00","date":"2026-06-11","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-11T20:11:04+08:00","date":"2026-06-11","gate":"review","result":"BLOCK","detail":"audit missing changed files"}
{"ts":"2026-06-11T20:11:04+08:00","date":"2026-06-11","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-11T20:11:04+08:00","date":"2026-06-11","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-11T20:11:04+08:00","date":"2026-06-11","gate":"summary","result":"BLOCK","detail":"5/7"}
{"ts":"2026-06-11T20:11:24+08:00","date":"2026-06-11","gate":"changelog","result":"PASS","detail":"2026-06-11-global-floating-browser.md 41lines"}
{"ts":"2026-06-11T20:11:24+08:00","date":"2026-06-11","gate":"audit","result":"PASS","detail":"2026-06-11-global-floating-browser.md"}
{"ts":"2026-06-11T20:11:24+08:00","date":"2026-06-11","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-11T20:11:24+08:00","date":"2026-06-11","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-11T20:11:24+08:00","date":"2026-06-11","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-11T20:11:24+08:00","date":"2026-06-11","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-11T20:11:24+08:00","date":"2026-06-11","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-06-11T20:11:24+08:00","date":"2026-06-11","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-11T20:11:24+08:00","date":"2026-06-11","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-11T20:11:24+08:00","date":"2026-06-11","gate":"summary","result":"PASS","detail":"7/7"}
{"ts":"2026-06-11T20:15:48+08:00","date":"2026-06-11","gate":"changelog","result":"PASS","detail":"2026-06-11-global-floating-browser.md 47lines"}
{"ts":"2026-06-11T20:15:48+08:00","date":"2026-06-11","gate":"audit","result":"PASS","detail":"2026-06-11-global-floating-browser.md"}
{"ts":"2026-06-11T20:15:48+08:00","date":"2026-06-11","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-11T20:15:48+08:00","date":"2026-06-11","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-11T20:15:48+08:00","date":"2026-06-11","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-11T20:15:48+08:00","date":"2026-06-11","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-11T20:15:48+08:00","date":"2026-06-11","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-06-11T20:15:48+08:00","date":"2026-06-11","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-11T20:15:48+08:00","date":"2026-06-11","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-11T20:15:48+08:00","date":"2026-06-11","gate":"summary","result":"PASS","detail":"7/7"}
{"ts":"2026-06-11T20:19:51+08:00","date":"2026-06-11","gate":"changelog","result":"PASS","detail":"2026-06-11-global-floating-browser.md 48lines"}
{"ts":"2026-06-11T20:19:51+08:00","date":"2026-06-11","gate":"audit","result":"PASS","detail":"2026-06-11-global-floating-browser.md"}
{"ts":"2026-06-11T20:19:51+08:00","date":"2026-06-11","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-11T20:19:51+08:00","date":"2026-06-11","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-11T20:19:51+08:00","date":"2026-06-11","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-11T20:19:51+08:00","date":"2026-06-11","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-11T20:19:51+08:00","date":"2026-06-11","gate":"review","result":"BLOCK","detail":"audit missing changed files"}
{"ts":"2026-06-11T20:19:51+08:00","date":"2026-06-11","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-11T20:19:51+08:00","date":"2026-06-11","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-11T20:19:51+08:00","date":"2026-06-11","gate":"summary","result":"BLOCK","detail":"6/7"}
{"ts":"2026-06-11T20:20:04+08:00","date":"2026-06-11","gate":"changelog","result":"PASS","detail":"2026-06-11-global-floating-browser.md 48lines"}
{"ts":"2026-06-11T20:20:04+08:00","date":"2026-06-11","gate":"audit","result":"PASS","detail":"2026-06-11-global-floating-browser.md"}
{"ts":"2026-06-11T20:20:04+08:00","date":"2026-06-11","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-11T20:20:04+08:00","date":"2026-06-11","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-11T20:20:04+08:00","date":"2026-06-11","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-11T20:20:04+08:00","date":"2026-06-11","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-11T20:20:04+08:00","date":"2026-06-11","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-06-11T20:20:04+08:00","date":"2026-06-11","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-11T20:20:04+08:00","date":"2026-06-11","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-11T20:20:04+08:00","date":"2026-06-11","gate":"summary","result":"PASS","detail":"7/7"}
{"ts":"2026-06-11T23:20:24+08:00","date":"2026-06-11","gate":"changelog","result":"PASS","detail":"2026-06-11-rsync-push-sudo-elevation.md 43lines"}
{"ts":"2026-06-11T23:20:24+08:00","date":"2026-06-11","gate":"audit","result":"PASS","detail":"2026-06-11-rsync-push-sudo-elevation.md"}
{"ts":"2026-06-11T23:20:24+08:00","date":"2026-06-11","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-11T23:20:24+08:00","date":"2026-06-11","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-11T23:20:24+08:00","date":"2026-06-11","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-11T23:20:24+08:00","date":"2026-06-11","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-11T23:20:24+08:00","date":"2026-06-11","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-06-11T23:20:24+08:00","date":"2026-06-11","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-11T23:20:24+08:00","date":"2026-06-11","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-11T23:20:24+08:00","date":"2026-06-11","gate":"summary","result":"PASS","detail":"7/7"}
{"ts":"2026-06-11T23:43:11+08:00","date":"2026-06-11","gate":"changelog","result":"PASS","detail":"2026-06-11-push-default-wwwroot.md 41lines"}
{"ts":"2026-06-11T23:43:11+08:00","date":"2026-06-11","gate":"audit","result":"PASS","detail":"2026-06-11-rsync-push-sudo-elevation.md"}
{"ts":"2026-06-11T23:43:11+08:00","date":"2026-06-11","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-11T23:43:11+08:00","date":"2026-06-11","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-11T23:43:11+08:00","date":"2026-06-11","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-11T23:43:11+08:00","date":"2026-06-11","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-11T23:43:11+08:00","date":"2026-06-11","gate":"review","result":"BLOCK","detail":"audit missing changed files"}
{"ts":"2026-06-11T23:43:11+08:00","date":"2026-06-11","gate":"shell_eol","result":"BLOCK","detail":"CR in tracked sh"}
{"ts":"2026-06-11T23:43:11+08:00","date":"2026-06-11","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-11T23:43:11+08:00","date":"2026-06-11","gate":"summary","result":"BLOCK","detail":"6/7"}
{"ts":"2026-06-11T23:45:44+08:00","date":"2026-06-11","gate":"changelog","result":"PASS","detail":"2026-06-11-push-default-wwwroot.md 41lines"}
{"ts":"2026-06-11T23:45:44+08:00","date":"2026-06-11","gate":"audit","result":"PASS","detail":"2026-06-11-push-default-wwwroot.md"}
{"ts":"2026-06-11T23:45:44+08:00","date":"2026-06-11","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-11T23:45:44+08:00","date":"2026-06-11","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-11T23:45:44+08:00","date":"2026-06-11","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-11T23:45:44+08:00","date":"2026-06-11","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-11T23:45:44+08:00","date":"2026-06-11","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-06-11T23:45:44+08:00","date":"2026-06-11","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-11T23:45:44+08:00","date":"2026-06-11","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-11T23:45:44+08:00","date":"2026-06-11","gate":"summary","result":"PASS","detail":"7/7"}
{"ts":"2026-06-12T00:18:55+08:00","date":"2026-06-12","gate":"changelog","result":"BLOCK","detail":"file not found"}
{"ts":"2026-06-12T00:18:55+08:00","date":"2026-06-12","gate":"audit","result":"BLOCK","detail":"file not found"}
{"ts":"2026-06-12T00:18:55+08:00","date":"2026-06-12","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-12T00:18:55+08:00","date":"2026-06-12","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-12T00:18:55+08:00","date":"2026-06-12","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-12T00:18:55+08:00","date":"2026-06-12","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-12T00:18:55+08:00","date":"2026-06-12","gate":"review","result":"BLOCK","detail":"no audit file"}
{"ts":"2026-06-12T00:18:55+08:00","date":"2026-06-12","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-12T00:18:55+08:00","date":"2026-06-12","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-12T00:18:55+08:00","date":"2026-06-12","gate":"summary","result":"BLOCK","detail":"4/7"}
{"ts":"2026-06-12T00:19:19+08:00","date":"2026-06-12","gate":"changelog","result":"PASS","detail":"2026-06-12-server-onboarding-automation.md 25lines"}
{"ts":"2026-06-12T00:19:19+08:00","date":"2026-06-12","gate":"audit","result":"PASS","detail":"2026-06-12-server-onboarding-automation.md"}
{"ts":"2026-06-12T00:19:19+08:00","date":"2026-06-12","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-12T00:19:19+08:00","date":"2026-06-12","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-12T00:19:19+08:00","date":"2026-06-12","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-12T00:19:19+08:00","date":"2026-06-12","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-12T00:19:19+08:00","date":"2026-06-12","gate":"review","result":"BLOCK","detail":"audit missing changed files"}
{"ts":"2026-06-12T00:19:19+08:00","date":"2026-06-12","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-12T00:19:19+08:00","date":"2026-06-12","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-12T00:19:19+08:00","date":"2026-06-12","gate":"summary","result":"BLOCK","detail":"6/7"}
{"ts":"2026-06-12T00:19:31+08:00","date":"2026-06-12","gate":"changelog","result":"PASS","detail":"2026-06-12-server-onboarding-automation.md 25lines"}
{"ts":"2026-06-12T00:19:31+08:00","date":"2026-06-12","gate":"audit","result":"PASS","detail":"2026-06-12-server-onboarding-automation.md"}
{"ts":"2026-06-12T00:19:31+08:00","date":"2026-06-12","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-12T00:19:31+08:00","date":"2026-06-12","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-12T00:19:31+08:00","date":"2026-06-12","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-12T00:19:31+08:00","date":"2026-06-12","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-12T00:19:31+08:00","date":"2026-06-12","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-06-12T00:19:31+08:00","date":"2026-06-12","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-12T00:19:31+08:00","date":"2026-06-12","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-12T00:19:31+08:00","date":"2026-06-12","gate":"summary","result":"PASS","detail":"7/7"}
{"ts":"2026-06-12T01:44:02+08:00","date":"2026-06-12","gate":"changelog","result":"PASS","detail":"2026-06-12-server-onboarding-automation.md 25lines"}
{"ts":"2026-06-12T01:44:02+08:00","date":"2026-06-12","gate":"audit","result":"PASS","detail":"2026-06-12-server-onboarding-automation.md"}
{"ts":"2026-06-12T01:44:02+08:00","date":"2026-06-12","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-12T01:44:02+08:00","date":"2026-06-12","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-12T01:44:02+08:00","date":"2026-06-12","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-12T01:44:02+08:00","date":"2026-06-12","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-12T01:44:02+08:00","date":"2026-06-12","gate":"review","result":"BLOCK","detail":"audit missing changed files"}
{"ts":"2026-06-12T01:44:02+08:00","date":"2026-06-12","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-12T01:44:02+08:00","date":"2026-06-12","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-12T01:44:02+08:00","date":"2026-06-12","gate":"summary","result":"BLOCK","detail":"6/7"}
{"ts":"2026-06-12T01:44:24+08:00","date":"2026-06-12","gate":"changelog","result":"PASS","detail":"2026-06-12-server-onboarding-automation.md 25lines"}
{"ts":"2026-06-12T01:44:24+08:00","date":"2026-06-12","gate":"audit","result":"PASS","detail":"2026-06-12-remove-browser-push-ui.md"}
{"ts":"2026-06-12T01:44:24+08:00","date":"2026-06-12","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-12T01:44:24+08:00","date":"2026-06-12","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-12T01:44:24+08:00","date":"2026-06-12","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-12T01:44:24+08:00","date":"2026-06-12","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-12T01:44:24+08:00","date":"2026-06-12","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-06-12T01:44:24+08:00","date":"2026-06-12","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-12T01:44:24+08:00","date":"2026-06-12","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-12T01:44:24+08:00","date":"2026-06-12","gate":"summary","result":"PASS","detail":"7/7"}
{"ts":"2026-06-12T01:47:53+08:00","date":"2026-06-12","gate":"changelog","result":"PASS","detail":"2026-06-12-terminal-server-rail-search-history.md 34lines"}
{"ts":"2026-06-12T01:47:53+08:00","date":"2026-06-12","gate":"audit","result":"PASS","detail":"2026-06-12-terminal-server-rail-search-history.md"}
{"ts":"2026-06-12T01:47:53+08:00","date":"2026-06-12","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-12T01:47:53+08:00","date":"2026-06-12","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-12T01:47:53+08:00","date":"2026-06-12","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-12T01:47:53+08:00","date":"2026-06-12","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-12T01:47:53+08:00","date":"2026-06-12","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-06-12T01:47:53+08:00","date":"2026-06-12","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-12T01:47:53+08:00","date":"2026-06-12","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-12T01:47:53+08:00","date":"2026-06-12","gate":"summary","result":"PASS","detail":"7/7"}
{"ts":"2026-06-12T01:51:46+08:00","date":"2026-06-12","gate":"changelog","result":"PASS","detail":"2026-06-12-push-remove-name-copy.md 29lines"}
{"ts":"2026-06-12T01:51:46+08:00","date":"2026-06-12","gate":"audit","result":"PASS","detail":"2026-06-12-push-remove-name-copy.md"}
{"ts":"2026-06-12T01:51:46+08:00","date":"2026-06-12","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-12T01:51:46+08:00","date":"2026-06-12","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-12T01:51:46+08:00","date":"2026-06-12","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-12T01:51:46+08:00","date":"2026-06-12","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-12T01:51:46+08:00","date":"2026-06-12","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-06-12T01:51:46+08:00","date":"2026-06-12","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-12T01:51:46+08:00","date":"2026-06-12","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-12T01:51:46+08:00","date":"2026-06-12","gate":"summary","result":"PASS","detail":"7/7"}
{"ts":"2026-06-12T01:58:39+08:00","date":"2026-06-12","gate":"changelog","result":"PASS","detail":"2026-06-12-terminal-search-center-empty-state.md 31lines"}
{"ts":"2026-06-12T01:58:39+08:00","date":"2026-06-12","gate":"audit","result":"PASS","detail":"2026-06-12-push-terminal-ui.md"}
{"ts":"2026-06-12T01:58:39+08:00","date":"2026-06-12","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-12T01:58:39+08:00","date":"2026-06-12","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-12T01:58:39+08:00","date":"2026-06-12","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-12T01:58:39+08:00","date":"2026-06-12","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-12T01:58:39+08:00","date":"2026-06-12","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-06-12T01:58:39+08:00","date":"2026-06-12","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-12T01:58:39+08:00","date":"2026-06-12","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-12T01:58:39+08:00","date":"2026-06-12","gate":"summary","result":"PASS","detail":"7/7"}
{"ts":"2026-06-12T02:03:52+08:00","date":"2026-06-12","gate":"changelog","result":"PASS","detail":"2026-06-12-search-history-isolated-scopes.md 35lines"}
{"ts":"2026-06-12T02:03:52+08:00","date":"2026-06-12","gate":"audit","result":"PASS","detail":"2026-06-12-search-history-isolated-scopes.md"}
{"ts":"2026-06-12T02:03:52+08:00","date":"2026-06-12","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-12T02:03:52+08:00","date":"2026-06-12","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-12T02:03:52+08:00","date":"2026-06-12","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-12T02:03:52+08:00","date":"2026-06-12","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-12T02:03:52+08:00","date":"2026-06-12","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-06-12T02:03:52+08:00","date":"2026-06-12","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-12T02:03:52+08:00","date":"2026-06-12","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-12T02:03:52+08:00","date":"2026-06-12","gate":"summary","result":"PASS","detail":"7/7"}
{"ts":"2026-06-12T02:55:32+08:00","date":"2026-06-12","gate":"changelog","result":"PASS","detail":"2026-06-12-server-watch-slots.md 54lines"}
{"ts":"2026-06-12T02:55:32+08:00","date":"2026-06-12","gate":"audit","result":"PASS","detail":"2026-06-12-server-watch-slots.md"}
{"ts":"2026-06-12T02:55:32+08:00","date":"2026-06-12","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-12T02:55:32+08:00","date":"2026-06-12","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-12T02:55:32+08:00","date":"2026-06-12","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-12T02:55:32+08:00","date":"2026-06-12","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-12T02:55:32+08:00","date":"2026-06-12","gate":"review","result":"BLOCK","detail":"audit missing changed files"}
{"ts":"2026-06-12T02:55:32+08:00","date":"2026-06-12","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-12T02:55:32+08:00","date":"2026-06-12","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-12T02:55:32+08:00","date":"2026-06-12","gate":"summary","result":"BLOCK","detail":"6/7"}
{"ts":"2026-06-12T02:56:25+08:00","date":"2026-06-12","gate":"changelog","result":"PASS","detail":"2026-06-12-server-watch-slots.md 54lines"}
{"ts":"2026-06-12T02:56:25+08:00","date":"2026-06-12","gate":"audit","result":"PASS","detail":"2026-06-12-server-watch-slots.md"}
{"ts":"2026-06-12T02:56:25+08:00","date":"2026-06-12","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-12T02:56:25+08:00","date":"2026-06-12","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-12T02:56:25+08:00","date":"2026-06-12","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-12T02:56:25+08:00","date":"2026-06-12","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-12T02:56:25+08:00","date":"2026-06-12","gate":"review","result":"BLOCK","detail":"audit missing changed files"}
{"ts":"2026-06-12T02:56:25+08:00","date":"2026-06-12","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-12T02:56:25+08:00","date":"2026-06-12","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-12T02:56:25+08:00","date":"2026-06-12","gate":"summary","result":"BLOCK","detail":"6/7"}
{"ts":"2026-06-12T02:56:43+08:00","date":"2026-06-12","gate":"changelog","result":"PASS","detail":"2026-06-12-server-watch-slots.md 54lines"}
{"ts":"2026-06-12T02:56:43+08:00","date":"2026-06-12","gate":"audit","result":"PASS","detail":"2026-06-12-server-watch-slots.md"}
{"ts":"2026-06-12T02:56:43+08:00","date":"2026-06-12","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-12T02:56:43+08:00","date":"2026-06-12","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-12T02:56:43+08:00","date":"2026-06-12","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-12T02:56:43+08:00","date":"2026-06-12","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-12T02:56:43+08:00","date":"2026-06-12","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-06-12T02:56:43+08:00","date":"2026-06-12","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-12T02:56:43+08:00","date":"2026-06-12","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-12T02:56:43+08:00","date":"2026-06-12","gate":"summary","result":"PASS","detail":"7/7"}
{"ts":"2026-06-12T03:08:34+08:00","date":"2026-06-12","gate":"changelog","result":"PASS","detail":"2026-06-12-server-watch-slots.md 54lines"}
{"ts":"2026-06-12T03:08:34+08:00","date":"2026-06-12","gate":"audit","result":"PASS","detail":"2026-06-12-server-watch-slots.md"}
{"ts":"2026-06-12T03:08:34+08:00","date":"2026-06-12","gate":"test","result":"BLOCK","detail":"tests failed"}
{"ts":"2026-06-12T03:08:34+08:00","date":"2026-06-12","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-12T03:08:34+08:00","date":"2026-06-12","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-12T03:08:34+08:00","date":"2026-06-12","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-12T03:08:34+08:00","date":"2026-06-12","gate":"review","result":"BLOCK","detail":"audit missing changed files"}
{"ts":"2026-06-12T03:08:34+08:00","date":"2026-06-12","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-12T03:08:34+08:00","date":"2026-06-12","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-12T03:08:34+08:00","date":"2026-06-12","gate":"summary","result":"BLOCK","detail":"5/7"}
{"ts":"2026-06-12T03:10:05+08:00","date":"2026-06-12","gate":"changelog","result":"PASS","detail":"2026-06-12-server-watch-slots.md 54lines"}
{"ts":"2026-06-12T03:10:05+08:00","date":"2026-06-12","gate":"audit","result":"PASS","detail":"2026-06-12-files-watch-ui-fixes.md"}
{"ts":"2026-06-12T03:10:05+08:00","date":"2026-06-12","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-12T03:10:05+08:00","date":"2026-06-12","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-12T03:10:05+08:00","date":"2026-06-12","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-12T03:10:05+08:00","date":"2026-06-12","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-12T03:10:05+08:00","date":"2026-06-12","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-06-12T03:10:05+08:00","date":"2026-06-12","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-12T03:10:05+08:00","date":"2026-06-12","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-12T03:10:05+08:00","date":"2026-06-12","gate":"summary","result":"PASS","detail":"7/7"}
{"ts":"2026-06-12T03:12:19+08:00","date":"2026-06-12","gate":"changelog","result":"PASS","detail":"2026-06-12-server-watch-slots.md 54lines"}
{"ts":"2026-06-12T03:12:19+08:00","date":"2026-06-12","gate":"audit","result":"PASS","detail":"2026-06-12-files-watch-ui-fixes.md"}
{"ts":"2026-06-12T03:12:19+08:00","date":"2026-06-12","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-12T03:12:19+08:00","date":"2026-06-12","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-12T03:12:19+08:00","date":"2026-06-12","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-12T03:12:19+08:00","date":"2026-06-12","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-12T03:12:19+08:00","date":"2026-06-12","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-06-12T03:12:19+08:00","date":"2026-06-12","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-12T03:12:19+08:00","date":"2026-06-12","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-12T03:12:19+08:00","date":"2026-06-12","gate":"summary","result":"PASS","detail":"7/7"}
{"ts":"2026-06-12T03:40:50+08:00","date":"2026-06-12","gate":"changelog","result":"PASS","detail":"2026-06-12-server-agent-action-hint.md 33lines"}
{"ts":"2026-06-12T03:40:50+08:00","date":"2026-06-12","gate":"audit","result":"PASS","detail":"2026-06-12-files-watch-ui-fixes.md"}
{"ts":"2026-06-12T03:40:50+08:00","date":"2026-06-12","gate":"test","result":"BLOCK","detail":"tests failed"}
{"ts":"2026-06-12T03:40:50+08:00","date":"2026-06-12","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-12T03:40:50+08:00","date":"2026-06-12","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-12T03:40:50+08:00","date":"2026-06-12","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-12T03:40:50+08:00","date":"2026-06-12","gate":"review","result":"BLOCK","detail":"audit missing changed files"}
{"ts":"2026-06-12T03:40:50+08:00","date":"2026-06-12","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-12T03:40:50+08:00","date":"2026-06-12","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-12T03:40:50+08:00","date":"2026-06-12","gate":"summary","result":"BLOCK","detail":"5/7"}
{"ts":"2026-06-12T03:41:07+08:00","date":"2026-06-12","gate":"changelog","result":"PASS","detail":"2026-06-12-server-agent-action-hint.md 33lines"}
{"ts":"2026-06-12T03:41:07+08:00","date":"2026-06-12","gate":"audit","result":"PASS","detail":"2026-06-12-files-watch-ui-fixes.md"}
{"ts":"2026-06-12T03:41:07+08:00","date":"2026-06-12","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-12T03:41:07+08:00","date":"2026-06-12","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-12T03:41:07+08:00","date":"2026-06-12","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-12T03:41:07+08:00","date":"2026-06-12","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-12T03:41:07+08:00","date":"2026-06-12","gate":"review","result":"BLOCK","detail":"audit missing changed files"}
{"ts":"2026-06-12T03:41:07+08:00","date":"2026-06-12","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-12T03:41:07+08:00","date":"2026-06-12","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-12T03:41:07+08:00","date":"2026-06-12","gate":"summary","result":"BLOCK","detail":"6/7"}
{"ts":"2026-06-12T03:42:10+08:00","date":"2026-06-12","gate":"changelog","result":"PASS","detail":"2026-06-12-server-agent-action-hint.md 33lines"}
{"ts":"2026-06-12T03:42:10+08:00","date":"2026-06-12","gate":"audit","result":"PASS","detail":"2026-06-12-files-watch-ui-fixes.md"}
{"ts":"2026-06-12T03:42:10+08:00","date":"2026-06-12","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-12T03:42:10+08:00","date":"2026-06-12","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-12T03:42:10+08:00","date":"2026-06-12","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-12T03:42:10+08:00","date":"2026-06-12","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-12T03:42:10+08:00","date":"2026-06-12","gate":"review","result":"BLOCK","detail":"audit missing changed files"}
{"ts":"2026-06-12T03:42:10+08:00","date":"2026-06-12","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-12T03:42:10+08:00","date":"2026-06-12","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-12T03:42:10+08:00","date":"2026-06-12","gate":"summary","result":"BLOCK","detail":"6/7"}
{"ts":"2026-06-12T03:48:00+08:00","date":"2026-06-12","gate":"changelog","result":"PASS","detail":"2026-06-12-watch-ttl-expiry-race-fix.md 35lines"}
{"ts":"2026-06-12T03:48:00+08:00","date":"2026-06-12","gate":"audit","result":"PASS","detail":"2026-06-12-watch-slot-monitoring-deploy.md"}
{"ts":"2026-06-12T03:48:00+08:00","date":"2026-06-12","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-12T03:48:00+08:00","date":"2026-06-12","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-12T03:48:00+08:00","date":"2026-06-12","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-12T03:48:00+08:00","date":"2026-06-12","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-12T03:48:00+08:00","date":"2026-06-12","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-06-12T03:48:00+08:00","date":"2026-06-12","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-12T03:48:00+08:00","date":"2026-06-12","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-12T03:48:00+08:00","date":"2026-06-12","gate":"summary","result":"PASS","detail":"7/7"}
{"ts":"2026-06-12T04:04:48+08:00","date":"2026-06-12","gate":"changelog","result":"PASS","detail":"2026-06-12-watch-install-psutil-ssh.md 40lines"}
{"ts":"2026-06-12T04:04:48+08:00","date":"2026-06-12","gate":"audit","result":"PASS","detail":"2026-06-12-watch-install-psutil-ssh.md"}
{"ts":"2026-06-12T04:04:48+08:00","date":"2026-06-12","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-12T04:04:48+08:00","date":"2026-06-12","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-12T04:04:48+08:00","date":"2026-06-12","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-12T04:04:48+08:00","date":"2026-06-12","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-12T04:04:48+08:00","date":"2026-06-12","gate":"review","result":"BLOCK","detail":"audit missing changed files"}
{"ts":"2026-06-12T04:04:48+08:00","date":"2026-06-12","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-12T04:04:48+08:00","date":"2026-06-12","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-12T04:04:48+08:00","date":"2026-06-12","gate":"summary","result":"BLOCK","detail":"6/7"}
{"ts":"2026-06-12T04:04:59+08:00","date":"2026-06-12","gate":"changelog","result":"PASS","detail":"2026-06-12-watch-install-psutil-ssh.md 40lines"}
{"ts":"2026-06-12T04:04:59+08:00","date":"2026-06-12","gate":"audit","result":"PASS","detail":"2026-06-12-watch-install-psutil-ssh.md"}
{"ts":"2026-06-12T04:04:59+08:00","date":"2026-06-12","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-12T04:04:59+08:00","date":"2026-06-12","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-12T04:04:59+08:00","date":"2026-06-12","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-12T04:04:59+08:00","date":"2026-06-12","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-12T04:04:59+08:00","date":"2026-06-12","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-06-12T04:04:59+08:00","date":"2026-06-12","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-12T04:04:59+08:00","date":"2026-06-12","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-12T04:04:59+08:00","date":"2026-06-12","gate":"summary","result":"PASS","detail":"7/7"}
{"ts":"2026-06-12T04:09:10+08:00","date":"2026-06-12","gate":"changelog","result":"PASS","detail":"2026-06-12-psutil-install-sudo-fix.md 19lines"}
{"ts":"2026-06-12T04:09:10+08:00","date":"2026-06-12","gate":"audit","result":"PASS","detail":"2026-06-12-psutil-install-sudo-fix.md"}
{"ts":"2026-06-12T04:09:10+08:00","date":"2026-06-12","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-12T04:09:10+08:00","date":"2026-06-12","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-12T04:09:10+08:00","date":"2026-06-12","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-12T04:09:10+08:00","date":"2026-06-12","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-12T04:09:10+08:00","date":"2026-06-12","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-06-12T04:09:10+08:00","date":"2026-06-12","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-12T04:09:10+08:00","date":"2026-06-12","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-12T04:09:10+08:00","date":"2026-06-12","gate":"summary","result":"PASS","detail":"7/7"}
{"ts":"2026-06-12T04:16:46+08:00","date":"2026-06-12","gate":"changelog","result":"PASS","detail":"2026-06-12-audit-log-server-target-name.md 18lines"}
{"ts":"2026-06-12T04:16:46+08:00","date":"2026-06-12","gate":"audit","result":"PASS","detail":"2026-06-12-audit-log-server-target-name.md"}
{"ts":"2026-06-12T04:16:46+08:00","date":"2026-06-12","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-12T04:16:46+08:00","date":"2026-06-12","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-12T04:16:46+08:00","date":"2026-06-12","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-12T04:16:46+08:00","date":"2026-06-12","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-12T04:16:46+08:00","date":"2026-06-12","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-06-12T04:16:46+08:00","date":"2026-06-12","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-12T04:16:46+08:00","date":"2026-06-12","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-12T04:16:46+08:00","date":"2026-06-12","gate":"summary","result":"PASS","detail":"7/7"}
{"ts":"2026-06-12T04:21:16+08:00","date":"2026-06-12","gate":"changelog","result":"PASS","detail":"2026-06-12-watch-slot-hover-border.md 15lines"}
{"ts":"2026-06-12T04:21:16+08:00","date":"2026-06-12","gate":"audit","result":"PASS","detail":"2026-06-12-audit-log-server-target-name.md"}
{"ts":"2026-06-12T04:21:16+08:00","date":"2026-06-12","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-12T04:21:16+08:00","date":"2026-06-12","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-12T04:21:16+08:00","date":"2026-06-12","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-12T04:21:16+08:00","date":"2026-06-12","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-12T04:21:16+08:00","date":"2026-06-12","gate":"review","result":"BLOCK","detail":"audit missing changed files"}
{"ts":"2026-06-12T04:21:16+08:00","date":"2026-06-12","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-12T04:21:16+08:00","date":"2026-06-12","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-12T04:21:16+08:00","date":"2026-06-12","gate":"summary","result":"BLOCK","detail":"6/7"}
{"ts":"2026-06-12T04:21:28+08:00","date":"2026-06-12","gate":"changelog","result":"PASS","detail":"2026-06-12-watch-slot-hover-border.md 15lines"}
{"ts":"2026-06-12T04:21:28+08:00","date":"2026-06-12","gate":"audit","result":"PASS","detail":"2026-06-12-watch-slot-hover-border.md"}
{"ts":"2026-06-12T04:21:28+08:00","date":"2026-06-12","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-12T04:21:28+08:00","date":"2026-06-12","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-12T04:21:28+08:00","date":"2026-06-12","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-12T04:21:28+08:00","date":"2026-06-12","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-12T04:21:28+08:00","date":"2026-06-12","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-06-12T04:21:28+08:00","date":"2026-06-12","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-12T04:21:28+08:00","date":"2026-06-12","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-12T04:21:28+08:00","date":"2026-06-12","gate":"summary","result":"PASS","detail":"7/7"}
{"ts":"2026-06-12T04:32:30+08:00","date":"2026-06-12","gate":"changelog","result":"PASS","detail":"2026-06-12-watch-slot-ttl-metrics-ui.md 43lines"}
{"ts":"2026-06-12T04:32:30+08:00","date":"2026-06-12","gate":"audit","result":"PASS","detail":"2026-06-12-watch-slot-hover-border.md"}
{"ts":"2026-06-12T04:32:30+08:00","date":"2026-06-12","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-12T04:32:30+08:00","date":"2026-06-12","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-12T04:32:30+08:00","date":"2026-06-12","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-12T04:32:30+08:00","date":"2026-06-12","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-12T04:32:30+08:00","date":"2026-06-12","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-06-12T04:32:30+08:00","date":"2026-06-12","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-12T04:32:30+08:00","date":"2026-06-12","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-12T04:32:30+08:00","date":"2026-06-12","gate":"summary","result":"PASS","detail":"7/7"}
{"ts":"2026-06-12T06:49:23+08:00","date":"2026-06-12","gate":"changelog","result":"PASS","detail":"2026-06-12-browser-top-appbar.md 19lines"}
{"ts":"2026-06-12T06:49:23+08:00","date":"2026-06-12","gate":"audit","result":"PASS","detail":"2026-06-12-audit-log-server-target-name.md"}
{"ts":"2026-06-12T06:49:23+08:00","date":"2026-06-12","gate":"test","result":"BLOCK","detail":"tests failed"}
{"ts":"2026-06-12T06:49:23+08:00","date":"2026-06-12","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-12T06:49:23+08:00","date":"2026-06-12","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-12T06:49:23+08:00","date":"2026-06-12","gate":"security","result":"BLOCK","detail":"3 HIGH findings"}
{"ts":"2026-06-12T06:49:23+08:00","date":"2026-06-12","gate":"review","result":"BLOCK","detail":"audit missing changed files"}
{"ts":"2026-06-12T06:49:23+08:00","date":"2026-06-12","gate":"shell_eol","result":"BLOCK","detail":"CR in tracked sh"}
{"ts":"2026-06-12T06:49:23+08:00","date":"2026-06-12","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-12T06:49:23+08:00","date":"2026-06-12","gate":"summary","result":"BLOCK","detail":"4/7"}
{"ts":"2026-06-12T06:50:36+08:00","date":"2026-06-12","gate":"changelog","result":"PASS","detail":"2026-06-12-browser-top-appbar.md 19lines"}
{"ts":"2026-06-12T06:50:36+08:00","date":"2026-06-12","gate":"audit","result":"PASS","detail":"2026-06-12-remote-browser-worker.md"}
{"ts":"2026-06-12T06:50:36+08:00","date":"2026-06-12","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-12T06:50:36+08:00","date":"2026-06-12","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-12T06:50:36+08:00","date":"2026-06-12","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-12T06:50:36+08:00","date":"2026-06-12","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-12T06:50:36+08:00","date":"2026-06-12","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-06-12T06:50:36+08:00","date":"2026-06-12","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-12T06:50:36+08:00","date":"2026-06-12","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-12T06:50:36+08:00","date":"2026-06-12","gate":"summary","result":"PASS","detail":"7/7"}
{"ts":"2026-06-13T01:43:01+08:00","date":"2026-06-13","gate":"changelog","result":"PASS","detail":"2026-06-13-offline-alert-telegram-split.md 37lines"}
{"ts":"2026-06-13T01:43:01+08:00","date":"2026-06-13","gate":"audit","result":"BLOCK","detail":"file not found"}
{"ts":"2026-06-13T01:43:01+08:00","date":"2026-06-13","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-13T01:43:01+08:00","date":"2026-06-13","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-13T01:43:01+08:00","date":"2026-06-13","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-13T01:43:01+08:00","date":"2026-06-13","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-13T01:43:01+08:00","date":"2026-06-13","gate":"review","result":"BLOCK","detail":"no audit file"}
{"ts":"2026-06-13T01:43:01+08:00","date":"2026-06-13","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-13T01:43:01+08:00","date":"2026-06-13","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-13T01:43:01+08:00","date":"2026-06-13","gate":"summary","result":"BLOCK","detail":"5/7"}
{"ts":"2026-06-13T01:45:17+08:00","date":"2026-06-13","gate":"changelog","result":"PASS","detail":"2026-06-13-offline-alert-telegram-split.md 37lines"}
{"ts":"2026-06-13T01:45:17+08:00","date":"2026-06-13","gate":"audit","result":"PASS","detail":"2026-06-13-offline-quick-cmd-telegram.md"}
{"ts":"2026-06-13T01:45:17+08:00","date":"2026-06-13","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-13T01:45:17+08:00","date":"2026-06-13","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-13T01:45:17+08:00","date":"2026-06-13","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-13T01:45:17+08:00","date":"2026-06-13","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-13T01:45:17+08:00","date":"2026-06-13","gate":"review","result":"PASS","detail":"audit covers changes"}
{"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"}
@@ -0,0 +1,35 @@
# 审计 — 文件管理「新建文件」修复
**Changelog**: `docs/changelog/2026-06-10-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 — 无 v-html;错误经 snackbar |
| `asyncssh_pool.py` | SAFE — 复用已有 `_ensure_remote_parent_dir` |
## DoD
- [x] changelog + 本审计
- [x] frontend type-check
- [ ] 生产:文件管理新建文件 → 编辑器打开
@@ -0,0 +1,20 @@
# 审计 — 审计日志目标列显示服务器名称
**日期**2026-06-12
**Changelog**: `docs/changelog/2026-06-12-audit-log-server-target-name.md`
## 范围
| 文件 |
|------|
| `server/api/settings.py` |
| `frontend/src/pages/AuditPage.vue` |
| `frontend/src/utils/auditLabels.ts` |
| `frontend/src/utils/auditNormalize.ts` |
| `frontend/src/types/api.ts` |
| `tests/integration/test_alerts_audit.py` |
## Step 3 / Closure / DoD
- PASS — 只读增强,批量 IN 查 name
- DoD: integration test passed
@@ -0,0 +1,142 @@
# 审计 — 远程浏览器(Playwright Worker
**Changelog**: `docs/changelog/2026-06-12-remote-browser-worker-deploy.md`
**设计 SSOT**: `docs/design/specs/2026-06-11-server-browser-chromium-design.md`
**范围**: 2026-06-12 新增/恢复的浏览器 Worker 全链路(未含 `web/app` 构建产物)
## 范围(文件清单)
| 文件 | 职责 |
|------|------|
| `browser-worker/Dockerfile` | Worker 镜像 |
| `browser-worker/docker-compose.yml` | Worker 编排 |
| `browser-worker/docker-entrypoint.sh` | headed + Xvfb 入口 |
| `browser-worker/requirements.txt` | Worker 依赖 |
| `browser-worker/config.py` | Worker 环境配置 |
| `browser-worker/main.py` | Worker HTTP/WS API |
| `browser-worker/session_manager.py` | Playwright + screencast |
| `browser-worker/browser_stealth.py` | 反自动化 / 验证码辅助 |
| `browser-worker/browser_url_safe.py` | SSRFWorker 侧) |
| `server/main.py` | 注册 browser 路由 |
| `server/config.py` | `BROWSER_*` 与 TLS verify |
| `server/api/settings.py` | 设置项默认值(push_complete_sound |
| `server/api/browser_session.py` | 桥接 REST + `/ws/browser` |
| `server/api/browser.py` | UI 状态 `/api/browser/state` |
| `server/infrastructure/browser/__init__.py` | 包入口 |
| `server/infrastructure/browser/worker_client.py` | 连 Worker(可配置 TLS verify |
| `server/infrastructure/browser/session_registry.py` | 内存会话归属 |
| `server/utils/browser_url_safe.py` | SSRF(桥接层) |
| `server/utils/browser_ui_state.py` | 状态解析 |
| `frontend/src/App.vue` | 顶栏 + 固定 GlobalBrowserPanel |
| `frontend/src/router/index.ts` | `/browser` 路由 |
| `frontend/src/pages/ServersPage.vue` | 服务器页打开浏览器 |
| `frontend/src/pages/BrowserPage.vue` | 独立浏览器页(兼容) |
| `frontend/src/components/GlobalBrowserPanel.vue` | 固定顶栏下整窗 |
| `frontend/src/composables/useGlobalBrowser.ts` | 全局标签/状态 |
| `frontend/src/composables/useRemoteBrowserSession.ts` | canvas + WS |
| `tests/test_browser_url_safe.py` | SSRF 单测 |
| `tests/test_browser_ui_state.py` | UI 状态单测 |
| `tests/test_settings_sound_defaults.py` | 设置默认音单测 |
## API / WS 入口表
| 方法 | 路径 | 鉴权 |
|------|------|------|
| GET | `/api/browser/status` | JWT `get_current_admin` |
| POST | `/api/browser/sessions` | JWT |
| DELETE | `/api/browser/sessions/{id}` | JWT + 会话归属 |
| GET/PUT | `/api/browser/state` | JWT + `admin_ui_preferences` |
| WS | `/ws/browser/{session_id}?token=` | JWT + 会话归属 |
| GET | Worker `/health` | **无** |
| POST/DELETE | Worker `/v1/sessions*` | `X-Nexus-Worker-Key` |
| WS | Worker `/v1/sessions/{id}/stream` | Worker KeyHeader |
## Step 3 规则扫描
| ID | 规则 | 结论 |
|----|------|------|
| H1 | SSRF / 私网访问 | **RISK** — 见 F1、F2 |
| H2 | 鉴权 / IDOR | PASS — WS 校验 admin_id |
| H3 | 密钥不落库/不入 git | PASS — `.env` / 持久卷 |
| H4 | 无静默吞错 | **RISK** — 见 F4 |
| H5 | Worker 暴露面 | **RISK** — 见 F3 |
| H6 | 多 worker 一致性 | **RISK** — 见 F5 |
| H7 | UI 状态 URL 白名单 | PASS — `browser_ui_state` |
| H8 | 设计符合度(redirect | **GAP** — 见 F1 |
## Closure(全表)
| ID | 判定 | 严重度 | 位置 | 说明 |
|----|------|--------|------|------|
| H1 | FINDING | **P1** | `browser-worker/session_manager.py` NAVIGATE 后 | **F1** 页内点击/重定向未逐跳 SSRF 校验;设计文档要求 redirect 后重验 |
| H1 | FINDING | **P1** | Worker 部署 ufw | **F3** 装机时额外 `ufw allow 8443/tcp`(全网),削弱「仅主站 IP」隔离 |
| H2 | SAFE | — | `browser_session.py:125-127` | `session_id` 必须属于当前 `admin.id` |
| H2 | SAFE | — | `browser_session.py:95-104` | 桥接层 NAVIGATE 二次校验 |
| H3 | SAFE | — | `worker_client.py`, Worker `.env` | `WORKER_SECRET` 仅环境变量 |
| H4 | FINDING | P2 | `worker_client.py:67-68` | **F4** `delete_worker_session` 网络失败仅 warning,可能残留 Worker 会话 |
| H5 | FINDING | P2 | `browser-worker/main.py:45-51` | `/health` 无认证;若 8443 对公网可见会泄露会话数 |
| H6 | FINDING | P2 | `session_registry.py` | **F5** 内存注册表不跨 uvicorn worker;多进程下 WS 可能 4403 |
| H7 | SAFE | — | `browser_ui_state.py` | http(s) only,无凭据 URL |
| H8 | SAFE | — | screencast 热修 | `cdp.on("Page.screencastFrame")` 已替代错误 API |
| — | SAFE | — | `browser_session.py:61-64` | 创建前清理陈旧会话,避免 409 |
| — | SAFE | — | 前端 | 不再 iframecanvas 远程渲染 |
| — | SAFE | — | CUD 审计 | 首版未记 session 审计,与设计「与 RDP 一致」一致 |
### F1 — 重定向 / 页内导航 SSRF 缺口(P1)
`validate_navigation_url` 仅在显式 `NAVIGATE` 消息时调用。用户在远程 Chromium 内**点击链接**或 **302 跳转**到 `http://10.x` / metadata 时,Playwright 会照常请求,**不经过**校验。
**建议**`page.on("framenavigated")` / `response` 监听,每跳对 `page.url` 调用 `validate_navigation_url`;失败则 `page.close()``about:blank` 并 WS `ERROR`
### F3 — Worker 防火墙过宽(P1
部署日志显示除 `from 20.24.218.235` 外还有 `ufw allow 8443/tcp`Anywhere)。攻击者若猜到 `WORKER_SECRET`(或暴力)可直连 Worker。
**建议**:删除全网 8443 规则,仅保留主站源 IP;`/health` 改内网或加 Key。
### F4 — Worker 删除失败(P2
`delete_worker_session` 异常时静默;依赖 Worker 空闲超时清理。
### F5 — 多 uvicorn workerP2
`session_registry` 进程内字典;生产若 `--workers>1` 可能 POST 在 worker A、WS 落到 worker B。当前 Docker 单进程可接受;扩 worker 时需 Redis 注册表。
## 外部输入 → Sink
| 输入 | Sink | 缓解 |
|------|------|------|
| `NAVIGATE.url` | Playwright `goto` | 双端 `validate_navigation_url` |
| 页内链接点击 | Playwright 导航 | **未缓解(F1** |
| WS `MOUSE`/`KEY` | Chromium | 仅已认证管理员 |
| `browser/state` tabs URL | MySQL JSON | `_is_safe_url` 过滤 |
| Worker Key | Worker API | 常量时间比较(`!=` |
## DoD
| 项 | 状态 |
|----|------|
| `pytest tests/test_browser_url_safe.py tests/test_browser_ui_state.py` | 7 passed(生产机);本地沙箱 DNS 失败属环境限制 |
| `npm run type-check` | 已通过(实现日) |
| 生产 `GET /api/browser/status` | `enabled:true`, `worker_ok:true` |
| 生产 screencast 冒烟 | `SCREENCAST_OK` ~4KB JPEG |
| changelog | `docs/changelog/2026-06-12-remote-browser-worker-deploy.md` |
## 总结
| 级别 | 数量 | 处置建议 |
|------|------|----------|
| P0 | 0 | — |
| P1 | 2 | F1 redirect/页内 SSRFF3 收紧 ufw |
| P2 | 3 | F4/F5 可排期;/health 加固 |
**合并结论**:功能链路可用,鉴权与显式 NAVIGATE SSRF 达标;**未达设计文档「每跳 redirect 校验」**,且 Worker **8443 应对公网收口**。建议先修 F3(运维),再实现 F1(代码)后复审计。
## 验证命令
```bash
venv/bin/pytest tests/test_browser_url_safe.py tests/test_browser_ui_state.py -q
cd frontend && npm run type-check
# 生产
curl -H "Authorization: Bearer $TOKEN" https://api.synaglobal.vip/api/browser/status
```
@@ -0,0 +1,15 @@
# 审计 — 监测槽 hover 外框高亮
**日期**2026-06-12
**Changelog**: `docs/changelog/2026-06-12-watch-slot-hover-border.md`
## 范围
| 文件 |
|------|
| `frontend/src/components/watch/WatchSlotCard.vue` |
## Step 3 / Closure / DoD
- PASS — 纯 CSS,无逻辑/安全风险
- DoD: 浏览器悬停仅边框变色
@@ -0,0 +1,50 @@
# 审计 — 快捷命令回车修复 + 离线告警 Telegram 分流
**Changelog**:
- `docs/changelog/2026-06-13-terminal-quick-cmd-enter-fix.md`
- `docs/changelog/2026-06-13-offline-alert-telegram-split.md`
**设计/计划**:
- `docs/design/specs/2026-06-13-offline-alert-telegram-split-design.md`
- `docs/design/plans/2026-06-13-offline-alert-telegram-split.md`
## 审计范围
| 文件 | 变更 | 状态 |
|------|------|------|
| `frontend/src/composables/useTerminalQuickCommands.ts` | `formatQuickCmdForSend` | ☑ |
| `frontend/src/composables/terminal/useTerminalSessions.ts` | `execQuickCmd` | ☑ |
| `frontend/src/components/TerminalQuickCommandsSettings.vue` | 保存不再 append `\r` | ☑ |
| `frontend/src/pages/SettingsPage.vue` | 离线专用 Bot UI | ☑ |
| `frontend/src/types/api.ts` | 离线开关文案 | ☑ |
| `frontend/src/utils/auditLabels.ts` | reveal 审计标签 | ☑ |
| `server/infrastructure/telegram/__init__.py` | 离线路由 | ☑ |
| `server/api/settings.py` | offline API + `_telegram_fetch_chats` | ☑ |
| `server/config.py` | `TELEGRAM_OFFLINE_*` | ☑ |
| `tests/test_telegram_offline_channel.py` | 新 | ☑ |
| `tests/test_terminal_quick_commands.py` | 新 | ☑ |
## Step 3 规则扫描
| H | 规则 | 结论 |
|---|------|------|
| H1 | 离线 Token 敏感掩码 | SAFE — `SENSITIVE_KEYS` |
| H2 | reveal 审计 | SAFE — `reveal_offline_telegram_token` |
| H3 | 半配离线回退默认 Bot | SAFE — `resolve_offline_telegram_credentials` |
| H4 | 资源告警不走离线 Bot | SAFE — `send_telegram_alert` 未改 |
| H5 | 快捷命令 `\r``trim()` | SAFE — 发送时 `formatQuickCmdForSend` |
| H6 | Layer3 不同步离线 Bot | SAFE — 设计 intentional |
## Closure
| H | 判定 | 依据 |
|---|------|------|
| H1H6 | SAFE | 代码 + pytest 10/10 + `test_ops_patrol_sync` 未破坏 |
## DoD
- [x] `pytest tests/test_telegram_offline_channel.py tests/test_terminal_quick_commands.py` passed
- [x] `bash scripts/local_verify.sh` 26/26
- [x] changelog / design / audit
- [x] 无 SECRET_KEY/API_KEY/ENCRYPTION_KEY/DATABASE_URL 变更
- [x] 前端 `vite build` 待部署脚本执行
@@ -0,0 +1,33 @@
# 审计 — Telegram 群聊 Chat ID 检测
**Changelog**: `docs/changelog/2026-06-13-telegram-chat-id-group-detect.md`
## 审计范围
| 文件 | 变更 | 状态 |
|------|------|------|
| `server/api/settings.py` | `_extract_chats_from_telegram_updates`、群聊更新类型 | ☑ |
| `frontend/src/pages/SettingsPage.vue` | 群聊检测说明与类型标签 | ☑ |
| `tests/test_settings_telegram.py` | my_chat_member / callback 单测 | ☑ |
## Step 3 规则扫描
| H | 规则 | 结论 |
|---|------|------|
| H1 | Token 仍经既有 settings 鉴权 | SAFE — 未改 auth |
| H2 | getUpdates 仍先清 webhook | SAFE — `_telegram_clear_webhook_if_set` |
| H3 | 不泄露其他用户 chat | SAFE — 仅返回去重 chat 元数据 |
| H4 | 群聊隐私模式说明 | SAFE — UI 提示 `/start@Bot` |
## Closure
| H | 判定 | 依据 |
|---|------|------|
| H1H4 | SAFE | `pytest tests/test_settings_telegram.py` 8/8 |
## DoD
- [x] `pytest tests/test_settings_telegram.py` passed
- [x] changelog / audit
- [x] 无密钥类 settings 变更
- [x] 前端 build 由部署脚本执行
@@ -0,0 +1,25 @@
# 2026-06-10 — 文件管理「新建文件」修复
## 摘要
修复文件页「新建文件」点击无反馈或创建后编辑器不弹出的问题。
## 动机
- 未选服务器时按钮被禁用,用户以为功能坏了
- 创建成功后未等待 `FileEditorWorkbench` 就绪即 `openFile`,编辑器静默不打开
- SFTP 写入前未确保父目录存在(部分路径下 write-file 失败)
## 涉及文件
- `frontend/src/composables/files/useFilesActions.ts``openNewFileDialog``waitForEditorWorkbench`
- `frontend/src/composables/files/useFilesEditor.ts` — 导出 `waitForEditorWorkbench`
- `frontend/src/components/files/FilesToolbar.vue` — 未选服务器时提示而非禁用
- `server/infrastructure/ssh/asyncssh_pool.py` — SFTP 写前 `mkdir -p` 父目录
## 验证
```bash
cd frontend && npm run type-check
# 文件管理 → 选服务器 → 新建文件 → 应创建并打开编辑器
```
@@ -46,3 +46,7 @@ cd frontend && npm run type-check
## 说明 ## 说明
iframe 在用户当前的 Chrome/Firefox 等引擎中渲染,非独立安装 Firefox;禁止嵌入的站点请用「新标签打开」。 iframe 在用户当前的 Chrome/Firefox 等引擎中渲染,非独立安装 Firefox;禁止嵌入的站点请用「新标签打开」。
## 待办(已记录需求)
- **网站导航 / 收藏**:用户可自建固定站点列表,见 [设计说明](../design/specs/2026-06-11-browser-nav-bookmarks-design.md) · [实施计划](../design/plans/2026-06-11-browser-nav-bookmarks.md)(后端 `nav_links` 已预留,前端 UI 待做)
@@ -0,0 +1,18 @@
# Changelog — 审计日志目标列显示服务器名称
**日期**2026-06-12
## 摘要
审计页「目标」列对 `target_type=server` 显示 **服务器名称**(如 `服务器 web-01`),不再仅显示 `#372`
## 变更
- API `/api/audit/` 列表项增加 `target_name`(批量查 `servers.name`
- 前端 `auditTargetText()` + `AuditPage` 目标列
## 验证
```bash
.venv/bin/pytest tests/integration/test_alerts_audit.py::test_audit_logs_server_target_name -q
```
@@ -0,0 +1,48 @@
# 2026-06-12 远程浏览器 — 验证码 / 反自动化优化
## 摘要
针对阿里云等站点验证码失败:Worker 改为 headed + Xvfb + 反自动化指纹;前端滑块拖动支持窗口级鼠标跟踪与平滑步进。
## 动机
用户反馈阿里云验证码过不去。根因非 Nexus URL 拦截,而是 headless Chromium 被风控识别,且拖滑块时鼠标移出 canvas 会丢失 move 事件。
## 涉及文件
| 区域 | 文件 |
|------|------|
| Worker | `browser_stealth.py`, `session_manager.py`, `config.py`, `Dockerfile`, `docker-entrypoint.sh`, `docker-compose.yml` |
| 前端 | `useRemoteBrowserSession.ts`, `GlobalBrowserPanel.vue` |
## 变更要点
- 去掉 `--enable-automation``disable-blink-features=AutomationControlled`init script 隐藏 `navigator.webdriver`
- 默认 `WORKER_HEADLESS=false`,容器内 Xvfb `:99`
- 中文 locale / Asia/Shanghai / 常见 Chrome UA
- Screencast JPEG quality 90
- MOUSE`down/up` 先 move 到位;`move` 支持 `steps`;新增 `click`
- 前端:mousedown 后 `window` 级 mousemove/mouseup,滑块拖出画面仍有效
## 部署
```bash
# Worker
cd /opt/nexus-browser-worker && docker compose up -d --build
# 前端
cd frontend && npx vite build
# sync_webapp_to_container.sh
```
Worker `.env` 可选:`WORKER_HEADLESS=false`(默认)、`WORKER_SCREENCAST_QUALITY=90`
## 验证
- 打开 `https://account.aliyun.com` 或控制台登录页,滑块可拖到底
- 仍失败时:Worker IP 为机房段,阿里云可能加强风控 — 可本机 Chrome 登录后仅浏览公网页,或换住宅 IP Worker
## 残余限制
- 无法保证 100% 通过所有风控(拼图、短信、设备指纹)
- Cookie 在 Worker Profile,与本机浏览器不共享
@@ -0,0 +1,47 @@
# 2026-06-12 远程浏览器 P0/P1 修复与生产同步
## 摘要
修复 Worker 僵尸 reservation 占槽;前端 `vite build` 同步生产;WS 连接失败时主动 DELETE 会话。
## 动机
Bug 巡查 #1#3:生产无浏览器 SPA(用户连不上);Worker `sessions:1``ready=false` 不进 idle 清理。
## 变更
| 区域 | 内容 |
|------|------|
| `browser-worker/config.py` | `WORKER_RESERVE_TIMEOUT_SEC=120` |
| `browser-worker/session_manager.py` | idle 清理未 attach 的 reservation |
| `browser-worker/main.py` | `attach_stream` RuntimeError 时 `destroy_session` |
| `frontend/.../useRemoteBrowserSession.ts` | WS/未登录失败时 `DELETE /browser/sessions/{id}` |
| 生产 `web/app` | `index-BimRgA6s.js` + BrowserPage 等已 `sync_webapp_to_container.sh` |
| Worker `66.154.115.131` | `docker compose up -d --build``/health``sessions:0` |
## 迁移 / 重启
- 无 DB 迁移
- Worker 已重建;主站仅同步 `web/app`(未重建 API 镜像)
## 验证
```bash
pytest tests/test_browser_url_safe.py tests/test_browser_ui_state.py -q
cd frontend && npm run type-check && npx vite build
curl https://api.synaglobal.vip/app/ # index-BimRgA6s.js
ssh nexus 'sudo docker exec nexus-prod-nexus-1 grep -rl ws/browser /app/web/app/assets/ | wc -l' # ≥1
curl http://66.154.115.131:8443/health # sessions:0
```
浏览器终验:登录 → 左下角地球 → `https://example.com` 有 canvas 画面。
## 未在本批修复
- F1 页内导航 SSRF`framenavigated`
- F3 Worker ufw 公网 8443
- P2 前进/后退 stack 与 Chromium 历史不同步
## 回滚
恢复上一版 `web/app` tarWorker `git checkout` + `docker compose up -d --build`(或保留 idle 修复)。
@@ -0,0 +1,19 @@
# 2026-06-12 浏览器固定顶栏(原悬浮窗)
## 摘要
保留 **原 GlobalBrowserPanel 完整 UI**(标签、地址栏、canvas),固定在 App Bar 正下方全宽展示;移除地球图标、拖动、最小化、缩放角。账号仍在 App Bar 最右侧,**不**把地址栏拆进 toolbar。
## 动机
用户要的是原悬浮窗形态固定置顶,而非顶栏内嵌地址栏。
## 涉及文件
- `frontend/src/App.vue` — 顶栏仅账号;`appChromeVars` 为浏览器预留 420px
- `frontend/src/components/GlobalBrowserPanel.vue` — 固定 `top: 64px` 全宽,无拖动/地球图标
- 删除误加的 `GlobalBrowserAppBar.vue``GlobalBrowserDock.vue``GlobalBrowserChromeHost.vue``useGlobalBrowserChrome.ts`
## 验证
`cd frontend && npm run type-check && npx vite build`;登录后 App Bar 下见完整浏览器窗(标签+地址栏+画面);账号在顶栏最右。
@@ -0,0 +1,24 @@
# 文件下载修复 + 文件/终端列表紧凑样式
**日期**: 2026-06-12
## 变更摘要
- **下载**:修复 SFTP 会话在流式响应开始前关闭导致下载失败;下载时显示 loading
- **文件列表**:表格 `compact` + 操作按钮与服务器页一致的 `text/x-small`
- **终端右侧列表**:行高与字号收紧,与服务器列表密度接近
## 涉及文件
- `server/api/sync_v2.py`
- `frontend/src/components/files/FilesList.vue`
- `frontend/src/composables/files/useFilesActions.ts`
- `frontend/src/components/terminal/TerminalServerPickerList.vue`
## 迁移 / 重启
- 需 API 重启(下载逻辑在后端)
## 验证
`pytest` 相关 sync 测试;文件页点击下载应保存文件;终端/文件列表行高与服务器页接近。
@@ -0,0 +1,22 @@
# 文件管理移除下载入口
**日期**: 2026-06-12
## 变更摘要
移除文件管理页行内「下载」按钮与右键菜单项;大文件提示改为仅建议使用终端。
## 动机
产品决策:文件管理不提供浏览器下载,避免大文件与 SFTP 流式链路问题。
## 涉及文件
- `frontend/src/components/files/FilesList.vue`
- `frontend/src/composables/files/useFilesActions.ts`
- `frontend/src/composables/files/useFilesPage.ts`
- `frontend/src/composables/files/useFilesEditor.ts`
## 验证
`npm run type-check`;文件页无下载按钮;右键菜单无下载项。
@@ -0,0 +1,69 @@
# 2026-06-12 远程浏览器(Playwright Worker)上线
## 摘要
**66.154.115.131** 部署 `browser-worker`Playwright Chromium),Nexus 主站桥接 WebSocket + canvas 浮动面板,替代已移除的 iframe 方案。
## 动机
运维需在面板内完整浏览公网站点(不受 X-Frame-Options 限制);Chromium 运行在独立 Worker,隔离 SSRF 与内存压力。
## 涉及文件
| 区域 | 文件 |
|------|------|
| Worker | `browser-worker/*` |
| 后端 | `server/api/browser.py`, `browser_session.py`, `server/infrastructure/browser/*`, `server/utils/browser_url_safe.py`, `server/utils/browser_ui_state.py`, `server/config.py`, `server/main.py` |
| 前端 | `GlobalBrowserPanel.vue`, `useGlobalBrowser.ts`, `useRemoteBrowserSession.ts`, `BrowserPage.vue`, `App.vue`, `router`, `ServersPage.vue` |
| 测试 | `tests/test_browser_url_safe.py`, `tests/test_browser_ui_state.py` |
## 部署拓扑
```
管理员 SPA → Nexus (20.24.218.235) → Worker (66.154.115.131:8443) → 公网
```
## 生产配置(勿提交 git
主站 `/var/lib/nexus/.env``docker/.env.prod` 均需:
```env
NEXUS_BROWSER_ENABLED=1
NEXUS_BROWSER_WORKER_URL=http://66.154.115.131:8443
NEXUS_BROWSER_WORKER_SECRET=<与 Worker .env WORKER_SECRET 相同>
NEXUS_BROWSER_MAX_SESSIONS=2
NEXUS_BROWSER_IDLE_TIMEOUT_SEC=1800
```
Worker`/opt/nexus-browser-worker/.env``WORKER_SECRET` 同上)
防火墙:Worker `8443` 已对 Nexus 主站 IP `20.24.218.235` 放行。
## 迁移 / 重启
- 无 DB 迁移(复用 `admin_ui_preferences` / `embedded_browser`
- Worker`docker compose up -d --build`
- 主站:重建/重启 `nexus-prod-nexus-1` + 同步 `web/app`
## 验证
```bash
venv/bin/pytest tests/test_browser_url_safe.py tests/test_browser_ui_state.py -q
cd frontend && npm run type-check && npx vite build
curl -H "Authorization: Bearer $TOKEN" https://api.synaglobal.vip/api/browser/status
# → enabled:true, worker_ok:true
```
浏览器:左下角地球按钮 / 侧栏「浏览器」;输入 `https://example.com` 应出现 canvas 画面。
## 回滚
`NEXUS_BROWSER_ENABLED=0` 重启主站;Worker `docker compose down` 不影响 Nexus 其他功能。
## 2026-06-12 热修 — 画面不显示 / 连不上
**根因**Worker `CDPSession` 误用 `wait_for_event`(不存在),screencast 立即崩溃;断线后会话残留导致 `409`
**修复**`browser-worker/session_manager.py` 改为 `cdp.on("Page.screencastFrame")` + 队列;`POST /api/browser/sessions` 创建前清理该管理员陈旧会话。
**验证**:主站容器内 WS 测试 `SCREENCAST_OK`example.com JPEG ~4KB)。
@@ -0,0 +1,33 @@
# 2026-06-12 修复 push_complete_sound GET 404
## 摘要
`GET /api/settings/push_complete_sound` 在 MySQL 尚无该行时返回 404;现对已有默认值的设置项返回默认 JSON。
## 动机
登录后 `App.vue` 调用 `syncPushSoundFromServer` 触发 404`script_exec_complete_sound` 同理。
## 涉及文件
- `server/api/settings.py``SETTING_DEFAULTS` + `get_setting` 回退
- `tests/test_settings_sound_defaults.py`
## 默认
| key | 默认 |
|-----|------|
| `push_complete_sound` | `beep_double` |
| `script_exec_complete_sound` | `beep_double` |
| `theme` | `dark` |
## 验证
```bash
venv/bin/pytest tests/test_settings_sound_defaults.py -q
# 生产(JWTGET /api/settings/push_complete_sound → 200 {"value":"beep_double",...}
```
## 迁移
无;首次 PUT 仍会 upsert 入库。
@@ -0,0 +1,16 @@
# 终端页右侧服务器列表搜索
**日期**: 2026-06-12
## 变更摘要
终端页右侧服务器栏增加与空态/弹窗一致的搜索框,支持按名称、域名筛选及搜索历史。
## 涉及文件
- `frontend/src/components/terminal/TerminalServerPicker.vue`
- `frontend/src/pages/TerminalPage.vue`
## 验证
`npm run type-check`;终端页右侧输入关键词,列表实时过滤。
@@ -0,0 +1,18 @@
# 监测历史:4 槽同时展示趋势
**日期**: 2026-06-12
## 变更摘要
- 资源趋势 Tab 改为 **2×2 四槽同屏**,每槽独立曲线;共用时间范围与指标选择
- 探针记录 Tab 默认展示 **全部槽位** 记录(不再单选服务器)
- 探针表头 MEM/DISK 改为「内存」「硬盘」
## 涉及文件
- `frontend/src/pages/WatchMetricsPage.vue`
- `frontend/src/components/watch/WatchProbeRecordsTable.vue`
## 验证
`npm run type-check`;打开 `#/watch-metrics`,4 槽图表同屏;探针记录含多机数据。
@@ -0,0 +1,19 @@
# Changelog — 监测槽卡片 hover 外框高亮
**日期**2026-06-12
## 摘要
监测槽卡片(空槽/满槽)hover 时改为**外框主题色**,不再整卡铺灰底。
## 补充(2026-06-12 晚)
- `v-card``:link="false"` `:ripple="false"`,并隐藏 `.v-card__overlay`,消除 Vuetify 默认可点击卡片的 hover 灰遮罩。
## 涉及文件
- `frontend/src/components/watch/WatchSlotCard.vue`
## 验证
浏览器:服务器页 → 实时监测 → 鼠标悬停槽位,仅边框变色。
@@ -0,0 +1,20 @@
# 监测槽:等待探针回传占位 UI
**日期**: 2026-06-12
## 变更摘要
开启实时监测后、首次探针尚未返回时,槽卡显示「等待服务器回传信息…」与加载动画;收到 `probe_status` 后自动切换为 CPU/内存/硬盘等指标。
## 动机
避免开关打开后空白进度条与「—」,明确告知用户正在轮询。
## 涉及文件
- `frontend/src/components/watch/WatchSlotCard.vue`
- `frontend/src/utils/watchFormat.ts``isWatchMetricsPending`
## 验证
`cd frontend && npm run type-check`;浏览器开监测见等待态,约 5–15s 后自动出现指标。
@@ -0,0 +1,47 @@
# 监测槽:时长分钟档 + 指标进度条 + 负载
**日期**: 2026-06-12
## 变更摘要
- 监测槽时长改为分钟粒度:30分 / 1时 / 2时 / 8时 / 24时,**默认 30 分钟**
- 槽卡 UI:chip 选择时长;CPU / 内存 / 硬盘 百分比进度条;负载展示 1 分钟平均(与宝塔「负载」同源)
- 后端 `watch_pins.ttl_minutes` 列;API 字段 `ttl_minutes`;兼容旧 `ttl_hours` 迁移
## 动机
用户需要更细粒度默认监测窗口,并在槽位卡片上直观看到资源占用与系统负载。
## 涉及文件
- `server/utils/watch_metrics.py` — TTL 分钟常量与归一化
- `server/domain/models/__init__.py``ttl_minutes`
- `server/infrastructure/database/migrations.py` — 迁移与回填
- `server/infrastructure/database/watch_repo.py`
- `server/application/services/watch_service.py`
- `server/api/watch.py`
- `frontend/src/constants/watchTtl.ts`
- `frontend/src/components/watch/WatchMetricBar.vue`
- `frontend/src/components/watch/WatchSlotCard.vue`
- `frontend/src/utils/watchFormat.ts`
- `frontend/src/composables/useWatchPins.ts`
- `tests/test_watch_pins.py`, `tests/test_watch_metrics.py`
## 迁移 / 重启
- 需 API 重启以执行 DB 迁移(`ttl_minutes` 列)
- 前端需 `vite build` 后部署
## 验证方式
```bash
pytest tests/test_watch_*.py -q
cd frontend && npm run type-check
bash scripts/local_verify.sh
```
浏览器:监测槽选 30 分默认、切换时长、进度条与负载 chip 显示正常。
## 补充(进度条阈值)
- CPU/内存/硬盘进度条:≥90% 红、7089% 黄、&lt;70% 绿(`WatchMetricBar.vue`)。
@@ -0,0 +1,37 @@
# 2026-06-13 子机离线告警独立 Telegram 通道
## 摘要
设置页「告警通知项目」下新增可选的离线专用 Bot:子机离线告警可发往另一 Telegram 机器人/群组,其它告警仍走默认 Bot。
## 动机
离线告警量大或受众不同,需与 CPU/内存等资源告警分流,避免主告警群被淹没。
## 变更
- 新 settings`telegram_offline_bot_token``telegram_offline_chat_id`(二者均配置时离线告警专用;否则回退默认 Bot)。
- `resolve_offline_telegram_credentials()` + `send_telegram_offline_alert` 路由。
- API`POST /settings/telegram/offline/test``GET /settings/telegram/offline/chats``POST /settings/telegram/offline/reveal-token`
- 设置页:「子机离线告警专用 Bot(可选)」配置区。
## 涉及文件
- `server/config.py`
- `server/infrastructure/telegram/__init__.py`
- `server/api/settings.py`
- `frontend/src/pages/SettingsPage.vue`
- `frontend/src/types/api.ts`
- `tests/test_telegram_offline_channel.py`
## 迁移 / 重启
- 后端:拉取后 `supervisorctl restart nexus`(新 API 端点)。
- 前端:重新 `vite build` 部署 `web/app/`
- 无 DB 迁移;首次保存写入 `settings` 表。
## 验证
1. 设置页配置离线专用 Token + Chat ID →「测试离线通道」收到消息。
2. 仅配置默认 Bot、不填离线专用 → 子机离线仍走默认群。
3. `pytest tests/test_telegram_offline_channel.py` 通过。
@@ -0,0 +1,29 @@
# Telegram Chat ID 群聊检测修复
**日期**2026-06-13
## 变更摘要
修复设置页「检测 Chat ID」无法识别群聊的问题:扩展 `getUpdates` 解析,覆盖 Bot 被拉入群时的 `my_chat_member` 等更新类型;更新前端操作说明。
## 动机
用户反馈群聊 Chat ID 检测不到。原实现仅从 `message` / `channel_post``chat`,而群聊常见首条可见更新为 `my_chat_member`Bot 入群),导致列表为空。
## 涉及文件
- `server/api/settings.py``_telegram_chat_entry``_extract_chats_from_telegram_updates``_telegram_fetch_chats`limit 100,最多返回 10 条,群组优先)
- `frontend/src/pages/SettingsPage.vue` — 群聊检测说明、空状态文案、类型中文标签
- `tests/test_settings_telegram.py` — 群聊/编辑消息/callback 解析单测
## 迁移 / 重启
无需数据库迁移;部署后端 + 前端后生效。
## 验证方式
```bash
pytest tests/test_settings_telegram.py -q
```
生产:将 Bot 拉入测试群 → 群内发送 `/start@Bot用户名` → 设置页点击「检测 Chat ID」应出现群组项(负 ID)。
@@ -0,0 +1,31 @@
# 2026-06-13 终端快捷命令发送时自动追加回车修复
## 摘要
修复自定义快捷命令点击执行后未自动回车、命令悬停在输入行的问题。
## 动机
设置页文案为「命令内容(发送时自动追加回车)」,但保存时在 `cmd` 末尾追加 `\r` 后,`useTerminalQuickCommands.create/update``cmd.trim()` 会把 `\r` 当作空白剥掉,导致入库命令无回车;终端 `execQuickCmd` 又原样发送,表现为快捷命令不执行。
## 变更
- `formatQuickCmdForSend()`:执行时统一追加 `\r`(与命令栏 `sendCmd` 一致),对已含 `\r`/`\n` 的命令幂等。
- `execQuickCmd`:发送前经 `formatQuickCmdForSend` 处理。
- 设置页保存:仅存 trimmed 命令正文,不再在保存阶段追加 `\r`
## 涉及文件
- `frontend/src/composables/useTerminalQuickCommands.ts`
- `frontend/src/composables/terminal/useTerminalSessions.ts`
- `frontend/src/components/TerminalQuickCommandsSettings.vue`
## 迁移 / 重启
- 仅前端:重新 `vite build` 并部署 `web/app/`
- 已有 DB 中带 `\r` 的内置/历史命令不受影响(发送时去尾后再追加一次 `\r`)。
## 验证
1. 设置 → 终端快捷命令:添加 `echo hello`,保存后在终端页点击执行,应出现 `hello` 输出并回到新提示符。
2. 内置命令(如「磁盘使用」)仍可一键执行。
@@ -0,0 +1,43 @@
# 实施说明 — 浏览器网站导航(收藏)
> 设计:`docs/design/specs/2026-06-11-browser-nav-bookmarks-design.md`
## 涉及文件(计划)
| 文件 | 动作 |
|------|------|
| `server/utils/browser_ui_state.py` | ✅ `nav_links` 解析与上限 |
| `server/api/browser.py` | ✅ `BrowserNavLinkState` / PUT 字段 |
| `tests/test_browser_ui_state.py` | ✅ `test_parse_nav_links` |
| `frontend/src/composables/useGlobalBrowser.ts` | `navLinks` + add/update/remove + persist |
| `frontend/src/components/GlobalBrowserPanel.vue` | 侧栏 Tab、列表、添加/编辑对话框、空白页快捷入口 |
| `docs/changelog/2026-06-11-browser-nav-bookmarks.md` | 完成后记录 |
## 前端步骤
1. `BrowserNavLink` 类型与 `applyServerState` / `persistState` 带上 `nav_links`
2. `addNavLink(title, url)` · `updateNavLink(id, …)` · `removeNavLink(id)`
3. `addCurrentPageToNav()` — 取 `activeTab.url``addressDraft`
4. 侧栏:`sidebarMode: 'nav' | 'history' | null`
5. 对话框:`v-dialog` 名称 + URL`normalizeBrowserUrl` 校验
6. 空白页:`v-chip` / 列表展示 `navLinks`,点击 `openUrl`
## 测试要点
```bash
pytest tests/test_browser_ui_state.py -q
cd frontend && npm run type-check
```
- 添加 3 条导航 → PUT → GET 往返一致
- 超 64 条截断;非法 URL 过滤
- UI:编辑后刷新仍显示
## 回滚
- 前端未发版:仅后端多返回空 `nav_links`,兼容
- 回滚前端:导航 UI 消失,DB 中 JSON 保留无害
## 依赖
- 全局浮动浏览器(`GlobalBrowserPanel` + `/api/browser/state`)已上线
@@ -0,0 +1,253 @@
# 浏览器 Worker 独立服务器 — 采购与部署计划
> **目标**:在**单独一台 VPS** 上运行 Playwright ChromiumNexus 主站(`api.synaglobal.vip`)通过内网 API 桥接,管理员在浮动面板 canvas 操作。
> **设计**[`2026-06-11-server-browser-chromium-design.md`](../specs/2026-06-11-server-browser-chromium-design.md)
> **状态**:待购机 → 装机 → Nexus 代码联调 → 生产接入
---
## 1. 为什么要单独买一台
| 项 | 全部塞进 Nexus 主站 | **独立 Worker(本计划)** |
|----|---------------------|---------------------------|
| 主站内存 | 每会话 +200~500MB | 主站几乎不增 |
| Docker 镜像 | +300500MB | 主镜像不变 |
| SSRF 风险面 | 与 API/DB 同机 | 隔离在 Worker |
| 扩缩容 | 和 API 绑死 | 可单独升配 Worker |
你已确认:**任意公网 https + Chromium****不要 iframe**。Worker 分离是推荐部署形态。
---
## 2. 购机规格(下单前对照)
### 2.1 最低可用(单人运维、≤2 并发会话)
| 项 | 建议 |
|----|------|
| **CPU** | 2 vCPUx86_64 |
| **内存** | **4 GB**Chromium 单会话约 300800MB,留系统与缓冲) |
| **磁盘** | 40 GB SSD(系统 + Chromium 缓存) |
| **系统** | **Ubuntu 22.04 LTS****24.04 LTS**x86_64 |
| **带宽** | ≥ 5 Mbps 出站(画面 JPEG 流;10 Mbps 更稳) |
| **地域** | 与 Nexus 主站**同区域或近区**(降延迟) |
### 2.2 推荐配置(留余量、偶尔双标签)
| 项 | 建议 |
|----|------|
| CPU | 4 vCPU |
| 内存 | **8 GB** |
| 磁盘 | 60 GB SSD |
### 2.3 不建议
- **1 GB / 2 GB 内存** — Chromium 易 OOM,会话被 kill
- **ARM 除非验证 Playwright** — 优先 x86_64,减少兼容问题
- **与 Nexus 主站共用同一台** — 违背隔离目的(若预算极紧可临时同机,不推荐)
### 2.4 厂商与形态
任意支持 **Ubuntu + 公网 IP** 的 VPS 即可(Azure、阿里云、腾讯云、Vultr 等)。
**不必**买 WindowsWorker 只跑 Linux + Docker。
---
## 3. 网络与安全(购机时一并规划)
### 3.1 拓扑
```
[ 管理员浏览器 ] ──HTTPS──► [ Nexus 主站 20.24.218.235 :443 ]
内网/VPN/专线(推荐)
[ Browser Worker :8443 ]
└──► 任意公网站点
```
### 3.2 防火墙(Worker 上)
| 方向 | 规则 |
|------|------|
| 入站 | **仅允许 Nexus 主站 IP** 访问 Worker 服务端口(默认 `8443/tcp` |
| 入站 | SSH `22/tcp` 仅你的运维 IP(或跳板机) |
| 入站 | **禁止** 0.0.0.0/0 访问 8443 |
| 出站 | 允许 `80/443` 访问公网(浏览器上网) |
| 出站 | 禁止 Worker 访问 Nexus 内网/MySQL/RedisWorker 不需要) |
> 若 Nexus 与 Worker **同云厂商同 VPC**,用**内网 IP** 通信,Worker **不要绑公网 8443**。
### 3.3 密钥
购机后生成一对:
- `NEXUS_BROWSER_WORKER_SECRET` — 随机 ≥ 32 字节,Worker 与 Nexus 主站 `.env` **各存一份相同值**
- Worker **不**存 Nexus `SECRET_KEY` / `DATABASE_URL`
---
## 4. 购机后你要收集的信息(交给 Agent 联调)
买好后请记录(可写在本地 `.env` 片段,**勿提交 git**):
```bash
# Browser Worker(新机器)
BROWSER_WORKER_PUBLIC_IP= # 若无内网则用
BROWSER_WORKER_PRIVATE_IP= # 与 Nexus 同 VPC 时优先
BROWSER_WORKER_SSH=user@ip
BROWSER_WORKER_SSH_KEY=/path/to/key.pem
# Nexus 主站侧(deploy/nexus-1panel.secrets.sh 或生产 .env
NEXUS_BROWSER_ENABLED=1
NEXUS_BROWSER_WORKER_URL=https://<worker-ip-or-internal>:8443
NEXUS_BROWSER_WORKER_SECRET=<与 Worker 相同>
NEXUS_BROWSER_MAX_SESSIONS=2
NEXUS_BROWSER_IDLE_TIMEOUT_SEC=1800
```
并确认:
- [ ]**Nexus 主站** `curl -k https://<worker>:8443/health` 能通(部署 Worker 后)
- [ ]**公网随机 IP** 访问 Worker 8443 **应失败**(防火墙生效)
---
## 5. Worker 机上安装步骤(购机后执行)
> 代码侧将提供 `browser-worker/` 目录与 `docker-compose.worker.yml`;以下为计划步骤,**Agent 实现后按 changelog 为准**。
### Phase A — 基础环境(约 15 分钟)
```bash
# 1. SSH 登录新机器
ssh -i key.pem ubuntu@<WORKER_IP>
# 2. 系统更新 + Docker
sudo apt-get update && sudo apt-get upgrade -y
sudo apt-get install -y ca-certificates curl
curl -fsSL https://get.docker.com | sudo sh
sudo usermod -aG docker $USER
# 重新登录使 docker 组生效
# 3. 防火墙(示例 ufw
sudo ufw default deny incoming
sudo ufw allow from <NEXUS_MAIN_IP> to any port 8443 proto tcp
sudo ufw allow from <YOUR_OPS_IP> to any port 22 proto tcp
sudo ufw enable
```
### Phase B — 部署 Browser Worker 容器(Agent 交付后)
```bash
# 在 Worker 上
git clone http://66.154.115.8:3000/admin/Nexus.git /opt/nexus-worker
cd /opt/nexus-worker/browser-worker # 待实现路径
cp .env.example .env
# 编辑 WORKER_SECRET、BIND、日志级别
docker compose up -d --build
docker compose logs -f # 确认 Chromium 就绪
curl -s http://127.0.0.1:8443/health # 应返回 ok
```
### Phase C — Nexus 主站接入(Agent 联调)
```bash
# 在 Nexus 主站 .env 增加 NEXUS_BROWSER_*
# 重启 APIsupervisorctl restart nexus 或 docker compose restart nexus
bash scripts/local_verify.sh # 开发机
# 生产:bash deploy/pre_deploy_check.sh && bash deploy/deploy-production.sh
```
### Phase D — 验收
| # | 检查项 | 通过标准 |
|---|--------|----------|
| 1 | Worker health | `/health` → ok |
| 2 | 公网站点 | 面板打开 `https://example.com` 有画面 |
| 3 | iframe 曾白屏的站 | 能显示(抽 1~2 个已知站) |
| 4 | SSRF | 面板输入 `http://127.0.0.1` → 明确错误,Worker 日志无成功请求 |
| 5 | 隔离 | 未授权 IP 无法连 Worker 8443 |
| 6 | 持久化 | 刷新 Nexus 后标签/收藏仍在(MySQL |
| 7 | 重启 Worker | Nexus 会话断开可重建,不拖垮主站 |
---
## 6. Nexus 代码实施顺序(与购机并行)
你可**先买服务器**;Agent 在仓库内并行开发,**不阻塞购机**。
| 阶段 | 内容 | 依赖 Worker 机器 |
|------|------|------------------|
| **W1** | `browser-worker/` 独立服务 + Docker + health | 否(本地 Docker 可测) |
| **W2** | SSRF 校验、Playwright screencast、Worker WS 协议 | 否 |
| **W3** | Nexus 主站 `browser_bridge` 桥接 + `/ws/browser` | 否(mock Worker |
| **W4** | 前端 canvas 替换 iframe | 否 |
| **W5** | 你在 Worker VPS 部署 + 主站 `.env` 联调 | **是** |
| **W6** | changelog、audit、门控、生产部署 | 是 |
**购机前可完成 W1W4****W5 需要你的 Worker IP + SSH**。
---
## 7. Worker 服务接口(预定,实现以代码为准)
Nexus 主站 → Worker(内网,Header `X-Nexus-Worker-Key`):
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/health` | 就绪探测 |
| POST | `/v1/sessions` | 创建 `{ session_id, viewport }` |
| DELETE | `/v1/sessions/{id}` | 销毁 |
| WS | `/v1/sessions/{id}/stream` | 双向:帧 + navigate/键鼠 |
管理员浏览器 **只连 Nexus**;不直连 Worker(Worker 不对管理员暴露)。
---
## 8. 预算参考(仅供参考)
| 配置 | 大致月费(国内/海外 VPS) |
|------|---------------------------|
| 2C4G | ¥50150 / $520 |
| 4C8G | ¥100300 / $1540 |
另计:出站流量(画面流持续使用时略高于纯 API)。
---
## 9. 购机 Checklist(打印勾选)
**下单前**
- [ ] 2 vCPU + **4 GB RAM** 及以上
- [ ] Ubuntu 22.04/24.04 x86_64
- [ ] 与 Nexus 主站同区域或近区
- [ ] 可配置安全组 / ufw
- [ ] 独立公网 IP 或与 Nexus **同 VPC 内网**
**到手后**
- [ ] SSH 可登录
- [ ] 记录公网 IP / 内网 IP
- [ ] ufw8443 仅 Nexus 主站 IP
- [ ] 生成 `NEXUS_BROWSER_WORKER_SECRET`
- [ ] 把 §4 信息发给 Agent / 写入本地 secrets
- [ ]`browser-worker/` 合并后执行 Phase BD
**回滚**
- [ ] 主站 `NEXUS_BROWSER_ENABLED=0` 即关闭,Worker 可关机不影响 Nexus
---
## 10. 与现有文档关系
| 文档 | 关系 |
|------|------|
| `server-browser-chromium-design.md` | 功能与安全 SSOT |
| `server-browser-chromium.md` | Nexus 主站 + 前端实现清单 |
| **本文** | **购机、网络、Worker 装机、联调验收** |
购机完成后,在新会话说:「Worker 已就绪,IP 是 …」并提供 SSHsecrets 本地),Agent 从 **W5 联调** 继续。
@@ -0,0 +1,102 @@
# 实施说明 — 服务端 Playwright Chromium 远程浏览器
> 设计:`docs/design/specs/2026-06-11-server-browser-chromium-design.md`
> **Worker 购机/装机**[`2026-06-11-browser-worker-server-procurement.md`](./2026-06-11-browser-worker-server-procurement.md) ← **先买服务器**
## 部署形态(2026-06-11 更新)
**选定独立 Worker**,不在 Nexus 主站 `Dockerfile.prod` 安装 Chromium。
| 仓库路径 | 运行位置 |
|----------|----------|
| `browser-worker/` | Worker VPS |
| `server/.../browser_bridge.py` 等 | Nexus 主站 |
## 涉及文件(计划)
| 文件 | 动作 | 位置 |
|------|------|------|
| `browser-worker/Dockerfile` | **新建** | Worker |
| `browser-worker/docker-compose.yml` | **新建** | Worker |
| `browser-worker/main.py` | Worker FastAPI + WS + Playwright | Worker |
| `browser-worker/browser_url_safe.py` | SSRF(与主站共享逻辑或复制) | Worker |
| `server/utils/browser_url_safe.py` | SSRF(桥接层二次校验) | Nexus |
| `server/infrastructure/browser/worker_client.py` | HTTP/WS 连 Worker | Nexus |
| `server/api/browser_session.py` | REST + 管理员 WS 桥接 | Nexus |
| `server/main.py` | 注册 router`NEXUS_BROWSER_*` | Nexus |
| `server/config.py` | `browser_enabled`, `browser_worker_url`, `worker_secret` | Nexus |
| `tests/test_browser_url_safe.py` | SSRF 单测 | Nexus |
| `tests/test_browser_worker_client.py` | mock Worker | Nexus |
| `frontend/src/composables/useRemoteBrowserSession.ts` | canvas + WS | 前端 |
| `frontend/src/components/GlobalBrowserPanel.vue` | iframe → canvas | 前端 |
| `frontend/src/composables/useGlobalBrowser.ts` | 对接 remote session | 前端 |
**不变**~~`server/api/browser.py`~~ **已删除**2026-06-11);Worker 方案将新建 `browser_session` API,不复用 iframe 状态接口。
**不修改**`Dockerfile.prod`(主站)
## 实现步骤
### Phase W0 — 你购机(并行)
按 procurement 文档:**2C4G+、Ubuntu 22.04/24.04、防火墙 8443 仅 Nexus IP**。
### Phase W1 — Browser Worker 服务
1. `browser-worker/` 独立 FastAPI`GET /health``POST /v1/sessions``DELETE``WS /v1/sessions/{id}/stream`
2. Header `X-Nexus-Worker-Key` 校验
3. Playwright Chromium + CDP screencast + 键鼠
4. SSRF 在 Worker 侧强制执行
### Phase W2 — Nexus 桥接
1. `worker_client.py`:创建/销毁会话、WS 双向转发
2. `browser_session.py`:管理员 JWT WS ↔ Worker WS
3. `NEXUS_BROWSER_ENABLED=0` → 503
### Phase W3 — 前端
1. `useRemoteBrowserSession.ts` + canvas
2. `GlobalBrowserPanel` 替换 iframe
3. 503 友好提示
### Phase W4 — 联调(需 Worker IP
1. Worker 部署 + ufw
2. Nexus `.env``NEXUS_BROWSER_WORKER_*`
3. 验收表(procurement §5 Phase D
4. changelog + audit → 门控 → 部署
## 测试要点
```bash
pytest tests/test_browser_url_safe.py tests/test_browser_ui_state.py -q
cd frontend && npm run type-check
# Worker 目录(实现后)
cd browser-worker && pytest -q
```
## 依赖与配置
**Nexus 主站**
```env
NEXUS_BROWSER_ENABLED=1
NEXUS_BROWSER_WORKER_URL=https://10.x.x.x:8443
NEXUS_BROWSER_WORKER_SECRET=<shared>
NEXUS_BROWSER_MAX_SESSIONS=2
NEXUS_BROWSER_IDLE_TIMEOUT_SEC=1800
```
**Worker `.env`**
```env
WORKER_SECRET=<same-as-above>
WORKER_BIND=0.0.0.0:8443
WORKER_MAX_SESSIONS=2
WORKER_IDLE_TIMEOUT_SEC=1800
```
## 回滚
1. `NEXUS_BROWSER_ENABLED=0` + 重启 Nexus
2. Worker 容器 `docker compose down`(主站不受影响)
@@ -0,0 +1,27 @@
# 子机离线告警独立 Telegram — 实施说明
## 涉及文件
- `server/config.py` — 新 settings 属性与 DB_OVERRIDE_MAP
- `server/infrastructure/telegram/__init__.py` — 路由与 `send_telegram` 参数化
- `server/api/settings.py` — MUTABLE_KEYS、离线 test/chats/reveal
- `frontend/src/pages/SettingsPage.vue` — 离线专用 Bot UI
- `frontend/src/types/api.ts` — 离线开关文案
- `frontend/src/utils/auditLabels.ts`
- `tests/test_telegram_offline_channel.py`
## 步骤
1. 后端配置与发送路由
2. 设置 API 与白名单
3. 前端设置页「子机离线专用 Bot」区块
4. pytest + local_verify
## 回滚
清空 `telegram_offline_*` 设置或删除 DB 行;离线告警自动回退默认 Bot。
## 测试要点
- `resolve_offline_telegram_credentials` 优先离线、回退主 Bot
- `send_telegram_offline_alert` 传入正确 token/chatmock httpx
@@ -0,0 +1,112 @@
# 内置浏览器 — 网站导航(收藏)设计说明
> 关联:[2026-06-11-embedded-browser-design.md](./2026-06-11-embedded-browser-design.md) · [2026-06-11-global-floating-browser 变更](../../changelog/2026-06-11-global-floating-browser.md)
## 背景与目标
用户希望在 Nexus **全局浮动浏览器**内维护一份**自定义网站导航**(类似浏览器收藏夹),便于反复打开常用运维站点(监控、面板、客户站点等),而不依赖浏览器自带书签。
### 用户原话(2026-06-11
> 浏览器内置网站导航,我自己可以增加,类似收藏功能。
### 与现有能力的关系
| 已有 | 导航(收藏) |
|------|----------------|
| **浏览记录** `visits` | 被动、按访问时间倒序,不可编辑名称 |
| **URL 历史** `url_history` | 去重 URL 列表,无自定义标题 |
| **网站导航** `nav_links` | 用户主动添加/编辑/删除,固定名称 + URL,长期入口 |
三者并存:记录 ≠ 收藏;收藏用于「我关心的固定站点」。
## 非目标
- 不做浏览器引擎替换(首版 iframe;**2026-06-11 改为服务端 Chromium**,见 [server-browser-chromium-design.md](./2026-06-11-server-browser-chromium-design.md)
- 不做 HTTP 反向代理
- 不做跨管理员共享导航(按账号隔离,与 `admin_ui_preferences` 一致)
- 首版不做文件夹/分组(可后续 `group` 字段扩展)
## 数据模型
存储:`admin_ui_preferences``context = embedded_browser`,字段 `nav_links`JSON 数组)。
```json
{
"nav_links": [
{
"id": "uuid",
"title": "Grafana",
"url": "https://grafana.example.com",
"sort_order": 0
}
]
}
```
约束(后端 `browser_ui_state.py`):
| 字段 | 规则 |
|------|------|
| `id` | 非空字符串,≤64 |
| `title` | 非空,≤80 |
| `url` | 仅 `http`/`https`,无凭据,与现有 URL 白名单一致 |
| 数量上限 | 64 条 |
| `sort_order` | 整数,用于排序 |
API:沿用 `GET/PUT /api/browser/state``nav_links` 随整体 state 读写(与 tabs、visits 相同)。
## 交互设计
### 入口
1. 浮动浏览器工具栏:**「导航」**按钮(`mdi-star-outline`),与「浏览记录」并列
2. 侧栏 Tab**网站导航** | **浏览记录**(同一侧栏区域切换,避免双栏占宽)
3. 空白页:展示导航卡片网格,点击即打开
### 用户操作
| 操作 | 行为 |
|------|------|
| 添加 | 对话框:名称 + URL;可预填当前地址栏 / 当前页 URL |
| 编辑 | 同对话框,改名称或 URL |
| 删除 | 列表项菜单或删除图标,需确认(可选) |
| 打开 | 点击条目 → 当前标签导航到该 URL |
| 从当前页收藏 | 地址栏旁「星标」一键添加(已存在则提示或进入编辑) |
### 持久化
- 保存至 MySQL,换设备/清 localStorage 不丢
- 与浏览记录、标签、窗口位置同一 debounce 写入策略
## 安全
- URL 校验复用 `_is_safe_url`,拒绝 `javascript:``data:`
- 无服务端 fetch,无 SSRF
-`admin_id` 隔离
## 内核与风控说明(用户关切)
内置浏览器 **不是** 独立 Firefox/Chrome 安装包,而是 **iframe + 用户当前浏览器引擎**
- User-Agent 与普通浏览器一致,**不会因 Nexus 被单独判为机器人**
- 部分站点禁止 iframe 或要求顶层窗口 → 仍用「新标签打开」
- 收藏导航仅保存 URL,不改变加载方式
## 实现状态(2026-06-11
| 层 | 状态 |
|----|------|
| 后端 `nav_links` 解析 / API 字段 | ✅ 已入代码(待前端联调) |
| 前端侧栏 + 增删改 UI | ⏳ 待实现 |
| 单测 `nav_links` 解析 | ✅ `tests/test_browser_ui_state.py::test_parse_nav_links` |
| 部署 | ⏳ 前端完成后一并门控 |
## 验收标准
- [ ] 可添加、编辑、删除导航项,刷新后仍在
- [ ] 点击导航项在当前标签打开对应 https 站点
- [ ] 可从当前页一键加入收藏
- [ ] 非法 URL 拒绝并提示
- [ ] 不同管理员账号导航互不可见
- [ ] 与浏览记录、标签、最小化状态可同时正常工作
@@ -12,7 +12,11 @@
| HTTP 反向代理 | 可绕过 frame 限制 | SSRF 风险、需维护代理与 Cookie | | HTTP 反向代理 | 可绕过 frame 限制 | SSRF 风险、需维护代理与 Cookie |
| 仅外链新标签 | 零风险 | 非「内置」 | | 仅外链新标签 | 零风险 | 非「内置」 |
**选定**iframe + 地址栏 + 新标签兜底;首版不做 HTTP 代理。 **选定2026-06-11 前)**iframe + 地址栏 + 新标签兜底;首版不做 HTTP 代理。
> **2026-06-11 变更**:用户拒绝 iframe 方案,改为 **服务端 Playwright Chromium + WebSocket 画面流**
> 见 [2026-06-11-server-browser-chromium-design.md](./2026-06-11-server-browser-chromium-design.md)。
> 本文档仍有效部分:路由入口、URL 白名单原则、UI 状态 API**iframe 渲染已废弃**。
## 接口与路由 ## 接口与路由
@@ -28,7 +32,9 @@
## 验收 ## 验收
- [ ] 侧栏「浏览器」进入全屏 iframe 页 - [x] 全局浮动面板、最小化可拖、不可关闭(见 global-floating-browser changelog
- [ ] 侧栏「浏览器」或左下角入口打开浮动窗
- [ ] 地址栏输入 URL 可导航;刷新 / 新标签打开 - [ ] 地址栏输入 URL 可导航;刷新 / 新标签打开
- [ ] 服务器列表「站点」跳转并加载域名 - [ ] 服务器列表「站点」跳转并加载域名
- [ ] 非法 URL 拒绝并提示 - [ ] 非法 URL 拒绝并提示
- [ ] **网站导航(收藏)** — 见 [browser-nav-bookmarks-design.md](./2026-06-11-browser-nav-bookmarks-design.md)
@@ -0,0 +1,173 @@
# 服务端远程浏览器(Playwright Chromium)— 设计说明
> **取代** iframe 渲染方案([2026-06-11-embedded-browser-design.md](./2026-06-11-embedded-browser-design.md) 中的页面加载方式)。
> UI 状态(标签、收藏 `nav_links`、窗口位置)仍走 `/api/browser/state`,不变。
## 背景与目标
运维需要在 Nexus 浮动面板内**完整浏览任意公网站点**,不受 `X-Frame-Options` / CSP `frame-ancestors` 限制,且使用**服务端独立浏览器内核**,而非用户本机标签页内的 iframe。
### 用户确认(2026-06-11
| 决策项 | 选择 |
|--------|------|
| 可访问范围 | **任意公网** `http` / `https` |
| 内核 | **Chromium**Playwright),不要求 Firefox |
| 交互 | 在 Nexus 面板内看画面 + 键鼠操作(非「新标签打开」为主) |
### 与 iframe 方案对比
| | iframe(现状) | 服务端 Chromium(目标) |
|--|----------------|---------------------------|
| 渲染位置 | 用户浏览器 | Nexus 服务器 |
| 站点嵌入限制 | 常被拒绝 | 无(顶层窗口) |
| SSRF 风险 | 无 | **有,必须缓解** |
| 资源 | 几乎无 | 每会话 ~200500MB RAM |
| Cookie / 登录 | 用户本机 | **服务器浏览器 Profile** |
## 方案概述
类比 RDPguacd → WebSocket → 前端画布),但 Nexus **拥有**浏览器进程与协议:
```
管理员浏览器 (Vue SPA)
│ JWT WebSocket + REST
Nexus API (FastAPI) — 桥接,不跑 Chromium
│ Worker Key + 内网 HTTPS
Browser Worker VPS — Playwright Chromiumheadless + CDP screencast
公网目标站点
```
| 组件 | 职责 |
|------|------|
| **Playwright Chromium** | 运行在 **Browser Worker**Nexus 桥接会话 |
| **URL 安全网关** | 导航前 DNS 解析 + IP 段校验,阻断私网/元数据 SSRF |
| **`/ws/browser/{session_id}`** | 推送 JPEG 帧;接收 navigate / 键鼠 / 滚动 |
| **`POST/DELETE /api/browser/sessions`** | 创建、销毁会话 |
| **`GlobalBrowserPanel`** | `<canvas>` 替代 `<iframe>`;保留地址栏、标签、收藏、最小化 |
| **`/api/browser/state`** | 不变 — tabs、visits、nav_links、rect |
**刻意不做(首版)**
- 不做 HTTP 反向代理(HTML 改写 / Cookie 注入)
- 不做浏览内容审计或页面录制落库
- 不做多管理员并发压测级扩展(单人运维,全局并发 ≤2)
- 不做本机 Cookie 与服务器 Profile 双向同步
## WebSocket 协议(草案)
鉴权:与终端/RDP 一致,query `?token=` = 常规 access JWT`sub` = admin_id),且 `session` 归属该校验通过的管理员。
### 服务端 → 客户端
| type | 字段 | 说明 |
|------|------|------|
| `BROWSER_INIT` | `session_id`, `viewport_w`, `viewport_h` | 连接就绪 |
| `SCREENCAST_FRAME` | `data_b64`, `metadata` | CDP screencast JPEG |
| `PAGE_INFO` | `url`, `title` | 导航完成或标题变化 |
| `ERROR` | `message`, `code` | URL 拒绝、超时、Playwright 错误 |
| `PONG` | — | 心跳 |
### 客户端 → 服务端
| type | 字段 | 说明 |
|------|------|------|
| `NAVIGATE` | `url` | 经 SSRF 校验后 `page.goto` |
| `RELOAD` / `GO_BACK` / `GO_FORWARD` | — | 历史导航 |
| `MOUSE` | `event`, `x`, `y`, `button` | move / down / up / wheel |
| `KEY` | `event`, `key`, `code`, `text` | down / up / type |
| `VIEWPORT` | `width`, `height` | 面板尺寸变化 |
| `PING` | — | 心跳 |
坐标:`x/y` 为相对 viewport 的 CSS 像素,与 screencast 帧尺寸一致。
## SSRF 防护(任意公网)
导航与每次 redirect 后 **重新校验** 最终 URL
1. 仅 `http:` / `https:`;拒绝凭据、`javascript:``data:``file:`
2. 解析 hostname → `getaddrinfo` → **所有**解析 IP 必须不在阻断段:
- `127.0.0.0/8`, `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`
- `169.254.0.0/16`, `0.0.0.0/8`, `100.64.0.0/10`
- IPv6: `::1`, `fc00::/7`, `fe80::/10`
3. 阻断常见 metadata 主机名(如 `169.254.169.254``*metadata*` 字面)
4. 导航超时 30s;单页资源不限制由 Chromium 自身处理
5. 失败时 WS 返回 `ERROR`**不**静默降级
> 单人运维下 residual riskDNS rebinding 需 TTL 短重查或 redirect 链逐跳校验;首版采用 **redirect 监听 + 每跳校验**
## 会话与资源
| 策略 | 值 |
|------|-----|
| 每管理员活跃会话 | 1 |
| 全局活跃会话 | ≤2 |
| 空闲超时 | 30 min 无 WS 活动 → 关闭 browser context |
| 视口默认 | 1280×720(可随面板 resize |
| Screencast | CDP `Page.startScreencast`, JPEG quality ~80 |
进程模型:单例 Playwright `Browser` 进程 + 每会话独立 `BrowserContext`(隔离 Cookie)。
## 部署(选定:独立 Worker 服务器)
> **购机与装机计划**[`2026-06-11-browser-worker-server-procurement.md`](../plans/2026-06-11-browser-worker-server-procurement.md)
Playwright Chromium **不装入 Nexus 主站镜像**,而在**单独 VPSBrowser Worker**运行;Nexus 主站仅桥接 WebSocket。
```
管理员浏览器 → Nexus APIJWT)→ Browser WorkerWorker Key)→ 公网
```
| 组件 | 部署位置 |
|------|----------|
| `browser-worker/` 服务 + Chromium | **Worker VPS**2C4G 起) |
| `/api/browser/sessions``/ws/browser/*` 桥接 | **Nexus 主站** |
| MySQL UI 状态 | Nexus 主站(不变) |
**Nexus 主站 `.env`**
```env
NEXUS_BROWSER_ENABLED=1
NEXUS_BROWSER_WORKER_URL=https://<worker-internal-or-vpn>:8443
NEXUS_BROWSER_WORKER_SECRET=<shared-secret>
NEXUS_BROWSER_MAX_SESSIONS=2
NEXUS_BROWSER_IDLE_TIMEOUT_SEC=1800
```
**Worker 防火墙**8443 **仅** Nexus 主站 IP;不对公网开放 Worker API。
**降级**`NEXUS_BROWSER_ENABLED=0` 或 Worker 不可达 → `POST /api/browser/sessions` **503**;前端提示「服务端浏览器未启用」。
~~`Dockerfile.prod` 增加 Chromium~~ — **Worker 模式不修改主站镜像体积**
## 前端变更
- `GlobalBrowserPanel.vue``<iframe>``<canvas>` + `useRemoteBrowserSession.ts`
- 保留:浮动/最小化/不可关闭/重启、标签、历史、收藏(nav_links 待做 UI
- 移除或隐藏:「新标签打开」可保留作 **导出 URL** 辅助,非主路径
- `useGlobalBrowser.ts``navigate` 改为 WS `NAVIGATE``frameKey` 不再需要
## 安全与隐私
- 页面内容经 JPEG 帧过 Nexus,**不持久化**截图
- 服务器 Chromium 内 Cookie 存于容器 ephemeral context(重启会话丢失)
- 仅 `get_current_admin` 可创建/连接会话
- CUD:创建/销毁会话可选写 audit(首版 **不做**,与 RDP 无连接审计一致)
## 验收标准
- [ ] 打开 `https://example.com` 等公网站,面板 canvas 有画面且可点击链接
- [ ] 曾 iframe 白屏的站点(如带 `X-Frame-Options: DENY` 的站)可正常显示
- [ ] 访问 `http://127.0.0.1` / `http://10.x` / metadata → 明确 ERROR,不发起请求
- [ ] 刷新页面后会话内 Cookie 保留;重启浏览器会话 Cookie 清空
- [ ] 标签、收藏、窗口位置仍持久化到 `/api/browser/state`
- [ ] `NEXUS_BROWSER_ENABLED=0` 时有可读降级提示
- [ ] Worker VPS 部署完成,防火墙仅 Nexus 主站可连
## 回滚
- 设 `NEXUS_BROWSER_ENABLED=0` → 503,前端可回退 iframe 分支(保留 feature flag 一段过渡期)
- 卸载 Playwright 依赖仅当确认不再使用
@@ -0,0 +1,41 @@
# 子机离线告警独立 Telegram 通道 — 设计
**日期**: 2026-06-13
**状态**: 已实现
## 背景与目标
运维希望将「子机离线」告警与 CPU/内存等资源告警分流:离线通知可发到专用群/专用 Bot,避免主告警群被大量离线消息淹没。
## 方案对比
| 方案 | 优点 | 缺点 |
|------|------|------|
| A. 新建独立 settings 键 + 发送时路由 | 改动小;与现有 Telegram 配置一致 | 需多配一组 Token/Chat |
| B. 按服务器标签/项目路由 | 更细粒度 | 复杂度高,当前无项目维度 |
| C. 多 Bot 列表 + 规则引擎 | 可扩展 | 过度设计 |
**选定 A**`telegram_offline_bot_token` + `telegram_offline_chat_id`;二者均配置时离线告警走专用通道,否则回退默认 Bot。
## 数据与接口
| settings 键 | Settings 属性 | 说明 |
|-------------|---------------|------|
| `telegram_offline_bot_token` | `TELEGRAM_OFFLINE_BOT_TOKEN` | 敏感,GET 掩码 |
| `telegram_offline_chat_id` | `TELEGRAM_OFFLINE_CHAT_ID` | 明文 |
- `send_telegram_offline_alert``resolve_offline_telegram_credentials()``send_telegram(..., bot_token, chat_id)`
- API`PUT /settings/{key}``POST /settings/telegram/offline/test``GET /settings/telegram/offline/chats``POST /settings/telegram/offline/reveal-token`
- Layer 3 巡检仍仅用默认 `telegram_bot_token`(不同步离线 Bot
## 安全
- Token 入 `SENSITIVE_KEYS`reveal 写审计
- 不改变 `notify_alert_offline` 开关语义
## 验收
1. 配置离线专用 Bot+Chat 后,子机离线 Telegram 发到专用群
2. 未配置离线专用时,离线告警仍走默认 Bot
3. CPU/内存等资源告警始终走默认 Bot
4. 设置页可保存、检测 Chat ID、发送测试消息
+29
View File
@@ -65,3 +65,32 @@ bash scripts/local_verify.sh # Docker + API + smoke + test_api + ruff
``` ```
文档:`docs/project/local-integration-test.md`(已取代 WSL 脚本) 文档:`docs/project/local-integration-test.md`(已取代 WSL 脚本)
---
## 待办 — 服务端远程浏览器(Worker 分离)
**用户确认**:任意公网;Chromium**独立 Worker 服务器**(用户去购机)。
| 文档 | 路径 |
|------|------|
| 设计 | `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`** |
**进度**iframe 内置浏览器 **已移除**changelog `2026-06-11-remove-embedded-browser`);待购 Worker 后按 W1W4 重建 canvas 浏览器。
**购机规格速记**2C4G 起(推荐 4C8G)· Ubuntu 22.04/24.04 · Worker 8443 仅允许 Nexus 主站 `20.24.218.235` 访问。
---
## 待办 — 内置浏览器网站导航(收藏)
**用户原话**:浏览器内置网站导航,我自己可以增加,类似收藏功能。
| 文档 | 路径 |
|------|------|
| 设计 | `docs/design/specs/2026-06-11-browser-nav-bookmarks-design.md` |
| 实施计划 | `docs/design/plans/2026-06-11-browser-nav-bookmarks.md` |
**进度**:随 iframe 浏览器一并移除;Worker 方案上线时重做。
@@ -0,0 +1,24 @@
# 文件管理移除下载 — 生产验证(2026-06-12
## 变更
- Commit `2b0413c`:前端移除行内/右键「下载」入口;后端 `/sync/download` 保留。
## 验证
| 项 | 结果 |
|----|------|
| 门控 | `pre_deploy_check.sh` 7/7 PASS(含 browser 合并提交后) |
| 部署 | `NEXUS_DEPLOY_PATH=/opt/nexus deploy-production.sh` 成功 |
| `/health` | `ok` |
| 主 bundle | `/app/assets/index-aYJp0WL5.js` HTTP 200 |
| FilesPage 最新 chunk | `FilesPage-BUUVZpbu.js``下载` / `mdi-download` 出现次数 **0** |
## 说明
容器内仍保留历史 hash 的 `FilesPage-*.js`(prune 未全删);当前入口引用的最新 chunk 已无下载文案与图标。
## 用户终验
1. 强刷 `https://api.synaglobal.vip/app/#/files`
2. 确认列表行与右键菜单无「下载」
@@ -0,0 +1,107 @@
# Bug 巡查 — 远程浏览器
**日期**2026-06-12
**范围**`browser-worker/`、桥接 API、前端 canvas 客户端、生产 `66.154.115.131:8443`
**方法**:代码走读 + 单测 + 生产探针复验
**关联**`docs/audit/2026-06-12-remote-browser-worker.md``docs/reports/2026-06-12-remote-browser-patrol.md`
## 发现与处理
| # | 严重度 | 问题 | 根因 | 状态 |
|---|--------|------|------|------|
| 1 | **P0** | 生产无浏览器 UI,用户「连不上」 | 后端 `BROWSER_ENABLED=1`,但 `web/app` **无** `ws/browser` bundle | **已修**`index-BimRgA6s.js` 已同步,容器 grep `ws/browser` ≥1 |
| 2 | **P1** | Worker `sessions:1` 长期占槽 | `ready=false` 不进 idle 清理 | **已修**`WORKER_RESERVE_TIMEOUT_SEC=120` + 重建 Worker`sessions:0` |
| 3 | **P1** | attach 失败留下僵尸 reservation | `RuntimeError``destroy_session` | **已修**`main.py` 失败路径销毁 |
| 4 | **P1** | 页内点击/重定向 SSRF | 仅 `NAVIGATE` 校验;`RELOAD`/`GO_BACK`/`页内导航` 无复验 | **未修**(审计 F1 |
| 5 | **P1** | Worker 8443 公网暴露 `/health` | ufw 过宽 | **未修**(审计 F3 |
| 6 | **P2** | 前进/后退与远程历史不同步 | UI `stack` 仅本地维护;`updateActiveFromRemote` **不**更新 stackcanvas 内点击只改 `url` | **未修** |
| 7 | **P2** | `connect()` 失败后无显式 DELETE | 同上 | **已修** — WS/未登录失败时 `DELETE /browser/sessions/{id}` |
| 8 | **P2** | `attach_stream` 并发竞态 | `ready=false` 时锁在 L140 释放,双 WS 可能各建 `BrowserContext` | 低概率(单桥接) |
| 9 | **P2** | `delete_worker_session` 失败静默 | 日志 warningWorker 会话残留 | **未修**(审计 F4 |
| 10 | P3 | 切标签每次新建 Worker 会话 | `watch(activeTabId)``disconnect()` → 新 `POST` | 设计可接受,略耗资源 |
| 11 | P3 | `persistState` catch 空实现 | 离线时本地状态与 MySQL 可能短暂不一致 | 可接受 |
## P0 — 前端未部署(用户可见)
```
生产容器: BROWSER_ENABLED=1
web/app/assets: grep ws/browser → 0 文件
Worker /health: sessions=1, max_sessions=2
```
管理员打开面板**没有任何**地球按钮 / canvas 客户端代码,与「连不上」完全一致。后端与 Worker 已就绪,**阻塞在 SPA 同步**。
## P1 — Worker 僵尸会话(占槽根因)
### 空闲清理漏掉 `ready=false`
```100:103:browser-worker/session_manager.py
stale = [
sid for sid, sess in self._sessions.items()
if sess.ready and now - sess.last_activity > settings.WORKER_IDLE_TIMEOUT_SEC
]
```
`POST /v1/sessions``reserve_session`Chromium 在 WS `attach_stream` 后才 `ready=True`
任一路径「POST 成功但 stream 未建立」都会永久占 `session_count`,直至 `max_sessions` 打满。
典型路径:
- 前端未部署时 API 测试 / 桥接探测只 POST 不挂 WS
- Nexus 桥接连 Worker WS 失败(`WORKER_UNREACHABLE`
- Worker `attach_stream``RuntimeError` 后:
```110:112:browser-worker/main.py
except RuntimeError:
await websocket.close(code=4503, reason="Browser not ready")
return
```
此处 **未** `destroy_session`reservation 泄漏。
**建议**idle 对 `not ready``created_at` 超时(如 120s);`RuntimeError`/`attach` 失败路径 `destroy_session`;运维可 `docker restart nexus-browser-worker` 清槽。
## P1 — SSRF / 暴露面(审计复查)
`docs/audit/2026-06-12-remote-browser-worker.md` 一致,代码层**无新增修复**。
## P2 — 前进/后退逻辑 Bug
`GlobalBrowserPanel.onGoBack`
```350:353:frontend/src/components/GlobalBrowserPanel.vue
function onGoBack() {
goBack() // 改本地 stack + addressDraft
if (connected.value) remoteGoBack() // Playwright page.go_back()
}
```
- 用户在 canvas 内点击链接 → `PAGE_INFO``updateActiveFromRemote` 只更新 `tab.url`**不 push stack**
- `canGoBack` 仍看本地 stack → 按钮状态错误
- 点后退时本地 stack 与 Chromium 历史**可能指向不同 URL**
**建议**:以远程为准时去掉本地 stack,或 `PAGE_INFO` 时同步 push stack;后退只发 `GO_BACK` 不回写本地 stack 直到 `PAGE_INFO`
## 本地验证
```text
pytest tests/test_browser_url_safe.py tests/test_browser_ui_state.py → 7 passed
npm run type-check → PASS
curl https://api.synaglobal.vip/health → ok
```
## 建议修复顺序
1. **P0**`vite build` + `sync_webapp_to_container.sh`(立刻恢复可用性)
2. **P1** — Worker 重启清槽 + 修 reservation 泄漏(idle 未 ready + RuntimeError 清理)
3. **P1** — ufw 收口 8443
4. **P1**`framenavigated` SSRF
5. **P2** — 历史栈与 `GO_BACK` 统一
## 复巡命令
```bash
ssh nexus 'sudo docker exec nexus-prod-nexus-1 grep -rl ws/browser /app/web/app/assets/ | wc -l'
curl -s http://66.154.115.131:8443/health
pytest tests/test_browser_url_safe.py tests/test_browser_ui_state.py -q
```
@@ -0,0 +1,90 @@
# 巡查 — 远程浏览器 + 生产基线
**日期**2026-06-12
**范围**:生产 `api.synaglobal.vip`、Worker `66.154.115.131:8443`、工作区浏览器改动、审计 F1/F3 闭环
**关联审计**`docs/audit/2026-06-12-remote-browser-worker.md`
## 进度条
| 步骤 | 状态 |
|------|------|
| 生产 `/health` + prod_probe | ✅ 全通过 |
| 浏览器后端配置 | ✅ `BROWSER_ENABLED=1`Worker URL 已配 |
| 浏览器前端 bundle | ❌ **未部署** |
| Worker 可达 / 暴露面 | ⚠️ 公网可访问 `/health` |
| 审计 P1 闭环 | ❌ F1 SSRF、F3 防火墙未修 |
| 工作区 git | ⚠️ 浏览器源码 largely 未 commit |
| 本地单测 | ✅ browser 7 + watch_pins 9 = 16 passed |
## 生产探针(`scripts/prod_health_check.sh`
```text
✓ external /health → ok
✓ external /app/ → HTTP 200
✓ login / health/detail / alert-history / schedules / cron / main bundle
=== All production probe checks passed ===
```
容器:`nexus-prod-nexus-1` **Up ~4min (healthy)**(巡查时刚重启不久)。
## 远程浏览器链路
| 检查项 | 结果 | 说明 |
|--------|------|------|
| 主站 `/health` | `ok` | 公网 + 容器内均 OK |
| 容器 `settings.BROWSER_ENABLED` | `1` | Worker URL `http://66.154.115.131:8443` |
| 主站 → Worker `/health` | `ok` | 容器内 curl 成功 |
| 公网 → Worker `/health` | **可达** | `{"status":"ok","sessions":1,"max_sessions":2}` |
| 无 Key `POST /v1/sessions` | `422` | 非 401,但无密钥无法建会话 |
| 生产 `web/app``GlobalBrowser` / `ws/browser` / screencast | **无** | 146 个 JSgrep 零命中 |
| 侧栏「浏览器」/ 地球按钮 UI | **未上线** | 需 `vite build` + `sync_webapp_to_container.sh` |
**结论**:后端与 Worker **已启用**,但 **SPA 未同步**,管理员面板仍无远程浏览器入口;与「连不上」用户反馈一致(旧 bundle 无 canvas 客户端)。
## 审计项复查(未闭环)
| ID | 审计结论 | 巡查结果 |
|----|----------|----------|
| **F1** | 页内点击/重定向无 SSRF 复验 | **未修**`session_manager.py``NAVIGATE``validate_navigation_url` |
| **F3** | Worker `ufw` 过宽 | **未确认修** — 公网 curl `/health` 成功,8443 仍暴露;Worker SSH 本机无凭据 |
| F4 | delete 失败静默 | 未改 |
| F5 | 内存 session_registry | 可接受(单进程 Docker |
Worker 当前 **`sessions:1`**:可能存在陈旧会话占槽(`max_sessions=2`),建议 Worker 侧重启或调 API 清理。
## 工作区状态
浏览器相关文件多为 **未跟踪 / 未提交**`browser-worker/``GlobalBrowserPanel.vue``browser_session.py` 等)。生产后端已跑新镜像,但 **前端构建产物未进容器**,形成 **前后端版本错位**
本地:
```bash
pytest tests/test_browser_url_safe.py tests/test_browser_ui_state.py tests/test_watch_pins.py -q
→ 16 passed
```
## 发现汇总
| # | 严重度 | 问题 | 建议 |
|---|--------|------|------|
| 1 | **P0 体验** | 前端未部署,用户无法使用浏览器 | `cd frontend && npx vite build``deploy/sync_webapp_to_container.sh` |
| 2 | **P1 安全** | Worker 8443 + `/health` 公网可见 | 收紧 ufw 仅 `20.24.218.235``/health` 加 Key 或禁公网 |
| 3 | **P1 安全** | F1 页内导航 SSRF 缺口 | `framenavigated` 逐跳校验 |
| 4 | P2 | Worker `sessions:1` 占槽 | 清理会话或等待 idle 超时 |
| 5 | P2 | 源码未 commit | 部署稳定后 `git add` 源码+文档(不含 `web/app/assets` 洪流) |
## 建议执行顺序
1. **同步前端**(解除 P0
2. **收紧 Worker 防火墙**F3
3. **实现 F1** 后复审计
4. **commit** 源码与 changelog/audit/report
## 验证命令(复巡)
```bash
bash scripts/prod_health_check.sh
ssh nexus 'sudo docker exec nexus-prod-nexus-1 python3 -c "from server.config import settings; print(settings.BROWSER_ENABLED)"'
ssh nexus 'sudo docker exec nexus-prod-nexus-1 grep -rl ws/browser /app/web/app/assets/ | head -1'
curl -s http://66.154.115.131:8443/health # 修 F3 后应仅主站可达
```
@@ -0,0 +1,30 @@
# 远程浏览器 Worker 部署验证 — 2026-06-12
## 环境
| 角色 | 地址 |
|------|------|
| Nexus 主站 | `20.24.218.235` / `api.synaglobal.vip` |
| Browser Worker | `66.154.115.131:8443`(主机名 mqtele,约 8GB RAM |
## Worker
| 检查 | 结果 |
|------|------|
| `curl http://66.154.115.131:8443/health`(从主站) | `{"status":"ok","sessions":0,"max_sessions":2}` |
| 容器 | `nexus-browser-worker` running |
| ufw | `8443` 已对 `20.24.218.235` 放行 |
## Nexus API
| 检查 | 结果 |
|------|------|
| `GET /api/browser/status` | `enabled: true`, `worker_ok: true` |
| `POST /api/browser/sessions` | 返回 `session_id`(已测后删除) |
| 前端 `web/app` 同步 | `sync_webapp_to_container.sh` 入口 bundle HTTP 200 |
## 待用户终验
- [ ] 登录后台 → 左下角浏览器 → 打开 `https://example.com` 有画面且可点击
- [ ] 服务器列表「站点」打开浮动窗(非新标签)
- [ ] 输入 `http://127.0.0.1` 显示明确错误
@@ -0,0 +1,42 @@
# 生产验证 — 监测槽分钟时长 + 指标进度条 + 负载
**日期**2026-06-12
**Commit**`647ebcb` — feat(watch): 监测槽分钟时长档、指标进度条与负载显示
**环境**https://api.synaglobal.vip
## 部署
| 项 | 结果 |
|----|------|
| push | `4c38bc0..647ebcb main → main` |
| deploy-production | ✓ Docker 重建 + `ServersPage-CfEy_-BY.js` 同步 |
| `/health` | ok |
| `/app/` | 200 |
## API 终验(生产 JWT
| 场景 | 结果 |
|------|------|
| GET `/api/watch/pins` | `ttl_minutes: 30` 字段存在 |
| PATCH ttl 60 | `ttl_minutes: 60` |
| PATCH ttl 30 | `ttl_minutes: 30` |
| PATCH ttl 99 | 422 校验拒绝 |
## 前端 bundle
`ServersPage-CfEy_-BY.js` 含:`30分` `1时` `2时` `8时` `24时` `默认 30 分` `内存` `硬盘` `负载` `watch-ttl` `WatchMetricBar`
## 浏览器 E2EPlaywright
`e2e/pages/watch-slot-ttl.spec.mjs` — **1 passed (7.3s)**
- 服务器页可见「默认 30 分」与五档时长 chip
- 开启监测后可见「内存」「硬盘」「负载」与进度条 `.watch-metric-bar`
## 负载说明
展示 `load_1`(1 分钟平均负载),与宝塔面板「负载」同源;悬停 tooltip 说明非 CPU 百分比。
## 结论
**部署成功**;时长默认 30 分钟、进度条与中文指标、负载 chip 均已上线。
@@ -0,0 +1,36 @@
# 子机离线告警独立 Telegram — 验证报告
**日期**: 2026-06-13
## 验证结果
| 项 | 方式 | 结果 |
|----|------|------|
| 通道路由单测 | `pytest tests/test_telegram_offline_channel.py` | **PASS** 4/4 |
| 配置与白名单 | Python import `MUTABLE_KEYS` / `resolve_offline_*` | **PASS** |
| API 路由注册 | FastAPI `telegram/offline/*` | **PASS** |
| 本地门控 | `bash scripts/local_verify.sh` | **PASS** 26/26 + ruff |
| 前端构建 | `npm run build` | **PASS**(含 `SettingsPage-*.js` |
| 构建产物 API 路径 | `telegram/offline/test` 等 | **PASS** |
| 真实 Telegram 发送 | 未执行 | **SKIP**(需生产/本地 Bot 凭据) |
| 子机离线边沿 E2E | 未执行 | **SKIP**(需可 SSH 子机 + Agent |
## 路由清单(已注册)
- `POST /api/settings/telegram/offline/test`
- `GET /api/settings/telegram/offline/chats`
- `POST /api/settings/telegram/offline/reveal-token`
- `PUT /api/settings/telegram_offline_bot_token`
- `PUT /api/settings/telegram_offline_chat_id`
## 路由逻辑(单测覆盖)
1. 专用 Token+Chat 均配置 → 离线告警走专用 Bot
2. 未配置专用 → 回退默认 `TELEGRAM_BOT_TOKEN` / `TELEGRAM_CHAT_ID`
3. 仅配置一半专用 → 回退默认(避免半配发不出去)
## 手动终验(部署后)
1. **设置** → 配置离线专用 Bot + Chat ID →「测试离线通道」收到消息。
2. 停一台子机 Agent → 离线通知进**专用群**(非默认群)。
3. 清空离线专用配置 → 离线通知回默认群。
@@ -0,0 +1,115 @@
# 审计报告 — 快捷命令回车修复 + 离线告警 Telegram 分流
**日期**: 2026-06-13
**范围**: 本会话两项功能(不含工作区其它未提交变更如 remote-browser 删除)
**方法**: 定向 diff 走读 + 安全/契约检查 + 已有 pytest 证据
**审计员**: Agent(定向审计,非全仓 Phase 2 走读)
---
## 1. 变更摘要
| 功能 | 核心改动 | 风险面 |
|------|----------|--------|
| 终端快捷命令回车 | `formatQuickCmdForSend` + `execQuickCmd` 发送时补 `\r` | 前端 WebSSH 数据流 |
| 离线告警 Telegram 分流 | `TELEGRAM_OFFLINE_*` + `resolve_offline_telegram_credentials` + 设置页/API | 凭据存储、出站 Telegram |
---
## 2. 审计文件(5 个)
| # | 文件 | 行数 | 状态 |
|---|------|------|------|
| 1 | `server/infrastructure/telegram/__init__.py` | 401 | 逐行完成 ✓ |
| 2 | `server/api/settings.py`offline 段 ~11881342 | 段 | 逐行完成 ✓ |
| 3 | `frontend/src/composables/useTerminalQuickCommands.ts` | 80 | 逐行完成 ✓ |
| 4 | `frontend/src/composables/terminal/useTerminalSessions.ts`execQuickCmd | 段 | 逐行完成 ✓ |
| 5 | `frontend/src/pages/SettingsPage.vue`(离线 Bot 段) | 段 | 逐行完成 ✓ |
辅助:`TerminalQuickCommandsSettings.vue`-2 行)、`server/config.py`+4 映射)、测试 2 文件。
---
## 3. API 入口表(新增)
| 方法 | 路径 | 鉴权 |
|------|------|------|
| PUT | `/api/settings/telegram_offline_bot_token` | JWT `get_current_admin` |
| PUT | `/api/settings/telegram_offline_chat_id` | JWT |
| POST | `/api/settings/telegram/offline/test` | JWT |
| GET | `/api/settings/telegram/offline/chats` | JWT |
| POST | `/api/settings/telegram/offline/reveal-token` | JWT + 审计 |
既有 `broadcast_offline_alert``send_telegram_offline_alert` 无新 HTTP 面。
---
## 4. Closure 表
| ID | 规则/检查点 | 文件:行 | 结论 | 说明 |
|----|-------------|---------|------|------|
| H1 | 敏感凭据不入 GET 明文 | settings.py SENSITIVE_KEYS | **SAFE** | `telegram_offline_bot_token` 已加入掩码集 |
| H2 | reveal 写 audit_logs | settings.py:13241331 | **SAFE** | `reveal_offline_telegram_token` |
| H3 | 出站消息 HTML 转义 | telegram/__init__.py:189195 | **SAFE** | `html.escape` 用于名称/心跳 |
| H4 | 离线专用半配不误发 | telegram/__init__.py:96107 | **SAFE** | 仅 token 或仅 chat 时整组回退默认 Bot |
| H5 | 资源告警仍走默认 Bot | telegram/__init__.py:163 | **SAFE** | `send_telegram_alert` 未改路由 |
| H6 | `send_telegram` 参数化不破坏默认调用 | telegram/__init__.py:110118 | **SAFE** | 默认 `bot_token/chat_id=None` 行为不变 |
| H7 | MUTABLE_KEYS 白名单 | settings.py:4748 | **SAFE** | 两新键已列入 |
| H8 | 快捷命令 `\r``trim()` 冲突 | useTerminalQuickCommands.ts:4349 | **SAFE** | 发送时 `formatQuickCmdForSend` 根治 |
| H9 | 内置命令 DB 带 `\r` 幂等 | useTerminalQuickCommands.ts:2123 | **SAFE** | 先去尾再追加 |
| H10 | 多行快捷命令语义 | useTerminalQuickCommands.ts:2123 | **SAFE** | 与命令栏一致:整段一条命令 + 单次 `\r` |
| H11 | 测试覆盖通道路由 | test_telegram_offline_channel.py | **SAFE** | 4 case 含 dedicated/fallback/partial |
| H12 | reveal 加载态共用 | SettingsPage.vue:886911 vs 离线 toggle | **FINDING P3** | 离线与默认共用 `revealingTelegramToken`,并发点击可能互斥显示 |
| H13 | test 端点重复 httpx | settings.py:12681309 | **FINDING P3** | 可复用 `send_telegram(..., bot_token, chat_id)` 减重复 |
| H14 | 新 API 错误文案 i18n | settings.py:1253,1264 | **FINDING P3** | `http_errors_zh` 未收录 `telegram_offline_*` 键名错误 |
| H15 | 子机恢复在线 Telegram | server_offline_monitor.py | **INFO** | 仅 offline 边沿推送,无「恢复在线」通知(既有设计,非回归) |
| H16 | Layer3 巡检不同步离线 Bot | settings.py:416422 | **SAFE** | 仅默认 token 写 .env,符合设计文档 |
| H17 | 无静默吞错 | telegram/__init__.py:136138 | **SAFE** | 失败打 error 日志 |
| H18 | CUD 审计 | settings.py set_setting | **SAFE** | PUT 走既有 `update_setting` 审计 |
`len(H)=18`Closure=18。
---
## 5. 严重度汇总
| 级别 | 数量 | 项 |
|------|------|-----|
| **P0** | 0 | — |
| **P1** | 0 | — |
| **P2** | 0 | — |
| **P3** | 3 | H12 加载态共用;H13 test 重复代码;H14 错误文案 i18n |
| **INFO** | 1 | H15 无离线恢复 Telegram |
**合并结论**: **可合并 / 可部署**。无安全阻断项;P3 可在后续小 PR 打磨。
---
## 6. 测试证据
```
pytest tests/test_telegram_offline_channel.py → 4 passed
pytest tests/test_terminal_quick_commands.py → 1 passed
bash scripts/local_verify.sh → 26/26 + ruff
frontend npm run build → OK
```
---
## 7. 建议(可选跟进)
1. **H12**: 为离线 Token 增加独立 `revealingOfflineTelegramToken`,避免与默认 Bot 眼睛按钮抢 loading。
2. **H13**: `telegram_offline_test_send` 改为 `await send_telegram(message, bot_token=token, chat_id=chat_id)`
3. **H14**: 在 `server/utils/http_errors_zh.py` 补充 `telegram_offline_bot_token 未配置` 等映射。
---
## 8. 验收对照(设计文档)
| 验收项 | 结果 |
|--------|------|
| 专用 Bot+Chat 配置后离线走专用通道 | ✓ 代码 + 单测 |
| 未配置专用时回退默认 | ✓ 单测 |
| CPU/内存等仍走默认 | ✓ 未改 `send_telegram_alert` |
| 设置页保存/检测/测试 | ✓ UI + API 已实现 |
| 快捷命令点击执行带回车 | ✓ `formatQuickCmdForSend` + 构建验证 |
@@ -0,0 +1,108 @@
# 巡检 — 快捷命令回车 + 离线 Telegram 分流 + Layer3 巡检
**日期**2026-06-13
**范围**:本会话两项功能 + 部署门控 + Layer3 宿主机巡检状态
**生产**https://api.synaglobal.vip(本机外网 curl 不可达,见下)
## 进度条
| 步骤 | 状态 |
|------|------|
| 实现 | ✅ 工作区完成 |
| 本地 L2b | ✅ 26/26 + ruff |
| 功能单测 | ✅ 10/10offline 4 + quick-cmd 1 + offline_monitor 5 |
| Layer3 单测 | ✅ `test_ops_patrol_sync.py` 7/7 |
| 前端 build | ✅(此前已构建) |
| 门控 7/7 | ❌ **5/7**Gate 2/7 审计路径、Gate 7 无审计文件) |
| 生产健康 | ⚠️ 外网 `curl` 连接重置;SSH `nexus` 主机名不可解析 |
| 本地 API | ✅ `GET /health``ok` |
| 浏览器终验 | ❌ 待部署 |
---
## 1. 功能变更巡检
### 1.1 终端快捷命令回车
| 检查项 | 结果 |
|--------|------|
| `formatQuickCmdForSend` 发送时补 `\r` | ✅ |
| 设置页不再 `trim``\r` 后入库 | ✅ |
| 内置命令 DB 带 `\r` 幂等 | ✅ 单测逻辑 |
| `pytest test_terminal_quick_commands` | ✅ 1 passed |
### 1.2 子机离线告警独立 Telegram
| 检查项 | 结果 |
|--------|------|
| `resolve_offline_telegram_credentials` 专用优先 / 半配回退 | ✅ 3 case |
| `send_telegram_offline_alert` 传入专用 token/chat | ✅ mock 单测 |
| 资源告警仍走默认 Bot | ✅ 未改 `send_telegram_alert` |
| 设置页离线 Bot UI + 3 API | ✅ 代码 + 构建产物 |
| Layer3 `health_monitor.sh` 使用离线 Bot | ✅ **否**(设计:仅默认 `NEXUS_TELEGRAM_*` |
| `ops_patrol_sync` 同步离线 Token | ✅ **否**(符合设计) |
---
## 2. Layer 3 宿主机巡检
| 项 | 本地状态 |
|----|----------|
| `deploy/health_monitor.sh` 语法 | ✅ `bash -n` 通过 |
| `test_ops_patrol_sync.py` | ✅ 7/7 |
| `.env``NEXUS_TELEGRAM_*` | ❌ 未写入(`env_synced: false` |
| DB `TELEGRAM_BOT_TOKEN/CHAT_ID` | ❌ 本地未配置 |
| 离线专用 `TELEGRAM_OFFLINE_*` | ❌ 未配置(预期:不影响 L3) |
| 生产 cron / `nexus_health.log` | ⚠️ 无法 SSH 核查(`nexus` 主机名不可解析) |
**说明**Layer3 巡检 Telegram 依赖设置页保存后「同步巡检 Telegram」或 `health_monitor.sh` 的 DB 回退;与本次**离线专用 Bot**无关。
---
## 3. 部署门控 `pre_deploy_check.sh`
| Gate | 结果 |
|------|------|
| 1 Changelog | ✅ `2026-06-13-offline-alert-telegram-split.md` |
| 2 Audit | ❌ 缺少 `docs/audit/2026-06-13-*.md`(审计写在 `docs/reports/` |
| 3 Test | ✅ |
| 4 Lint | ✅ |
| 5 Import | ✅ |
| 6 Security | ✅ bandit 0 HIGH |
| 7 Review | ❌ 无 audit 文件对照 diff |
**部署结论****BLOCKED**,需将门控格式审计写入 `docs/audit/2026-06-13-*.md`(含 Step 3 / Closure / DoD 段落)后方可 `deploy-production.sh`
---
## 4. 生产可达性(本环境)
```
curl https://api.synaglobal.vip/health → Recv failure: 连接被对方重置
ssh nexus → Could not resolve hostname
curl http://127.0.0.1:8600/health → ok
```
外网/SSH 失败可能为本机网络或 DNS 限制,**不能据此判定生产宕机**;请在可访问生产的环境复验。
---
## 5. 风险与待办
| 优先级 | 项 | 建议 |
|--------|-----|------|
| P0 部署 | 门控审计路径 | 补 `docs/audit/2026-06-13-offline-quick-cmd-telegram.md` |
| P1 上线 | 前端 `web/app/` | `vite build` 后部署 |
| P1 上线 | 后端重启 | 新 `telegram/offline/*` 端点 |
| P2 运维 | 离线专用 Bot | 设置页配置 + 测试离线通道 |
| P2 运维 | Layer3 | 保存默认 Telegram 后点「同步巡检 Telegram」 |
| P3 | 审计 P3 项 | 见 `docs/reports/2026-06-13-session-features-audit.md` |
---
## 6. 建议部署后终验清单
1. 设置 → 离线专用 Bot → **测试离线通道**
2. 终端 → 自定义快捷命令 → 点击执行有回车
3. 设置 → Layer3 状态芯片「巡检 Telegram 就绪」+ `.env 已同步`(若使用 L3
4. `https://api.synaglobal.vip/health``ok`
@@ -0,0 +1,33 @@
# 终端快捷命令回车修复 — 验证报告
**日期**: 2026-06-13
**变更**: `formatQuickCmdForSend` + `execQuickCmd` 发送时追加 `\r`
## 验证结果
| 项 | 命令/方式 | 结果 |
|----|-----------|------|
| 前端构建 | `cd frontend && npm run build` | PASS5.37s,含 `TerminalPage-*.js` |
| 本地门控 | `bash scripts/local_verify.sh` | PASS26/26 API smoke + ruff |
| 发送逻辑单测 | Node/Python 镜像 `formatQuickCmdForSend` | PASS(含 `trim()``\r` 后仍能补回) |
| API 集成 | `pytest tests/test_terminal_quick_commands.py` | PASS |
| 浏览器 E2E(WebSSH 点击执行) | 未执行 | SKIP — 需已登录 + 可 SSH 子机 |
## 根因复现(已确认)
```javascript
'ls\r'.trim() // => 'ls' \r 被当作空白剥掉)
```
修复后:`formatQuickCmdForSend('ls')` => `'ls\r'`,与命令栏 `sendCmd` 行为一致。
## 手动终验(部署后)
1. 设置 → 终端快捷命令 → 添加 `echo hello`,保存。
2. 终端页连接子机 → 点击该快捷命令。
3. 预期:输出 `hello` 并回到新提示符(非悬停在输入行)。
## 备注
- 内置命令 DB 中可能仍带 `\r``formatQuickCmdForSend` 先去尾再追加,幂等安全。
- 仅前端变更;部署 `web/app/` 即可,无需重启后端。
@@ -0,0 +1,25 @@
/** Production smoke — watch slot TTL chips + metric bars */
import { test, expect } from '../fixtures.mjs'
import { loginWithAccessToken, nav } from '../helpers.mjs'
test('watch slot TTL chips and metric labels', async ({ page }) => {
const token = process.env.NEXUS_E2E_ACCESS_TOKEN || ''
test.skip(!token, 'NEXUS_E2E_ACCESS_TOKEN required')
await loginWithAccessToken(page, token)
await nav(page, '/servers')
await expect(page.getByText('默认 30 分')).toBeVisible({ timeout: 20000 })
await expect(page.getByText('30分').first()).toBeVisible()
await expect(page.getByText('1时').first()).toBeVisible()
const card = page.locator('.watch-slot-card--filled').first()
await expect(card).toBeVisible()
await expect(card.getByText('时长')).toBeVisible()
const switchEl = card.locator('.watch-slot-switch input[type="checkbox"]')
if (!(await switchEl.isChecked())) {
await card.locator('.watch-slot-switch').click()
await page.waitForTimeout(1500)
}
await expect(card.getByText('内存').first()).toBeVisible({ timeout: 15000 })
await expect(card.getByText('硬盘').first()).toBeVisible()
await expect(card.getByText('负载').first()).toBeVisible()
await expect(card.locator('.watch-metric-bar').first()).toBeVisible()
})
+23 -8
View File
@@ -1,5 +1,5 @@
<template> <template>
<v-app> <v-app :style="appChromeVars">
<!-- App Bar (隐藏于登录页) --> <!-- App Bar (隐藏于登录页) -->
<v-app-bar v-if="auth.isLoggedIn" color="primary" elevation="0" flat class="ps-4"> <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" /> <v-app-bar-nav-icon class="mr-3" @click="drawer = !drawer" />
@@ -202,8 +202,10 @@
</v-alert> </v-alert>
</div> </div>
<GlobalBrowserPanel v-if="auth.isLoggedIn" />
<!-- Main Content --> <!-- Main Content -->
<v-main :class="mainLayoutClass" :style="scriptExecBarOffset"> <v-main :class="mainLayoutClass" :style="mainContentOffset">
<router-view v-slot="{ Component, route: activeRoute }"> <router-view v-slot="{ Component, route: activeRoute }">
<keep-alive :include="cachedPageNames" :max="12"> <keep-alive :include="cachedPageNames" :max="12">
<component :is="Component" :key="activeRoute.name ?? activeRoute.path" /> <component :is="Component" :key="activeRoute.name ?? activeRoute.path" />
@@ -249,6 +251,7 @@ import { clearCachedQueries } from '@/composables/useCachedQuery'
import { scheduleRoutePrefetch, cancelRoutePrefetch } from '@/composables/useRoutePrefetch' import { scheduleRoutePrefetch, cancelRoutePrefetch } from '@/composables/useRoutePrefetch'
import { CACHED_PAGE_NAMES } from '@/constants/cachedPages' import { CACHED_PAGE_NAMES } from '@/constants/cachedPages'
import { api } from '@/api' import { api } from '@/api'
import GlobalBrowserPanel from '@/components/GlobalBrowserPanel.vue'
const cachedPageNames = [...CACHED_PAGE_NAMES] const cachedPageNames = [...CACHED_PAGE_NAMES]
@@ -330,12 +333,24 @@ const mainLayoutClass = computed(() => ({
'nexus-page-main': route.path !== '/terminal' && route.path !== '/login', 'nexus-page-main': route.path !== '/terminal' && route.path !== '/login',
})) }))
const scriptExecBarOffset = computed(() => { const BROWSER_PANEL_HEIGHT = 420
const n = runningItems.value.length
if (!n || !auth.isLoggedIn) return undefined const appChromeVars = computed(() => {
const extra = n * 52 const dock = auth.isLoggedIn ? `${BROWSER_PANEL_HEIGHT}px` : '0px'
return { return {
paddingTop: `calc(var(--v-layout-top, 64px) + ${extra}px)`, '--nexus-browser-dock-height': dock,
'--nexus-browser-dock-offset': dock,
}
})
const mainContentOffset = 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)`,
} }
}) })
@@ -437,7 +452,7 @@ window.$snackbar = (text: string, color = 'success') => {
.nexus-script-exec-bar { .nexus-script-exec-bar {
position: fixed; position: fixed;
top: var(--v-layout-top, 64px); top: calc(var(--v-layout-top, 64px) + var(--nexus-browser-dock-offset, 0px));
left: var(--v-layout-left, 0); left: var(--v-layout-left, 0);
right: 0; right: 0;
z-index: 1005; z-index: 1005;
@@ -0,0 +1,328 @@
<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>
@@ -138,8 +138,7 @@ async function save() {
snackbar('名称和命令不能为空', 'warning') snackbar('名称和命令不能为空', 'warning')
return return
} }
let cmdText = formCmd.value.trim() const cmdText = formCmd.value.trim()
if (!cmdText.endsWith('\r')) cmdText += '\r'
saving.value = true saving.value = true
try { try {
if (editingId.value) await update(editingId.value, formName.value, cmdText) if (editingId.value) await update(editingId.value, formName.value, cmdText)
+12 -47
View File
@@ -37,13 +37,13 @@ const p = useFilesPageContext()
show-select show-select
return-object return-object
hover hover
density="comfortable" density="compact"
item-value="name" item-value="name"
:row-props="p.fileRowProps" :row-props="p.fileRowProps"
> >
<template #item.name="{ item }"> <template #item.name="{ item }">
<div class="d-flex align-center ga-2 min-w-0"> <div class="d-flex align-center ga-2 min-w-0">
<v-icon :color="p.entryIconColor(item)" size="20"> <v-icon :color="p.entryIconColor(item)" size="18">
{{ p.entryIcon(item) }} {{ p.entryIcon(item) }}
</v-icon> </v-icon>
<v-icon <v-icon
@@ -96,41 +96,28 @@ const p = useFilesPageContext()
<div class="files-row-actions"> <div class="files-row-actions">
<v-btn <v-btn
v-if="p.isRegularFile(item)" v-if="p.isRegularFile(item)"
variant="outlined" variant="text"
size="small" size="x-small"
color="primary" color="primary"
class="files-action-btn" density="compact"
prepend-icon="mdi-pencil-outline"
@click.stop="p.editFile(item)" @click.stop="p.editFile(item)"
> >
编辑 编辑
</v-btn> </v-btn>
<v-btn <v-btn
v-if="p.isRegularFile(item)" v-if="p.isRegularFile(item)"
variant="outlined" variant="text"
size="small" size="x-small"
class="files-action-btn" density="compact"
prepend-icon="mdi-eye-outline"
@click.stop="p.previewFile(item)" @click.stop="p.previewFile(item)"
> >
查看 查看
</v-btn> </v-btn>
<v-btn <v-btn
v-if="p.isRegularFile(item)" variant="text"
variant="outlined" size="x-small"
size="small"
class="files-action-btn"
prepend-icon="mdi-download-outline"
@click.stop="p.downloadFile(item)"
>
下载
</v-btn>
<v-btn
variant="outlined"
size="small"
color="error" color="error"
class="files-action-btn" density="compact"
prepend-icon="mdi-delete-outline"
@click.stop="p.confirmDeleteFile(item)" @click.stop="p.confirmDeleteFile(item)"
> >
删除 删除
@@ -176,13 +163,6 @@ const p = useFilesPageContext()
> >
<v-list-item-title>查看</v-list-item-title> <v-list-item-title>查看</v-list-item-title>
</v-list-item> </v-list-item>
<v-list-item
v-if="p.contextFile && p.isRegularFile(p.contextFile)"
prepend-icon="mdi-download"
@click="p.contextAction('download')"
>
<v-list-item-title>下载</v-list-item-title>
</v-list-item>
<v-list-item <v-list-item
v-if="p.contextFile" v-if="p.contextFile"
prepend-icon="mdi-content-copy" prepend-icon="mdi-content-copy"
@@ -260,26 +240,11 @@ const p = useFilesPageContext()
outline-offset: -2px; outline-offset: -2px;
} }
/* 行内操作:独立小按钮、字号略大、横向可换行 */
.files-row-actions { .files-row-actions {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
justify-content: flex-end; justify-content: flex-end;
align-items: center; align-items: center;
gap: 6px; gap: 2px;
padding: 2px 0;
}
.files-action-btn {
text-transform: none;
font-size: 0.8125rem;
font-weight: 500;
letter-spacing: 0.01em;
min-height: 32px;
padding-inline: 10px 12px;
}
.files-action-btn :deep(.v-btn__prepend) {
margin-inline-end: 4px;
} }
</style> </style>
@@ -97,8 +97,7 @@ const p = useFilesPageContext()
size="small" size="small"
variant="tonal" variant="tonal"
prepend-icon="mdi-file-plus" prepend-icon="mdi-file-plus"
:disabled="!p.selectedServer" @click="p.openNewFileDialog()"
@click="p.showNewFile = true"
> >
新建文件 新建文件
</v-btn> </v-btn>
@@ -4,24 +4,17 @@
服务器 服务器
<span class="text-caption text-medium-emphasis ml-1">({{ servers.length }})</span> <span class="text-caption text-medium-emphasis ml-1">({{ servers.length }})</span>
</div> </div>
<v-skeleton-loader v-if="loading" type="list-item@8" class="px-2 pt-2" /> <div class="terminal-server-rail__search px-2 pt-2 pb-1">
<v-list v-else density="compact" nav class="terminal-server-rail__list flex-grow-1 py-0"> <PickerSearch
<v-list-item :search="search"
v-for="s in servers" :search-history="searchHistory"
:key="s.id" @update:search="$emit('update:search', $event)"
rounded="lg" @search-commit="$emit('search-commit')"
class="mb-1" />
@click="$emit('select', s.id)" </div>
> <v-skeleton-loader v-if="loading" type="list-item@8" class="px-2 pt-1" />
<template #prepend> <v-list v-else density="compact" nav class="terminal-server-rail__list flex-grow-1 py-0 px-2">
<v-icon :color="s.is_online ? 'success' : 'grey'" size="8">mdi-circle</v-icon> <PickerListItems :servers="servers" @select="$emit('select', $event)" />
</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-truncate" :title="s.domain">{{ s.domain }}</v-list-item-subtitle>
</v-list-item>
<v-list-item v-if="servers.length === 0" class="text-medium-emphasis">
暂无匹配的服务器
</v-list-item>
</v-list> </v-list>
</aside> </aside>
@@ -2,16 +2,20 @@
<v-list-item <v-list-item
v-for="s in servers" v-for="s in servers"
:key="s.id" :key="s.id"
:title="s.name"
:subtitle="s.domain"
rounded="lg" rounded="lg"
density="compact"
class="terminal-server-item"
@click="$emit('select', s.id)" @click="$emit('select', s.id)"
> >
<template #prepend> <template #prepend>
<v-icon :color="s.is_online ? 'success' : 'grey'" size="8">mdi-circle</v-icon> <v-icon :color="s.is_online ? 'success' : 'grey'" size="8">mdi-circle</v-icon>
</template> </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">
{{ s.domain }}
</v-list-item-subtitle>
</v-list-item> </v-list-item>
<v-list-item v-if="servers.length === 0" class="text-medium-emphasis"> <v-list-item v-if="servers.length === 0" density="compact" class="text-medium-emphasis terminal-server-item">
暂无匹配的服务器 暂无匹配的服务器
</v-list-item> </v-list-item>
</template> </template>
@@ -27,3 +31,13 @@ defineEmits<{
select: [serverId: number] select: [serverId: number]
}>() }>()
</script> </script>
<style scoped>
.terminal-server-item {
min-height: 40px !important;
padding-block: 2px;
}
.terminal-server-item :deep(.v-list-item__prepend) {
align-self: center;
}
</style>
@@ -0,0 +1,32 @@
<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>
@@ -37,8 +37,8 @@ const headers = [
{ title: '状态', key: 'probe_status', width: 100 }, { title: '状态', key: 'probe_status', width: 100 },
{ title: '来源', key: 'source', width: 72 }, { title: '来源', key: 'source', width: 72 },
{ title: 'CPU', key: 'cpu_pct', width: 64 }, { title: 'CPU', key: 'cpu_pct', width: 64 },
{ title: 'MEM', key: 'mem_pct', width: 64 }, { title: '内存', key: 'mem_pct', width: 64 },
{ title: 'DISK', key: 'disk_pct', width: 64 }, { title: '硬盘', key: 'disk_pct', width: 64 },
{ title: '↑', key: 'net_up_bps', width: 88 }, { title: '↑', key: 'net_up_bps', width: 88 },
{ title: '↓', key: 'net_down_bps', width: 88 }, { title: '↓', key: 'net_down_bps', width: 88 },
{ title: '耗时', key: 'duration_ms', width: 72 }, { title: '耗时', key: 'duration_ms', width: 72 },
+107 -31
View File
@@ -1,8 +1,16 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import type { WatchSlot } from '@/composables/useWatchPins' import type { WatchSlot } from '@/composables/useWatchPins'
import { formatBytesPerSec, formatRemaining, probeStatusColor, probeStatusLabel, formatProbeError, isPsutilMissingError } from '@/utils/watchFormat' 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 WatchSparkline from '@/components/watch/WatchSparkline.vue' import WatchSparkline from '@/components/watch/WatchSparkline.vue'
import WatchMetricBar from '@/components/watch/WatchMetricBar.vue'
const props = defineProps<{ const props = defineProps<{
slot: WatchSlot slot: WatchSlot
@@ -15,21 +23,22 @@ const emit = defineEmits<{
history: [slot: WatchSlot] history: [slot: WatchSlot]
pick: [slotIndex: number] pick: [slotIndex: number]
monitoring: [slotIndex: number, enabled: boolean] monitoring: [slotIndex: number, enabled: boolean]
ttl: [slotIndex: number, hours: 8 | 24] ttl: [slotIndex: number, minutes: WatchTtlMinutes]
installPsutil: [slotIndex: number] installPsutil: [slotIndex: number]
}>() }>()
const monitoringOn = computed(() => props.slot.monitoring_enabled !== false) const monitoringOn = computed(() => props.slot.monitoring_enabled !== false)
const slotTtl = computed(() => (props.slot.ttl_hours === 24 ? 24 : 8) as 8 | 24) const slotTtlMinutes = computed(() =>
normalizeWatchTtlMinutes(props.slot.ttl_minutes ?? (props.slot.ttl_hours === 24 ? 1440 : props.slot.ttl_hours === 8 ? 480 : undefined)),
)
const showPsutilInstall = computed(() => const showPsutilInstall = computed(() =>
monitoringOn.value && isPsutilMissingError(props.slot.metrics?.error, props.slot.metrics?.probe_status), monitoringOn.value && isPsutilMissingError(props.slot.metrics?.error, props.slot.metrics?.probe_status),
) )
const chartMetric = ref<'cpu_pct' | 'mem_pct' | 'disk_pct'>('cpu_pct') const chartMetric = ref<'cpu_pct' | 'mem_pct' | 'disk_pct'>('cpu_pct')
function pct(v: number | null | undefined) { const load1 = computed(() => props.slot.metrics?.load_1)
return v != null ? `${v}%` : '—' const metricsPending = computed(() => monitoringOn.value && isWatchMetricsPending(props.slot.metrics))
}
</script> </script>
<template> <template>
@@ -38,6 +47,8 @@ function pct(v: number | null | undefined) {
elevation="0" elevation="0"
border border
rounded="lg" rounded="lg"
:link="false"
:ripple="false"
class="watch-slot-card pa-3 watch-slot-card--filled" class="watch-slot-card pa-3 watch-slot-card--filled"
:class="{ :class="{
'watch-slot-card--error': monitoringOn && slot.metrics?.probe_status && slot.metrics.probe_status !== 'ok', 'watch-slot-card--error': monitoringOn && slot.metrics?.probe_status && slot.metrics.probe_status !== 'ok',
@@ -67,19 +78,22 @@ function pct(v: number | null | undefined) {
<v-btn icon="mdi-close" size="x-small" variant="text" @click="emit('remove', slot.slot_index)" /> <v-btn icon="mdi-close" size="x-small" variant="text" @click="emit('remove', slot.slot_index)" />
</div> </div>
<div class="d-flex align-center mb-2 ga-1" @click.stop> <div class="watch-ttl-row mb-2" @click.stop>
<span class="text-caption text-medium-emphasis mr-1">时长</span> <span class="text-caption text-medium-emphasis watch-ttl-label">时长</span>
<v-btn-toggle <div class="watch-ttl-chips d-flex flex-wrap ga-1">
:model-value="slotTtl" <v-chip
density="compact" v-for="opt in WATCH_TTL_OPTIONS"
variant="outlined" :key="opt.minutes"
divided :color="slotTtlMinutes === opt.minutes ? 'primary' : undefined"
mandatory :variant="slotTtlMinutes === opt.minutes ? 'flat' : 'outlined'"
@update:model-value="emit('ttl', slot.slot_index, ($event ?? 8) as 8 | 24)" size="x-small"
> label
<v-btn :value="8" size="x-small">8h</v-btn> class="watch-ttl-chip"
<v-btn :value="24" size="x-small">24h</v-btn> @click="emit('ttl', slot.slot_index, opt.minutes)"
</v-btn-toggle> >
{{ opt.label }}
</v-chip>
</div>
</div> </div>
<div v-if="!monitoringOn" class="text-caption text-medium-emphasis mb-2" @click.stop> <div v-if="!monitoringOn" class="text-caption text-medium-emphasis mb-2" @click.stop>
@@ -88,6 +102,18 @@ function pct(v: number | null | undefined) {
<template v-if="monitoringOn"> <template v-if="monitoringOn">
<div @click.stop> <div @click.stop>
<div
v-if="metricsPending"
class="watch-slot-waiting d-flex flex-column align-center justify-center text-center py-6"
>
<v-progress-circular indeterminate color="primary" size="36" width="3" />
<div class="text-body-2 mt-3">等待服务器回传信息</div>
<div class="text-caption text-medium-emphasis mt-1 px-2">
探针约每 5 秒轮询首次通常 515
</div>
</div>
<template v-else>
<v-chip <v-chip
v-if="slot.metrics?.probe_status" v-if="slot.metrics?.probe_status"
size="x-small" size="x-small"
@@ -99,10 +125,25 @@ function pct(v: number | null | undefined) {
{{ probeStatusLabel(slot.metrics.probe_status) }} {{ probeStatusLabel(slot.metrics.probe_status) }}
</v-chip> </v-chip>
<div class="d-flex justify-space-between text-caption mb-1"> <WatchMetricBar label="CPU" :value="slot.metrics?.cpu_pct" />
<span>CPU {{ pct(slot.metrics?.cpu_pct) }}</span> <WatchMetricBar label="内存" :value="slot.metrics?.mem_pct" />
<span>MEM {{ pct(slot.metrics?.mem_pct) }}</span> <WatchMetricBar label="硬盘" :value="slot.metrics?.disk_pct" />
<span>DISK {{ pct(slot.metrics?.disk_pct) }}</span>
<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> </div>
<div class="text-caption text-medium-emphasis mb-1"> <div class="text-caption text-medium-emphasis mb-1">
@@ -116,8 +157,8 @@ function pct(v: number | null | undefined) {
<v-btn-toggle v-model="chartMetric" density="compact" variant="outlined" divided class="mb-1"> <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="cpu_pct" size="x-small">CPU</v-btn>
<v-btn value="mem_pct" size="x-small">MEM</v-btn> <v-btn value="mem_pct" size="x-small">内存</v-btn>
<v-btn value="disk_pct" size="x-small">DISK</v-btn> <v-btn value="disk_pct" size="x-small">硬盘</v-btn>
</v-btn-toggle> </v-btn-toggle>
<WatchSparkline :points="slot.sparkline || []" :metric="chartMetric" /> <WatchSparkline :points="slot.sparkline || []" :metric="chartMetric" />
@@ -145,18 +186,26 @@ function pct(v: number | null | undefined) {
<div class="d-flex align-center justify-space-between mt-2"> <div class="d-flex align-center justify-space-between mt-2">
<span class="text-caption text-medium-emphasis"> <span class="text-caption text-medium-emphasis">
剩余 {{ formatRemaining(slot.remaining_sec) }} · {{ slotTtl }}h 剩余 {{ formatRemaining(slot.remaining_sec) }} · {{ watchTtlShortLabel(slotTtlMinutes) }}
</span> </span>
<div class="d-flex ga-1"> <div class="d-flex ga-1">
<v-btn size="x-small" variant="text" @click.stop="emit('processes', slot)">进程</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> <v-btn size="x-small" variant="text" @click.stop="emit('history', slot)">历史</v-btn>
</div> </div>
</div> </div>
</template>
<div v-if="metricsPending" class="d-flex align-center justify-space-between mt-2">
<span class="text-caption text-medium-emphasis">
剩余 {{ formatRemaining(slot.remaining_sec) }} · {{ watchTtlShortLabel(slotTtlMinutes) }}
</span>
<v-btn size="x-small" variant="text" @click.stop="emit('history', slot)">历史</v-btn>
</div>
</div> </div>
</template> </template>
<div v-else class="d-flex align-center justify-space-between mt-2" @click.stop> <div v-else class="d-flex align-center justify-space-between mt-2" @click.stop>
<span class="text-caption text-medium-emphasis">已暂停 · {{ slotTtl }}h</span> <span class="text-caption text-medium-emphasis">已暂停 · {{ watchTtlLabel(slotTtlMinutes) }}</span>
<div class="d-flex ga-1"> <div class="d-flex ga-1">
<v-btn size="x-small" variant="text" @click.stop="emit('history', slot)">历史</v-btn> <v-btn size="x-small" variant="text" @click.stop="emit('history', slot)">历史</v-btn>
</div> </div>
@@ -168,6 +217,8 @@ function pct(v: number | null | undefined) {
elevation="0" elevation="0"
border border
rounded="lg" rounded="lg"
:link="false"
:ripple="false"
class="watch-slot-card watch-slot-card--empty d-flex align-center justify-center pa-4" class="watch-slot-card watch-slot-card--empty d-flex align-center justify-center pa-4"
variant="outlined" variant="outlined"
role="button" role="button"
@@ -184,22 +235,27 @@ function pct(v: number | null | undefined) {
<style scoped> <style scoped>
.watch-slot-card { .watch-slot-card {
min-height: 220px; min-height: 280px;
}
/* Vuetify v-card--link 会在 hover 时铺 v-card__overlay 灰层,此处仅保留外框高亮 */
.watch-slot-card :deep(.v-card__overlay) {
opacity: 0 !important;
} }
.watch-slot-card--empty { .watch-slot-card--empty {
min-height: 220px; min-height: 220px;
border-style: dashed !important; border-style: dashed !important;
cursor: pointer; cursor: pointer;
transition: background-color 0.15s ease; transition: border-color 0.15s ease;
} }
.watch-slot-card--empty:hover { .watch-slot-card--empty:hover {
background: rgba(var(--v-theme-primary), 0.04); border-color: rgb(var(--v-theme-primary)) !important;
} }
.watch-slot-card--filled { .watch-slot-card--filled {
cursor: pointer; cursor: pointer;
transition: border-color 0.15s ease;
} }
.watch-slot-card--filled:hover { .watch-slot-card--filled:hover {
background: rgba(var(--v-theme-surface-variant), 0.3); border-color: rgb(var(--v-theme-primary)) !important;
} }
.watch-slot-card--error { .watch-slot-card--error {
border-color: rgb(var(--v-theme-error)) !important; border-color: rgb(var(--v-theme-error)) !important;
@@ -212,4 +268,24 @@ function pct(v: number | null | undefined) {
flex: 0 0 auto; flex: 0 0 auto;
transform: scale(0.85); transform: scale(0.85);
} }
.watch-ttl-row {
display: flex;
align-items: flex-start;
gap: 6px;
}
.watch-ttl-label {
flex: 0 0 auto;
line-height: 24px;
}
.watch-ttl-chips {
flex: 1 1 auto;
min-width: 0;
}
.watch-ttl-chip {
cursor: pointer;
user-select: none;
}
.watch-slot-waiting {
min-height: 160px;
}
</style> </style>
@@ -8,6 +8,7 @@ import { useWatchPins, type WatchSlot } from '@/composables/useWatchPins'
import { useSnackbar } from '@/composables/useSnackbar' import { useSnackbar } from '@/composables/useSnackbar'
import { formatApiError } from '@/utils/apiError' import { formatApiError } from '@/utils/apiError'
import { http } from '@/api' import { http } from '@/api'
import { watchTtlShortLabel, type WatchTtlMinutes } from '@/constants/watchTtl'
const router = useRouter() const router = useRouter()
const snackbar = useSnackbar() const snackbar = useSnackbar()
@@ -42,12 +43,12 @@ async function onMonitoring(slotIndex: number, enabled: boolean) {
} }
} }
async function onTtl(slotIndex: number, hours: 8 | 24) { async function onTtl(slotIndex: number, minutes: WatchTtlMinutes) {
const slot = slots.value.find((s) => s.slot_index === slotIndex) const slot = slots.value.find((s) => s.slot_index === slotIndex)
if (!slot || slot.empty || slot.ttl_hours === hours) return if (!slot || slot.empty || slot.ttl_minutes === minutes) return
try { try {
await setSlotTtl(slotIndex, hours) await setSlotTtl(slotIndex, minutes)
snackbar(`监测时长已设为 ${hours} 小时 · 槽 ${slotIndex + 1}`, 'success') snackbar(`监测时长已设为 ${watchTtlShortLabel(minutes)} · 槽 ${slotIndex + 1}`, 'success')
} catch (e: unknown) { } catch (e: unknown) {
snackbar(formatApiError(e, '更新监测时长失败'), 'error') snackbar(formatApiError(e, '更新监测时长失败'), 'error')
} }
@@ -114,7 +115,7 @@ async function onPickServer(serverId: number) {
<div class="watch-slot-row mb-4"> <div class="watch-slot-row mb-4">
<div class="d-flex align-center mb-2"> <div class="d-flex align-center mb-2">
<span class="text-subtitle-2">实时监测</span> <span class="text-subtitle-2">实时监测</span>
<v-chip size="x-small" variant="tonal" class="ml-2" label>最多 4 · 默认 8h · 暂停不占 TTL</v-chip> <v-chip size="x-small" variant="tonal" class="ml-2" label>最多 4 · 默认 30 · 暂停不占 TTL</v-chip>
<v-spacer /> <v-spacer />
<v-btn size="small" variant="text" prepend-icon="mdi-history" :to="{ name: 'WatchMetrics' }"> <v-btn size="small" variant="text" prepend-icon="mdi-history" :to="{ name: 'WatchMetrics' }">
监测历史 监测历史
@@ -6,7 +6,6 @@ import { useSnackbar } from '@/composables/useSnackbar'
import { useFilesStore } from '@/stores/files' import { useFilesStore } from '@/stores/files'
import type { FileEntry } from '@/types/api' import type { FileEntry } from '@/types/api'
import { isArchiveFile } from '@/utils/fileSort' import { isArchiveFile } from '@/utils/fileSort'
import { saveBlobAsFile } from '@/utils/fileDownload'
import { invalidateCachedFile } from '@/utils/filePreload' import { invalidateCachedFile } from '@/utils/filePreload'
import { import {
joinRemotePath, joinRemotePath,
@@ -27,6 +26,7 @@ export function useFilesActions(options: {
previewFile: (item: FileEntry) => Promise<void> previewFile: (item: FileEntry) => Promise<void>
openInTerminal: () => void openInTerminal: () => void
editorWorkbench: Ref<{ openFile: (p: OpenFilePayload) => void } | null> editorWorkbench: Ref<{ openFile: (p: OpenFilePayload) => void } | null>
waitForEditorWorkbench?: () => Promise<{ openFile: (p: OpenFilePayload) => void } | null>
actionLoading?: Ref<boolean> actionLoading?: Ref<boolean>
}) { }) {
const snackbar = useSnackbar() const snackbar = useSnackbar()
@@ -172,9 +172,6 @@ export function useFilesActions(options: {
case 'preview': case 'preview':
void options.previewFile(item) void options.previewFile(item)
break break
case 'download':
void downloadFile(item)
break
case 'copyPath': case 'copyPath':
void copyRemotePath(item) void copyRemotePath(item)
break break
@@ -293,7 +290,11 @@ export function useFilesActions(options: {
} }
async function doNewFile() { async function doNewFile() {
if (!selectedServer.value || !newFileName.value) return if (!selectedServer.value) {
snackbar('请先选择服务器', 'warning')
return
}
if (!newFileName.value) return
const check = validatePathSegment(newFileName.value) const check = validatePathSegment(newFileName.value)
if (check !== true) { if (check !== true) {
snackbar(check, 'warning') snackbar(check, 'warning')
@@ -315,7 +316,13 @@ export function useFilesActions(options: {
await options.browse({ force: true }) await options.browse({ force: true })
snackbar('文件已创建') snackbar('文件已创建')
await nextTick() await nextTick()
options.editorWorkbench.value?.openFile({ const wb =
(await options.waitForEditorWorkbench?.()) ?? options.editorWorkbench.value
if (!wb?.openFile) {
snackbar('文件已创建;编辑器未就绪,请从列表双击打开', 'warning')
return
}
wb.openFile({
path, path,
name, name,
content: '', content: '',
@@ -414,22 +421,6 @@ export function useFilesActions(options: {
else snackbar(`删除完成:成功 ${ok},失败 ${fail}`, fail > 0 ? 'warning' : 'success') else snackbar(`删除完成:成功 ${ok},失败 ${fail}`, fail > 0 ? 'warning' : 'success')
} }
async function downloadFile(item: FileEntry) {
if (!selectedServer.value) return
try {
const remotePath = joinRemotePath(currentPath.value, item.name)
const { blob, filename } = await http.download('/sync/download', {
server_id: selectedServer.value,
path: remotePath,
})
saveBlobAsFile(blob, filename)
snackbar(`已下载 ${filename}`)
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : '下载失败'
snackbar(msg, 'error')
}
}
async function confirmDeleteFile(item: FileEntry) { async function confirmDeleteFile(item: FileEntry) {
deletingFile.value = item deletingFile.value = item
showFileDelete.value = true showFileDelete.value = true
@@ -561,6 +552,14 @@ export function useFilesActions(options: {
showContextMenu.value = true showContextMenu.value = true
} }
function openNewFileDialog() {
if (!selectedServer.value) {
snackbar('请先选择服务器', 'warning')
return
}
showNewFile.value = true
}
return { return {
dropActive, dropActive,
showUpload, showUpload,
@@ -600,12 +599,12 @@ export function useFilesActions(options: {
doCompress, doCompress,
doDecompress, doDecompress,
doNewFile, doNewFile,
openNewFileDialog,
startRename, startRename,
doRename, doRename,
startChmod, startChmod,
doChmod, doChmod,
batchDelete, batchDelete,
downloadFile,
confirmDeleteFile, confirmDeleteFile,
doDeleteFile, doDeleteFile,
doMkdir, doMkdir,
@@ -63,7 +63,7 @@ export function useFilesEditor(options: {
function rejectOversizedFile(item: FileEntry, action: '编辑' | '查看'): boolean { function rejectOversizedFile(item: FileEntry, action: '编辑' | '查看'): boolean {
if (exceedsEditorMaxSize(item.size)) { if (exceedsEditorMaxSize(item.size)) {
snackbar( snackbar(
`文件超过 2MB${formatSize(item.size)}),无法${action},请下载或使用终端`, `文件超过 2MB${formatSize(item.size)}),无法${action},请使用终端`,
'warning', 'warning',
) )
return true return true
@@ -155,6 +155,7 @@ export function useFilesEditor(options: {
previewContent, previewContent,
previewLoading, previewLoading,
editorWorkbench, editorWorkbench,
waitForEditorWorkbench,
previewFile, previewFile,
editFile, editFile,
onEditorSaved, onEditorSaved,
@@ -89,6 +89,7 @@ export function useFilesPage() {
previewFile: editor.previewFile, previewFile: editor.previewFile,
openInTerminal: nav.openInTerminal, openInTerminal: nav.openInTerminal,
editorWorkbench: editor.editorWorkbench, editorWorkbench: editor.editorWorkbench,
waitForEditorWorkbench: editor.waitForEditorWorkbench,
actionLoading, actionLoading,
}) })
@@ -140,7 +141,7 @@ export function useFilesPage() {
{ title: '归属', key: 'owner', width: 110, sortable: false }, { title: '归属', key: 'owner', width: 110, sortable: false },
{ title: '大小', key: 'size', width: 120 }, { title: '大小', key: 'size', width: 120 },
{ title: '修改时间', key: 'modified', width: 180 }, { title: '修改时间', key: 'modified', width: 180 },
{ title: '操作', key: 'actions', width: 300, sortable: false, align: 'end' as const }, { title: '操作', key: 'actions', width: 160, sortable: false, align: 'end' as const },
] ]
useFilesHotkeys( useFilesHotkeys(
@@ -228,6 +229,7 @@ export function useFilesPage() {
showNewFile: actions.showNewFile, showNewFile: actions.showNewFile,
newFileName: actions.newFileName, newFileName: actions.newFileName,
doNewFile: actions.doNewFile, doNewFile: actions.doNewFile,
openNewFileDialog: actions.openNewFileDialog,
showMkdir: actions.showMkdir, showMkdir: actions.showMkdir,
mkdirName: actions.mkdirName, mkdirName: actions.mkdirName,
doMkdir: actions.doMkdir, doMkdir: actions.doMkdir,
@@ -268,7 +270,6 @@ export function useFilesPage() {
pasteClipboard: actions.pasteClipboard, pasteClipboard: actions.pasteClipboard,
editFile: editor.editFile, editFile: editor.editFile,
previewFile: editor.previewFile, previewFile: editor.previewFile,
downloadFile: actions.downloadFile,
confirmDeleteFile: actions.confirmDeleteFile, confirmDeleteFile: actions.confirmDeleteFile,
openInTerminal: nav.openInTerminal, openInTerminal: nav.openInTerminal,
} }
@@ -5,7 +5,11 @@ import { FitAddon } from '@xterm/addon-fit'
import { WebLinksAddon } from '@xterm/addon-web-links' import { WebLinksAddon } from '@xterm/addon-web-links'
import { http } from '@/api' import { http } from '@/api'
import { useSnackbar } from '@/composables/useSnackbar' import { useSnackbar } from '@/composables/useSnackbar'
import { sortQuickCommands, type QuickCmdItem } from '@/composables/useTerminalQuickCommands' import {
formatQuickCmdForSend,
sortQuickCommands,
type QuickCmdItem,
} from '@/composables/useTerminalQuickCommands'
import { buildWebSocketUrl } from '@/utils/wsUrl' import { buildWebSocketUrl } from '@/utils/wsUrl'
import { import {
createNative, createNative,
@@ -704,7 +708,7 @@ export function useTerminalSessions(nexusDrawer: Ref<boolean>) {
snackbar(s?.status === 'connecting' ? '终端连接中,请稍候' : '终端未连接', 'warning') snackbar(s?.status === 'connecting' ? '终端连接中,请稍候' : '终端未连接', 'warning')
return return
} }
sendWsData(s.id, cmd) sendWsData(s.id, formatQuickCmdForSend(cmd))
getNative(s.id)?.term?.focus() getNative(s.id)?.term?.focus()
} }
@@ -0,0 +1,382 @@
/**
* Global floating browser persists tabs/history to MySQL via /api/browser/state.
*/
import { computed, ref } from 'vue'
import { http } from '@/api'
import { normalizeBrowserUrl } from '@/utils/browserUrl'
import type { FloatRect } from '@/composables/useFloatingPanel'
const LEGACY_HISTORY_KEY = 'nexus_browser_history_v1'
const SAVE_DEBOUNCE_MS = 700
export interface BrowserVisit {
url: string
title: string
at: string
}
export interface BrowserTab {
id: string
url: string
title: string
stack: string[]
stackIndex: number
}
export interface BrowserStateResponse {
url_history: string[]
visits: BrowserVisit[]
tabs: Array<{
id: string
url: string
title?: string
stack?: string[]
stack_index?: number
}>
active_tab_id: string | null
minimized: boolean
rect: FloatRect | null
minimized_rect: FloatRect | null
}
const bootstrapped = ref(false)
const saving = ref(false)
const panelOpen = ref(false)
const minimized = ref(false)
const maximized = ref(false)
const lastError = ref<string | null>(null)
const addressDraft = ref('')
const urlHistory = ref<string[]>([])
const visits = ref<BrowserVisit[]>([])
const tabs = ref<BrowserTab[]>([])
const activeTabId = ref<string | null>(null)
const panelRect = ref<FloatRect | null>(null)
const minimizedRect = ref<FloatRect | null>(null)
let saveTimer: ReturnType<typeof setTimeout> | null = null
function tabLabel(tab: BrowserTab): string {
try {
return tab.title || new URL(tab.url).hostname
} catch {
return tab.title || tab.url
}
}
function newTabId(): string {
return crypto.randomUUID()
}
function serializeTabs(): BrowserStateResponse['tabs'] {
return tabs.value.map((t) => ({
id: t.id,
url: t.url,
title: t.title,
stack: t.stack,
stack_index: t.stackIndex,
}))
}
function applyServerState(data: BrowserStateResponse) {
urlHistory.value = data.url_history || []
visits.value = (data.visits || []).map((v) => ({
url: v.url,
title: v.title || v.url,
at: v.at,
}))
tabs.value = (data.tabs || []).map((t) => ({
id: t.id,
url: t.url,
title: t.title || t.url,
stack: t.stack?.length ? t.stack : (t.url ? [t.url] : []),
stackIndex: typeof t.stack_index === 'number' ? t.stack_index : (t.stack?.length ?? 1) - 1,
}))
activeTabId.value = data.active_tab_id
if (!activeTabId.value && tabs.value.length) {
activeTabId.value = tabs.value[0]!.id
}
minimized.value = Boolean(data.minimized)
panelRect.value = data.rect
minimizedRect.value = data.minimized_rect
panelOpen.value = tabs.value.length > 0 && !minimized.value
syncAddressFromActive()
}
function syncAddressFromActive() {
const tab = activeTab.value
addressDraft.value = tab?.url ?? ''
}
const activeTab = computed(() => tabs.value.find((t) => t.id === activeTabId.value) ?? null)
const isActive = computed(() => tabs.value.length > 0 || panelOpen.value)
const canGoBack = computed(() => {
const tab = activeTab.value
return tab ? tab.stackIndex > 0 : false
})
const canGoForward = computed(() => {
const tab = activeTab.value
return tab ? tab.stackIndex < tab.stack.length - 1 : false
})
function scheduleSave(extra?: { recordUrl?: string; recordTitle?: string }) {
if (saveTimer) clearTimeout(saveTimer)
saveTimer = setTimeout(() => {
void persistState(extra)
}, SAVE_DEBOUNCE_MS)
}
async function persistState(extra?: { recordUrl?: string; recordTitle?: string }) {
saving.value = true
try {
const body: Record<string, unknown> = {
tabs: serializeTabs(),
active_tab_id: activeTabId.value,
minimized: minimized.value,
rect: panelRect.value,
minimized_rect: minimizedRect.value,
}
if (extra?.recordUrl) {
body.record_url = extra.recordUrl
if (extra.recordTitle) body.record_title = extra.recordTitle
} else {
body.url_history = urlHistory.value
body.visits = visits.value
}
const res = await http.put<BrowserStateResponse>('/browser/state', body)
applyServerState(res)
} catch {
/* keep local state */
} finally {
saving.value = false
}
}
async function migrateLegacyHistory() {
try {
const raw = sessionStorage.getItem(LEGACY_HISTORY_KEY)
if (!raw) return
const parsed = JSON.parse(raw) as unknown
if (!Array.isArray(parsed)) return
const urls = parsed.filter((u): u is string => typeof u === 'string' && !!normalizeBrowserUrl(u))
if (!urls.length) return
for (const url of [...urls].reverse()) {
const normalized = normalizeBrowserUrl(url)
if (normalized) {
await persistState({ recordUrl: normalized, recordTitle: normalized })
}
}
sessionStorage.removeItem(LEGACY_HISTORY_KEY)
} catch {
/* ignore */
}
}
async function bootstrap() {
if (bootstrapped.value) return
try {
const res = await http.get<BrowserStateResponse>('/browser/state')
applyServerState(res)
if (!visits.value.length && !urlHistory.value.length) {
await migrateLegacyHistory()
}
} catch {
/* offline */
} finally {
bootstrapped.value = true
}
}
function openPanel() {
panelOpen.value = true
minimized.value = false
scheduleSave()
}
function minimizePanel() {
minimized.value = true
panelOpen.value = false
scheduleSave()
}
function restartBrowser() {
const tab: BrowserTab = {
id: newTabId(),
url: '',
title: '新标签',
stack: [],
stackIndex: -1,
}
tabs.value = [tab]
activeTabId.value = tab.id
addressDraft.value = ''
lastError.value = null
openPanel()
scheduleSave()
}
function openUrl(input: string, opts?: { title?: string; newTab?: boolean }) {
const normalized = normalizeBrowserUrl(input)
if (!normalized) {
lastError.value = '请输入有效的 http 或 https 地址'
return false
}
lastError.value = null
if (opts?.newTab || !tabs.value.length || !activeTab.value) {
const tab: BrowserTab = {
id: newTabId(),
url: normalized,
title: opts?.title || normalized,
stack: [normalized],
stackIndex: 0,
}
tabs.value = [...tabs.value, tab].slice(-12)
activeTabId.value = tab.id
} else {
const tab = activeTab.value
const stack = tab.stack.slice(0, tab.stackIndex + 1)
if (!stack.length || stack[stack.length - 1] !== normalized) stack.push(normalized)
tab.url = normalized
if (opts?.title) tab.title = opts.title
tab.stack = stack
tab.stackIndex = stack.length - 1
}
addressDraft.value = normalized
openPanel()
scheduleSave({ recordUrl: normalized, recordTitle: opts?.title })
return true
}
function navigate(input: string) {
return openUrl(input)
}
function updateActiveFromRemote(url: string, title: string) {
const tab = activeTab.value
if (!tab || !url) return
tab.url = url
if (title) tab.title = title
addressDraft.value = url
scheduleSave({ recordUrl: url, recordTitle: title })
}
function refreshActive() {
/* remote reload handled in panel */
}
function goBack() {
const tab = activeTab.value
if (!tab || tab.stackIndex <= 0) return
tab.stackIndex -= 1
tab.url = tab.stack[tab.stackIndex]!
addressDraft.value = tab.url
scheduleSave()
}
function goForward() {
const tab = activeTab.value
if (!tab || tab.stackIndex >= tab.stack.length - 1) return
tab.stackIndex += 1
tab.url = tab.stack[tab.stackIndex]!
addressDraft.value = tab.url
scheduleSave()
}
function openExternal() {
const tab = activeTab.value
if (!tab?.url) return
window.open(tab.url, '_blank', 'noopener,noreferrer')
}
function switchTab(id: string) {
activeTabId.value = id
syncAddressFromActive()
scheduleSave()
}
function closeTab(id: string) {
if (tabs.value.length <= 1) return
const idx = tabs.value.findIndex((t) => t.id === id)
if (idx < 0) return
const next = tabs.value.filter((t) => t.id !== id)
tabs.value = next
if (activeTabId.value === id) {
activeTabId.value = next[idx]?.id ?? next[idx - 1]?.id ?? null
}
syncAddressFromActive()
scheduleSave()
}
function newEmptyTab() {
const tab: BrowserTab = {
id: newTabId(),
url: '',
title: '新标签',
stack: [],
stackIndex: -1,
}
tabs.value = [...tabs.value, tab].slice(-12)
activeTabId.value = tab.id
addressDraft.value = ''
openPanel()
scheduleSave()
}
function openFromHistory(url: string) {
openUrl(url)
}
function savePanelRect(rect: FloatRect) {
panelRect.value = rect
scheduleSave()
}
function saveMinimizedRect(rect: FloatRect) {
minimizedRect.value = rect
scheduleSave()
}
export function useGlobalBrowser() {
return {
bootstrapped,
saving,
panelOpen,
minimized,
maximized,
lastError,
addressDraft,
urlHistory,
visits,
tabs,
activeTabId,
activeTab,
panelRect,
minimizedRect,
isActive,
canGoBack,
canGoForward,
tabLabel,
bootstrap,
openPanel,
minimizePanel,
restartBrowser,
openUrl,
navigate,
updateActiveFromRemote,
refreshActive,
goBack,
goForward,
openExternal,
switchTab,
closeTab,
newEmptyTab,
openFromHistory,
savePanelRect,
saveMinimizedRect,
scheduleSave,
}
}
@@ -0,0 +1,257 @@
/**
* Remote browser session canvas screencast over /ws/browser/{session_id}.
*/
import { onUnmounted, ref, shallowRef } from 'vue'
import { http } from '@/api'
import { useAuthStore } from '@/stores/auth'
import { buildWebSocketUrl } from '@/utils/wsUrl'
export interface BrowserSessionInfo {
session_id: string
viewport_w: number
viewport_h: number
}
export function useRemoteBrowserSession() {
const connected = ref(false)
const connecting = ref(false)
const remoteError = ref<string | null>(null)
const sessionId = shallowRef<string | null>(null)
const viewportW = ref(1280)
const viewportH = ref(720)
let ws: WebSocket | null = null
let canvasEl: HTMLCanvasElement | null = null
let onPageInfo: ((url: string, title: string) => void) | null = null
let dragActive = false
let lastPointerX = 0
let lastPointerY = 0
function setCanvas(el: HTMLCanvasElement | null) {
canvasEl = el
}
function setPageInfoHandler(fn: (url: string, title: string) => void) {
onPageInfo = fn
}
function send(msg: Record<string, unknown>) {
if (!ws || ws.readyState !== WebSocket.OPEN) return
ws.send(JSON.stringify(msg))
}
function drawFrame(dataB64: string, metadata?: { deviceWidth?: number; deviceHeight?: number }) {
if (!canvasEl || !dataB64) return
const img = new Image()
img.onload = () => {
if (!canvasEl) return
const w = metadata?.deviceWidth || img.width
const h = metadata?.deviceHeight || img.height
if (canvasEl.width !== w) canvasEl.width = w
if (canvasEl.height !== h) canvasEl.height = h
const ctx = canvasEl.getContext('2d')
ctx?.drawImage(img, 0, 0, w, h)
}
img.src = `data:image/jpeg;base64,${dataB64}`
}
function canvasCoords(event: MouseEvent): { x: number; y: number } {
if (!canvasEl) return { x: 0, y: 0 }
const rect = canvasEl.getBoundingClientRect()
const scaleX = canvasEl.width / rect.width
const scaleY = canvasEl.height / rect.height
return {
x: (event.clientX - rect.left) * scaleX,
y: (event.clientY - rect.top) * scaleY,
}
}
function sendPointerMove(x: number, y: number) {
const dx = x - lastPointerX
const dy = y - lastPointerY
const dist = Math.sqrt(dx * dx + dy * dy)
const steps = Math.max(1, Math.min(25, Math.ceil(dist / 6)))
send({ type: 'MOUSE', event: 'move', x, y, steps })
lastPointerX = x
lastPointerY = y
}
function onWindowMouseMove(e: MouseEvent) {
if (!dragActive || !canvasEl) return
const { x, y } = canvasCoords(e)
sendPointerMove(x, y)
}
function endPointerDrag(e: MouseEvent) {
if (!dragActive) return
dragActive = false
window.removeEventListener('mousemove', onWindowMouseMove)
window.removeEventListener('mouseup', endPointerDrag)
const { x, y } = canvasCoords(e)
const button = e.button === 2 ? 'right' : e.button === 1 ? 'middle' : 'left'
send({ type: 'MOUSE', event: 'up', x, y, button })
}
function onCanvasMouseMove(e: MouseEvent) {
if (dragActive) return
const { x, y } = canvasCoords(e)
sendPointerMove(x, y)
}
function onCanvasMouseDown(e: MouseEvent) {
e.preventDefault()
const { x, y } = canvasCoords(e)
lastPointerX = x
lastPointerY = y
const button = e.button === 2 ? 'right' : e.button === 1 ? 'middle' : 'left'
send({ type: 'MOUSE', event: 'down', x, y, button })
dragActive = true
window.addEventListener('mousemove', onWindowMouseMove)
window.addEventListener('mouseup', endPointerDrag)
}
function onCanvasMouseUp(e: MouseEvent) {
endPointerDrag(e)
}
function onCanvasWheel(e: WheelEvent) {
e.preventDefault()
const { x, y } = canvasCoords(e)
send({ type: 'MOUSE', event: 'wheel', x, y, delta_y: e.deltaY })
}
function onCanvasKeyDown(e: KeyboardEvent) {
e.preventDefault()
send({ type: 'KEY', event: 'down', key: e.key, code: e.code })
if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
send({ type: 'KEY', event: 'type', text: e.key })
}
}
function disconnect() {
connected.value = false
if (ws) {
ws.close()
ws = null
}
sessionId.value = null
}
async function connect(viewportWIn = 1280, viewportHIn = 720) {
if (connecting.value || connected.value) return
connecting.value = true
remoteError.value = null
try {
const status = await http.get<{ enabled: boolean }>('/browser/status')
if (!status.enabled) {
remoteError.value = '服务端浏览器未启用'
return
}
const created = await http.post<BrowserSessionInfo>('/browser/sessions', {
viewport_w: viewportWIn,
viewport_h: viewportHIn,
})
sessionId.value = created.session_id
viewportW.value = created.viewport_w
viewportH.value = created.viewport_h
const token = useAuthStore().token
if (!token) {
remoteError.value = '未登录'
await http.delete(`/browser/sessions/${created.session_id}`).catch(() => {})
sessionId.value = null
return
}
const url = buildWebSocketUrl(`/ws/browser/${created.session_id}`, { token })
try {
await new Promise<void>((resolve, reject) => {
const socket = new WebSocket(url)
ws = socket
socket.onopen = () => {
connected.value = true
resolve()
}
socket.onerror = () => reject(new Error('WebSocket 连接失败'))
socket.onclose = () => {
connected.value = false
}
socket.onmessage = (ev) => {
try {
const msg = JSON.parse(String(ev.data)) as Record<string, unknown>
const type = msg.type as string
if (type === 'SCREENCAST_FRAME') {
drawFrame(String(msg.data_b64 || ''), msg.metadata as { deviceWidth?: number; deviceHeight?: number })
} else if (type === 'PAGE_INFO') {
onPageInfo?.(String(msg.url || ''), String(msg.title || ''))
} else if (type === 'ERROR') {
remoteError.value = String(msg.message || '远程浏览器错误')
} else if (type === 'BROWSER_INIT') {
viewportW.value = Number(msg.viewport_w) || viewportW.value
viewportH.value = Number(msg.viewport_h) || viewportH.value
}
} catch {
/* ignore malformed */
}
}
})
} catch (wsErr) {
await http.delete(`/browser/sessions/${created.session_id}`).catch(() => {})
throw wsErr
}
} catch (e) {
remoteError.value = e instanceof Error ? e.message : '无法连接远程浏览器'
disconnect()
} finally {
connecting.value = false
}
}
function navigate(url: string) {
send({ type: 'NAVIGATE', url })
}
function reload() {
send({ type: 'RELOAD' })
}
function goBack() {
send({ type: 'GO_BACK' })
}
function goForward() {
send({ type: 'GO_FORWARD' })
}
function resizeViewport(width: number, height: number) {
send({ type: 'VIEWPORT', width, height })
}
onUnmounted(() => {
window.removeEventListener('mousemove', onWindowMouseMove)
window.removeEventListener('mouseup', endPointerDrag)
disconnect()
})
return {
connected,
connecting,
remoteError,
sessionId,
viewportW,
viewportH,
setCanvas,
setPageInfoHandler,
connect,
disconnect,
navigate,
reload,
goBack,
goForward,
resizeViewport,
onCanvasMouseMove,
onCanvasMouseDown,
onCanvasMouseUp,
onCanvasWheel,
onCanvasKeyDown,
}
}
@@ -17,6 +17,12 @@ export function sortQuickCommands(items: QuickCmdItem[]): QuickCmdItem[] {
}) })
} }
/** 快捷命令点击执行时追加回车(与命令栏 sendCmd 一致);已含 \\r/\\n 时不重复追加 */
export function formatQuickCmdForSend(cmd: string): string {
const body = cmd.replace(/[\r\n]+$/, '')
return body + '\r'
}
export function useTerminalQuickCommands() { export function useTerminalQuickCommands() {
const commands = ref<QuickCmdItem[]>([]) const commands = ref<QuickCmdItem[]>([])
const loading = ref(false) const loading = ref(false)
+18 -10
View File
@@ -7,6 +7,8 @@ import { buildWebSocketUrl } from '@/utils/wsUrl'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { formatApiError } from '@/utils/apiError' import { formatApiError } from '@/utils/apiError'
import type { WatchTtlMinutes } from '@/constants/watchTtl'
export interface WatchMetrics { export interface WatchMetrics {
probe_status?: string probe_status?: string
source?: string source?: string
@@ -14,6 +16,9 @@ export interface WatchMetrics {
cpu_pct?: number | null cpu_pct?: number | null
mem_pct?: number | null mem_pct?: number | null
disk_pct?: number | null disk_pct?: number | null
load_1?: number | null
load_5?: number | null
load_15?: number | null
net_up_bps?: number | null net_up_bps?: number | null
net_down_bps?: number | null net_down_bps?: number | null
disk_read_bps?: number | null disk_read_bps?: number | null
@@ -49,7 +54,9 @@ export interface WatchSlot {
expires_at?: string expires_at?: string
remaining_sec?: number remaining_sec?: number
monitoring_enabled?: boolean monitoring_enabled?: boolean
ttl_hours?: 8 | 24 ttl_minutes?: WatchTtlMinutes
/** @deprecated 使用 ttl_minutes */
ttl_hours?: number
metrics?: WatchMetrics | null metrics?: WatchMetrics | null
sparkline?: WatchSparkPoint[] sparkline?: WatchSparkPoint[]
processes?: WatchProcess[] processes?: WatchProcess[]
@@ -78,11 +85,14 @@ function tickCountdown() {
let shouldRefresh = false let shouldRefresh = false
for (const slot of slots.value) { for (const slot of slots.value) {
if (slot.empty || slot.monitoring_enabled === false) continue if (slot.empty || slot.monitoring_enabled === false) continue
if (slot.remaining_sec != null && slot.remaining_sec > 0) { if (slot.remaining_sec == null) continue
slot.remaining_sec -= 1 if (slot.remaining_sec <= 0) {
if (slot.remaining_sec <= 0) { shouldRefresh = true
shouldRefresh = true continue
} }
slot.remaining_sec -= 1
if (slot.remaining_sec <= 0) {
shouldRefresh = true
} }
} }
if (shouldRefresh) { if (shouldRefresh) {
@@ -196,12 +206,10 @@ async function setSlotMonitoring(slotIndex: number, monitoringEnabled: boolean)
applySlots(data) applySlots(data)
} }
export type WatchTtlHours = 8 | 24 async function setSlotTtl(slotIndex: number, ttlMinutes: WatchTtlMinutes) {
async function setSlotTtl(slotIndex: number, ttlHours: WatchTtlHours) {
const data = await http.patch<{ slots: WatchSlot[] }>( const data = await http.patch<{ slots: WatchSlot[] }>(
`/watch/pins/${slotIndex}/ttl`, `/watch/pins/${slotIndex}/ttl`,
{ ttl_hours: ttlHours }, { ttl_minutes: ttlMinutes },
) )
applySlots(data) applySlots(data)
} }
+36
View File
@@ -0,0 +1,36 @@
/** Watch slot monitoring duration options (minutes). */
export type WatchTtlMinutes = 30 | 60 | 120 | 480 | 1440
export const WATCH_TTL_DEFAULT_MINUTES: WatchTtlMinutes = 30
export const WATCH_TTL_OPTIONS: { minutes: WatchTtlMinutes; label: string }[] = [
{ minutes: 30, label: '30分' },
{ minutes: 60, label: '1时' },
{ minutes: 120, label: '2时' },
{ minutes: 480, label: '8时' },
{ minutes: 1440, label: '24时' },
]
export function normalizeWatchTtlMinutes(raw: number | null | undefined): WatchTtlMinutes {
const allowed = new Set(WATCH_TTL_OPTIONS.map((o) => o.minutes))
if (raw != null && allowed.has(raw as WatchTtlMinutes)) return raw as WatchTtlMinutes
if (raw === 8) return 480
if (raw === 24) return 1440
return WATCH_TTL_DEFAULT_MINUTES
}
export function watchTtlLabel(minutes: WatchTtlMinutes): string {
return WATCH_TTL_OPTIONS.find((o) => o.minutes === minutes)?.label ?? `${minutes}`
}
export function watchTtlShortLabel(minutes: WatchTtlMinutes): string {
const map: Record<WatchTtlMinutes, string> = {
30: '30分钟',
60: '1小时',
120: '2小时',
480: '8小时',
1440: '24小时',
}
return map[minutes] ?? `${minutes}分钟`
}
+2 -2
View File
@@ -67,7 +67,7 @@
<template #item.target="{ item }"> <template #item.target="{ item }">
<span class="text-body-2"> <span class="text-body-2">
{{ auditTargetLabel(item.resource_type) }}{{ item.resource_id ? ` #${item.resource_id}` : '' }} {{ auditTargetText(item) }}
</span> </span>
</template> </template>
@@ -112,7 +112,7 @@ import { usePageActivateRefresh } from '@/composables/usePageActivateRefresh'
import { http } from '@/api' import { http } from '@/api'
import { useSnackbar } from '@/composables/useSnackbar' import { useSnackbar } from '@/composables/useSnackbar'
import type { PaginatedResponse, AuditItem } from '@/types/api' import type { PaginatedResponse, AuditItem } from '@/types/api'
import { auditActionLabel, auditTargetLabel, auditActionFilterOptions } from '@/utils/auditLabels' import { auditActionLabel, auditTargetLabel, auditTargetText, auditActionFilterOptions } from '@/utils/auditLabels'
import { import {
formatAuditBatchDetailText, formatAuditBatchDetailText,
isBatchJobAuditAction, isBatchJobAuditAction,
+20
View File
@@ -0,0 +1,20 @@
<template>
<div class="pa-4">
<p class="text-body-2 text-medium-emphasis">正在打开全局浏览器</p>
</div>
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useGlobalBrowser } from '@/composables/useGlobalBrowser'
const router = useRouter()
const { openPanel, newEmptyTab, tabs } = useGlobalBrowser()
onMounted(() => {
if (!tabs.value.length) newEmptyTab()
else openPanel()
router.replace('/')
})
</script>
+3 -1
View File
@@ -738,6 +738,7 @@ import type { AddByIpResponse, AddByIpsBatchResult, BatchJobStarted, PendingServ
import { normalizeServerIds } from '@/utils/serverSelection' import { normalizeServerIds } from '@/utils/serverSelection'
import { formatApiError } from '@/utils/apiError' import { formatApiError } from '@/utils/apiError'
import { guessSiteUrlFromDomain } from '@/utils/browserUrl' import { guessSiteUrlFromDomain } from '@/utils/browserUrl'
import { useGlobalBrowser } from '@/composables/useGlobalBrowser'
import { registerServerBatchJob, onScriptExecutionComplete } from '@/composables/useScriptExecutionQueue' import { registerServerBatchJob, onScriptExecutionComplete } from '@/composables/useScriptExecutionQueue'
import { showScriptSubmitToast } from '@/composables/useScriptSubmitToast' import { showScriptSubmitToast } from '@/composables/useScriptSubmitToast'
import StatCardsRow from '@/components/StatCardsRow.vue' import StatCardsRow from '@/components/StatCardsRow.vue'
@@ -875,6 +876,7 @@ async function confirmReplaceSlot(slotIndex: number) {
} }
} }
const router = useRouter() const router = useRouter()
const globalBrowser = useGlobalBrowser()
const route = useRoute() const route = useRoute()
const showCredentialsDialog = ref(false) const showCredentialsDialog = ref(false)
@@ -1545,7 +1547,7 @@ function openBrowser(item: ServerApiItem) {
snackbar('该服务器无有效域名', 'warning') snackbar('该服务器无有效域名', 'warning')
return return
} }
window.open(url, '_blank', 'noopener,noreferrer') globalBrowser.openUrl(url, { title: item.name || url })
} }
function openFiles(item: ServerApiItem) { function openFiles(item: ServerApiItem) {
router.push({ path: '/files', query: { server_id: String(item.id) } }) router.push({ path: '/files', query: { server_id: String(item.id) } })
+205 -6
View File
@@ -134,7 +134,7 @@
<v-chip v-if="telegramTokenSet && !telegramTokenDraft" size="small" color="success" variant="tonal" class="mb-3">Token 已配置</v-chip> <v-chip v-if="telegramTokenSet && !telegramTokenDraft" size="small" color="success" variant="tonal" class="mb-3">Token 已配置</v-chip>
<v-text-field v-model="settings.telegram_chat_id" label="Chat ID" variant="outlined" density="compact" class="mb-3" /> <v-text-field v-model="settings.telegram_chat_id" label="Chat ID" variant="outlined" density="compact" class="mb-3" />
<p class="text-caption text-medium-emphasis mb-3"> <p class="text-caption text-medium-emphasis mb-3">
Telegram Bot 发送任意消息点击下方按钮自动检测 Chat ID 私聊 Bot 发送任意消息群聊 Bot 拉入群后发送 <code>/start@你的Bot用户名</code> @提及 Bot点击下方检测
</p> </p>
<div class="d-flex flex-wrap ga-2 mb-4"> <div class="d-flex flex-wrap ga-2 mb-4">
<v-btn size="small" variant="tonal" prepend-icon="mdi-radar" @click="detectTelegramChats" :loading="detectingTgChats">检测 Chat ID</v-btn> <v-btn size="small" variant="tonal" prepend-icon="mdi-radar" @click="detectTelegramChats" :loading="detectingTgChats">检测 Chat ID</v-btn>
@@ -165,6 +165,72 @@
/> />
</template> </template>
<v-divider class="my-3" />
<div class="text-subtitle-2 mb-1">子机离线告警专用 Bot可选</div>
<p class="text-caption text-medium-emphasis mb-3">
与上方默认 Bot 分离子机离线告警走此通道Bot Token Chat ID 均填写后生效留空则离线告警仍走默认 Bot
</p>
<v-text-field
v-model="telegramOfflineTokenDraft"
label="离线告警 Bot Token"
variant="outlined"
density="compact"
class="mb-3"
:placeholder="telegramOfflineTokenSet ? '已配置(留空保持不变)' : '输入专用 Bot Token'"
:type="showOfflineToken ? 'text' : 'password'"
:append-inner-icon="showOfflineToken ? 'mdi-eye-off' : 'mdi-eye'"
@click:append-inner="toggleOfflineTelegramTokenVisibility"
/>
<v-chip
v-if="telegramOfflineTokenSet && !telegramOfflineTokenDraft"
size="small"
color="success"
variant="tonal"
class="mb-3"
>
离线 Token 已配置
</v-chip>
<v-text-field
v-model="settings.telegram_offline_chat_id"
label="离线告警 Chat ID"
variant="outlined"
density="compact"
class="mb-3"
/>
<p class="text-caption text-medium-emphasis mb-3">
检测规则与默认 Bot 相同群聊需拉入专用 Bot 后在群内发送 <code>/start@Bot用户名</code>
</p>
<div class="d-flex flex-wrap ga-2 mb-2">
<v-btn
size="small"
variant="tonal"
prepend-icon="mdi-radar"
:loading="detectingOfflineTgChats"
@click="detectOfflineTelegramChats"
>
检测离线 Chat ID
</v-btn>
<v-btn
size="small"
variant="tonal"
prepend-icon="mdi-message-text"
:loading="testingOfflineTg"
@click="testOfflineTelegram"
>
测试离线通道
</v-btn>
<v-btn
size="small"
variant="tonal"
color="primary"
prepend-icon="mdi-content-save"
:loading="savingOfflineTelegram"
@click="saveOfflineTelegramConfig"
>
保存离线 Bot
</v-btn>
</div>
<v-divider class="my-4" /> <v-divider class="my-4" />
<div class="text-subtitle-2 mb-1">Layer 3 宿主机巡检</div> <div class="text-subtitle-2 mb-1">Layer 3 宿主机巡检</div>
<p class="text-caption text-medium-emphasis mb-3"> <p class="text-caption text-medium-emphasis mb-3">
@@ -387,14 +453,14 @@
<v-card-title>选择 Chat ID</v-card-title> <v-card-title>选择 Chat ID</v-card-title>
<v-card-text> <v-card-text>
<div v-if="!tgChats.length" class="text-body-2 text-medium-emphasis"> <div v-if="!tgChats.length" class="text-body-2 text-medium-emphasis">
未检测到对话请先在 Telegram Bot 发送一条消息然后重新检测 未检测到对话私聊请向 Bot 发消息群聊请将 Bot 拉入群后在群内发送 <code>/start@Bot用户名</code>然后重新检测
</div> </div>
<v-list v-else density="compact" lines="two"> <v-list v-else density="compact" lines="two">
<v-list-item <v-list-item
v-for="chat in tgChats" v-for="chat in tgChats"
:key="chat.id" :key="chat.id"
:title="chat.title || chat.username || `Chat ${chat.id}`" :title="chat.title || chat.username || `Chat ${chat.id}`"
:subtitle="`${chat.type}${chat.username ? ' · @' + chat.username : ''} · ID ${chat.id}`" :subtitle="`${formatTelegramChatType(chat.type)}${chat.username ? ' · @' + chat.username : ''} · ID ${chat.id}`"
@click="applyTelegramChat(chat)" @click="applyTelegramChat(chat)"
/> />
</v-list> </v-list>
@@ -475,9 +541,17 @@ const settings = ref({
cpu_alert_threshold: 80, mem_alert_threshold: 80, disk_alert_threshold: 80, cpu_alert_threshold: 80, mem_alert_threshold: 80, disk_alert_threshold: 80,
db_pool_size: 10, db_max_overflow: 20, db_pool_size: 10, db_max_overflow: 20,
telegram_bot_token: '', telegram_chat_id: '', telegram_bot_token: '', telegram_chat_id: '',
telegram_offline_chat_id: '',
}) })
const telegramTokenDraft = ref('') const telegramTokenDraft = ref('')
const telegramTokenSet = ref(false) const telegramTokenSet = ref(false)
const telegramOfflineTokenDraft = ref('')
const telegramOfflineTokenSet = ref(false)
const showOfflineToken = ref(false)
const savingOfflineTelegram = ref(false)
const testingOfflineTg = ref(false)
const detectingOfflineTgChats = ref(false)
const tgChatTarget = ref<'default' | 'offline'>('default')
const saving = ref(false) const saving = ref(false)
const systemSettingsFormRef = ref<{ validate: () => Promise<{ valid: boolean }> } | null>(null) const systemSettingsFormRef = ref<{ validate: () => Promise<{ valid: boolean }> } | null>(null)
const testingTg = ref(false) const testingTg = ref(false)
@@ -501,6 +575,16 @@ const notifyToggleGroups = computed(() => {
return groups return groups
}) })
function formatTelegramChatType(type: string): string {
const labels: Record<string, string> = {
private: '私聊',
group: '群组',
supergroup: '群组',
channel: '频道',
}
return labels[type] || type
}
function parseNotifyEnabled(val: unknown): boolean { function parseNotifyEnabled(val: unknown): boolean {
const s = String(val ?? 'true').trim().toLowerCase() const s = String(val ?? 'true').trim().toLowerCase()
return !['false', '0', 'no', 'off'].includes(s) return !['false', '0', 'no', 'off'].includes(s)
@@ -554,6 +638,11 @@ async function loadSettings(silent = false) {
telegramTokenDraft.value = '' telegramTokenDraft.value = ''
continue continue
} }
if (key === 'telegram_offline_bot_token') {
telegramOfflineTokenSet.value = Boolean(item.value_set)
telegramOfflineTokenDraft.value = ''
continue
}
if (key === 'script_exec_complete_sound') { if (key === 'script_exec_complete_sound') {
scriptExecCompleteSound.value = normalizeScriptCompleteSound(item.value) scriptExecCompleteSound.value = normalizeScriptCompleteSound(item.value)
continue continue
@@ -697,6 +786,109 @@ async function saveTelegramConfig() {
} }
} }
async function persistOfflineTelegramConfig(options?: { requireToken?: boolean; requireChatId?: boolean }): Promise<boolean> {
const tokenUpdate = telegramOfflineTokenDraft.value.trim()
const chatId = String(settings.value.telegram_offline_chat_id || '').trim()
if (options?.requireToken && !tokenUpdate && !telegramOfflineTokenSet.value) {
snackbar('请先填写离线告警 Bot Token', 'error')
return false
}
if (options?.requireChatId && !chatId) {
snackbar('请先填写或检测离线告警 Chat ID', 'error')
return false
}
if (tokenUpdate) {
await http.put('/settings/telegram_offline_bot_token', { value: tokenUpdate })
telegramOfflineTokenSet.value = true
telegramOfflineTokenDraft.value = ''
showOfflineToken.value = false
}
if (chatId) {
await http.put('/settings/telegram_offline_chat_id', { value: chatId })
}
return true
}
async function saveOfflineTelegramConfig() {
savingOfflineTelegram.value = true
try {
const tokenUpdate = telegramOfflineTokenDraft.value.trim()
const chatId = String(settings.value.telegram_offline_chat_id || '').trim()
if (!tokenUpdate && !chatId && !telegramOfflineTokenSet.value) {
snackbar('请先填写离线 Bot Token 或 Chat ID', 'warning')
return
}
await persistOfflineTelegramConfig()
snackbar('离线告警 Bot 已保存')
} catch (e: unknown) {
snackbar(e instanceof Error ? e.message : '保存失败', 'error')
} finally {
savingOfflineTelegram.value = false
}
}
async function testOfflineTelegram() {
testingOfflineTg.value = true
try {
if (!await persistOfflineTelegramConfig({ requireToken: true, requireChatId: true })) return
await http.post('/settings/telegram/offline/test')
snackbar('离线通道测试消息已发送')
} catch (e: unknown) {
snackbar(e instanceof Error ? e.message : '发送失败', 'error')
} finally {
testingOfflineTg.value = false
}
}
async function detectOfflineTelegramChats() {
detectingOfflineTgChats.value = true
try {
if (!await persistOfflineTelegramConfig({ requireToken: true })) return
const res = await http.get<TelegramChatsResponse>('/settings/telegram/offline/chats')
tgChats.value = res.chats || []
if (!tgChats.value.length) {
snackbar('未检测到对话:私聊发消息,或群聊拉入 Bot 后发送 /start@Bot用户名', 'warning')
return
}
tgChatTarget.value = 'offline'
tgChatDialog.value = true
} catch (e: unknown) {
snackbar(e instanceof Error ? e.message : '检测失败', 'error')
} finally {
detectingOfflineTgChats.value = false
}
}
async function toggleOfflineTelegramTokenVisibility() {
if (showOfflineToken.value) {
showOfflineToken.value = false
if (telegramOfflineTokenSet.value) {
telegramOfflineTokenDraft.value = ''
}
return
}
if (telegramOfflineTokenDraft.value.trim()) {
showOfflineToken.value = true
return
}
if (!telegramOfflineTokenSet.value) {
showOfflineToken.value = true
return
}
revealingTelegramToken.value = true
try {
const res = await http.post<{ value: string }>('/settings/telegram/offline/reveal-token', {})
telegramOfflineTokenDraft.value = res.value
showOfflineToken.value = true
} catch (e: unknown) {
snackbar(e instanceof Error ? e.message : '获取离线 Bot Token 失败', 'error')
} finally {
revealingTelegramToken.value = false
}
}
async function onScriptExecSoundChange(value: ScriptCompleteSoundId | null) { async function onScriptExecSoundChange(value: ScriptCompleteSoundId | null) {
if (!value || scriptSoundSaving.value) return if (!value || scriptSoundSaving.value) return
const prev = scriptExecCompleteSound.value const prev = scriptExecCompleteSound.value
@@ -769,9 +961,10 @@ async function detectTelegramChats() {
const res = await http.get<TelegramChatsResponse>('/settings/telegram/chats') const res = await http.get<TelegramChatsResponse>('/settings/telegram/chats')
tgChats.value = res.chats || [] tgChats.value = res.chats || []
if (!tgChats.value.length) { if (!tgChats.value.length) {
snackbar('未检测到对话,请先向 Bot 发送一条消息后再试', 'warning') snackbar('未检测到对话:私聊发消息,或群聊拉入 Bot 发送 /start@Bot用户名', 'warning')
return return
} }
tgChatTarget.value = 'default'
tgChatDialog.value = true tgChatDialog.value = true
} catch (e: unknown) { } catch (e: unknown) {
snackbar(e instanceof Error ? e.message : '检测失败', 'error') snackbar(e instanceof Error ? e.message : '检测失败', 'error')
@@ -782,10 +975,16 @@ async function detectTelegramChats() {
async function applyTelegramChat(chat: TelegramChatItem) { async function applyTelegramChat(chat: TelegramChatItem) {
const chatId = String(chat.id) const chatId = String(chat.id)
settings.value.telegram_chat_id = chatId const isOffline = tgChatTarget.value === 'offline'
if (isOffline) {
settings.value.telegram_offline_chat_id = chatId
} else {
settings.value.telegram_chat_id = chatId
}
tgChatDialog.value = false tgChatDialog.value = false
try { try {
await http.put('/settings/telegram_chat_id', { value: chatId }) const key = isOffline ? 'telegram_offline_chat_id' : 'telegram_chat_id'
await http.put(`/settings/${key}`, { value: chatId })
snackbar(`已选择 Chat ID: ${chatId}`) snackbar(`已选择 Chat ID: ${chatId}`)
} catch (e: unknown) { } catch (e: unknown) {
snackbar(e instanceof Error ? e.message : '保存 Chat ID 失败', 'error') snackbar(e instanceof Error ? e.message : '保存 Chat ID 失败', 'error')
+4
View File
@@ -101,7 +101,11 @@
<TerminalServerPicker <TerminalServerPicker
mode="sidebar" mode="sidebar"
:servers="filteredServers" :servers="filteredServers"
:search="serverSearch"
:search-history="terminalSearchHistory"
:loading="serversLoading" :loading="serversLoading"
@update:search="onServerSearchUpdate"
@search-commit="commitServerSearch"
@select="(id) => newSession(id)" @select="(id) => newSession(id)"
/> />
</div> </div>
+90 -37
View File
@@ -8,11 +8,11 @@ import { useWatchPins } from '@/composables/useWatchPins'
defineOptions({ name: 'WatchMetricsPage' }) defineOptions({ name: 'WatchMetricsPage' })
const WATCH_SLOT_COUNT = 4
const route = useRoute() const route = useRoute()
const tab = ref('trend') const tab = ref('trend')
const hours = ref(24) const hours = ref(24)
const serverId = ref<number | null>(null)
const sessionId = ref<number | null>(null)
const points = ref<WatchTrendPoint[]>([]) const points = ref<WatchTrendPoint[]>([])
const loading = ref(false) const loading = ref(false)
const selectedSeries = ref<Array<'cpu_pct' | 'mem_pct' | 'disk_pct' | 'net_up_bps' | 'net_down_bps'>>([ const selectedSeries = ref<Array<'cpu_pct' | 'mem_pct' | 'disk_pct' | 'net_up_bps' | 'net_down_bps'>>([
@@ -21,21 +21,47 @@ const selectedSeries = ref<Array<'cpu_pct' | 'mem_pct' | 'disk_pct' | 'net_up_bp
const { slots, refreshPins } = useWatchPins() const { slots, refreshPins } = useWatchPins()
const serverOptions = computed(() => const slotPanels = computed(() =>
slots.value Array.from({ length: WATCH_SLOT_COUNT }, (_, slotIndex) => {
.filter((s) => !s.empty && s.server_id) const slot = slots.value.find((s) => s.slot_index === slotIndex)
.map((s) => ({ title: s.server_name || `ID ${s.server_id}`, value: s.server_id! })), const empty = !slot || slot.empty
return {
slotIndex,
empty,
serverId: empty ? null : (slot.server_id ?? null),
serverName: empty ? '' : (slot.server_name || `ID ${slot.server_id}`),
}
}),
) )
const pinnedServerIds = computed(() =>
slotPanels.value
.map((p) => p.serverId)
.filter((id): id is number => id != null),
)
const pointsByServer = computed(() => {
const map = new Map<number, WatchTrendPoint[]>()
for (const p of points.value) {
const sid = p.server_id
if (sid == null) continue
const list = map.get(sid) ?? []
list.push(p)
map.set(sid, list)
}
return map
})
async function loadTrend() { async function loadTrend() {
if (!serverId.value) { const ids = pinnedServerIds.value
if (!ids.length) {
points.value = [] points.value = []
return return
} }
loading.value = true loading.value = true
try { try {
const data = await http.get<{ points: WatchTrendPoint[] }>('/watch/metrics', { const data = await http.get<{ points: WatchTrendPoint[] }>('/watch/metrics', {
server_ids: String(serverId.value), server_ids: ids.join(','),
hours: hours.value, hours: hours.value,
}) })
points.value = data.points || [] points.value = data.points || []
@@ -46,22 +72,18 @@ async function loadTrend() {
onMounted(async () => { onMounted(async () => {
await refreshPins() await refreshPins()
const qSid = route.query.server_id const qTab = route.query.tab
const qSess = route.query.session_id if (qTab === 'records') tab.value = 'records'
if (qSid) serverId.value = Number(qSid)
if (qSess) sessionId.value = Number(qSess)
if (!serverId.value && serverOptions.value.length) {
serverId.value = serverOptions.value[0].value
}
}) })
watch([serverId, hours], loadTrend, { immediate: true }) watch([pinnedServerIds, hours], loadTrend, { immediate: true, deep: true })
</script> </script>
<template> <template>
<v-container fluid class="pa-4 pa-md-6"> <v-container fluid class="pa-4 pa-md-6">
<div class="d-flex align-center mb-4"> <div class="d-flex align-center mb-4">
<h1 class="text-h6">监测历史</h1> <h1 class="text-h6">监测历史</h1>
<v-chip size="x-small" variant="tonal" class="ml-2" label>4 槽同时展示</v-chip>
<v-spacer /> <v-spacer />
<v-btn variant="text" prepend-icon="mdi-arrow-left" :to="{ name: 'Servers' }">返回服务器</v-btn> <v-btn variant="text" prepend-icon="mdi-arrow-left" :to="{ name: 'Servers' }">返回服务器</v-btn>
</div> </div>
@@ -76,17 +98,6 @@ watch([serverId, hours], loadTrend, { immediate: true })
<v-window v-model="tab"> <v-window v-model="tab">
<v-window-item value="trend"> <v-window-item value="trend">
<div class="d-flex flex-wrap ga-2 mb-4"> <div class="d-flex flex-wrap ga-2 mb-4">
<v-select
v-model="serverId"
:items="serverOptions"
item-title="title"
item-value="value"
label="服务器"
density="compact"
hide-details
variant="outlined"
style="min-width: 200px"
/>
<v-btn-toggle v-model="hours" mandatory density="compact" variant="outlined" divided> <v-btn-toggle v-model="hours" mandatory density="compact" variant="outlined" divided>
<v-btn :value="1" size="small">1h</v-btn> <v-btn :value="1" size="small">1h</v-btn>
<v-btn :value="6" size="small">6h</v-btn> <v-btn :value="6" size="small">6h</v-btn>
@@ -104,7 +115,7 @@ watch([serverId, hours], loadTrend, { immediate: true })
]" ]"
item-title="title" item-title="title"
item-value="value" item-value="value"
label="曲线" label="曲线(各槽共用)"
multiple multiple
chips chips
density="compact" density="compact"
@@ -114,20 +125,62 @@ watch([serverId, hours], loadTrend, { immediate: true })
/> />
</div> </div>
<v-progress-linear v-if="loading" indeterminate class="mb-2" /> <v-progress-linear v-if="loading" indeterminate class="mb-2" />
<WatchTrendChart v-if="points.length" :points="points" :series="selectedSeries" /> <v-row dense>
<div v-else class="text-center text-medium-emphasis py-8"> <v-col
暂无趋势数据需先 Pin 监测并等待探针采样 v-for="panel in slotPanels"
</div> :key="panel.slotIndex"
cols="12"
md="6"
>
<v-card variant="outlined" rounded="lg" class="watch-metrics-slot pa-3">
<div class="d-flex align-center mb-2">
<v-chip size="x-small" variant="tonal" color="info" label>
{{ panel.slotIndex + 1 }}
</v-chip>
<span class="text-subtitle-2 text-truncate ml-2" :title="panel.serverName">
{{ panel.empty ? '未占用' : panel.serverName }}
</span>
</div>
<WatchTrendChart
v-if="!panel.empty && panel.serverId != null && (pointsByServer.get(panel.serverId)?.length ?? 0) > 0"
:points="pointsByServer.get(panel.serverId!) ?? []"
:series="selectedSeries"
:height="280"
/>
<div
v-else-if="panel.empty"
class="text-center text-medium-emphasis watch-metrics-slot__empty"
>
未添加监测
</div>
<div
v-else
class="text-center text-medium-emphasis watch-metrics-slot__empty"
>
暂无趋势数据需开启监测并等待探针采样
</div>
</v-card>
</v-col>
</v-row>
</v-window-item> </v-window-item>
<v-window-item value="records"> <v-window-item value="records">
<WatchProbeRecordsTable <WatchProbeRecordsTable :hours="hours" />
:server-id="serverId"
:session-id="sessionId"
:hours="hours"
/>
</v-window-item> </v-window-item>
</v-window> </v-window>
</v-card-text> </v-card-text>
</v-card> </v-card>
</v-container> </v-container>
</template> </template>
<style scoped>
.watch-metrics-slot {
min-height: 320px;
}
.watch-metrics-slot__empty {
display: flex;
align-items: center;
justify-content: center;
min-height: 280px;
padding: 1rem;
}
</style>
+1
View File
@@ -6,6 +6,7 @@ const routes = [
{ path: '/servers', name: 'Servers', component: () => import('@/pages/ServersPage.vue') }, { path: '/servers', name: 'Servers', component: () => import('@/pages/ServersPage.vue') },
{ path: '/watch-metrics', name: 'WatchMetrics', component: () => import('@/pages/WatchMetricsPage.vue') }, { path: '/watch-metrics', name: 'WatchMetrics', component: () => import('@/pages/WatchMetricsPage.vue') },
{ path: '/terminal', name: 'Terminal', component: () => import('@/pages/TerminalPage.vue') }, { path: '/terminal', name: 'Terminal', component: () => import('@/pages/TerminalPage.vue') },
{ path: '/browser', name: 'Browser', component: () => import('@/pages/BrowserPage.vue') },
{ path: '/files', name: 'Files', component: () => import('@/pages/FilesPage.vue') }, { path: '/files', name: 'Files', component: () => import('@/pages/FilesPage.vue') },
{ path: '/push', name: 'Push', component: () => import('@/pages/PushPage.vue') }, { path: '/push', name: 'Push', component: () => import('@/pages/PushPage.vue') },
{ path: '/scripts', name: 'Scripts', component: () => import('@/pages/ScriptsPage.vue') }, { path: '/scripts', name: 'Scripts', component: () => import('@/pages/ScriptsPage.vue') },
+3 -1
View File
@@ -75,7 +75,7 @@ export const NOTIFY_TOGGLE_ITEMS: NotifyToggleItem[] = [
{ key: 'notify_alert_mem', label: '内存超阈值告警', desc: '内存超阈值或恢复时推送;关闭则该类告警与恢复均不通知', group: '服务器资源' }, { key: 'notify_alert_mem', label: '内存超阈值告警', desc: '内存超阈值或恢复时推送;关闭则该类告警与恢复均不通知', group: '服务器资源' },
{ key: 'notify_alert_disk', label: '磁盘超阈值告警', desc: '磁盘超阈值或恢复时推送;关闭则该类告警与恢复均不通知', group: '服务器资源' }, { key: 'notify_alert_disk', label: '磁盘超阈值告警', desc: '磁盘超阈值或恢复时推送;关闭则该类告警与恢复均不通知', group: '服务器资源' },
{ key: 'notify_recovery', label: '资源恢复正常', desc: 'CPU/内存/磁盘回落正常时推送(须总开关开启且对应资源开关未关)', group: '服务器资源' }, { key: 'notify_recovery', label: '资源恢复正常', desc: 'CPU/内存/磁盘回落正常时推送(须总开关开启且对应资源开关未关)', group: '服务器资源' },
{ key: 'notify_alert_offline', label: '子机离线告警', desc: 'Agent 心跳丢失、子机由在线变为离线时推送一次', group: '服务器' }, { key: 'notify_alert_offline', label: '子机离线告警', desc: 'Agent 心跳丢失、子机由在线变为离线时推送一次;可在下方配置专用 Telegram Bot', group: '服务器' },
{ key: 'notify_time_drift', label: '时钟偏差严重 (≥60s)', desc: '子机与主机时间偏差过大影响 TOTP/cron', group: '服务器' }, { key: 'notify_time_drift', label: '时钟偏差严重 (≥60s)', desc: '子机与主机时间偏差过大影响 TOTP/cron', group: '服务器' },
{ key: 'notify_system_redis', label: 'Redis 连接异常 / 恢复', desc: 'Redis 不可达或恢复时推送', group: '系统' }, { key: 'notify_system_redis', label: 'Redis 连接异常 / 恢复', desc: 'Redis 不可达或恢复时推送', group: '系统' },
{ key: 'notify_system_mysql', label: 'MySQL 连接异常 / 恢复', desc: 'MySQL 不可达或恢复时推送', group: '系统' }, { key: 'notify_system_mysql', label: 'MySQL 连接异常 / 恢复', desc: 'MySQL 不可达或恢复时推送', group: '系统' },
@@ -419,6 +419,8 @@ export interface AuditItem {
detail: string | null detail: string | null
ip_address: string | null ip_address: string | null
created_at: string | null created_at: string | null
/** 目标为 server 时由 API 解析的服务器名称 */
target_name?: string | null
} }
/** DB credential from /api/scripts/credentials (password never returned) */ /** DB credential from /api/scripts/credentials (password never returned) */
+22
View File
@@ -90,6 +90,7 @@ const ACTION_LABELS: Record<string, string> = {
delete_ssh_key_preset: '删除 SSH 密钥预设', delete_ssh_key_preset: '删除 SSH 密钥预设',
reveal_ssh_key_preset: '查看 SSH 密钥预设', reveal_ssh_key_preset: '查看 SSH 密钥预设',
reveal_telegram_token: '查看 Telegram Token', reveal_telegram_token: '查看 Telegram Token',
reveal_offline_telegram_token: '查看离线告警 Telegram Token',
parse_subscription: '解析订阅节点', parse_subscription: '解析订阅节点',
update_ip_allowlist: '更新登录 IP 白名单', update_ip_allowlist: '更新登录 IP 白名单',
toggle_ip_allowlist: '开关登录 IP 白名单', toggle_ip_allowlist: '开关登录 IP 白名单',
@@ -267,6 +268,27 @@ export function auditTargetLabel(type: string): string {
return fallbackActionLabel(key) return fallbackActionLabel(key)
} }
/** 审计列表「目标」列:服务器显示名称,其它类型保留 #id。 */
export function auditTargetText(item: {
resource_type?: string
target_type?: string
resource_id?: number | null
target_id?: number | null
target_name?: string | null
}): string {
const type = (item.resource_type || item.target_type || '').trim()
const id = item.resource_id ?? item.target_id ?? null
const label = auditTargetLabel(type)
if (type === 'server') {
const name = (item.target_name || '').trim()
if (name) return `${label} ${name}`
if (id != null && id > 0) return `${label} #${id}`
return label || '—'
}
if (id != null && id > 0) return `${label} #${id}`
return label || '—'
}
/** 审计页「操作类型」筛选(中文标签 + 英文 value) */ /** 审计页「操作类型」筛选(中文标签 + 英文 value) */
export const auditActionFilterOptions: { label: string; value: string }[] = [ export const auditActionFilterOptions: { label: string; value: string }[] = [
{ label: '登录成功', value: 'login_success' }, { label: '登录成功', value: 'login_success' },
+1
View File
@@ -15,6 +15,7 @@ export function normalizeAuditItem(raw: Record<string, unknown>): AuditItem {
detail: raw.detail != null ? String(raw.detail) : null, detail: raw.detail != null ? String(raw.detail) : null,
ip_address: raw.ip_address != null ? String(raw.ip_address) : null, ip_address: raw.ip_address != null ? String(raw.ip_address) : null,
created_at: raw.created_at != null ? String(raw.created_at) : null, created_at: raw.created_at != null ? String(raw.created_at) : null,
target_name: raw.target_name != null ? String(raw.target_name) : null,
} }
} }
+23
View File
@@ -1,5 +1,15 @@
/** Format watch probe byte rates for display. */ /** Format watch probe byte rates for display. */
export interface WatchMetricsLike {
probe_status?: string | null
}
/** 监测已开启但尚未收到任何探针回传(含失败态) */
export function isWatchMetricsPending(metrics: WatchMetricsLike | null | undefined): boolean {
const status = metrics?.probe_status
return status == null || status === ''
}
export function formatBytesPerSec(bps: number | null | undefined): string { export function formatBytesPerSec(bps: number | null | undefined): string {
if (bps == null || bps < 0) return '—' if (bps == null || bps < 0) return '—'
if (bps < 1024) return `${bps} B/s` if (bps < 1024) return `${bps} B/s`
@@ -7,6 +17,19 @@ export function formatBytesPerSec(bps: number | null | undefined): string {
return `${(bps / 1024 / 1024).toFixed(1)} MB/s` return `${(bps / 1024 / 1024).toFixed(1)} MB/s`
} }
/** Linux 1 分钟平均负载(宝塔「负载」同源) */
export function formatLoadAvg(load: number | null | undefined): string {
if (load == null || Number.isNaN(load)) return '—'
return load.toFixed(2)
}
export function loadAvgColor(load: number | null | undefined): string {
if (load == null || Number.isNaN(load)) return 'grey'
if (load >= 2) return 'error'
if (load >= 1) return 'warning'
return 'success'
}
export function formatRemaining(sec: number | null | undefined): string { export function formatRemaining(sec: number | null | undefined): string {
if (sec == null || sec <= 0) return '已到期' if (sec == null || sec <= 0) return '已到期'
const h = Math.floor(sec / 3600) const h = Math.floor(sec / 3600)
+87
View File
@@ -0,0 +1,87 @@
"""Embedded browser UI preferences API."""
from __future__ import annotations
from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from server.api.auth_jwt import get_current_admin
from server.api.dependencies import get_db
from server.domain.models import Admin
from server.infrastructure.database.admin_ui_preference_repo import AdminUiPreferenceRepositoryImpl
from server.utils.browser_ui_state import (
BROWSER_UI_CONTEXT,
merge_browser_state,
parse_browser_state,
record_visit,
)
router = APIRouter(prefix="/api/browser", tags=["browser"])
class BrowserVisitRecord(BaseModel):
url: str
title: str | None = None
class BrowserTabState(BaseModel):
id: str
url: str
title: str | None = None
stack: list[str] | None = None
stack_index: int | None = None
class BrowserPanelRect(BaseModel):
x: float
y: float
w: float
h: float
class BrowserStateUpdate(BaseModel):
url_history: list[str] | None = None
visits: list[dict] | None = None
tabs: list[BrowserTabState] | None = None
active_tab_id: str | None = None
minimized: bool | None = None
rect: BrowserPanelRect | None = None
minimized_rect: BrowserPanelRect | None = None
record_url: str | None = Field(default=None, description="Append one visit + history entry")
record_title: str | None = None
@router.get("/state")
async def get_browser_state(
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
repo = AdminUiPreferenceRepositoryImpl(db)
raw = await repo.get(admin.id, BROWSER_UI_CONTEXT)
return parse_browser_state(raw)
@router.put("/state")
async def put_browser_state(
payload: BrowserStateUpdate,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
repo = AdminUiPreferenceRepositoryImpl(db)
current = parse_browser_state(await repo.get(admin.id, BROWSER_UI_CONTEXT))
incoming = payload.model_dump(exclude_none=True)
if payload.tabs is not None:
incoming["tabs"] = [t.model_dump(exclude_none=True) for t in payload.tabs]
if payload.rect is not None:
incoming["rect"] = payload.rect.model_dump()
if payload.minimized_rect is not None:
incoming["minimized_rect"] = payload.minimized_rect.model_dump()
merged = merge_browser_state(current, incoming)
if payload.record_url:
merged = record_visit(merged, payload.record_url, payload.record_title)
await repo.upsert(admin.id, BROWSER_UI_CONTEXT, merged)
return merged
+197
View File
@@ -0,0 +1,197 @@
"""Remote browser session API — bridge to Browser Worker."""
from __future__ import annotations
import asyncio
import json
import logging
import uuid
from typing import Any, Optional
import websockets
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect, Query
from pydantic import BaseModel, Field
from server.api.auth_jwt import get_current_admin
from server.api.websocket import _verify_ws_token
from server.config import settings
from server.domain.models import Admin
from server.infrastructure.browser import session_registry
from server.infrastructure.browser.worker_client import (
BrowserWorkerError,
browser_enabled,
create_worker_session,
delete_worker_session,
worker_health,
worker_stream_url,
)
from server.utils.browser_url_safe import validate_navigation_url
logger = logging.getLogger("nexus.browser.session")
router = APIRouter(prefix="/api/browser", tags=["browser"])
ws_router = APIRouter(tags=["browser"])
class CreateBrowserSessionBody(BaseModel):
viewport_w: int = Field(default=1280, ge=320, le=1920)
viewport_h: int = Field(default=720, ge=240, le=1080)
@router.get("/status")
async def browser_status(admin: Admin = Depends(get_current_admin)):
del admin
enabled = browser_enabled()
health = await worker_health() if enabled else None
return {
"enabled": enabled,
"worker_ok": health is not None,
"worker_sessions": health.get("sessions") if health else None,
"max_sessions": settings.BROWSER_MAX_SESSIONS,
}
@router.post("/sessions")
async def create_browser_session(
body: CreateBrowserSessionBody,
admin: Admin = Depends(get_current_admin),
):
if not browser_enabled():
raise HTTPException(status_code=503, detail="服务端浏览器未启用")
# 清理断线残留(避免 409 导致前端无法重连)
for stale_id in session_registry.session_ids_for_admin(admin.id):
session_registry.remove(stale_id)
await delete_worker_session(stale_id)
if session_registry.total_count() >= settings.BROWSER_MAX_SESSIONS:
raise HTTPException(status_code=503, detail="浏览器会话已达上限")
session_id = str(uuid.uuid4())
try:
await create_worker_session(session_id, body.viewport_w, body.viewport_h)
except BrowserWorkerError as exc:
raise HTTPException(status_code=exc.status_code, detail=str(exc)) from exc
session_registry.register(session_id, admin.id, body.viewport_w, body.viewport_h)
return {
"session_id": session_id,
"viewport_w": body.viewport_w,
"viewport_h": body.viewport_h,
}
@router.delete("/sessions/{session_id}")
async def delete_browser_session(
session_id: str,
admin: Admin = Depends(get_current_admin),
):
sess = session_registry.get(session_id)
if not sess or sess.admin_id != admin.id:
raise HTTPException(status_code=404, detail="Session not found")
session_registry.remove(session_id)
await delete_worker_session(session_id)
return {"ok": True}
def _validate_client_navigate(msg: dict[str, Any]) -> dict[str, Any] | None:
if msg.get("type") != "NAVIGATE":
return msg
url = msg.get("url", "")
normalized, err = validate_navigation_url(str(url))
if err:
return {"type": "ERROR", "message": err, "code": "URL_BLOCKED"}
out = dict(msg)
out["url"] = normalized
return out
@ws_router.websocket("/ws/browser/{session_id}")
async def browser_ws(
websocket: WebSocket,
session_id: str,
token: Optional[str] = Query(None),
):
if not browser_enabled():
await websocket.close(code=4503, reason="Browser not enabled")
return
if not token:
await websocket.close(code=4001, reason="Missing JWT token")
return
admin = await _verify_ws_token(token)
if not admin:
await websocket.close(code=4401, reason="Invalid or expired JWT token")
return
sess = session_registry.get(session_id)
if not sess or sess.admin_id != admin.id:
await websocket.close(code=4403, reason="Session not authorized")
return
await websocket.accept()
worker_url = worker_stream_url(session_id)
headers = {"X-Nexus-Worker-Key": settings.BROWSER_WORKER_SECRET}
try:
async with websockets.connect(
worker_url,
additional_headers=headers,
open_timeout=15,
max_size=8 * 1024 * 1024,
) as worker_ws:
async def client_to_worker() -> None:
try:
while True:
raw = await websocket.receive_text()
session_registry.touch(session_id)
try:
msg = json.loads(raw)
except json.JSONDecodeError:
await websocket.send_json({
"type": "ERROR",
"message": "Invalid JSON",
"code": "BAD_JSON",
})
continue
if not isinstance(msg, dict):
continue
validated = _validate_client_navigate(msg)
if validated:
await worker_ws.send(json.dumps(validated))
except WebSocketDisconnect:
pass
async def worker_to_client() -> None:
try:
async for raw in worker_ws:
session_registry.touch(session_id)
if isinstance(raw, bytes):
raw = raw.decode("utf-8", errors="replace")
await websocket.send_text(raw)
except websockets.ConnectionClosed:
pass
done, pending = await asyncio.wait(
[asyncio.create_task(client_to_worker()), asyncio.create_task(worker_to_client())],
return_when=asyncio.FIRST_COMPLETED,
)
for task in pending:
task.cancel()
for task in done:
try:
await task
except Exception:
pass
except Exception as exc:
logger.warning("browser bridge error session=%s: %s", session_id, exc)
try:
await websocket.send_json({
"type": "ERROR",
"message": "无法连接 Browser Worker",
"code": "WORKER_UNREACHABLE",
})
except Exception:
pass
finally:
session_registry.remove(session_id)
await delete_worker_session(session_id)
+218 -35
View File
@@ -37,7 +37,13 @@ from sqlalchemy.ext.asyncio import AsyncSession
IMMUTABLE_KEYS = {"secret_key", "api_key", "encryption_key", "database_url"} IMMUTABLE_KEYS = {"secret_key", "api_key", "encryption_key", "database_url"}
# Settings whose values are masked in GET responses # Settings whose values are masked in GET responses
SENSITIVE_KEYS = {"secret_key", "api_key", "encryption_key", "telegram_bot_token"} SENSITIVE_KEYS = {
"secret_key",
"api_key",
"encryption_key",
"telegram_bot_token",
"telegram_offline_bot_token",
}
# S-02: Whitelist of keys accepted by PUT /{key} (superset of DB_OVERRIDE_MAP + notify toggles) # S-02: Whitelist of keys accepted by PUT /{key} (superset of DB_OVERRIDE_MAP + notify toggles)
MUTABLE_KEYS: set[str] = { MUTABLE_KEYS: set[str] = {
@@ -45,6 +51,7 @@ MUTABLE_KEYS: set[str] = {
"redis_url", "heartbeat_timeout", "api_base_url", "redis_url", "heartbeat_timeout", "api_base_url",
"cpu_alert_threshold", "mem_alert_threshold", "disk_alert_threshold", "cpu_alert_threshold", "mem_alert_threshold", "disk_alert_threshold",
"telegram_bot_token", "telegram_chat_id", "telegram_bot_token", "telegram_chat_id",
"telegram_offline_bot_token", "telegram_offline_chat_id",
"login_allowlist_enabled", "login_subscription_url", "login_allowlist_enabled", "login_subscription_url",
"login_subscription_ips", "login_manual_ips", "login_allowed_ips", "login_subscription_ips", "login_manual_ips", "login_allowed_ips",
"notify_alert_cpu", "notify_alert_mem", "notify_alert_disk", "notify_alert_cpu", "notify_alert_mem", "notify_alert_disk",
@@ -64,6 +71,13 @@ SETTING_ENUM_VALIDATORS: dict[str, frozenset[str]] = {
"theme": frozenset({"dark", "light"}), "theme": frozenset({"dark", "light"}),
} }
# GET /{key} when row not yet in MySQL (PUT upserts on first save)
SETTING_DEFAULTS: dict[str, str] = {
"script_exec_complete_sound": "beep_double",
"push_complete_sound": "beep_double",
"theme": "dark",
}
# S-02: Per-key validation rules: (type, min, max) for int settings # S-02: Per-key validation rules: (type, min, max) for int settings
SETTING_VALIDATORS: dict[str, tuple] = { SETTING_VALIDATORS: dict[str, tuple] = {
"db_pool_size": (int, 1, 1000), "db_pool_size": (int, 1, 1000),
@@ -310,7 +324,10 @@ async def get_setting(key: str, admin: Admin = Depends(get_current_admin), db: A
repo = SettingRepositoryImpl(db) repo = SettingRepositoryImpl(db)
value = await repo.get(key) value = await repo.get(key)
if value is None: if value is None:
raise HTTPException(status_code=404, detail="Setting not found") default = SETTING_DEFAULTS.get(key)
if default is None:
raise HTTPException(status_code=404, detail="Setting not found")
return _format_setting_response(key, default, None)
return _format_setting_response(key, value, None) return _format_setting_response(key, value, None)
@@ -1115,6 +1132,7 @@ async def telegram_test_send(
f"具体情况:Bot Token 与 Chat ID 配置正确,Telegram 通道可用\n" f"具体情况:Bot Token 与 Chat ID 配置正确,Telegram 通道可用\n"
f"将接收的通知类型:\n" f"将接收的通知类型:\n"
f"• 子机 CPU/内存/磁盘超阈值告警与恢复\n" f"• 子机 CPU/内存/磁盘超阈值告警与恢复\n"
f"• 子机离线告警(可另配专用 Bot,见设置页)\n"
f"• Redis / MySQL 连接异常与恢复\n" f"• Redis / MySQL 连接异常与恢复\n"
f"• 子机时钟偏差、文件推送结果\n" f"• 子机时钟偏差、文件推送结果\n"
f"• Layer 3 宿主机巡检 /health 失败与自动重启\n" f"• Layer 3 宿主机巡检 /health 失败与自动重启\n"
@@ -1167,30 +1185,67 @@ async def reveal_telegram_token(
return {"key": "telegram_bot_token", "value": value} return {"key": "telegram_bot_token", "value": value}
@router.get("/telegram/chats", response_model=dict) def _telegram_chat_entry(chat: dict) -> dict | None:
async def telegram_get_chats( cid = chat.get("id")
admin: Admin = Depends(get_current_admin), if cid is None:
): return None
"""Call getUpdates to detect recent chats and extract unique chat_ids. return {
"id": cid,
"type": chat.get("type", ""),
"title": chat.get("title") or chat.get("first_name") or chat.get("username") or "",
"username": chat.get("username") or "",
}
User must send at least one message to the Bot before calling this.
Returns up to 5 distinct chats from recent messages. def _extract_chats_from_telegram_updates(updates: list) -> list[dict]:
""" """Extract unique chats from getUpdates (DM, group join, channel, callbacks)."""
seen: set[int] = set()
chats: list[dict] = []
def add(chat: object) -> None:
if not isinstance(chat, dict):
return
entry = _telegram_chat_entry(chat)
if not entry or entry["id"] in seen:
return
seen.add(entry["id"])
chats.append(entry)
for update in updates:
if not isinstance(update, dict):
continue
for key in ("message", "edited_message", "channel_post", "edited_channel_post"):
msg = update.get(key)
if isinstance(msg, dict):
add(msg.get("chat"))
mcm = update.get("my_chat_member")
if isinstance(mcm, dict):
add(mcm.get("chat"))
cm = update.get("chat_member")
if isinstance(cm, dict):
add(cm.get("chat"))
cq = update.get("callback_query")
if isinstance(cq, dict):
msg = cq.get("message")
if isinstance(msg, dict):
add(msg.get("chat"))
type_order = {"supergroup": 0, "group": 1, "channel": 2, "private": 3}
chats.sort(key=lambda c: (type_order.get(c.get("type", ""), 9), c.get("id", 0)))
return chats
async def _telegram_fetch_chats(token: str) -> dict:
import httpx import httpx
from server.config import settings as _settings
from server.infrastructure.telegram import TELEGRAM_API_BASE from server.infrastructure.telegram import TELEGRAM_API_BASE
token = _settings.TELEGRAM_BOT_TOKEN
if not token:
raise HTTPException(status_code=400, detail="TELEGRAM_BOT_TOKEN 未配置")
try: try:
async with httpx.AsyncClient(timeout=10.0) as client: async with httpx.AsyncClient(timeout=10.0) as client:
base_url = f"{TELEGRAM_API_BASE}/bot{token}" base_url = f"{TELEGRAM_API_BASE}/bot{token}"
await _telegram_clear_webhook_if_set(client, base_url) await _telegram_clear_webhook_if_set(client, base_url)
resp = await client.get( resp = await client.get(
f"{base_url}/getUpdates", f"{base_url}/getUpdates",
params={"limit": 50}, params={"limit": 100},
) )
if resp.status_code != 200: if resp.status_code != 200:
raise HTTPException(status_code=502, detail=f"Telegram API 返回 {resp.status_code}") raise HTTPException(status_code=502, detail=f"Telegram API 返回 {resp.status_code}")
@@ -1203,27 +1258,125 @@ async def telegram_get_chats(
except Exception as e: except Exception as e:
raise HTTPException(status_code=502, detail=f"请求失败: {e}") from e raise HTTPException(status_code=502, detail=f"请求失败: {e}") from e
# Extract unique chats chats = _extract_chats_from_telegram_updates(data.get("result", []))[:10]
seen: set[int] = set()
chats: list[dict] = []
for update in data.get("result", []):
msg = update.get("message") or update.get("channel_post") or {}
chat = msg.get("chat", {})
cid = chat.get("id")
if cid and cid not in seen:
seen.add(cid)
chats.append({
"id": cid,
"type": chat.get("type", ""),
"title": chat.get("title") or chat.get("first_name") or "",
"username": chat.get("username") or "",
})
if len(chats) >= 5:
break
return {"chats": chats, "count": len(chats)} return {"chats": chats, "count": len(chats)}
@router.get("/telegram/chats", response_model=dict)
async def telegram_get_chats(
admin: Admin = Depends(get_current_admin),
):
"""Call getUpdates to detect recent chats and extract unique chat_ids.
For private chats: send any message to the Bot.
For groups: add the Bot to the group, then send /start@BotName (privacy mode)
or any message mentioning the Bot. Bot join events (my_chat_member) are included.
Returns up to 10 distinct chats, groups listed first.
"""
from server.config import settings as _settings
token = (_settings.TELEGRAM_BOT_TOKEN or "").strip()
if not token:
raise HTTPException(status_code=400, detail="TELEGRAM_BOT_TOKEN 未配置")
return await _telegram_fetch_chats(token)
def _offline_telegram_token_or_400() -> str:
from server.config import settings as _settings
token = (_settings.TELEGRAM_OFFLINE_BOT_TOKEN or "").strip()
if not token:
raise HTTPException(status_code=400, detail="telegram_offline_bot_token 未配置")
return token
def _offline_telegram_credentials_or_400() -> tuple[str, str]:
"""Resolve offline-dedicated Bot; 400 if not fully configured."""
from server.config import settings as _settings
token = _offline_telegram_token_or_400()
chat_id = (_settings.TELEGRAM_OFFLINE_CHAT_ID or "").strip()
if not chat_id:
raise HTTPException(status_code=400, detail="telegram_offline_chat_id 未配置,请先检测并填写")
return token, chat_id
@router.post("/telegram/offline/test", response_model=dict)
async def telegram_offline_test_send(
admin: Admin = Depends(get_current_admin),
):
"""Send a test message via offline-dedicated Telegram Bot (not the default channel)."""
import httpx
from server.config import settings as _settings
from server.infrastructure.telegram import TELEGRAM_API_BASE
from server.utils.display_time import format_beijing_now
token, chat_id = _offline_telegram_credentials_or_400()
now = format_beijing_now()
sys_name = html.escape((_settings.SYSTEM_NAME or "Nexus").strip())
message = (
f"✅ <b>【离线告警 Telegram 测试】消息发送成功</b>\n"
f"平台:{sys_name}\n"
f"具体情况:离线专用 Bot Token 与 Chat ID 配置正确\n"
f"将接收的通知:子机由在线变为离线时的 Telegram 推送(须开启「子机离线告警」开关)\n"
f"说明:未配置离线专用 Bot 时,离线告警回退至默认 Telegram 通道\n"
f"时间:{now}"
)
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.post(
f"{TELEGRAM_API_BASE}/bot{token}/sendMessage",
json={
"chat_id": chat_id,
"text": message,
"parse_mode": "HTML",
},
)
data = resp.json()
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=502, detail=f"请求失败: {e}") from e
if resp.status_code == 200 and data.get("ok"):
return {"success": True}
desc = data.get("description") or f"HTTP {resp.status_code}"
raise HTTPException(status_code=502, detail=f"Telegram API: {desc}")
@router.post("/telegram/offline/reveal-token", response_model=dict)
async def reveal_offline_telegram_token(
request: Request,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
"""Reveal offline-dedicated Telegram Bot Token (JWT + audit)."""
repo = SettingRepositoryImpl(db)
value = await repo.get("telegram_offline_bot_token")
if value is None:
raise HTTPException(status_code=404, detail="telegram_offline_bot_token not found")
audit_repo = AuditLogRepositoryImpl(db)
await audit_repo.create(AuditLog(
admin_username=admin.username,
action="reveal_offline_telegram_token",
target_type="setting",
detail="查看离线告警 Telegram Bot Token",
ip_address=request.client.host if request.client else "",
))
return {"key": "telegram_offline_bot_token", "value": value}
@router.get("/telegram/offline/chats", response_model=dict)
async def telegram_offline_get_chats(
admin: Admin = Depends(get_current_admin),
):
"""Detect chats for offline-dedicated Bot via getUpdates (same rules as /telegram/chats)."""
token = _offline_telegram_token_or_400()
return await _telegram_fetch_chats(token)
# ── IP Allowlist ── # ── IP Allowlist ──
_IP_RE = re.compile(r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}(/\d{1,2})?$") _IP_RE = re.compile(r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}(/\d{1,2})?$")
@@ -1539,10 +1692,40 @@ async def list_audit_logs(
"total": total, "total": total,
"limit": limit, "limit": limit,
"offset": offset, "offset": offset,
"items": [_audit_to_dict(log) for log in logs], "items": await _audit_items_enriched(db, logs),
} }
async def _audit_items_enriched(db, logs: list[AuditLog]) -> list[dict]:
"""Attach target_name for server rows (audit UI shows name instead of #id)."""
from sqlalchemy import select
from server.domain.models import Server
server_ids = {
int(log.target_id)
for log in logs
if log.target_type == "server" and log.target_id
}
name_by_id: dict[int, str] = {}
if server_ids:
rows = (
await db.execute(
select(Server.id, Server.name).where(Server.id.in_(server_ids))
)
).all()
name_by_id = {int(sid): (name or f"server-{sid}") for sid, name in rows}
items: list[dict] = []
for log in logs:
row = _audit_to_dict(log)
if log.target_type == "server" and log.target_id:
tid = int(log.target_id)
row["target_name"] = name_by_id.get(tid)
items.append(row)
return items
# ── Retry Jobs ── # ── Retry Jobs ──
retry_router = APIRouter(prefix="/api/retries", tags=["retries"]) retry_router = APIRouter(prefix="/api/retries", tags=["retries"])
+23 -27
View File
@@ -1448,12 +1448,10 @@ async def download_file(
raise HTTPException(status_code=400, detail=str(exc)) from exc raise HTTPException(status_code=400, detail=str(exc)) from exc
conn = await ssh_pool.acquire(server) conn = await ssh_pool.acquire(server)
# NOTE: connection is released INSIDE file_generator (after stream finishes), # Pre-check in a short SFTP session; stream opens its own session so the
# not in a finally block here — releasing before the generator runs would close # context manager is not exited before StreamingResponse consumes the body.
# the SFTP channel mid-download.
try: try:
async with sftp_session(conn) as sftp: async with sftp_session(conn) as sftp:
# Check file exists and size
try: try:
stat = await sftp.stat(remote_path) stat = await sftp.stat(remote_path)
except FileNotFoundError: except FileNotFoundError:
@@ -1467,42 +1465,40 @@ async def download_file(
await ssh_pool.release(server.id) await ssh_pool.release(server.id)
raise HTTPException(status_code=400, detail="只能下载文件,不能下载目录") raise HTTPException(status_code=400, detail="只能下载文件,不能下载目录")
# H-15: Download file size limit (500MB)
MAX_DOWNLOAD_SIZE = 524_288_000 MAX_DOWNLOAD_SIZE = 524_288_000
if hasattr(stat, "size") and stat.size and stat.size > MAX_DOWNLOAD_SIZE: if hasattr(stat, "size") and stat.size and stat.size > MAX_DOWNLOAD_SIZE:
await ssh_pool.release(server.id) await ssh_pool.release(server.id)
raise HTTPException(status_code=413, detail=f"文件大小 {stat.size} 字节超过下载限制 100MB") raise HTTPException(status_code=413, detail=f"文件大小 {stat.size} 字节超过下载限制 100MB")
# H-02: Sanitize filename for Content-Disposition (RFC 6266)
import re as _re import re as _re
filename = _re.sub(r'[^\w.\-]', '_', posix_basename(remote_path)) or "download" filename = _re.sub(r'[^\w.\-]', '_', posix_basename(remote_path)) or "download"
async def file_generator():
try:
async with sftp.open(remote_path, "rb") as f:
while chunk := await f.read(65536):
yield chunk
finally:
# Release after stream is fully consumed (or on client disconnect)
await ssh_pool.release(server.id)
await _audit_sync(
"file_download", "server", payload.server_id,
f"下载文件: {remote_path}",
admin.username, request,
)
return StreamingResponse(
file_generator(),
media_type="application/octet-stream",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
await ssh_pool.release(server.id) await ssh_pool.release(server.id)
raise HTTPException(status_code=502, detail=f"下载失败: {e}") from e raise HTTPException(status_code=502, detail=f"下载失败: {e}") from e
async def file_generator():
try:
async with sftp_session(conn) as sftp:
async with sftp.open(remote_path, "rb") as f:
while chunk := await f.read(65536):
yield chunk
finally:
await ssh_pool.release(server.id)
await _audit_sync(
"file_download", "server", payload.server_id,
f"下载文件: {remote_path}",
admin.username, request,
)
return StreamingResponse(
file_generator(),
media_type="application/octet-stream",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@router.post("/read-file") @router.post("/read-file")
async def read_file( async def read_file(
+9 -7
View File
@@ -11,7 +11,9 @@ from server.api.auth_jwt import get_current_admin
from server.api.dependencies import get_db from server.api.dependencies import get_db
from server.application.services.watch_service import SlotsFullError, WatchService from server.application.services.watch_service import SlotsFullError, WatchService
from server.domain.models import Admin from server.domain.models import Admin
from server.utils.watch_metrics import WATCH_PIN_TTL_HOURS from server.utils.watch_metrics import WATCH_PIN_TTL_DEFAULT_MINUTES
WatchTtlMinutes = Literal[30, 60, 120, 480, 1440]
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
router = APIRouter(prefix="/api/watch", tags=["watch"]) router = APIRouter(prefix="/api/watch", tags=["watch"])
@@ -21,9 +23,9 @@ class WatchPinCreate(BaseModel):
server_id: int = Field(..., gt=0) server_id: int = Field(..., gt=0)
slot_index: int | None = Field(None, ge=0, le=3) slot_index: int | None = Field(None, ge=0, le=3)
replace_slot: int | None = Field(None, ge=0, le=3, description="监测已满时指定替换槽位") replace_slot: int | None = Field(None, ge=0, le=3, description="监测已满时指定替换槽位")
ttl_hours: Literal[8, 24] = Field( ttl_minutes: WatchTtlMinutes = Field(
WATCH_PIN_TTL_HOURS, WATCH_PIN_TTL_DEFAULT_MINUTES,
description="监测窗口时长,默认 8 小时;24 小时需显式指定", description="监测窗口30分/1h/2h/8h/24h,默认 30 分钟",
) )
@@ -36,7 +38,7 @@ class WatchPinMonitoringUpdate(BaseModel):
class WatchPinTtlUpdate(BaseModel): class WatchPinTtlUpdate(BaseModel):
ttl_hours: Literal[8, 24] = Field(..., description="监测窗口时长:8 或 24 小时") ttl_minutes: WatchTtlMinutes = Field(..., description="监测窗口30分/1h/2h/8h/24h")
def _client_ip(request: Request) -> str: def _client_ip(request: Request) -> str:
@@ -66,7 +68,7 @@ async def create_watch_pin(
server_id=payload.server_id, server_id=payload.server_id,
slot_index=payload.slot_index, slot_index=payload.slot_index,
replace_slot=payload.replace_slot, replace_slot=payload.replace_slot,
ttl_hours=payload.ttl_hours, ttl_minutes=payload.ttl_minutes,
ip_address=_client_ip(request), ip_address=_client_ip(request),
) )
except SlotsFullError as e: except SlotsFullError as e:
@@ -146,7 +148,7 @@ async def update_watch_pin_ttl(
return await svc.set_slot_ttl( return await svc.set_slot_ttl(
admin=admin, admin=admin,
slot_index=slot_index, slot_index=slot_index,
ttl_hours=payload.ttl_hours, ttl_minutes=payload.ttl_minutes,
ip_address=_client_ip(request), ip_address=_client_ip(request),
) )
except ValueError as e: except ValueError as e:
+31 -22
View File
@@ -16,8 +16,9 @@ from server.infrastructure.redis.client import get_redis
from server.utils.watch_state import clear_watch_redis_snapshot from server.utils.watch_state import clear_watch_redis_snapshot
from server.utils.watch_metrics import ( from server.utils.watch_metrics import (
WATCH_MAX_SLOTS, WATCH_MAX_SLOTS,
WATCH_PIN_TTL_HOURS, WATCH_PIN_TTL_DEFAULT_MINUTES,
normalize_watch_ttl_hours, normalize_watch_ttl_minutes,
resolve_pin_ttl_minutes,
server_can_be_watched, server_can_be_watched,
) )
@@ -119,7 +120,8 @@ async def _slot_payload(
"expires_at": pin_row["expires_at"].isoformat() if pin_row.get("expires_at") else None, "expires_at": pin_row["expires_at"].isoformat() if pin_row.get("expires_at") else None,
"remaining_sec": remaining, "remaining_sec": remaining,
"monitoring_enabled": monitoring_enabled, "monitoring_enabled": monitoring_enabled,
"ttl_hours": int(pin_row.get("ttl_hours") or WATCH_PIN_TTL_HOURS), "ttl_minutes": int(pin_row.get("ttl_minutes") or WATCH_PIN_TTL_DEFAULT_MINUTES),
"ttl_hours": int(pin_row.get("ttl_hours") or max(1, (int(pin_row.get("ttl_minutes") or 30) + 59) // 60)),
"metrics": metrics, "metrics": metrics,
"sparkline": sparkline, "sparkline": sparkline,
"processes": processes, "processes": processes,
@@ -150,11 +152,11 @@ class WatchService:
server_id: int, server_id: int,
slot_index: int | None = None, slot_index: int | None = None,
replace_slot: int | None = None, replace_slot: int | None = None,
ttl_hours: int = WATCH_PIN_TTL_HOURS, ttl_minutes: int = WATCH_PIN_TTL_DEFAULT_MINUTES,
ip_address: str = "", ip_address: str = "",
) -> dict[str, Any]: ) -> dict[str, Any]:
await _finalize_due_pins(self.watch_repo, self.db) await _finalize_due_pins(self.watch_repo, self.db)
hours = normalize_watch_ttl_hours(ttl_hours) minutes = normalize_watch_ttl_minutes(ttl_minutes)
server = await self.server_repo.get_by_id(server_id) server = await self.server_repo.get_by_id(server_id)
if not server: if not server:
raise ValueError("服务器不存在") raise ValueError("服务器不存在")
@@ -165,12 +167,12 @@ class WatchService:
existing = await self.watch_repo.get_pin_by_server(admin.id, server_id) existing = await self.watch_repo.get_pin_by_server(admin.id, server_id)
if existing: if existing:
await self.watch_repo.reset_pin_ttl(admin.id, server_id, hours) await self.watch_repo.reset_pin_ttl(admin.id, server_id, minutes)
await self._audit( await self._audit(
admin=admin, admin=admin,
action="watch_pin_add", action="watch_pin_add",
server=server, server=server,
detail=f"重置监测 {hours}h · 槽 {existing.slot_index + 1}", detail=f"重置监测 {minutes}min · 槽 {existing.slot_index + 1}",
ip_address=ip_address, ip_address=ip_address,
) )
await self.db.commit() await self.db.commit()
@@ -181,13 +183,13 @@ class WatchService:
admin_id=admin.id, admin_id=admin.id,
slot_index=slot_index, slot_index=slot_index,
server_id=server_id, server_id=server_id,
ttl_hours=hours, ttl_minutes=minutes,
) )
await self._audit( await self._audit(
admin=admin, admin=admin,
action="watch_pin_replace" if replace_slot is not None else "watch_pin_add", action="watch_pin_replace" if replace_slot is not None else "watch_pin_add",
server=server, server=server,
detail=f"加入监测 · 槽 {slot_index + 1} · {hours}h", detail=f"加入监测 · 槽 {slot_index + 1} · {minutes}min",
ip_address=ip_address, ip_address=ip_address,
) )
await self.db.commit() await self.db.commit()
@@ -199,13 +201,13 @@ class WatchService:
admin_id=admin.id, admin_id=admin.id,
server_id=server_id, server_id=server_id,
slot_index=empty, slot_index=empty,
ttl_hours=hours, ttl_minutes=minutes,
) )
await self._audit( await self._audit(
admin=admin, admin=admin,
action="watch_pin_add", action="watch_pin_add",
server=server, server=server,
detail=f"加入监测 · 槽 {empty + 1} · {hours}h", detail=f"加入监测 · 槽 {empty + 1} · {minutes}min",
ip_address=ip_address, ip_address=ip_address,
) )
await self.db.commit() await self.db.commit()
@@ -216,13 +218,13 @@ class WatchService:
admin_id=admin.id, admin_id=admin.id,
slot_index=replace_slot, slot_index=replace_slot,
server_id=server_id, server_id=server_id,
ttl_hours=hours, ttl_minutes=minutes,
) )
await self._audit( await self._audit(
admin=admin, admin=admin,
action="watch_pin_replace", action="watch_pin_replace",
server=server, server=server,
detail=f"替换监测 · 槽 {replace_slot + 1} · {hours}h", detail=f"替换监测 · 槽 {replace_slot + 1} · {minutes}min",
ip_address=ip_address, ip_address=ip_address,
) )
await self.db.commit() await self.db.commit()
@@ -252,18 +254,25 @@ class WatchService:
await self.watch_repo.end_session(other.session_id, "replaced") await self.watch_repo.end_session(other.session_id, "replaced")
await self.watch_repo.delete_pin(other.id) await self.watch_repo.delete_pin(other.id)
existing = await self.watch_repo.get_pin_by_slot(admin.id, slot_index) existing = await self.watch_repo.get_pin_by_slot(admin.id, slot_index)
slot_ttl = normalize_watch_ttl_hours(int(existing.ttl_hours or 8)) if existing else WATCH_PIN_TTL_HOURS slot_ttl = (
resolve_pin_ttl_minutes(
ttl_minutes=getattr(existing, "ttl_minutes", None),
ttl_hours=existing.ttl_hours,
)
if existing
else WATCH_PIN_TTL_DEFAULT_MINUTES
)
await self.watch_repo.replace_pin_server( await self.watch_repo.replace_pin_server(
admin_id=admin.id, admin_id=admin.id,
slot_index=slot_index, slot_index=slot_index,
server_id=server_id, server_id=server_id,
ttl_hours=slot_ttl, ttl_minutes=slot_ttl,
) )
await self._audit( await self._audit(
admin=admin, admin=admin,
action="watch_pin_replace", action="watch_pin_replace",
server=server, server=server,
detail=f"替换监测 · 槽 {slot_index + 1} · {slot_ttl}h", detail=f"替换监测 · 槽 {slot_index + 1} · {slot_ttl}min",
ip_address=ip_address, ip_address=ip_address,
) )
await self.db.commit() await self.db.commit()
@@ -314,10 +323,10 @@ class WatchService:
if not pin: if not pin:
raise ValueError("槽位为空") raise ValueError("槽位为空")
server = await self.server_repo.get_by_id(pin.server_id) server = await self.server_repo.get_by_id(pin.server_id)
hours = int(pin.ttl_hours or WATCH_PIN_TTL_HOURS) minutes = int(pin.ttl_minutes or WATCH_PIN_TTL_DEFAULT_MINUTES)
action = "watch_pin_resume" if monitoring_enabled else "watch_pin_pause" action = "watch_pin_resume" if monitoring_enabled else "watch_pin_pause"
detail = ( detail = (
f"开启实时监测 · 槽 {slot_index + 1} · 重置 {hours}h" f"开启实时监测 · 槽 {slot_index + 1} · 重置 {minutes}min"
if monitoring_enabled if monitoring_enabled
else f"暂停实时监测 · 槽 {slot_index + 1} · 恢复 Agent 60s" else f"暂停实时监测 · 槽 {slot_index + 1} · 恢复 Agent 60s"
) )
@@ -338,14 +347,14 @@ class WatchService:
*, *,
admin: Admin, admin: Admin,
slot_index: int, slot_index: int,
ttl_hours: int, ttl_minutes: int,
ip_address: str = "", ip_address: str = "",
) -> dict[str, Any]: ) -> dict[str, Any]:
await _finalize_due_pins(self.watch_repo, self.db) await _finalize_due_pins(self.watch_repo, self.db)
if slot_index < 0 or slot_index >= WATCH_MAX_SLOTS: if slot_index < 0 or slot_index >= WATCH_MAX_SLOTS:
raise ValueError("槽位无效") raise ValueError("槽位无效")
hours = normalize_watch_ttl_hours(ttl_hours) minutes = normalize_watch_ttl_minutes(ttl_minutes)
pin = await self.watch_repo.set_pin_ttl_hours(admin.id, slot_index, hours) pin = await self.watch_repo.set_pin_ttl_minutes(admin.id, slot_index, minutes)
if not pin: if not pin:
raise ValueError("槽位为空") raise ValueError("槽位为空")
server = await self.server_repo.get_by_id(pin.server_id) server = await self.server_repo.get_by_id(pin.server_id)
@@ -353,7 +362,7 @@ class WatchService:
admin=admin, admin=admin,
action="watch_pin_ttl", action="watch_pin_ttl",
server=server, server=server,
detail=f"监测时长 · 槽 {slot_index + 1} · {hours}h", detail=f"监测时长 · 槽 {slot_index + 1} · {minutes}min",
ip_address=ip_address, ip_address=ip_address,
) )
await self.db.commit() await self.db.commit()
+5
View File
@@ -80,6 +80,9 @@ class Settings(BaseSettings):
# Telegram (mutable — configurable from settings UI) # Telegram (mutable — configurable from settings UI)
TELEGRAM_BOT_TOKEN: str = "" TELEGRAM_BOT_TOKEN: str = ""
TELEGRAM_CHAT_ID: str = "" TELEGRAM_CHAT_ID: str = ""
# 子机离线告警专用(二者均配置时优先于默认 Bot;否则回退 TELEGRAM_*
TELEGRAM_OFFLINE_BOT_TOKEN: str = ""
TELEGRAM_OFFLINE_CHAT_ID: str = ""
# API docs — "true" exposes /docs /redoc /openapi.json (default off for production) # API docs — "true" exposes /docs /redoc /openapi.json (default off for production)
EXPOSE_API_DOCS: str = "false" EXPOSE_API_DOCS: str = "false"
@@ -131,6 +134,8 @@ class Settings(BaseSettings):
"disk_alert_threshold": "DISK_ALERT_THRESHOLD", "disk_alert_threshold": "DISK_ALERT_THRESHOLD",
"telegram_bot_token": "TELEGRAM_BOT_TOKEN", "telegram_bot_token": "TELEGRAM_BOT_TOKEN",
"telegram_chat_id": "TELEGRAM_CHAT_ID", "telegram_chat_id": "TELEGRAM_CHAT_ID",
"telegram_offline_bot_token": "TELEGRAM_OFFLINE_BOT_TOKEN",
"telegram_offline_chat_id": "TELEGRAM_OFFLINE_CHAT_ID",
"login_allowlist_enabled": "LOGIN_ALLOWLIST_ENABLED", "login_allowlist_enabled": "LOGIN_ALLOWLIST_ENABLED",
"login_subscription_url": "LOGIN_SUBSCRIPTION_URL", "login_subscription_url": "LOGIN_SUBSCRIPTION_URL",
"login_subscription_ips": "LOGIN_SUBSCRIPTION_IPS", "login_subscription_ips": "LOGIN_SUBSCRIPTION_IPS",
+7 -1
View File
@@ -193,7 +193,13 @@ class WatchPin(Base):
Integer, Integer,
nullable=False, nullable=False,
default=8, default=8,
comment="监测窗口时长(小时),仅允许 8 或 24", comment="遗留字段(小时),新逻辑用 ttl_minutes",
)
ttl_minutes = Column(
Integer,
nullable=False,
default=30,
comment="监测窗口时长(分钟) 30|60|120|480|1440",
) )
__table_args__ = ( __table_args__ = (
@@ -0,0 +1 @@
"""Browser Worker bridge infrastructure."""
@@ -0,0 +1,51 @@
"""In-memory browser session ownership (single-operator; primary worker only)."""
from __future__ import annotations
import time
from dataclasses import dataclass, field
@dataclass
class BridgeSession:
session_id: str
admin_id: int
viewport_w: int
viewport_h: int
created_at: float = field(default_factory=time.time)
last_activity: float = field(default_factory=time.time)
_sessions: dict[str, BridgeSession] = {}
def register(session_id: str, admin_id: int, viewport_w: int, viewport_h: int) -> BridgeSession:
sess = BridgeSession(session_id, admin_id, viewport_w, viewport_h)
_sessions[session_id] = sess
return sess
def get(session_id: str) -> BridgeSession | None:
return _sessions.get(session_id)
def touch(session_id: str) -> None:
sess = _sessions.get(session_id)
if sess:
sess.last_activity = time.time()
def remove(session_id: str) -> BridgeSession | None:
return _sessions.pop(session_id, None)
def count_for_admin(admin_id: int) -> int:
return sum(1 for s in _sessions.values() if s.admin_id == admin_id)
def session_ids_for_admin(admin_id: int) -> list[str]:
return [sid for sid, s in _sessions.items() if s.admin_id == admin_id]
def total_count() -> int:
return len(_sessions)
@@ -0,0 +1,86 @@
"""HTTP client for Browser Worker VPS."""
from __future__ import annotations
import logging
from typing import Any
from urllib.parse import urlparse, urlunparse
import httpx
from server.config import settings
logger = logging.getLogger("nexus.browser.worker")
class BrowserWorkerError(Exception):
def __init__(self, message: str, status_code: int = 503) -> None:
super().__init__(message)
self.status_code = status_code
def browser_enabled() -> bool:
return (
settings.BROWSER_ENABLED.lower() in ("1", "true", "yes")
and bool(settings.BROWSER_WORKER_URL)
and bool(settings.BROWSER_WORKER_SECRET)
)
def _headers() -> dict[str, str]:
return {"X-Nexus-Worker-Key": settings.BROWSER_WORKER_SECRET}
def _tls_verify() -> bool:
return settings.BROWSER_WORKER_TLS_VERIFY.lower() in ("1", "true", "yes")
def worker_stream_url(session_id: str) -> str:
base = settings.BROWSER_WORKER_URL.rstrip("/")
parsed = urlparse(base)
scheme = "wss" if parsed.scheme == "https" else "ws"
netloc = parsed.netloc or parsed.path
path = f"/v1/sessions/{session_id}/stream"
return urlunparse((scheme, netloc, path, "", "", ""))
async def create_worker_session(session_id: str, viewport_w: int, viewport_h: int) -> dict[str, Any]:
if not browser_enabled():
raise BrowserWorkerError("Browser worker not enabled", 503)
url = f"{settings.BROWSER_WORKER_URL.rstrip('/')}/v1/sessions"
payload = {"session_id": session_id, "viewport_w": viewport_w, "viewport_h": viewport_h}
try:
async with httpx.AsyncClient(timeout=30.0, verify=_tls_verify()) as client:
resp = await client.post(url, json=payload, headers=_headers())
except httpx.RequestError as exc:
logger.warning("worker create failed: %s", exc)
raise BrowserWorkerError("Browser worker unreachable", 503) from exc
if resp.status_code >= 400:
detail = resp.text[:200]
raise BrowserWorkerError(detail or "Worker rejected session", resp.status_code)
return resp.json()
async def delete_worker_session(session_id: str) -> None:
if not browser_enabled():
return
url = f"{settings.BROWSER_WORKER_URL.rstrip('/')}/v1/sessions/{session_id}"
try:
async with httpx.AsyncClient(timeout=15.0, verify=_tls_verify()) as client:
await client.delete(url, headers=_headers())
except httpx.RequestError as exc:
logger.warning("worker delete failed session=%s: %s", session_id, exc)
async def worker_health() -> dict[str, Any] | None:
if not browser_enabled():
return None
url = f"{settings.BROWSER_WORKER_URL.rstrip('/')}/health"
try:
async with httpx.AsyncClient(timeout=5.0, verify=_tls_verify()) as client:
resp = await client.get(url)
if resp.status_code == 200:
return resp.json()
except httpx.RequestError:
return None
return None
+4 -1
View File
@@ -240,7 +240,8 @@ async def run_schema_migrations():
`pinned_at` DATETIME NOT NULL, `pinned_at` DATETIME NOT NULL,
`expires_at` DATETIME NOT NULL, `expires_at` DATETIME NOT NULL,
`monitoring_enabled` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否开启实时探针', `monitoring_enabled` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否开启实时探针',
`ttl_hours` INT NOT NULL DEFAULT 8 COMMENT '监测窗口时长(小时) 8|24', `ttl_hours` INT NOT NULL DEFAULT 1 COMMENT '遗留字段(小时)',
`ttl_minutes` INT NOT NULL DEFAULT 30 COMMENT '监测窗口时长(分钟)',
UNIQUE KEY `uq_watch_pins_admin_slot` (`admin_id`, `slot_index`), UNIQUE KEY `uq_watch_pins_admin_slot` (`admin_id`, `slot_index`),
UNIQUE KEY `uq_watch_pins_admin_server` (`admin_id`, `server_id`), UNIQUE KEY `uq_watch_pins_admin_server` (`admin_id`, `server_id`),
INDEX `idx_watch_pins_expires` (`expires_at`), INDEX `idx_watch_pins_expires` (`expires_at`),
@@ -285,6 +286,8 @@ async def run_schema_migrations():
"ALTER TABLE `watch_probe_records` ADD COLUMN `processes_json` JSON NULL COMMENT 'Top5 processes snapshot'", "ALTER TABLE `watch_probe_records` ADD COLUMN `processes_json` JSON NULL COMMENT 'Top5 processes snapshot'",
"ALTER TABLE `watch_pins` ADD COLUMN `monitoring_enabled` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否开启实时探针'", "ALTER TABLE `watch_pins` ADD COLUMN `monitoring_enabled` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否开启实时探针'",
"ALTER TABLE `watch_pins` ADD COLUMN `ttl_hours` INT NOT NULL DEFAULT 8 COMMENT '监测窗口时长(小时) 8|24'", "ALTER TABLE `watch_pins` ADD COLUMN `ttl_hours` INT NOT NULL DEFAULT 8 COMMENT '监测窗口时长(小时) 8|24'",
"ALTER TABLE `watch_pins` ADD COLUMN `ttl_minutes` INT NOT NULL DEFAULT 30 COMMENT '监测窗口时长(分钟)'",
"UPDATE `watch_pins` SET `ttl_minutes` = `ttl_hours` * 60 WHERE `ttl_hours` IN (8, 24)",
] ]
async with AsyncSessionLocal() as session: async with AsyncSessionLocal() as session:
for sql in migrations: for sql in migrations:
+44 -22
View File
@@ -9,7 +9,26 @@ from sqlalchemy import delete, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from server.domain.models import Server, WatchPin, WatchPinSession, WatchProbeRecord from server.domain.models import Server, WatchPin, WatchPinSession, WatchProbeRecord
from server.utils.watch_metrics import normalize_watch_ttl_hours from server.utils.watch_metrics import (
WATCH_PIN_TTL_DEFAULT_MINUTES,
normalize_watch_ttl_minutes,
)
def _pin_ttl_minutes(pin: WatchPin) -> int:
raw = getattr(pin, "ttl_minutes", None)
if raw and int(raw) in {30, 60, 120, 480, 1440}:
return int(raw)
if pin.ttl_hours in (8, 24):
return int(pin.ttl_hours) * 60
return WATCH_PIN_TTL_DEFAULT_MINUTES
def _apply_pin_ttl(pin: WatchPin, minutes: int) -> int:
m = normalize_watch_ttl_minutes(minutes)
pin.ttl_minutes = m
pin.ttl_hours = max(1, (m + 59) // 60)
return m
def _utc_naive_now() -> datetime: def _utc_naive_now() -> datetime:
@@ -54,6 +73,7 @@ class WatchRepositoryImpl:
"pinned_at": pin.pinned_at, "pinned_at": pin.pinned_at,
"expires_at": pin.expires_at, "expires_at": pin.expires_at,
"monitoring_enabled": bool(pin.monitoring_enabled), "monitoring_enabled": bool(pin.monitoring_enabled),
"ttl_minutes": _pin_ttl_minutes(pin),
"ttl_hours": int(pin.ttl_hours or 8), "ttl_hours": int(pin.ttl_hours or 8),
} }
) )
@@ -109,11 +129,11 @@ class WatchRepositoryImpl:
admin_id: int, admin_id: int,
server_id: int, server_id: int,
slot_index: int, slot_index: int,
ttl_hours: int = 8, ttl_minutes: int = WATCH_PIN_TTL_DEFAULT_MINUTES,
) -> dict[str, Any]: ) -> dict[str, Any]:
now = _utc_naive_now() now = _utc_naive_now()
hours = normalize_watch_ttl_hours(ttl_hours) minutes = normalize_watch_ttl_minutes(ttl_minutes)
expires = now + timedelta(hours=hours) expires = now + timedelta(minutes=minutes)
sess = WatchPinSession( sess = WatchPinSession(
admin_id=admin_id, admin_id=admin_id,
server_id=server_id, server_id=server_id,
@@ -130,7 +150,8 @@ class WatchRepositoryImpl:
pinned_at=now, pinned_at=now,
expires_at=expires, expires_at=expires,
monitoring_enabled=True, monitoring_enabled=True,
ttl_hours=hours, ttl_hours=max(1, (minutes + 59) // 60),
ttl_minutes=minutes,
) )
self.session.add(pin) self.session.add(pin)
await self.session.flush() await self.session.flush()
@@ -141,7 +162,8 @@ class WatchRepositoryImpl:
"server_id": server_id, "server_id": server_id,
"pinned_at": now, "pinned_at": now,
"expires_at": expires, "expires_at": expires,
"ttl_hours": hours, "ttl_minutes": minutes,
"ttl_hours": pin.ttl_hours,
} }
async def end_session(self, session_id: int, reason: str) -> None: async def end_session(self, session_id: int, reason: str) -> None:
@@ -168,7 +190,7 @@ class WatchRepositoryImpl:
admin_id: int, admin_id: int,
slot_index: int, slot_index: int,
server_id: int, server_id: int,
ttl_hours: int = 8, ttl_minutes: int = WATCH_PIN_TTL_DEFAULT_MINUTES,
) -> dict[str, Any]: ) -> dict[str, Any]:
existing = await self.get_pin_by_slot(admin_id, slot_index) existing = await self.get_pin_by_slot(admin_id, slot_index)
if existing: if existing:
@@ -182,23 +204,25 @@ class WatchRepositoryImpl:
admin_id=admin_id, admin_id=admin_id,
server_id=server_id, server_id=server_id,
slot_index=slot_index, slot_index=slot_index,
ttl_hours=ttl_hours, ttl_minutes=ttl_minutes,
) )
async def reset_pin_ttl(self, admin_id: int, server_id: int, ttl_hours: int | None = None) -> dict[str, Any]: async def reset_pin_ttl(self, admin_id: int, server_id: int, ttl_minutes: int | None = None) -> dict[str, Any]:
"""Re-pin same server: end old session and start fresh monitoring window.""" """Re-pin same server: end old session and start fresh monitoring window."""
pin = await self.get_pin_by_server(admin_id, server_id) pin = await self.get_pin_by_server(admin_id, server_id)
if not pin: if not pin:
raise ValueError("pin not found") raise ValueError("pin not found")
slot_index = pin.slot_index slot_index = pin.slot_index
hours = normalize_watch_ttl_hours(ttl_hours if ttl_hours is not None else int(pin.ttl_hours or 8)) minutes = normalize_watch_ttl_minutes(
ttl_minutes if ttl_minutes is not None else _pin_ttl_minutes(pin)
)
await self.end_session(pin.session_id, "replaced") await self.end_session(pin.session_id, "replaced")
await self.delete_pin(pin.id) await self.delete_pin(pin.id)
return await self.create_pin( return await self.create_pin(
admin_id=admin_id, admin_id=admin_id,
server_id=server_id, server_id=server_id,
slot_index=slot_index, slot_index=slot_index,
ttl_hours=hours, ttl_minutes=minutes,
) )
async def remove_pin_by_slot(self, admin_id: int, slot_index: int) -> WatchPin | None: async def remove_pin_by_slot(self, admin_id: int, slot_index: int) -> WatchPin | None:
@@ -233,37 +257,35 @@ class WatchRepositoryImpl:
slot_index: int, slot_index: int,
enabled: bool, enabled: bool,
*, *,
ttl_hours: int | None = None, ttl_minutes: int | None = None,
) -> WatchPin | None: ) -> WatchPin | None:
pin = await self.get_pin_by_slot(admin_id, slot_index) pin = await self.get_pin_by_slot(admin_id, slot_index)
if not pin: if not pin:
return None return None
if ttl_hours is not None: if ttl_minutes is not None:
pin.ttl_hours = normalize_watch_ttl_hours(ttl_hours) _apply_pin_ttl(pin, ttl_minutes)
if enabled: if enabled:
await self._ensure_open_session(pin) await self._ensure_open_session(pin)
hours = normalize_watch_ttl_hours(int(pin.ttl_hours or 8)) minutes = _apply_pin_ttl(pin, _pin_ttl_minutes(pin))
pin.ttl_hours = hours
pin.monitoring_enabled = True pin.monitoring_enabled = True
pin.expires_at = _utc_naive_now() + timedelta(hours=hours) pin.expires_at = _utc_naive_now() + timedelta(minutes=minutes)
else: else:
pin.monitoring_enabled = False pin.monitoring_enabled = False
await self.session.flush() await self.session.flush()
return pin return pin
async def set_pin_ttl_hours( async def set_pin_ttl_minutes(
self, self,
admin_id: int, admin_id: int,
slot_index: int, slot_index: int,
ttl_hours: int, ttl_minutes: int,
) -> WatchPin | None: ) -> WatchPin | None:
pin = await self.get_pin_by_slot(admin_id, slot_index) pin = await self.get_pin_by_slot(admin_id, slot_index)
if not pin: if not pin:
return None return None
hours = normalize_watch_ttl_hours(ttl_hours) minutes = _apply_pin_ttl(pin, ttl_minutes)
pin.ttl_hours = hours
if pin.monitoring_enabled: if pin.monitoring_enabled:
pin.expires_at = _utc_naive_now() + timedelta(hours=hours) pin.expires_at = _utc_naive_now() + timedelta(minutes=minutes)
await self.session.flush() await self.session.flush()
return pin return pin
@@ -419,6 +419,9 @@ async def remote_write_bytes(
if not skip_sftp: if not skip_sftp:
try: try:
parent = posix_dirname(remote_path)
if parent and parent != "/":
await _ensure_remote_parent_dir(conn, remote_path, use_sudo=False)
async with await conn.start_sftp_client() as sftp: async with await conn.start_sftp_client() as sftp:
await sftp_write_bytes(sftp, remote_path, data) await sftp_write_bytes(sftp, remote_path, data)
return return
+33 -5
View File
@@ -93,17 +93,38 @@ async def close_client():
_http_client = None _http_client = None
async def send_telegram(message: str) -> bool: def resolve_offline_telegram_credentials() -> tuple[str, str] | None:
"""Bot + chat for offline alerts: dedicated pair if both set, else default."""
off_token = (settings.TELEGRAM_OFFLINE_BOT_TOKEN or "").strip()
off_chat = (settings.TELEGRAM_OFFLINE_CHAT_ID or "").strip()
if off_token and off_chat:
return off_token, off_chat
main_token = (settings.TELEGRAM_BOT_TOKEN or "").strip()
main_chat = (settings.TELEGRAM_CHAT_ID or "").strip()
if main_token and main_chat:
return main_token, main_chat
return None
async def send_telegram(
message: str,
*,
bot_token: str | None = None,
chat_id: str | None = None,
) -> bool:
"""Send a Telegram message via Bot API.""" """Send a Telegram message via Bot API."""
if not settings.TELEGRAM_BOT_TOKEN or not settings.TELEGRAM_CHAT_ID: token = (bot_token or settings.TELEGRAM_BOT_TOKEN or "").strip()
chat = (chat_id or settings.TELEGRAM_CHAT_ID or "").strip()
if not token or not chat:
return False return False
try: try:
client = await _get_client() client = await _get_client()
resp = await client.post( resp = await client.post(
f"{TELEGRAM_API_BASE}/bot{settings.TELEGRAM_BOT_TOKEN}/sendMessage", f"{TELEGRAM_API_BASE}/bot{token}/sendMessage",
json={ json={
"chat_id": settings.TELEGRAM_CHAT_ID, "chat_id": chat,
"text": message, "text": message,
"parse_mode": "HTML", "parse_mode": "HTML",
}, },
@@ -175,6 +196,11 @@ async def send_telegram_offline_alert(
ip_line = _telegram_ip_line(public_ip, private_ip) ip_line = _telegram_ip_line(public_ip, private_ip)
creds = resolve_offline_telegram_credentials()
if not creds:
return False
bot_token, tg_chat_id = creds
return await send_telegram( return await send_telegram(
f"🔴 <b>【子机离线】Agent 心跳丢失</b>\n" f"🔴 <b>【子机离线】Agent 心跳丢失</b>\n"
f"平台:{_system_name()}\n" f"平台:{_system_name()}\n"
@@ -182,7 +208,9 @@ async def send_telegram_offline_alert(
f"检测来源:Layer 2 server_offline_monitor30 秒周期)\n" f"检测来源:Layer 2 server_offline_monitor30 秒周期)\n"
f"具体情况:子机未在预期时间内上报心跳,平台已将其标记为离线\n" f"具体情况:子机未在预期时间内上报心跳,平台已将其标记为离线\n"
f"建议操作:检查子机 Agent 进程、网络与防火墙;或在 Nexus 服务器页执行诊断\n" f"建议操作:检查子机 Agent 进程、网络与防火墙;或在 Nexus 服务器页执行诊断\n"
f"时间:{_now_cn()}" f"时间:{_now_cn()}",
bot_token=bot_token,
chat_id=tg_chat_id,
) )
+7
View File
@@ -72,6 +72,8 @@ from server.api.files import router as files_router
from server.api.search import router as search_router from server.api.search import router as search_router
from server.api.terminal import router as terminal_router from server.api.terminal import router as terminal_router
from server.api.watch import router as watch_router from server.api.watch import router as watch_router
from server.api.browser import router as browser_router
from server.api.browser_session import router as browser_session_router, ws_router as browser_ws_router
# Background tasks # Background tasks
from server.background.heartbeat_flush import heartbeat_flush_loop from server.background.heartbeat_flush import heartbeat_flush_loop
@@ -696,6 +698,11 @@ app.include_router(search_router)
app.include_router(terminal_router) app.include_router(terminal_router)
app.include_router(watch_router) app.include_router(watch_router)
# Remote browser (UI state + Worker bridge)
app.include_router(browser_router)
app.include_router(browser_session_router)
app.include_router(browser_ws_router)
# ── Custom 404: return blank HTML to hide backend identity ── # ── Custom 404: return blank HTML to hide backend identity ──
@app.exception_handler(StarletteHTTPException) @app.exception_handler(StarletteHTTPException)

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