feat(watch): show probe history in Beijing time and retire remote browser

Monitoring history charts and tables now format UTC timestamps as Asia/Shanghai.
Remove Playwright worker, global browser panel, and related API routes; open site URLs in a new tab instead. Fix chain test Redis pipeline mock.
This commit is contained in:
Nexus Agent
2026-06-13 16:31:14 +08:00
parent adee25deb6
commit ba7c790f38
39 changed files with 471 additions and 2432 deletions
-19
View File
@@ -1,19 +0,0 @@
FROM mcr.microsoft.com/playwright/python:v1.49.1-jammy
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
RUN apt-get update && apt-get install -y --no-install-recommends xvfb \
&& rm -rf /var/lib/apt/lists/*
COPY browser_url_safe.py browser_stealth.py config.py session_manager.py main.py docker-entrypoint.sh ./
RUN sed -i 's/\r$//' docker-entrypoint.sh && chmod +x docker-entrypoint.sh
ENV WORKER_BIND=0.0.0.0:8443
ENV DISPLAY=:99
EXPOSE 8443
ENTRYPOINT ["/bin/bash", "docker-entrypoint.sh"]
-46
View File
@@ -1,46 +0,0 @@
"""Reduce automation fingerprints for sites with CAPTCHA (e.g. 阿里云)."""
from __future__ import annotations
CHROMIUM_ARGS = [
"--disable-blink-features=AutomationControlled",
"--no-sandbox",
"--disable-dev-shm-usage",
"--disable-infobars",
"--window-position=0,0",
"--lang=zh-CN",
]
# Playwright adds --enable-automation by default; drop it for CAPTCHA sites.
IGNORE_DEFAULT_ARGS = ["--enable-automation"]
STEALTH_INIT_SCRIPT = """
(() => {
Object.defineProperty(navigator, 'webdriver', { get: () => undefined, configurable: true });
Object.defineProperty(navigator, 'languages', { get: () => ['zh-CN', 'zh', 'en-US', 'en'] });
Object.defineProperty(navigator, 'plugins', { get: () => [1, 2, 3, 4, 5] });
const originalQuery = window.navigator.permissions.query;
window.navigator.permissions.query = (parameters) => (
parameters.name === 'notifications'
? Promise.resolve({ state: Notification.permission })
: originalQuery(parameters)
);
window.chrome = window.chrome || { runtime: {} };
})();
"""
DEFAULT_USER_AGENT = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
)
CONTEXT_OPTIONS = {
"user_agent": DEFAULT_USER_AGENT,
"locale": "zh-CN",
"timezone_id": "Asia/Shanghai",
"color_scheme": "light",
"device_scale_factor": 1,
"has_touch": False,
"is_mobile": False,
"java_script_enabled": True,
}
-77
View File
@@ -1,77 +0,0 @@
"""SSRF-safe URL validation (Worker copy — keep in sync with server/utils/browser_url_safe.py)."""
from __future__ import annotations
import ipaddress
import socket
from urllib.parse import urlparse
BLOCKED_HOSTNAMES = frozenset({
"localhost",
"localhost.localdomain",
"metadata",
"metadata.google.internal",
"169.254.169.254",
})
def _blocked_ip(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved or ip.is_multicast:
return True
if isinstance(ip, ipaddress.IPv4Address):
if ip in ipaddress.ip_network("0.0.0.0/8"):
return True
if ip in ipaddress.ip_network("100.64.0.0/10"):
return True
if isinstance(ip, ipaddress.IPv6Address):
if ip == ipaddress.IPv6Address("::1"):
return True
if ip in ipaddress.ip_network("fc00::/7"):
return True
if ip in ipaddress.ip_network("fe80::/10"):
return True
return False
def resolve_host_blocked(hostname: str) -> str | None:
host = hostname.lower().strip(".")
if not host:
return "Empty hostname"
if host in BLOCKED_HOSTNAMES or "metadata" in host:
return f"Blocked hostname: {hostname}"
try:
infos = socket.getaddrinfo(host, None, type=socket.SOCK_STREAM)
except socket.gaierror as exc:
return f"DNS resolution failed: {exc}"
if not infos:
return f"No addresses for hostname: {hostname}"
for info in infos:
ip_str = info[4][0]
try:
ip = ipaddress.ip_address(ip_str)
except ValueError:
return f"Invalid IP: {ip_str}"
if _blocked_ip(ip):
return f"Blocked IP: {ip_str}"
return None
def validate_navigation_url(url: str) -> tuple[str | None, str | None]:
trimmed = url.strip()
if not trimmed:
return None, "Empty URL"
try:
with_proto = trimmed if "://" in trimmed else f"https://{trimmed}"
parsed = urlparse(with_proto)
except Exception:
return None, "Invalid URL"
if parsed.scheme not in ("http", "https"):
return None, f"Unsupported scheme: {parsed.scheme}"
if parsed.username or parsed.password:
return None, "Credentials in URL are not allowed"
if not parsed.hostname:
return None, "Missing hostname"
host_err = resolve_host_blocked(parsed.hostname)
if host_err:
return None, host_err
return parsed.geturl(), None
-22
View File
@@ -1,22 +0,0 @@
"""Browser Worker configuration."""
from pydantic_settings import BaseSettings, SettingsConfigDict
class WorkerSettings(BaseSettings):
WORKER_SECRET: str = ""
WORKER_BIND: str = "0.0.0.0:8443"
WORKER_MAX_SESSIONS: int = 2
WORKER_IDLE_TIMEOUT_SEC: int = 1800
# POST /v1/sessions without WS attach — reclaim zombie reservations
WORKER_RESERVE_TIMEOUT_SEC: int = 120
# false + Xvfb — better for 阿里云等验证码;true 省资源
WORKER_HEADLESS: str = "false"
WORKER_SCREENCAST_QUALITY: int = 90
WORKER_DEFAULT_VIEWPORT_W: int = 1280
WORKER_DEFAULT_VIEWPORT_H: int = 720
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
settings = WorkerSettings()
-11
View File
@@ -1,11 +0,0 @@
services:
browser-worker:
build: .
container_name: nexus-browser-worker
restart: unless-stopped
env_file:
- .env
ports:
- "8443:8443"
shm_size: "1gb"
ipc: host
-11
View File
@@ -1,11 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# Headed Chromium needs a virtual display (CAPTCHA / anti-bot).
if [[ "${WORKER_HEADLESS:-false}" != "true" && "${WORKER_HEADLESS:-false}" != "1" ]]; then
export DISPLAY="${DISPLAY:-:99}"
if ! pgrep -x Xvfb >/dev/null 2>&1; then
Xvfb "${DISPLAY}" -screen 0 1920x1080x24 -ac +extension GLX +render -noreset &
sleep 1
fi
fi
exec uvicorn main:app --host 0.0.0.0 --port 8443 --log-level info
-147
View File
@@ -1,147 +0,0 @@
"""Browser Worker — Playwright Chromium + WebSocket stream."""
from __future__ import annotations
import asyncio
import json
import logging
from contextlib import asynccontextmanager
from typing import Any
from fastapi import FastAPI, Header, HTTPException, WebSocket, WebSocketDisconnect
from pydantic import BaseModel, Field
from config import settings
from session_manager import manager
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("browser_worker")
def _check_key(header: str | None) -> None:
if not settings.WORKER_SECRET:
raise HTTPException(status_code=503, detail="Worker secret not configured")
if not header or header != settings.WORKER_SECRET:
raise HTTPException(status_code=401, detail="Invalid worker key")
class CreateSessionBody(BaseModel):
session_id: str = Field(min_length=8, max_length=64)
viewport_w: int = Field(default=1280, ge=320, le=1920)
viewport_h: int = Field(default=720, ge=240, le=1080)
@asynccontextmanager
async def lifespan(_app: FastAPI):
await manager.start()
logger.info("Playwright Chromium ready")
yield
await manager.stop()
app = FastAPI(title="Nexus Browser Worker", lifespan=lifespan)
@app.get("/health")
async def health():
return {
"status": "ok",
"sessions": manager.session_count(),
"max_sessions": settings.WORKER_MAX_SESSIONS,
}
@app.post("/v1/sessions")
async def create_session(
body: CreateSessionBody,
x_nexus_worker_key: str | None = Header(default=None, alias="X-Nexus-Worker-Key"),
):
_check_key(x_nexus_worker_key)
try:
await manager.reserve_session(body.session_id, body.viewport_w, body.viewport_h)
except ValueError as exc:
code = str(exc)
if code == "max_sessions":
raise HTTPException(status_code=503, detail="Max sessions reached") from exc
if code == "session_exists":
raise HTTPException(status_code=409, detail="Session already exists") from exc
raise HTTPException(status_code=400, detail=code) from exc
return {"session_id": body.session_id, "viewport_w": body.viewport_w, "viewport_h": body.viewport_h}
@app.delete("/v1/sessions/{session_id}")
async def delete_session(
session_id: str,
x_nexus_worker_key: str | None = Header(default=None, alias="X-Nexus-Worker-Key"),
):
_check_key(x_nexus_worker_key)
if not await manager.destroy_session(session_id):
raise HTTPException(status_code=404, detail="Session not found")
return {"ok": True}
@app.websocket("/v1/sessions/{session_id}/stream")
async def session_stream(
websocket: WebSocket,
session_id: str,
x_nexus_worker_key: str | None = Header(default=None, alias="X-Nexus-Worker-Key"),
):
key = x_nexus_worker_key or websocket.headers.get("x-nexus-worker-key")
if not settings.WORKER_SECRET or key != settings.WORKER_SECRET:
await websocket.close(code=4401, reason="Invalid worker key")
return
if not manager.get_session(session_id):
await websocket.close(code=4404, reason="Session not found")
return
await websocket.accept()
outbound: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
async def send(msg: dict[str, Any]) -> None:
await outbound.put(msg)
try:
await manager.attach_stream(session_id, send)
except ValueError:
await websocket.close(code=4404, reason="Session not found")
return
except RuntimeError:
await manager.destroy_session(session_id)
await websocket.close(code=4503, reason="Browser not ready")
return
async def pump_out() -> None:
while True:
msg = await outbound.get()
await websocket.send_json(msg)
pump_task = asyncio.create_task(pump_out(), name=f"ws_out_{session_id}")
try:
while True:
raw = await websocket.receive_text()
try:
msg = json.loads(raw)
except json.JSONDecodeError:
await send({"type": "ERROR", "message": "Invalid JSON", "code": "BAD_JSON"})
continue
if not isinstance(msg, dict):
continue
await manager.handle_message(session_id, msg)
except WebSocketDisconnect:
pass
finally:
pump_task.cancel()
try:
await pump_task
except asyncio.CancelledError:
pass
await manager.destroy_session(session_id)
if __name__ == "__main__":
import uvicorn
host, _, port = settings.WORKER_BIND.partition(":")
uvicorn.run("main:app", host=host or "0.0.0.0", port=int(port or 8443), log_level="info")
-4
View File
@@ -1,4 +0,0 @@
fastapi==0.115.6
uvicorn[standard]==0.34.0
playwright==1.49.1
pydantic-settings==2.7.1
-346
View File
@@ -1,346 +0,0 @@
"""Playwright Chromium session manager with CDP screencast."""
from __future__ import annotations
import asyncio
import logging
import time
from dataclasses import dataclass, field
from typing import Any, Callable, Awaitable
from playwright.async_api import Browser, BrowserContext, Page, async_playwright
from browser_stealth import (
CHROMIUM_ARGS,
CONTEXT_OPTIONS,
IGNORE_DEFAULT_ARGS,
STEALTH_INIT_SCRIPT,
)
from browser_url_safe import validate_navigation_url
from config import settings
def _headless() -> bool:
return settings.WORKER_HEADLESS.lower() in ("1", "true", "yes")
logger = logging.getLogger("browser_worker.session")
SendFn = Callable[[dict[str, Any]], Awaitable[None]]
@dataclass
class BrowserSession:
session_id: str
viewport_w: int
viewport_h: int
send: SendFn
ready: bool = False
context: BrowserContext | None = None
page: Page | None = None
cdp: Any = None
last_activity: float = field(default_factory=time.monotonic)
reserved_at: float = field(default_factory=time.monotonic)
_screencast_task: asyncio.Task | None = None
_title_task: asyncio.Task | None = None
async def touch(self) -> None:
self.last_activity = time.monotonic()
async def close(self) -> None:
for task in (self._screencast_task, self._title_task):
if task and not task.done():
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
self._screencast_task = None
self._title_task = None
if self.cdp:
try:
await self.cdp.send("Page.stopScreencast")
except Exception:
pass
self.cdp = None
if self.context:
try:
await self.context.close()
except Exception as exc:
logger.warning("context close failed session=%s: %s", self.session_id, exc)
self.context = None
self.page = None
self.ready = False
class SessionManager:
def __init__(self) -> None:
self._browser: Browser | None = None
self._playwright = None
self._sessions: dict[str, BrowserSession] = {}
self._lock = asyncio.Lock()
self._idle_task: asyncio.Task | None = None
async def start(self) -> None:
self._playwright = await async_playwright().start()
self._browser = await self._playwright.chromium.launch(
headless=_headless(),
args=CHROMIUM_ARGS,
ignore_default_args=IGNORE_DEFAULT_ARGS,
)
self._idle_task = asyncio.create_task(self._idle_loop(), name="browser_idle")
async def stop(self) -> None:
if self._idle_task:
self._idle_task.cancel()
try:
await self._idle_task
except asyncio.CancelledError:
pass
for sid in list(self._sessions):
await self.destroy_session(sid)
if self._browser:
await self._browser.close()
self._browser = None
if self._playwright:
await self._playwright.stop()
self._playwright = None
async def _idle_loop(self) -> None:
while True:
await asyncio.sleep(60)
now = time.monotonic()
stale: list[str] = []
for sid, sess in self._sessions.items():
if sess.ready:
if now - sess.last_activity > settings.WORKER_IDLE_TIMEOUT_SEC:
stale.append(sid)
elif now - sess.reserved_at > settings.WORKER_RESERVE_TIMEOUT_SEC:
stale.append(sid)
for sid in stale:
logger.info("idle timeout session=%s", sid)
await self.destroy_session(sid)
def session_count(self) -> int:
return len(self._sessions)
async def reserve_session(
self,
session_id: str,
viewport_w: int,
viewport_h: int,
) -> None:
async with self._lock:
if session_id in self._sessions:
raise ValueError("session_exists")
if len(self._sessions) >= settings.WORKER_MAX_SESSIONS:
raise ValueError("max_sessions")
self._sessions[session_id] = BrowserSession(
session_id=session_id,
viewport_w=viewport_w,
viewport_h=viewport_h,
send=self._noop_send,
)
async def attach_stream(self, session_id: str, send: SendFn) -> BrowserSession:
async with self._lock:
session = self._sessions.get(session_id)
if not session:
raise ValueError("session_not_found")
if session.ready:
session.send = send
return session
if not self._browser:
raise RuntimeError("browser_not_ready")
session.send = send
context = await self._browser.new_context(
viewport={"width": session.viewport_w, "height": session.viewport_h},
ignore_https_errors=False,
**CONTEXT_OPTIONS,
)
await context.add_init_script(STEALTH_INIT_SCRIPT)
page = await context.new_page()
session.context = context
session.page = page
cdp = await context.new_cdp_session(page)
session.cdp = cdp
await cdp.send("Page.startScreencast", {
"format": "jpeg",
"quality": max(60, min(settings.WORKER_SCREENCAST_QUALITY, 100)),
"everyNthFrame": 1,
})
session._screencast_task = asyncio.create_task(
self._screencast_loop(session, cdp),
name=f"screencast_{session_id}",
)
session._title_task = asyncio.create_task(
self._title_loop(session),
name=f"title_{session_id}",
)
session.ready = True
await session.send({
"type": "BROWSER_INIT",
"session_id": session_id,
"viewport_w": session.viewport_w,
"viewport_h": session.viewport_h,
})
return session
async def _noop_send(self, _msg: dict[str, Any]) -> None:
return
async def destroy_session(self, session_id: str) -> bool:
async with self._lock:
session = self._sessions.pop(session_id, None)
if not session:
return False
await session.close()
return True
def get_session(self, session_id: str) -> BrowserSession | None:
return self._sessions.get(session_id)
async def _screencast_loop(self, session: BrowserSession, cdp: Any) -> None:
"""CDPSession has no wait_for_event — use on() + asyncio.Queue."""
frame_queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
def _on_screencast_frame(params: dict[str, Any]) -> None:
try:
frame_queue.put_nowait(params)
except asyncio.QueueFull:
pass
cdp.on("Page.screencastFrame", _on_screencast_frame)
try:
while session.session_id in self._sessions and session.ready:
try:
msg = await asyncio.wait_for(frame_queue.get(), timeout=30.0)
except asyncio.TimeoutError:
continue
await session.touch()
await session.send({
"type": "SCREENCAST_FRAME",
"data_b64": msg.get("data", ""),
"metadata": msg.get("metadata") or {},
})
await cdp.send("Page.screencastFrameAck", {"sessionId": msg["sessionId"]})
except asyncio.CancelledError:
raise
except Exception as exc:
logger.warning("screencast ended session=%s: %s", session.session_id, exc)
async def _title_loop(self, session: BrowserSession) -> None:
page = session.page
if not page:
return
last_url = ""
last_title = ""
try:
while session.session_id in self._sessions and session.ready:
await asyncio.sleep(1)
await session.touch()
try:
url = page.url
title = await page.title()
except Exception:
continue
if url != last_url or title != last_title:
last_url = url
last_title = title
await session.send({
"type": "PAGE_INFO",
"url": url,
"title": title,
})
except asyncio.CancelledError:
raise
async def handle_message(self, session_id: str, msg: dict[str, Any]) -> None:
session = self.get_session(session_id)
if not session or not session.page or not session.ready:
return
await session.touch()
page = session.page
mtype = msg.get("type")
if mtype == "PING":
await session.send({"type": "PONG"})
return
if mtype == "NAVIGATE":
url = msg.get("url", "")
normalized, err = validate_navigation_url(str(url))
if err:
await session.send({"type": "ERROR", "message": err, "code": "URL_BLOCKED"})
return
try:
await page.goto(normalized, wait_until="domcontentloaded", timeout=30_000)
await session.send({
"type": "PAGE_INFO",
"url": page.url,
"title": await page.title(),
})
except Exception as exc:
await session.send({
"type": "ERROR",
"message": str(exc)[:500],
"code": "NAVIGATE_FAILED",
})
return
if mtype == "RELOAD":
await page.reload(wait_until="domcontentloaded", timeout=30_000)
return
if mtype == "GO_BACK":
await page.go_back(wait_until="domcontentloaded", timeout=30_000)
return
if mtype == "GO_FORWARD":
await page.go_forward(wait_until="domcontentloaded", timeout=30_000)
return
if mtype == "VIEWPORT":
w = int(msg.get("width") or session.viewport_w)
h = int(msg.get("height") or session.viewport_h)
w = max(320, min(w, 1920))
h = max(240, min(h, 1080))
session.viewport_w = w
session.viewport_h = h
await page.set_viewport_size({"width": w, "height": h})
return
if mtype == "MOUSE":
event = msg.get("event")
x = float(msg.get("x", 0))
y = float(msg.get("y", 0))
button = msg.get("button", "left")
steps = int(msg.get("steps") or 1)
steps = max(1, min(steps, 25))
if event == "move":
await page.mouse.move(x, y, steps=steps)
elif event == "down":
await page.mouse.move(x, y)
await page.mouse.down(button=button)
elif event == "up":
await page.mouse.move(x, y)
await page.mouse.up(button=button)
elif event == "click":
await page.mouse.click(x, y, button=button, delay=50)
elif event == "wheel":
delta_y = float(msg.get("delta_y", 0))
await page.mouse.wheel(0, delta_y)
return
if mtype == "KEY":
event = msg.get("event")
key = msg.get("key", "")
text = msg.get("text", "")
if event == "down" and key:
await page.keyboard.down(key)
elif event == "up" and key:
await page.keyboard.up(key)
elif event == "type" and text:
await page.keyboard.type(text)
return
manager = SessionManager()
@@ -0,0 +1,69 @@
# 审计 — 监测历史北京时间 + 下线远程浏览器
**Changelog**:
- `docs/changelog/2026-06-13-watch-history-beijing-time.md`
- `docs/changelog/2026-06-12-remove-remote-browser.md`
## 审计范围
| 文件 | 变更 | 状态 |
|------|------|------|
| `frontend/src/utils/datetime.ts` | `formatChartAxisBeijing` | ☑ |
| `frontend/src/components/watch/WatchProbeRecordsTable.vue` | 北京时间列 | ☑ |
| `frontend/src/components/watch/WatchSparkline.vue` | tooltip 北京时间 | ☑ |
| `frontend/src/components/watch/WatchTrendChart.vue` | 轴/tooltip 北京时间 | ☑ |
| `frontend/src/pages/WatchMetricsPage.vue` | 「北京时间」标签 | ☑ |
| `frontend/src/App.vue` | 移除 GlobalBrowserPanel | ☑ |
| `frontend/src/router/index.ts` | 移除 `/browser` 路由 | ☑ |
| `frontend/src/pages/ServersPage.vue` | 站点 `window.open` | ☑ |
| `frontend/src/pages/AuditPage.vue` | 移除未用 import | ☑ |
| `frontend/src/components/GlobalBrowserPanel.vue` | 删除 | ☑ |
| `frontend/src/composables/useGlobalBrowser.ts` | 删除 | ☑ |
| `frontend/src/composables/useRemoteBrowserSession.ts` | 删除 | ☑ |
| `frontend/src/pages/BrowserPage.vue` | 删除 | ☑ |
| `server/main.py` | 注销 browser 路由 | ☑ |
| `server/api/browser.py` | 删除 | ☑ |
| `server/api/browser_session.py` | 删除 | ☑ |
| `server/infrastructure/browser/` | 删除 | ☑ |
| `__init__.py` | browser 包删除 | ☑ |
| `server/utils/browser_ui_state.py` | 删除 | ☑ |
| `server/utils/browser_url_safe.py` | 删除 | ☑ |
| `browser-worker/` | 删除 | ☑ |
| `Dockerfile` | browser-worker 删除 | ☑ |
| `browser-worker/main.py` | 删除 | ☑ |
| `browser-worker/browser_url_safe.py` | 删除 | ☑ |
| `browser-worker/browser_stealth.py` | 删除 | ☑ |
| `browser-worker/config.py` | 删除 | ☑ |
| `browser-worker/docker-compose.yml` | 删除 | ☑ |
| `browser-worker/docker-entrypoint.sh` | 删除 | ☑ |
| `browser-worker/requirements.txt` | 删除 | ☑ |
| `browser-worker/session_manager.py` | 删除 | ☑ |
| `server/infrastructure/browser/__init__.py` | 删除 | ☑ |
| `server/infrastructure/browser/session_registry.py` | 删除 | ☑ |
| `server/infrastructure/browser/worker_client.py` | 删除 | ☑ |
| `tests/test_browser_ui_state.py` | 删除 | ☑ |
| `tests/test_browser_url_safe.py` | 删除 | ☑ |
| `tests/chain/test_agent_dashboard_flow.py` | Redis pipeline mock | ☑ |
## Step 3 规则扫描
| H | 规则 | 结论 |
|---|------|------|
| H1 | 北京时间仅前端展示,不改 API 时区 | SAFE |
| H2 | 浏览器下线后无孤立 `/api/browser` 注册 | SAFE |
| H3 | `browserUrl.ts` 保留供站点外链 | SAFE |
| H4 | 无新密钥/明文 | SAFE |
## Closure
| H | 判定 | 依据 |
|---|------|------|
| H1H4 | SAFE | L2b 26/26chain 9/9vite build OK |
## DoD
- [x] `bash scripts/local_verify.sh`
- [x] `pytest tests/chain -q`
- [x] `cd frontend && npx vite build`
- [x] changelog + audit
- [ ] 生产 `/health` + `#/watch-metrics` 北京时间终验
@@ -0,0 +1,31 @@
# 2026-06-12 移除远程内置浏览器方案
## 摘要
整体下线 Playwright Worker + 全局浮动 canvas 浏览器:删前端组件/路由、后端 `/api/browser/*``/ws/browser``browser-worker/` 目录及单测。
## 动机
黑屏与状态机复杂、验证码与 SSRF 未闭环;运维场景下「站点」新标签打开即可。经验沉淀见 `docs/design/specs/2026-06-12-remote-browser-retired-lessons.md`
## 涉及文件(删/改)
- 删:`frontend/src/components/GlobalBrowserPanel.vue``useGlobalBrowser.ts``useRemoteBrowserSession.ts``BrowserPage.vue`
- 删:`server/api/browser.py``browser_session.py``server/infrastructure/browser/*``server/utils/browser_*`
- 删:`browser-worker/``tests/test_browser_*`
- 改:`server/main.py`(已不注册路由)、`server/config.py`(移除 `BROWSER_*`
- 保留:`frontend/src/utils/browserUrl.ts``ServersPage`「站点」→ `window.open`
## 迁移/重启
- 生产可保留 `NEXUS_BROWSER_*``.env``Settings.extra=ignore` 忽略);建议删除 Worker 容器与 8443 规则。
-**重启 Nexus API****前端 build 同步**
## 验证
```bash
venv/bin/pytest tests/test_settings_sound_defaults.py -q
cd frontend && npm run type-check && npx vite build
curl -sf https://api.synaglobal.vip/health
# 404 预期:GET /api/browser/status(需 JWT
```
@@ -0,0 +1,27 @@
# 监测历史时间显示改为北京时间
**日期**2026-06-13
## 变更摘要
监测历史页(探针记录表、资源趋势图、插槽折线 tooltip)将 API 返回的 UTC 时间按 `Asia/Shanghai` 格式化展示,与审计/告警等页一致。
## 动机
此前直接显示 UTC 的 `recorded_at` 原文,比北京时间少 8 小时,易误解。
## 涉及文件
- `frontend/src/utils/datetime.ts``formatChartAxisBeijing`
- `frontend/src/components/watch/WatchProbeRecordsTable.vue`
- `frontend/src/components/watch/WatchTrendChart.vue`
- `frontend/src/components/watch/WatchSparkline.vue`
- `frontend/src/pages/WatchMetricsPage.vue`
## 迁移 / 重启
仅前端;`vite build` 后生效,无需重启后端。
## 验证方式
`cd frontend && npx vite build`;打开 `#/watch-metrics`,探针记录「时间」列应与本地北京时间一致。
@@ -0,0 +1,68 @@
# 远程内置浏览器方案 — 下线经验(2026-06-12
> **状态**:已整体移除(代码 + API + Worker 目录)。下次换方案前必读本文。
> 历史设计归档见 `docs/design/specs/2026-06-11-server-browser-chromium-design.md` 等。
## 演进简史
| 阶段 | 方案 | 结果 |
|------|------|------|
| 1 | 全局浮动窗 + **iframe** 嵌站点 | 大量站点 `X-Frame-Options` 拒嵌 → 已移除 |
| 2 | **独立 Worker**Playwright + JPEG screencast+ Nexus 桥接 WS + 前端 canvas | 2026-06-12 下线 |
## 曾验证可行的部分
- Worker `GET /health`、主站 `NEXUS_BROWSER_*` 门控、会话 `X-Nexus-Worker-Key` 鉴权链路可通。
- `Page.startScreencast` + `cdp.on("Page.screencastFrame")` 能推 JPEG 到前端 canvas(冒烟 ~4KB/帧)。
- 显式 `NAVIGATE` 双端 `validate_navigation_url` 可挡明显 SSRF。
- 服务器页「站点」用域名拼 `https://` 打开(`guessSiteUrlFromDomain`)简单可靠。
## 未达标的根因(勿重复踩坑)
### 1. 产品 / UX
- 浮动窗 + 最小化条 + 顶栏按钮 + 默认收起/展开状态机过于复杂,易出现「看不见窗」「展开黑屏」。
- 用户真实诉求是「在运维后台旁路打开站点」,**新标签 `window.open`** 往往足够;内嵌 canvas 收益不成比例。
### 2. 技术
- **黑屏**:最小化/切标签/重连时 canvas 与 WS 会话生命周期不同步;`disconnect` 后未稳定 `connect`+`navigate`;容器尺寸为 0 时 viewport 异常。
- **验证码**:阿里云等需 headed + 反自动化 + 人工拖动;Worker 侧成本高且仍不稳定。
- **SSRFP1**:仅校验显式 `NAVIGATE`;页内点击/302 **未**逐跳校验(设计文档要求但未完)。
- **多 worker**`session_registry` 进程内字典,uvicorn 多进程时 WS 可能 4403。
- **运维**Worker 8443 防火墙、TLS 自签、会话泄漏(reservation idle)需持续值守。
### 3. 工程
- 前后端 + Worker 三仓逻辑 + `admin_ui_preferences` 状态同步,变更面大、门控/审计成本高。
- 与监测槽/文件/终端等主线并行时,合并与部署易拖慢主线。
## 下线后保留
| 保留 | 说明 |
|------|------|
| `frontend/src/utils/browserUrl.ts` | 服务器列表「站点」→ 新标签打开 |
| 设计/审计/changelog 历史 | 仅供复盘,勿当 SSOT |
| MySQL `admin_ui_preferences``embedded_browser` 行 | 可留可清,不再读写 |
## 下次可选方向(未选型)
1. **维持新标签**(当前):零维护,无 SSRF 面在 Nexus 内。
2. **RDP / noVNC 到子机桌面浏览器**:已有 RDP 栈时可复用,但资源重。
3. **SSH 隧道 + 本地 Chrome**:仅高级用户,不适合默认产品路径。
4. **第三方堡垒机式 Web 终端浏览器**:参考 JumpServer,独立产品级投入。
5. 若再做内嵌:必须先闭环 **framenavigated SSRF**、**单进程会话注册表/Redis**、**验证码策略**、**E2E 黑屏回归**,再动 UI。
## 验收清单(换方案时)
- [ ] 明确是否必须内嵌(vs 新标签/远程桌面)
- [ ] 威胁模型:SSRF、Worker 暴露面、会话归属
- [ ] 最小 UI:一种入口、一种展开态,避免三套入口(地球/顶栏/最小化条)
- [ ] 门控:定向 pytest + 生产 `/api/browser/*` 不存在或 404
- [ ] 下线时:删路由、删前端、删 Worker 部署、文档一篇 lessons
## 相关文档
- Changelog`docs/changelog/2026-06-12-remove-remote-browser.md`
- 审计(历史):`docs/audit/2026-06-12-remote-browser-worker.md`
- 巡查(历史):`docs/reports/2026-06-12-remote-browser-bug-patrol.md`
+104
View File
@@ -0,0 +1,104 @@
# Bug 巡查 — 2026-06-13
**日期**2026-06-13
**方法**:全量 pytest + L2b + 前端 build/type-check + 代码走读 + 工作区 diff 审查
**生产**https://api.synaglobal.vip(本机外网/SSH 不可达,见 §5)
## 进度条
| 步骤 | 状态 |
|------|------|
| `local_verify.sh` | ✅ 26/26 |
| `pytest tests/` | ✅ 581/581(修 mock 后) |
| `pytest tests/chain` | ✅ 9/9 |
| `vite build` | ✅ |
| `npm run type-check` | ❌ 工作区未提交删除浏览器文件 |
| 生产探针 | ⚠️ 跳过 |
---
## 发现与处理
| # | 严重度 | 问题 | 根因 | 状态 |
|---|--------|------|------|------|
| 1 | **P2** | `CH-AGT-001` 链条测试失败 | `count_monitored_connectivity` 改用 `redis.pipeline().hgetall`,测试仍 mock 顶层 `hgetall` | **已修**`test_agent_dashboard_flow.py` 补 pipeline mock |
| 2 | P1 | 监测历史仍显示 UTC | 北京时间 UI 未提交/未部署 | **未修** — 见 `docs/reports/2026-06-13-watch-session-patrol.md` |
| 3 | P1 | 远程浏览器「半删」工作区 | `browser-*` 已从磁盘删除但未 `git commit`HEAD/生产仍有 `/browser` 路由 | **未提交** — 需整包提交或 `git restore` |
| 4 | P2 | `npm run type-check` 失败 | 浏览器相关 `.vue/.ts` 已删但未入 index`vue-tsc --build` 报 TS6053 | 随 #3 一并 `git add` 删除后恢复 |
| 5 | P3 | 仪表盘 `online=0` 难察觉 Redis 故障 | `server_stats` catch 后静默归零 | 设计取舍(有 warning 日志) |
| 6 | — | SSH 探针全线失败 | `.strip('"')` 破坏 bash | **已修已部署** `adee25d` |
| 7 | — | TTL 到期 ghost pin | API 未同步 `_finalize_due_pins` | **已修** `2026-06-12` |
| 8 | — | `onRemove` 无错误提示 | — | **已修**`WatchSlotRow` 已有 try/catch |
---
## P2 — 链条测试回归(已修)
```
FAILED tests/chain/test_agent_dashboard_flow.py::test_heartbeat_then_stats_online_count
WARNING: Failed to count monitored connectivity: 'coroutine' object has no attribute 'hgetall'
```
引入提交:`7209f53`(仪表盘 `count_monitored_connectivity` + Redis pipeline)。
门控 Gate 3 只跑 `tests/test_api.py`**不跑 chain**;但 Gitea CI 有独立 `pytest tests/chain` job,会持续红。
修复:mock `redis.pipeline()``test_server_connectivity.py` 同模式。
---
## P1 — 监测历史北京时间(用户可见)
工作区已改 `formatChartAxisBeijing` / `formatDateTimeBeijing`,生产 `web/app/` 仍为旧 bundle → **时间列比北京时间少 8 小时**
---
## P1 — 远程浏览器删除未提交
| 层 | HEAD / 生产 | 工作区 |
|----|-------------|--------|
| 路由 `#/browser` | ✅ 有 | ❌ 已删 |
| `GlobalBrowserPanel` | ✅ 有 | ❌ 已删 |
| `/api/browser/*` | ✅ 有 | ❌ 已删 |
| `vite build` | — | ✅ 通过 |
| `type-check` | — | ❌ 失败 |
**风险**:只部署前端 build(工作区)而不部署后端删除 → 404;只提交后端不提交前端 → 导航仍指向已删页面。
**建议**:按 `docs/changelog/2026-06-12-remove-remote-browser.md` **整包提交** 后一次部署。
---
## P3 — 仪表盘统计静默降级
```192:197:server/api/servers.py
except Exception as e:
logger.warning(f"Failed to count monitored connectivity in server_stats: {e}")
fleet = {"monitored": 0, "online": 0, "offline": 0}
main_scope = {"monitored": 0, "online": 0, "offline": 0}
```
Redis 异常时 UI 显示 0 在线而非错误态;运维需查日志。非本次引入,记录备查。
---
## 本地验证命令
```bash
bash scripts/local_verify.sh # 26/26
.venv/bin/pytest tests/ -q # 581 passed
.venv/bin/pytest tests/chain -q # 9 passed
cd frontend && npx vite build # OK
cd frontend && npm run type-check # 浏览器删除提交前 FAIL
```
---
## 待办优先级
| 优先级 | 项 |
|--------|-----|
| P0 | 提交链条测试修复(`test_agent_dashboard_flow.py` |
| P1 | 部署监测历史北京时间(含 audit + changelog |
| P1 | 远程浏览器删除:整包 commit + 部署,或 `git restore` 回滚工作区 |
| P2 | 生产复验:监测插槽数据、北京时间、CI chain job 绿 |
@@ -0,0 +1,120 @@
# 巡检 — 监测 SSH 探针 / 硬件详情 / 历史北京时间
**日期**2026-06-13
**范围**:本会话监测相关修复与 UI 改动 + 本地门控 + 生产可达性
**生产**https://api.synaglobal.vip
## 进度条
| 步骤 | 状态 |
|------|------|
| SSH 探针引号修复 (`adee25d`) | ✅ 已提交;前序会话已部署 |
| 插槽硬件详情 (`4acbf04`) | ✅ 已提交;前序会话已部署 |
| 监测历史北京时间 | ✅ 工作区实现 + changelog;❌ 未提交、未部署 |
| 本地 L2b | ✅ 26/26 + ruff |
| 功能单测 | ✅ 29/29watch 22 + ops_patrol 7 |
| 前端 build | ✅ `vite build` 6.5s |
| 门控 7/7 | ✅(针对当前 index 已跟踪变更) |
| 生产健康 | ⚠️ 本机外网 `curl` 失败;SSH 无私钥 |
| 浏览器终验 | ⚠️ 北京时间需部署后终验 |
---
## 1. 已上线功能(代码库 HEAD)
### 1.1 SSH 探针引号破坏 bash`adee25d`
| 检查项 | 结果 |
|--------|------|
| `remote_probe.py` 使用 `.strip(chr(34))` 而非 `.strip('"')` | ✅ |
| `test_ssh_watch_metrics_cmd_runs_locally` | ✅ |
| `test_parse_ssh_watch_payload*` | ✅ |
| 审计 `docs/audit/2026-06-13-ssh-watch-probe-quote-fix.md` | ✅ |
**根因回顾**`.strip('"')` 会剥掉脚本内嵌 bash 双引号,导致远端探针语法错误、插槽全线 `ssh_probe_failed`
### 1.2 监测插槽硬件详情(`4acbf04`
| 检查项 | 结果 |
|--------|------|
| `WatchProbeSample``cpu_cores` / `mem_total_gb` 等 | ✅ |
| `fill_hardware_gaps_from_percent` 单测 | ✅ |
| `test_parse_ssh_watch_payload_includes_hardware_specs` | ✅ |
| Agent `_hardware_specs_dict()` | ✅ 代码存在 |
### 1.3 Telegram 群聊 Chat ID`40cba6a` / `e95e522`
| 检查项 | 结果 |
|--------|------|
| `_extract_chats_from_telegram_updates``my_chat_member` | ✅(前序已验) |
| `test_settings_telegram.py` | ✅ 纳入 29 passed |
---
## 2. 工作区待部署:监测历史北京时间
| 检查项 | 结果 |
|--------|------|
| `formatChartAxisBeijing` / 各 watch 组件引用 | ✅ |
| `WatchMetricsPage` 「北京时间」标签 | ✅ |
| Changelog `2026-06-13-watch-history-beijing-time.md` | ✅ 27 行 |
| 审计 `docs/audit/2026-06-13-watch-history-beijing-time.md` | ❌ **缺失**(提交前建议补,避免 Gate 2/7 在含该 diff 时失败) |
| `git commit` / 生产 `web/app/` | ❌ 未做 |
**逻辑**API `recorded_at` 为 UTC naive / ISO-Z`parseApiDateTime` 转 instant 后 `Asia/Shanghai` 格式化。示例:`2026-06-13 04:30:00` UTC → 展示 `2026-06-13 12:30:00`
---
## 3. 本地验证
```
bash scripts/local_verify.sh → 26/26 passed, ruff OK
pytest test_watch_metrics.py → 22 passed
pytest test_ops_patrol_sync.py → 7 passed
cd frontend && npx vite build → OK
deploy/pre_deploy_check.sh → 7/7 gates passed
curl http://127.0.0.1:8600/health → ok
```
---
## 4. 生产可达性(本环境)
```
curl -sk https://api.synaglobal.vip/health → 连接重置 / TLS 失败 (exit 35)
ssh nexus → 主机名不可解析
ssh azureuser@20.24.218.235 → Permission denied (publickey)
(本机无 ~/.ssh/id_rsa_nexus
```
**结论**:无法在本 Agent 环境复验生产 `/health` 与监测插槽实时数据;**不能据此判定生产宕机**(与前序巡检一致,疑为本机网络/DNS/密钥限制)。请在可 SSH 生产的环境复验。
---
## 5. 工作区噪音(勿混入北京时间提交)
以下改动与本次监测巡检**无关**,提交北京时间时不应 `git add`
- `browser-worker/` 整目录删除
- `GlobalBrowserPanel.vue` / `BrowserPage.vue` / `useGlobalBrowser.ts` 等远程浏览器移除
- `deploy/gate_log.jsonl``AI-HANDOFF` 等杂项
---
## 6. 风险与待办
| 优先级 | 项 | 建议 |
|--------|-----|------|
| P0 | 北京时间上线 | 补 audit → 仅 add watch+datetime+changelog → commit → `vite build` → 部署前端 |
| P1 | 生产复验 | 有可 SSH 密钥环境:`curl -sk https://127.0.0.1:8600/health`;打开 `#/watch-metrics` 看时间列 |
| P2 | 插槽数据 | 确认无 `ssh_probe_failed`;卡片显示核数/内存/磁盘 |
| P3 | Layer3 巡检 | 见 `docs/reports/2026-06-13-session-features-patrol.md`(与监测 UI 独立) |
---
## 7. 部署后终验清单
1. `#/watch-metrics` → 探针记录「时间」列为北京时间(与系统时钟差 ≤1 分钟)
2. 趋势图横轴 `MM-DD HH:mm` 与 tooltip 全文时间为北京时间
3. 监测插槽有 CPU/内存/磁盘用量与规格(非全线失败)
4. `https://api.synaglobal.vip/health``ok`
+4 -19
View File
@@ -1,5 +1,5 @@
<template>
<v-app :style="appChromeVars">
<v-app>
<!-- App Bar (隐藏于登录页) -->
<v-app-bar v-if="auth.isLoggedIn" color="primary" elevation="0" flat class="ps-4">
<v-app-bar-nav-icon class="mr-3" @click="drawer = !drawer" />
@@ -202,8 +202,6 @@
</v-alert>
</div>
<GlobalBrowserPanel v-if="auth.isLoggedIn" />
<!-- Main Content -->
<v-main :class="mainLayoutClass" :style="mainContentOffset">
<router-view v-slot="{ Component, route: activeRoute }">
@@ -251,8 +249,6 @@ import { clearCachedQueries } from '@/composables/useCachedQuery'
import { scheduleRoutePrefetch, cancelRoutePrefetch } from '@/composables/useRoutePrefetch'
import { CACHED_PAGE_NAMES } from '@/constants/cachedPages'
import { api } from '@/api'
import GlobalBrowserPanel from '@/components/GlobalBrowserPanel.vue'
const cachedPageNames = [...CACHED_PAGE_NAMES]
const route = useRoute()
@@ -333,24 +329,13 @@ const mainLayoutClass = computed(() => ({
'nexus-page-main': route.path !== '/terminal' && route.path !== '/login',
}))
const BROWSER_PANEL_HEIGHT = 420
const appChromeVars = computed(() => {
const dock = auth.isLoggedIn ? `${BROWSER_PANEL_HEIGHT}px` : '0px'
return {
'--nexus-browser-dock-height': dock,
'--nexus-browser-dock-offset': dock,
}
})
const mainContentOffset = computed(() => {
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
if (!execExtra) return undefined
return {
paddingTop: `calc(var(--v-layout-top, 64px) + ${total}px)`,
paddingTop: `calc(var(--v-layout-top, 64px) + ${execExtra}px)`,
}
})
@@ -452,7 +437,7 @@ window.$snackbar = (text: string, color = 'success') => {
.nexus-script-exec-bar {
position: fixed;
top: calc(var(--v-layout-top, 64px) + var(--nexus-browser-dock-offset, 0px));
top: var(--v-layout-top, 64px);
left: var(--v-layout-left, 0);
right: 0;
z-index: 1005;
@@ -1,328 +0,0 @@
<template>
<div
v-if="tabs.length"
class="global-browser-fixed"
:class="{ 'global-browser-fixed--maximized': maximized }"
>
<v-card border rounded="0" class="global-browser-panel d-flex flex-column h-100">
<div class="global-browser-toolbar d-flex align-center flex-shrink-0">
<v-tabs
:model-value="activeTabId ?? undefined"
density="compact"
show-arrows
class="global-browser-tabs flex-grow-1 min-w-0"
@update:model-value="onTabSelected"
>
<v-tab v-for="tab in tabs" :key="tab.id" :value="tab.id" class="text-none px-2">
<span class="text-truncate" style="max-width: 120px">{{ tabLabel(tab) }}</span>
<v-btn
v-if="tabs.length > 1"
icon="mdi-close"
size="x-small"
variant="text"
class="ml-1 opacity-60"
density="compact"
@click.stop="closeTab(tab.id)"
/>
</v-tab>
</v-tabs>
<v-btn size="small" variant="text" icon="mdi-plus" title="新标签" density="compact" @click="newEmptyTab" />
<v-divider vertical class="mx-1" style="height: 20px; align-self: center" />
<v-btn size="small" variant="text" icon="mdi-history" title="浏览记录" density="compact" @click="showHistory = !showHistory" />
<v-btn size="small" variant="text" icon="mdi-restart" title="重启浏览器(保留历史记录)" density="compact" @click="onRestart" />
<v-btn
size="small"
variant="text"
:icon="maximized ? 'mdi-fullscreen-exit' : 'mdi-fullscreen'"
density="compact"
title="全屏"
class="mr-1"
@click="maximized = !maximized"
/>
</div>
<div class="global-browser-nav d-flex align-center px-2 py-1 flex-shrink-0 border-b">
<v-btn icon="mdi-arrow-left" variant="text" size="small" :disabled="!canGoBack" @click="onGoBack" />
<v-btn icon="mdi-arrow-right" variant="text" size="small" :disabled="!canGoForward" @click="onGoForward" />
<v-btn icon="mdi-refresh" variant="text" size="small" :disabled="!activeTab?.url" @click="onRefresh" />
<v-text-field
v-model="addressDraft"
density="compact"
variant="outlined"
hide-details
placeholder="https://example.com"
prepend-inner-icon="mdi-web"
class="global-browser-url mx-2"
@keydown.enter.prevent="onGo"
/>
<v-btn color="primary" variant="flat" size="small" class="text-none" @click="onGo">打开</v-btn>
<v-btn icon="mdi-open-in-new" variant="text" size="small" :disabled="!activeTab?.url" @click="openExternal" />
</div>
<v-alert
v-if="displayError"
type="warning"
density="compact"
variant="tonal"
class="ma-2 mb-0 flex-shrink-0"
closable
@click:close="clearErrors"
>
{{ displayError }}
</v-alert>
<div class="global-browser-main d-flex flex-grow-1 min-h-0">
<aside v-if="showHistory" class="global-browser-history flex-shrink-0 border-e">
<div class="text-caption font-weight-medium px-3 py-2">浏览记录已同步账号</div>
<v-list density="compact" nav class="py-0 global-browser-history-list">
<v-list-item
v-for="(visit, idx) in visits"
:key="`${visit.at}-${idx}`"
:title="visit.title"
:subtitle="visit.url"
@click="openFromHistory(visit.url)"
/>
<v-list-item v-if="!visits.length" title="暂无记录" disabled />
</v-list>
</aside>
<div class="global-browser-content flex-grow-1 d-flex flex-column min-w-0 min-h-0">
<div
v-if="!activeTab?.url"
class="flex-grow-1 d-flex flex-column align-center justify-center text-medium-emphasis pa-4"
>
<v-icon icon="mdi-web" size="48" class="mb-3 opacity-50" />
<p class="text-body-2 text-center">输入地址后按回车或点打开</p>
<p class="text-caption mt-2 text-center">
Worker 上的 Chromium 远程渲染不受 iframe 嵌入限制
</p>
</div>
<div v-else class="global-browser-canvas-wrap flex-grow-1 d-flex flex-column min-h-0">
<div v-if="connecting" class="d-flex align-center justify-center flex-grow-1">
<v-progress-circular indeterminate color="primary" />
<span class="ml-3 text-body-2">正在连接远程浏览器</span>
</div>
<canvas
v-show="connected"
ref="canvasRef"
class="global-browser-canvas flex-grow-1"
tabindex="0"
@mousemove="onCanvasMouseMove"
@mousedown="onCanvasMouseDown"
@mouseup="onCanvasMouseUp"
@wheel.prevent="onCanvasWheel"
@keydown="onCanvasKeyDown"
@contextmenu.prevent
/>
</div>
</div>
</div>
</v-card>
</div>
<v-dialog v-model="showRestartConfirm" max-width="400">
<v-card border>
<v-card-title>重启浏览器</v-card-title>
<v-card-text>将关闭所有标签并打开空白页浏览历史记录会保留</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="showRestartConfirm = false">取消</v-btn>
<v-btn color="primary" variant="flat" @click="confirmRestart">重启</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { useGlobalBrowser } from '@/composables/useGlobalBrowser'
import { useRemoteBrowserSession } from '@/composables/useRemoteBrowserSession'
const showHistory = ref(false)
const showRestartConfirm = ref(false)
const maximized = ref(false)
const canvasRef = ref<HTMLCanvasElement | null>(null)
const {
lastError,
addressDraft,
visits,
tabs,
activeTabId,
activeTab,
canGoBack,
canGoForward,
tabLabel,
openPanel,
restartBrowser,
navigate,
updateActiveFromRemote,
goBack,
goForward,
openExternal,
switchTab,
closeTab,
newEmptyTab,
openFromHistory,
bootstrap,
} = useGlobalBrowser()
const {
connected,
connecting,
remoteError,
setCanvas,
setPageInfoHandler,
connect,
disconnect,
navigate: remoteNavigate,
reload,
goBack: remoteGoBack,
goForward: remoteGoForward,
onCanvasMouseMove,
onCanvasMouseDown,
onCanvasMouseUp,
onCanvasWheel,
onCanvasKeyDown,
} = useRemoteBrowserSession()
const displayError = computed(() => remoteError.value || lastError.value)
function clearErrors() {
lastError.value = null
if (remoteError.value) remoteError.value = null
}
setPageInfoHandler((url, title) => updateActiveFromRemote(url, title))
async function ensureRemoteSession() {
if (!activeTab.value?.url || connecting.value || connected.value) return
await nextTick()
if (canvasRef.value) setCanvas(canvasRef.value)
const w = Math.max(640, Math.floor(canvasRef.value?.clientWidth || 1280))
const h = Math.max(480, Math.floor(canvasRef.value?.clientHeight || 360))
await connect(w, h)
if (connected.value && activeTab.value?.url) {
remoteNavigate(activeTab.value.url)
}
}
watch(activeTab, () => {
if (activeTab.value?.url) {
openPanel()
void ensureRemoteSession()
}
}, { flush: 'post' })
watch(activeTabId, () => {
disconnect()
if (activeTab.value?.url) {
void ensureRemoteSession()
}
})
onMounted(async () => {
await bootstrap()
if (!tabs.value.length) {
newEmptyTab()
}
openPanel()
if (activeTab.value?.url) {
void ensureRemoteSession()
}
})
function onTabSelected(id: unknown) {
if (typeof id === 'string') switchTab(id)
}
function onGo() {
if (navigate(addressDraft.value)) {
void ensureRemoteSession().then(() => {
if (activeTab.value?.url) remoteNavigate(activeTab.value.url)
})
}
}
function onRestart() {
showRestartConfirm.value = true
}
function confirmRestart() {
showRestartConfirm.value = false
disconnect()
restartBrowser()
}
function onRefresh() {
if (connected.value) reload()
}
function onGoBack() {
goBack()
if (connected.value) remoteGoBack()
}
function onGoForward() {
goForward()
if (connected.value) remoteGoForward()
}
</script>
<style scoped>
.global-browser-fixed {
position: fixed;
top: var(--v-layout-top, 64px);
left: var(--v-layout-left, 0);
right: 0;
z-index: 1004;
height: var(--nexus-browser-dock-height, 420px);
display: flex;
flex-direction: column;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.12);
}
.global-browser-fixed--maximized {
height: calc(100dvh - var(--v-layout-top, 64px)) !important;
}
.global-browser-panel {
overflow: hidden;
height: 100%;
background: rgb(var(--v-theme-surface));
border-radius: 0 !important;
}
.global-browser-toolbar {
height: 40px;
min-height: 40px;
border-bottom: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
}
.global-browser-nav {
background: rgb(var(--v-theme-surface));
}
.global-browser-url {
flex: 1 1 auto;
min-width: 100px;
}
.global-browser-main {
min-height: 0;
}
.global-browser-history {
width: 280px;
max-width: 40%;
background: rgb(var(--v-theme-surface));
}
.global-browser-history-list {
max-height: 100%;
overflow-y: auto;
}
.global-browser-canvas-wrap {
min-height: 0;
background: #1a1a1a;
}
.global-browser-canvas {
width: 100%;
min-height: 0;
cursor: default;
outline: none;
}
</style>
@@ -2,6 +2,7 @@
import { ref, watch } from 'vue'
import { http } from '@/api'
import { formatBytesPerSec, probeStatusColor, probeStatusLabel, formatProbeError, probeSourceLabel, PROBE_STATUS_FILTER_ITEMS } from '@/utils/watchFormat'
import { formatDateTimeBeijing } from '@/utils/datetime'
export interface ProbeRecordRow {
id: number
@@ -98,6 +99,9 @@ watch(
item-value="id"
hide-default-footer
>
<template #item.recorded_at="{ item }">
{{ formatDateTimeBeijing(item.recorded_at) }}
</template>
<template #item.probe_status="{ item }">
<v-chip size="x-small" :color="probeStatusColor(item.probe_status)" variant="tonal" label>
{{ probeStatusLabel(item.probe_status) }}
@@ -6,6 +6,7 @@ import { LineChart } from 'echarts/charts'
import { GridComponent, TooltipComponent } from 'echarts/components'
import VChart from 'vue-echarts'
import type { WatchSparkPoint } from '@/composables/useWatchPins'
import { formatDateTimeBeijing } from '@/utils/datetime'
use([CanvasRenderer, LineChart, GridComponent, TooltipComponent])
@@ -19,7 +20,7 @@ const metricKey = computed(() => props.metric || 'cpu_pct')
const chartOption = computed(() => {
const key = metricKey.value
const xs = props.points.map((p) => p.ts || '')
const xs = props.points.map((p) => formatDateTimeBeijing(p.ts))
const ys = props.points.map((p) => (p[key] ?? null) as number | null)
return {
animation: false,
@@ -5,6 +5,7 @@ import { CanvasRenderer } from 'echarts/renderers'
import { LineChart } from 'echarts/charts'
import { GridComponent, LegendComponent, TooltipComponent, DataZoomComponent } from 'echarts/components'
import VChart from 'vue-echarts'
import { formatChartAxisBeijing, formatDateTimeBeijing } from '@/utils/datetime'
use([CanvasRenderer, LineChart, GridComponent, LegendComponent, TooltipComponent, DataZoomComponent])
@@ -39,7 +40,10 @@ const labels: Record<string, string> = {
}
const chartOption = computed(() => {
const xs = props.points.map((p) => p.recorded_at || '')
const timeLabels = props.points.map((p) => ({
axis: formatChartAxisBeijing(p.recorded_at),
full: formatDateTimeBeijing(p.recorded_at),
}))
const series = props.series.map((key) => ({
name: labels[key] || key,
type: 'line',
@@ -48,11 +52,22 @@ const chartOption = computed(() => {
data: props.points.map((p) => p[key] ?? null),
}))
return {
tooltip: { trigger: 'axis' },
tooltip: {
trigger: 'axis',
formatter: (params: Array<{ dataIndex?: number; seriesName?: string; value?: number | null }>) => {
if (!params?.length) return ''
const idx = params[0]?.dataIndex ?? 0
const when = timeLabels[idx]?.full ?? ''
const lines = params.map(
(p) => `${p.seriesName}: ${p.value != null ? p.value : '—'}`,
)
return [when, ...lines].join('<br/>')
},
},
legend: { type: 'scroll', top: 0 },
grid: { left: 48, right: 16, top: 40, bottom: 56 },
dataZoom: [{ type: 'inside' }, { type: 'slider', height: 18, bottom: 8 }],
xAxis: { type: 'category', data: xs, axisLabel: { showMaxLabel: true } },
xAxis: { type: 'category', data: timeLabels.map((t) => t.axis), axisLabel: { showMaxLabel: true } },
yAxis: { type: 'value', scale: true },
series,
}
@@ -1,382 +0,0 @@
/**
* 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,
}
}
@@ -1,257 +0,0 @@
/**
* 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,
}
}
+1 -1
View File
@@ -112,7 +112,7 @@ import { usePageActivateRefresh } from '@/composables/usePageActivateRefresh'
import { http } from '@/api'
import { useSnackbar } from '@/composables/useSnackbar'
import type { PaginatedResponse, AuditItem } from '@/types/api'
import { auditActionLabel, auditTargetLabel, auditTargetText, auditActionFilterOptions } from '@/utils/auditLabels'
import { auditActionLabel, auditTargetText, auditActionFilterOptions } from '@/utils/auditLabels'
import {
formatAuditBatchDetailText,
isBatchJobAuditAction,
-20
View File
@@ -1,20 +0,0 @@
<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>
+1 -3
View File
@@ -738,7 +738,6 @@ import type { AddByIpResponse, AddByIpsBatchResult, BatchJobStarted, PendingServ
import { normalizeServerIds } from '@/utils/serverSelection'
import { formatApiError } from '@/utils/apiError'
import { guessSiteUrlFromDomain } from '@/utils/browserUrl'
import { useGlobalBrowser } from '@/composables/useGlobalBrowser'
import { registerServerBatchJob, onScriptExecutionComplete } from '@/composables/useScriptExecutionQueue'
import { showScriptSubmitToast } from '@/composables/useScriptSubmitToast'
import StatCardsRow from '@/components/StatCardsRow.vue'
@@ -876,7 +875,6 @@ async function confirmReplaceSlot(slotIndex: number) {
}
}
const router = useRouter()
const globalBrowser = useGlobalBrowser()
const route = useRoute()
const showCredentialsDialog = ref(false)
@@ -1547,7 +1545,7 @@ function openBrowser(item: ServerApiItem) {
snackbar('该服务器无有效域名', 'warning')
return
}
globalBrowser.openUrl(url, { title: item.name || url })
window.open(url, '_blank', 'noopener,noreferrer')
}
function openFiles(item: ServerApiItem) {
router.push({ path: '/files', query: { server_id: String(item.id) } })
+1
View File
@@ -84,6 +84,7 @@ watch([pinnedServerIds, hours], loadTrend, { immediate: true, deep: true })
<div class="d-flex align-center mb-4">
<h1 class="text-h6">监测历史</h1>
<v-chip size="x-small" variant="tonal" class="ml-2" label>4 槽同时展示</v-chip>
<v-chip size="x-small" variant="tonal" class="ml-1" label>北京时间</v-chip>
<v-spacer />
<v-btn variant="text" prepend-icon="mdi-arrow-left" :to="{ name: 'Servers' }">返回服务器</v-btn>
</div>
-1
View File
@@ -6,7 +6,6 @@ const routes = [
{ path: '/servers', name: 'Servers', component: () => import('@/pages/ServersPage.vue') },
{ path: '/watch-metrics', name: 'WatchMetrics', component: () => import('@/pages/WatchMetricsPage.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: '/push', name: 'Push', component: () => import('@/pages/PushPage.vue') },
{ path: '/scripts', name: 'Scripts', component: () => import('@/pages/ScriptsPage.vue') },
+7
View File
@@ -113,6 +113,13 @@ export function formatTimeBeijing(raw: string | Date | null | undefined): string
return formatParts(d, 'time')
}
/** Chart axis label: MM-DD HH:mm in Beijing (API timestamps are UTC). */
export function formatChartAxisBeijing(raw: string | Date | null | undefined): string {
const full = formatDateTimeBeijing(raw)
if (full === '—') return ''
return full.slice(5, 16)
}
/** Today's date in Beijing (for filenames, etc.). */
export function beijingTodayYmd(): string {
return formatDateBeijing(new Date())
-87
View File
@@ -1,87 +0,0 @@
"""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
@@ -1,197 +0,0 @@
"""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)
@@ -1 +0,0 @@
"""Browser Worker bridge infrastructure."""
@@ -1,51 +0,0 @@
"""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)
@@ -1,86 +0,0 @@
"""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
-9
View File
@@ -72,9 +72,6 @@ from server.api.files import router as files_router
from server.api.search import router as search_router
from server.api.terminal import router as terminal_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
from server.background.heartbeat_flush import heartbeat_flush_loop
from server.background.script_execution_flush import script_execution_flush_loop
@@ -698,12 +695,6 @@ app.include_router(search_router)
app.include_router(terminal_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 ──
@app.exception_handler(StarletteHTTPException)
async def custom_http_exception_handler(request: Request, exc: StarletteHTTPException):
-161
View File
@@ -1,161 +0,0 @@
"""Embedded browser UI state — history, tabs, panel layout."""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any
from urllib.parse import urlparse
BROWSER_UI_CONTEXT = "embedded_browser"
MAX_URL_HISTORY = 128
MAX_VISIT_LOG = 512
MAX_TABS = 12
def _utcnow_iso() -> str:
return datetime.now(timezone.utc).replace(tzinfo=None).isoformat(timespec="seconds")
def _is_safe_url(url: str) -> bool:
try:
parsed = urlparse(url.strip())
except Exception:
return False
if parsed.scheme not in ("http", "https"):
return False
if parsed.username or parsed.password:
return False
return bool(parsed.netloc)
def _hostname(url: str) -> str:
try:
return urlparse(url).hostname or url
except Exception:
return url
def empty_browser_state() -> dict[str, Any]:
return {
"url_history": [],
"visits": [],
"tabs": [],
"active_tab_id": None,
"minimized": False,
"rect": None,
"minimized_rect": None,
}
def _parse_rect(raw: dict | None, *, default_w: float, default_h: float) -> dict[str, float] | None:
if raw is None or not isinstance(raw, dict):
return None
try:
return {
"x": float(raw.get("x", 0)),
"y": float(raw.get("y", 0)),
"w": float(raw.get("w", default_w)),
"h": float(raw.get("h", default_h)),
}
except (TypeError, ValueError):
return None
def parse_browser_state(raw: dict | None) -> dict[str, Any]:
base = empty_browser_state()
if not raw or not isinstance(raw, dict):
return base
url_history = [
u for u in raw.get("url_history") or []
if isinstance(u, str) and _is_safe_url(u)
][:MAX_URL_HISTORY]
visits: list[dict[str, str]] = []
for item in raw.get("visits") or []:
if not isinstance(item, dict):
continue
url = item.get("url")
if not isinstance(url, str) or not _is_safe_url(url):
continue
visits.append({
"url": url,
"title": str(item.get("title") or _hostname(url))[:200],
"at": str(item.get("at") or _utcnow_iso()),
})
visits = visits[:MAX_VISIT_LOG]
tabs: list[dict[str, Any]] = []
for item in raw.get("tabs") or []:
if not isinstance(item, dict):
continue
tab_id = item.get("id")
url = item.get("url")
if not isinstance(tab_id, str) or not tab_id.strip():
continue
if not isinstance(url, str):
continue
if url.strip() and not _is_safe_url(url):
continue
if url.strip():
stack = [
u for u in item.get("stack") or [url]
if isinstance(u, str) and _is_safe_url(u)
] or [url]
stack_index = item.get("stack_index", len(stack) - 1)
if not isinstance(stack_index, int):
stack_index = len(stack) - 1
stack_index = max(0, min(stack_index, len(stack) - 1))
else:
stack = []
stack_index = -1
tabs.append({
"id": tab_id.strip()[:64],
"url": url.strip(),
"title": str(item.get("title") or (_hostname(url) if url.strip() else "新标签"))[:200],
"stack": stack[-64:],
"stack_index": stack_index,
})
tabs = tabs[:MAX_TABS]
active_tab_id = raw.get("active_tab_id")
if not isinstance(active_tab_id, str) or not any(t["id"] == active_tab_id for t in tabs):
active_tab_id = tabs[0]["id"] if tabs else None
rect = _parse_rect(raw.get("rect"), default_w=960, default_h=640)
minimized_rect = _parse_rect(raw.get("minimized_rect"), default_w=320, default_h=52)
return {
"url_history": url_history,
"visits": visits,
"tabs": tabs,
"active_tab_id": active_tab_id,
"minimized": bool(raw.get("minimized")),
"rect": rect,
"minimized_rect": minimized_rect,
}
def merge_browser_state(current: dict[str, Any], incoming: dict[str, Any]) -> dict[str, Any]:
parsed = parse_browser_state({**current, **incoming})
if incoming.get("url_history") is not None:
parsed["url_history"] = parse_browser_state({"url_history": incoming["url_history"]})["url_history"]
if incoming.get("visits") is not None:
parsed["visits"] = parse_browser_state({"visits": incoming["visits"]})["visits"]
return parsed
def record_visit(state: dict[str, Any], url: str, title: str | None = None) -> dict[str, Any]:
if not _is_safe_url(url):
return state
next_state = parse_browser_state(state)
hist = [url] + [u for u in next_state["url_history"] if u != url]
next_state["url_history"] = hist[:MAX_URL_HISTORY]
visit = {
"url": url,
"title": (title or _hostname(url))[:200],
"at": _utcnow_iso(),
}
visits = [visit] + next_state["visits"]
next_state["visits"] = visits[:MAX_VISIT_LOG]
return next_state
-79
View File
@@ -1,79 +0,0 @@
"""SSRF-safe URL validation for remote browser navigation."""
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]:
"""Return (normalized_url, error_message)."""
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
normalized = parsed.geturl()
return normalized, None
+14
View File
@@ -27,6 +27,7 @@ async def test_heartbeat_then_stats_online_count(
await db_session.commit()
stored: dict[str, dict] = {}
pipeline_keys: list[str] = []
async def fake_hset(key, mapping=None):
if mapping:
@@ -39,6 +40,18 @@ async def test_heartbeat_then_stats_online_count(
async def fake_hgetall(key):
return stored.get(key, {})
class FakePipeline:
def hgetall(self, key: str) -> None:
pipeline_keys.append(key)
async def execute(self):
keys = list(pipeline_keys)
pipeline_keys.clear()
return [stored.get(k, {}) for k in keys]
def fake_pipeline():
return FakePipeline()
mock_redis = AsyncMock()
mock_redis.hset = fake_hset
mock_redis.expire = AsyncMock()
@@ -49,6 +62,7 @@ async def test_heartbeat_then_stats_online_count(
mock_redis.delete = AsyncMock()
mock_redis.scan = fake_scan
mock_redis.hgetall = fake_hgetall
mock_redis.pipeline = fake_pipeline
monkeypatch.setattr("server.api.agent.get_redis", lambda: mock_redis)
monkeypatch.setattr("server.api.servers.get_redis", lambda: mock_redis)
-33
View File
@@ -1,33 +0,0 @@
"""Tests for embedded browser UI state."""
from server.utils.browser_ui_state import (
empty_browser_state,
parse_browser_state,
record_visit,
)
def test_empty_browser_state():
assert parse_browser_state(None) == empty_browser_state()
def test_parse_filters_unsafe_urls():
raw = {
"url_history": ["https://ok.example", "javascript:alert(1)", "ftp://x"],
"tabs": [
{"id": "t1", "url": "https://a.com", "stack": ["https://a.com"], "stack_index": 0},
{"id": "t2", "url": "data:text/html,x"},
],
}
state = parse_browser_state(raw)
assert state["url_history"] == ["https://ok.example"]
assert len(state["tabs"]) == 1
assert state["tabs"][0]["id"] == "t1"
def test_record_visit_dedupes_history():
state = empty_browser_state()
state = record_visit(state, "https://a.example", "A")
state = record_visit(state, "https://b.example", "B")
assert state["url_history"][0] == "https://b.example"
assert len(state["visits"]) == 2
-30
View File
@@ -1,30 +0,0 @@
"""SSRF URL validation for remote browser."""
import pytest
from server.utils.browser_url_safe import validate_navigation_url
def test_accepts_https_public():
url, err = validate_navigation_url("https://example.com/path")
assert err is None
assert url is not None
assert url.startswith("https://example.com")
def test_rejects_javascript():
url, err = validate_navigation_url("javascript:alert(1)")
assert url is None
assert err is not None
def test_rejects_localhost():
url, err = validate_navigation_url("http://127.0.0.1")
assert url is None
assert err is not None
def test_rejects_private_ip_literal():
url, err = validate_navigation_url("http://10.0.0.1")
assert url is None
assert err is not None