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:
@@ -1,19 +0,0 @@
|
||||
FROM mcr.microsoft.com/playwright/python:v1.49.1-jammy
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends xvfb \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY browser_url_safe.py browser_stealth.py config.py session_manager.py main.py docker-entrypoint.sh ./
|
||||
RUN sed -i 's/\r$//' docker-entrypoint.sh && chmod +x docker-entrypoint.sh
|
||||
|
||||
ENV WORKER_BIND=0.0.0.0:8443
|
||||
ENV DISPLAY=:99
|
||||
|
||||
EXPOSE 8443
|
||||
|
||||
ENTRYPOINT ["/bin/bash", "docker-entrypoint.sh"]
|
||||
@@ -1,46 +0,0 @@
|
||||
"""Reduce automation fingerprints for sites with CAPTCHA (e.g. 阿里云)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
CHROMIUM_ARGS = [
|
||||
"--disable-blink-features=AutomationControlled",
|
||||
"--no-sandbox",
|
||||
"--disable-dev-shm-usage",
|
||||
"--disable-infobars",
|
||||
"--window-position=0,0",
|
||||
"--lang=zh-CN",
|
||||
]
|
||||
|
||||
# Playwright adds --enable-automation by default; drop it for CAPTCHA sites.
|
||||
IGNORE_DEFAULT_ARGS = ["--enable-automation"]
|
||||
|
||||
STEALTH_INIT_SCRIPT = """
|
||||
(() => {
|
||||
Object.defineProperty(navigator, 'webdriver', { get: () => undefined, configurable: true });
|
||||
Object.defineProperty(navigator, 'languages', { get: () => ['zh-CN', 'zh', 'en-US', 'en'] });
|
||||
Object.defineProperty(navigator, 'plugins', { get: () => [1, 2, 3, 4, 5] });
|
||||
const originalQuery = window.navigator.permissions.query;
|
||||
window.navigator.permissions.query = (parameters) => (
|
||||
parameters.name === 'notifications'
|
||||
? Promise.resolve({ state: Notification.permission })
|
||||
: originalQuery(parameters)
|
||||
);
|
||||
window.chrome = window.chrome || { runtime: {} };
|
||||
})();
|
||||
"""
|
||||
|
||||
DEFAULT_USER_AGENT = (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
|
||||
)
|
||||
|
||||
CONTEXT_OPTIONS = {
|
||||
"user_agent": DEFAULT_USER_AGENT,
|
||||
"locale": "zh-CN",
|
||||
"timezone_id": "Asia/Shanghai",
|
||||
"color_scheme": "light",
|
||||
"device_scale_factor": 1,
|
||||
"has_touch": False,
|
||||
"is_mobile": False,
|
||||
"java_script_enabled": True,
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
"""SSRF-safe URL validation (Worker copy — keep in sync with server/utils/browser_url_safe.py)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import socket
|
||||
from urllib.parse import urlparse
|
||||
|
||||
BLOCKED_HOSTNAMES = frozenset({
|
||||
"localhost",
|
||||
"localhost.localdomain",
|
||||
"metadata",
|
||||
"metadata.google.internal",
|
||||
"169.254.169.254",
|
||||
})
|
||||
|
||||
|
||||
def _blocked_ip(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
|
||||
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved or ip.is_multicast:
|
||||
return True
|
||||
if isinstance(ip, ipaddress.IPv4Address):
|
||||
if ip in ipaddress.ip_network("0.0.0.0/8"):
|
||||
return True
|
||||
if ip in ipaddress.ip_network("100.64.0.0/10"):
|
||||
return True
|
||||
if isinstance(ip, ipaddress.IPv6Address):
|
||||
if ip == ipaddress.IPv6Address("::1"):
|
||||
return True
|
||||
if ip in ipaddress.ip_network("fc00::/7"):
|
||||
return True
|
||||
if ip in ipaddress.ip_network("fe80::/10"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def resolve_host_blocked(hostname: str) -> str | None:
|
||||
host = hostname.lower().strip(".")
|
||||
if not host:
|
||||
return "Empty hostname"
|
||||
if host in BLOCKED_HOSTNAMES or "metadata" in host:
|
||||
return f"Blocked hostname: {hostname}"
|
||||
try:
|
||||
infos = socket.getaddrinfo(host, None, type=socket.SOCK_STREAM)
|
||||
except socket.gaierror as exc:
|
||||
return f"DNS resolution failed: {exc}"
|
||||
if not infos:
|
||||
return f"No addresses for hostname: {hostname}"
|
||||
for info in infos:
|
||||
ip_str = info[4][0]
|
||||
try:
|
||||
ip = ipaddress.ip_address(ip_str)
|
||||
except ValueError:
|
||||
return f"Invalid IP: {ip_str}"
|
||||
if _blocked_ip(ip):
|
||||
return f"Blocked IP: {ip_str}"
|
||||
return None
|
||||
|
||||
|
||||
def validate_navigation_url(url: str) -> tuple[str | None, str | None]:
|
||||
trimmed = url.strip()
|
||||
if not trimmed:
|
||||
return None, "Empty URL"
|
||||
try:
|
||||
with_proto = trimmed if "://" in trimmed else f"https://{trimmed}"
|
||||
parsed = urlparse(with_proto)
|
||||
except Exception:
|
||||
return None, "Invalid URL"
|
||||
if parsed.scheme not in ("http", "https"):
|
||||
return None, f"Unsupported scheme: {parsed.scheme}"
|
||||
if parsed.username or parsed.password:
|
||||
return None, "Credentials in URL are not allowed"
|
||||
if not parsed.hostname:
|
||||
return None, "Missing hostname"
|
||||
host_err = resolve_host_blocked(parsed.hostname)
|
||||
if host_err:
|
||||
return None, host_err
|
||||
return parsed.geturl(), None
|
||||
@@ -1,22 +0,0 @@
|
||||
"""Browser Worker configuration."""
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class WorkerSettings(BaseSettings):
|
||||
WORKER_SECRET: str = ""
|
||||
WORKER_BIND: str = "0.0.0.0:8443"
|
||||
WORKER_MAX_SESSIONS: int = 2
|
||||
WORKER_IDLE_TIMEOUT_SEC: int = 1800
|
||||
# POST /v1/sessions without WS attach — reclaim zombie reservations
|
||||
WORKER_RESERVE_TIMEOUT_SEC: int = 120
|
||||
# false + Xvfb — better for 阿里云等验证码;true 省资源
|
||||
WORKER_HEADLESS: str = "false"
|
||||
WORKER_SCREENCAST_QUALITY: int = 90
|
||||
WORKER_DEFAULT_VIEWPORT_W: int = 1280
|
||||
WORKER_DEFAULT_VIEWPORT_H: int = 720
|
||||
|
||||
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
||||
|
||||
|
||||
settings = WorkerSettings()
|
||||
@@ -1,11 +0,0 @@
|
||||
services:
|
||||
browser-worker:
|
||||
build: .
|
||||
container_name: nexus-browser-worker
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
ports:
|
||||
- "8443:8443"
|
||||
shm_size: "1gb"
|
||||
ipc: host
|
||||
@@ -1,11 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
# Headed Chromium needs a virtual display (CAPTCHA / anti-bot).
|
||||
if [[ "${WORKER_HEADLESS:-false}" != "true" && "${WORKER_HEADLESS:-false}" != "1" ]]; then
|
||||
export DISPLAY="${DISPLAY:-:99}"
|
||||
if ! pgrep -x Xvfb >/dev/null 2>&1; then
|
||||
Xvfb "${DISPLAY}" -screen 0 1920x1080x24 -ac +extension GLX +render -noreset &
|
||||
sleep 1
|
||||
fi
|
||||
fi
|
||||
exec uvicorn main:app --host 0.0.0.0 --port 8443 --log-level info
|
||||
@@ -1,147 +0,0 @@
|
||||
"""Browser Worker — Playwright Chromium + WebSocket stream."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any
|
||||
|
||||
from fastapi import FastAPI, Header, HTTPException, WebSocket, WebSocketDisconnect
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from config import settings
|
||||
from session_manager import manager
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger("browser_worker")
|
||||
|
||||
|
||||
def _check_key(header: str | None) -> None:
|
||||
if not settings.WORKER_SECRET:
|
||||
raise HTTPException(status_code=503, detail="Worker secret not configured")
|
||||
if not header or header != settings.WORKER_SECRET:
|
||||
raise HTTPException(status_code=401, detail="Invalid worker key")
|
||||
|
||||
|
||||
class CreateSessionBody(BaseModel):
|
||||
session_id: str = Field(min_length=8, max_length=64)
|
||||
viewport_w: int = Field(default=1280, ge=320, le=1920)
|
||||
viewport_h: int = Field(default=720, ge=240, le=1080)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_app: FastAPI):
|
||||
await manager.start()
|
||||
logger.info("Playwright Chromium ready")
|
||||
yield
|
||||
await manager.stop()
|
||||
|
||||
|
||||
app = FastAPI(title="Nexus Browser Worker", lifespan=lifespan)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {
|
||||
"status": "ok",
|
||||
"sessions": manager.session_count(),
|
||||
"max_sessions": settings.WORKER_MAX_SESSIONS,
|
||||
}
|
||||
|
||||
|
||||
@app.post("/v1/sessions")
|
||||
async def create_session(
|
||||
body: CreateSessionBody,
|
||||
x_nexus_worker_key: str | None = Header(default=None, alias="X-Nexus-Worker-Key"),
|
||||
):
|
||||
_check_key(x_nexus_worker_key)
|
||||
try:
|
||||
await manager.reserve_session(body.session_id, body.viewport_w, body.viewport_h)
|
||||
except ValueError as exc:
|
||||
code = str(exc)
|
||||
if code == "max_sessions":
|
||||
raise HTTPException(status_code=503, detail="Max sessions reached") from exc
|
||||
if code == "session_exists":
|
||||
raise HTTPException(status_code=409, detail="Session already exists") from exc
|
||||
raise HTTPException(status_code=400, detail=code) from exc
|
||||
return {"session_id": body.session_id, "viewport_w": body.viewport_w, "viewport_h": body.viewport_h}
|
||||
|
||||
|
||||
@app.delete("/v1/sessions/{session_id}")
|
||||
async def delete_session(
|
||||
session_id: str,
|
||||
x_nexus_worker_key: str | None = Header(default=None, alias="X-Nexus-Worker-Key"),
|
||||
):
|
||||
_check_key(x_nexus_worker_key)
|
||||
if not await manager.destroy_session(session_id):
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.websocket("/v1/sessions/{session_id}/stream")
|
||||
async def session_stream(
|
||||
websocket: WebSocket,
|
||||
session_id: str,
|
||||
x_nexus_worker_key: str | None = Header(default=None, alias="X-Nexus-Worker-Key"),
|
||||
):
|
||||
key = x_nexus_worker_key or websocket.headers.get("x-nexus-worker-key")
|
||||
if not settings.WORKER_SECRET or key != settings.WORKER_SECRET:
|
||||
await websocket.close(code=4401, reason="Invalid worker key")
|
||||
return
|
||||
|
||||
if not manager.get_session(session_id):
|
||||
await websocket.close(code=4404, reason="Session not found")
|
||||
return
|
||||
|
||||
await websocket.accept()
|
||||
|
||||
outbound: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
|
||||
|
||||
async def send(msg: dict[str, Any]) -> None:
|
||||
await outbound.put(msg)
|
||||
|
||||
try:
|
||||
await manager.attach_stream(session_id, send)
|
||||
except ValueError:
|
||||
await websocket.close(code=4404, reason="Session not found")
|
||||
return
|
||||
except RuntimeError:
|
||||
await manager.destroy_session(session_id)
|
||||
await websocket.close(code=4503, reason="Browser not ready")
|
||||
return
|
||||
|
||||
async def pump_out() -> None:
|
||||
while True:
|
||||
msg = await outbound.get()
|
||||
await websocket.send_json(msg)
|
||||
|
||||
pump_task = asyncio.create_task(pump_out(), name=f"ws_out_{session_id}")
|
||||
try:
|
||||
while True:
|
||||
raw = await websocket.receive_text()
|
||||
try:
|
||||
msg = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
await send({"type": "ERROR", "message": "Invalid JSON", "code": "BAD_JSON"})
|
||||
continue
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
await manager.handle_message(session_id, msg)
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
finally:
|
||||
pump_task.cancel()
|
||||
try:
|
||||
await pump_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
await manager.destroy_session(session_id)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
host, _, port = settings.WORKER_BIND.partition(":")
|
||||
uvicorn.run("main:app", host=host or "0.0.0.0", port=int(port or 8443), log_level="info")
|
||||
@@ -1,4 +0,0 @@
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.34.0
|
||||
playwright==1.49.1
|
||||
pydantic-settings==2.7.1
|
||||
@@ -1,346 +0,0 @@
|
||||
"""Playwright Chromium session manager with CDP screencast."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, Awaitable
|
||||
|
||||
from playwright.async_api import Browser, BrowserContext, Page, async_playwright
|
||||
|
||||
from browser_stealth import (
|
||||
CHROMIUM_ARGS,
|
||||
CONTEXT_OPTIONS,
|
||||
IGNORE_DEFAULT_ARGS,
|
||||
STEALTH_INIT_SCRIPT,
|
||||
)
|
||||
from browser_url_safe import validate_navigation_url
|
||||
from config import settings
|
||||
|
||||
|
||||
def _headless() -> bool:
|
||||
return settings.WORKER_HEADLESS.lower() in ("1", "true", "yes")
|
||||
|
||||
logger = logging.getLogger("browser_worker.session")
|
||||
|
||||
SendFn = Callable[[dict[str, Any]], Awaitable[None]]
|
||||
|
||||
|
||||
@dataclass
|
||||
class BrowserSession:
|
||||
session_id: str
|
||||
viewport_w: int
|
||||
viewport_h: int
|
||||
send: SendFn
|
||||
ready: bool = False
|
||||
context: BrowserContext | None = None
|
||||
page: Page | None = None
|
||||
cdp: Any = None
|
||||
last_activity: float = field(default_factory=time.monotonic)
|
||||
reserved_at: float = field(default_factory=time.monotonic)
|
||||
_screencast_task: asyncio.Task | None = None
|
||||
_title_task: asyncio.Task | None = None
|
||||
|
||||
async def touch(self) -> None:
|
||||
self.last_activity = time.monotonic()
|
||||
|
||||
async def close(self) -> None:
|
||||
for task in (self._screencast_task, self._title_task):
|
||||
if task and not task.done():
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._screencast_task = None
|
||||
self._title_task = None
|
||||
if self.cdp:
|
||||
try:
|
||||
await self.cdp.send("Page.stopScreencast")
|
||||
except Exception:
|
||||
pass
|
||||
self.cdp = None
|
||||
if self.context:
|
||||
try:
|
||||
await self.context.close()
|
||||
except Exception as exc:
|
||||
logger.warning("context close failed session=%s: %s", self.session_id, exc)
|
||||
self.context = None
|
||||
self.page = None
|
||||
self.ready = False
|
||||
|
||||
|
||||
class SessionManager:
|
||||
def __init__(self) -> None:
|
||||
self._browser: Browser | None = None
|
||||
self._playwright = None
|
||||
self._sessions: dict[str, BrowserSession] = {}
|
||||
self._lock = asyncio.Lock()
|
||||
self._idle_task: asyncio.Task | None = None
|
||||
|
||||
async def start(self) -> None:
|
||||
self._playwright = await async_playwright().start()
|
||||
self._browser = await self._playwright.chromium.launch(
|
||||
headless=_headless(),
|
||||
args=CHROMIUM_ARGS,
|
||||
ignore_default_args=IGNORE_DEFAULT_ARGS,
|
||||
)
|
||||
self._idle_task = asyncio.create_task(self._idle_loop(), name="browser_idle")
|
||||
|
||||
async def stop(self) -> None:
|
||||
if self._idle_task:
|
||||
self._idle_task.cancel()
|
||||
try:
|
||||
await self._idle_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
for sid in list(self._sessions):
|
||||
await self.destroy_session(sid)
|
||||
if self._browser:
|
||||
await self._browser.close()
|
||||
self._browser = None
|
||||
if self._playwright:
|
||||
await self._playwright.stop()
|
||||
self._playwright = None
|
||||
|
||||
async def _idle_loop(self) -> None:
|
||||
while True:
|
||||
await asyncio.sleep(60)
|
||||
now = time.monotonic()
|
||||
stale: list[str] = []
|
||||
for sid, sess in self._sessions.items():
|
||||
if sess.ready:
|
||||
if now - sess.last_activity > settings.WORKER_IDLE_TIMEOUT_SEC:
|
||||
stale.append(sid)
|
||||
elif now - sess.reserved_at > settings.WORKER_RESERVE_TIMEOUT_SEC:
|
||||
stale.append(sid)
|
||||
for sid in stale:
|
||||
logger.info("idle timeout session=%s", sid)
|
||||
await self.destroy_session(sid)
|
||||
|
||||
def session_count(self) -> int:
|
||||
return len(self._sessions)
|
||||
|
||||
async def reserve_session(
|
||||
self,
|
||||
session_id: str,
|
||||
viewport_w: int,
|
||||
viewport_h: int,
|
||||
) -> None:
|
||||
async with self._lock:
|
||||
if session_id in self._sessions:
|
||||
raise ValueError("session_exists")
|
||||
if len(self._sessions) >= settings.WORKER_MAX_SESSIONS:
|
||||
raise ValueError("max_sessions")
|
||||
self._sessions[session_id] = BrowserSession(
|
||||
session_id=session_id,
|
||||
viewport_w=viewport_w,
|
||||
viewport_h=viewport_h,
|
||||
send=self._noop_send,
|
||||
)
|
||||
|
||||
async def attach_stream(self, session_id: str, send: SendFn) -> BrowserSession:
|
||||
async with self._lock:
|
||||
session = self._sessions.get(session_id)
|
||||
if not session:
|
||||
raise ValueError("session_not_found")
|
||||
if session.ready:
|
||||
session.send = send
|
||||
return session
|
||||
if not self._browser:
|
||||
raise RuntimeError("browser_not_ready")
|
||||
session.send = send
|
||||
|
||||
context = await self._browser.new_context(
|
||||
viewport={"width": session.viewport_w, "height": session.viewport_h},
|
||||
ignore_https_errors=False,
|
||||
**CONTEXT_OPTIONS,
|
||||
)
|
||||
await context.add_init_script(STEALTH_INIT_SCRIPT)
|
||||
page = await context.new_page()
|
||||
session.context = context
|
||||
session.page = page
|
||||
cdp = await context.new_cdp_session(page)
|
||||
session.cdp = cdp
|
||||
await cdp.send("Page.startScreencast", {
|
||||
"format": "jpeg",
|
||||
"quality": max(60, min(settings.WORKER_SCREENCAST_QUALITY, 100)),
|
||||
"everyNthFrame": 1,
|
||||
})
|
||||
session._screencast_task = asyncio.create_task(
|
||||
self._screencast_loop(session, cdp),
|
||||
name=f"screencast_{session_id}",
|
||||
)
|
||||
session._title_task = asyncio.create_task(
|
||||
self._title_loop(session),
|
||||
name=f"title_{session_id}",
|
||||
)
|
||||
session.ready = True
|
||||
await session.send({
|
||||
"type": "BROWSER_INIT",
|
||||
"session_id": session_id,
|
||||
"viewport_w": session.viewport_w,
|
||||
"viewport_h": session.viewport_h,
|
||||
})
|
||||
return session
|
||||
|
||||
async def _noop_send(self, _msg: dict[str, Any]) -> None:
|
||||
return
|
||||
|
||||
async def destroy_session(self, session_id: str) -> bool:
|
||||
async with self._lock:
|
||||
session = self._sessions.pop(session_id, None)
|
||||
if not session:
|
||||
return False
|
||||
await session.close()
|
||||
return True
|
||||
|
||||
def get_session(self, session_id: str) -> BrowserSession | None:
|
||||
return self._sessions.get(session_id)
|
||||
|
||||
async def _screencast_loop(self, session: BrowserSession, cdp: Any) -> None:
|
||||
"""CDPSession has no wait_for_event — use on() + asyncio.Queue."""
|
||||
frame_queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
|
||||
|
||||
def _on_screencast_frame(params: dict[str, Any]) -> None:
|
||||
try:
|
||||
frame_queue.put_nowait(params)
|
||||
except asyncio.QueueFull:
|
||||
pass
|
||||
|
||||
cdp.on("Page.screencastFrame", _on_screencast_frame)
|
||||
try:
|
||||
while session.session_id in self._sessions and session.ready:
|
||||
try:
|
||||
msg = await asyncio.wait_for(frame_queue.get(), timeout=30.0)
|
||||
except asyncio.TimeoutError:
|
||||
continue
|
||||
await session.touch()
|
||||
await session.send({
|
||||
"type": "SCREENCAST_FRAME",
|
||||
"data_b64": msg.get("data", ""),
|
||||
"metadata": msg.get("metadata") or {},
|
||||
})
|
||||
await cdp.send("Page.screencastFrameAck", {"sessionId": msg["sessionId"]})
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.warning("screencast ended session=%s: %s", session.session_id, exc)
|
||||
|
||||
async def _title_loop(self, session: BrowserSession) -> None:
|
||||
page = session.page
|
||||
if not page:
|
||||
return
|
||||
last_url = ""
|
||||
last_title = ""
|
||||
try:
|
||||
while session.session_id in self._sessions and session.ready:
|
||||
await asyncio.sleep(1)
|
||||
await session.touch()
|
||||
try:
|
||||
url = page.url
|
||||
title = await page.title()
|
||||
except Exception:
|
||||
continue
|
||||
if url != last_url or title != last_title:
|
||||
last_url = url
|
||||
last_title = title
|
||||
await session.send({
|
||||
"type": "PAGE_INFO",
|
||||
"url": url,
|
||||
"title": title,
|
||||
})
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
|
||||
async def handle_message(self, session_id: str, msg: dict[str, Any]) -> None:
|
||||
session = self.get_session(session_id)
|
||||
if not session or not session.page or not session.ready:
|
||||
return
|
||||
await session.touch()
|
||||
page = session.page
|
||||
mtype = msg.get("type")
|
||||
|
||||
if mtype == "PING":
|
||||
await session.send({"type": "PONG"})
|
||||
return
|
||||
|
||||
if mtype == "NAVIGATE":
|
||||
url = msg.get("url", "")
|
||||
normalized, err = validate_navigation_url(str(url))
|
||||
if err:
|
||||
await session.send({"type": "ERROR", "message": err, "code": "URL_BLOCKED"})
|
||||
return
|
||||
try:
|
||||
await page.goto(normalized, wait_until="domcontentloaded", timeout=30_000)
|
||||
await session.send({
|
||||
"type": "PAGE_INFO",
|
||||
"url": page.url,
|
||||
"title": await page.title(),
|
||||
})
|
||||
except Exception as exc:
|
||||
await session.send({
|
||||
"type": "ERROR",
|
||||
"message": str(exc)[:500],
|
||||
"code": "NAVIGATE_FAILED",
|
||||
})
|
||||
return
|
||||
|
||||
if mtype == "RELOAD":
|
||||
await page.reload(wait_until="domcontentloaded", timeout=30_000)
|
||||
return
|
||||
if mtype == "GO_BACK":
|
||||
await page.go_back(wait_until="domcontentloaded", timeout=30_000)
|
||||
return
|
||||
if mtype == "GO_FORWARD":
|
||||
await page.go_forward(wait_until="domcontentloaded", timeout=30_000)
|
||||
return
|
||||
|
||||
if mtype == "VIEWPORT":
|
||||
w = int(msg.get("width") or session.viewport_w)
|
||||
h = int(msg.get("height") or session.viewport_h)
|
||||
w = max(320, min(w, 1920))
|
||||
h = max(240, min(h, 1080))
|
||||
session.viewport_w = w
|
||||
session.viewport_h = h
|
||||
await page.set_viewport_size({"width": w, "height": h})
|
||||
return
|
||||
|
||||
if mtype == "MOUSE":
|
||||
event = msg.get("event")
|
||||
x = float(msg.get("x", 0))
|
||||
y = float(msg.get("y", 0))
|
||||
button = msg.get("button", "left")
|
||||
steps = int(msg.get("steps") or 1)
|
||||
steps = max(1, min(steps, 25))
|
||||
if event == "move":
|
||||
await page.mouse.move(x, y, steps=steps)
|
||||
elif event == "down":
|
||||
await page.mouse.move(x, y)
|
||||
await page.mouse.down(button=button)
|
||||
elif event == "up":
|
||||
await page.mouse.move(x, y)
|
||||
await page.mouse.up(button=button)
|
||||
elif event == "click":
|
||||
await page.mouse.click(x, y, button=button, delay=50)
|
||||
elif event == "wheel":
|
||||
delta_y = float(msg.get("delta_y", 0))
|
||||
await page.mouse.wheel(0, delta_y)
|
||||
return
|
||||
|
||||
if mtype == "KEY":
|
||||
event = msg.get("event")
|
||||
key = msg.get("key", "")
|
||||
text = msg.get("text", "")
|
||||
if event == "down" and key:
|
||||
await page.keyboard.down(key)
|
||||
elif event == "up" and key:
|
||||
await page.keyboard.up(key)
|
||||
elif event == "type" and text:
|
||||
await page.keyboard.type(text)
|
||||
return
|
||||
|
||||
|
||||
manager = SessionManager()
|
||||
@@ -0,0 +1,69 @@
|
||||
# 审计 — 监测历史北京时间 + 下线远程浏览器
|
||||
|
||||
**Changelog**:
|
||||
- `docs/changelog/2026-06-13-watch-history-beijing-time.md`
|
||||
- `docs/changelog/2026-06-12-remove-remote-browser.md`
|
||||
|
||||
## 审计范围
|
||||
|
||||
| 文件 | 变更 | 状态 |
|
||||
|------|------|------|
|
||||
| `frontend/src/utils/datetime.ts` | `formatChartAxisBeijing` | ☑ |
|
||||
| `frontend/src/components/watch/WatchProbeRecordsTable.vue` | 北京时间列 | ☑ |
|
||||
| `frontend/src/components/watch/WatchSparkline.vue` | tooltip 北京时间 | ☑ |
|
||||
| `frontend/src/components/watch/WatchTrendChart.vue` | 轴/tooltip 北京时间 | ☑ |
|
||||
| `frontend/src/pages/WatchMetricsPage.vue` | 「北京时间」标签 | ☑ |
|
||||
| `frontend/src/App.vue` | 移除 GlobalBrowserPanel | ☑ |
|
||||
| `frontend/src/router/index.ts` | 移除 `/browser` 路由 | ☑ |
|
||||
| `frontend/src/pages/ServersPage.vue` | 站点 `window.open` | ☑ |
|
||||
| `frontend/src/pages/AuditPage.vue` | 移除未用 import | ☑ |
|
||||
| `frontend/src/components/GlobalBrowserPanel.vue` | 删除 | ☑ |
|
||||
| `frontend/src/composables/useGlobalBrowser.ts` | 删除 | ☑ |
|
||||
| `frontend/src/composables/useRemoteBrowserSession.ts` | 删除 | ☑ |
|
||||
| `frontend/src/pages/BrowserPage.vue` | 删除 | ☑ |
|
||||
| `server/main.py` | 注销 browser 路由 | ☑ |
|
||||
| `server/api/browser.py` | 删除 | ☑ |
|
||||
| `server/api/browser_session.py` | 删除 | ☑ |
|
||||
| `server/infrastructure/browser/` | 删除 | ☑ |
|
||||
| `__init__.py` | browser 包删除 | ☑ |
|
||||
| `server/utils/browser_ui_state.py` | 删除 | ☑ |
|
||||
| `server/utils/browser_url_safe.py` | 删除 | ☑ |
|
||||
| `browser-worker/` | 删除 | ☑ |
|
||||
| `Dockerfile` | browser-worker 删除 | ☑ |
|
||||
| `browser-worker/main.py` | 删除 | ☑ |
|
||||
| `browser-worker/browser_url_safe.py` | 删除 | ☑ |
|
||||
| `browser-worker/browser_stealth.py` | 删除 | ☑ |
|
||||
| `browser-worker/config.py` | 删除 | ☑ |
|
||||
| `browser-worker/docker-compose.yml` | 删除 | ☑ |
|
||||
| `browser-worker/docker-entrypoint.sh` | 删除 | ☑ |
|
||||
| `browser-worker/requirements.txt` | 删除 | ☑ |
|
||||
| `browser-worker/session_manager.py` | 删除 | ☑ |
|
||||
| `server/infrastructure/browser/__init__.py` | 删除 | ☑ |
|
||||
| `server/infrastructure/browser/session_registry.py` | 删除 | ☑ |
|
||||
| `server/infrastructure/browser/worker_client.py` | 删除 | ☑ |
|
||||
| `tests/test_browser_ui_state.py` | 删除 | ☑ |
|
||||
| `tests/test_browser_url_safe.py` | 删除 | ☑ |
|
||||
| `tests/chain/test_agent_dashboard_flow.py` | Redis pipeline mock | ☑ |
|
||||
|
||||
## Step 3 规则扫描
|
||||
|
||||
| H | 规则 | 结论 |
|
||||
|---|------|------|
|
||||
| H1 | 北京时间仅前端展示,不改 API 时区 | SAFE |
|
||||
| H2 | 浏览器下线后无孤立 `/api/browser` 注册 | SAFE |
|
||||
| H3 | `browserUrl.ts` 保留供站点外链 | SAFE |
|
||||
| H4 | 无新密钥/明文 | SAFE |
|
||||
|
||||
## Closure
|
||||
|
||||
| H | 判定 | 依据 |
|
||||
|---|------|------|
|
||||
| H1–H4 | SAFE | L2b 26/26;chain 9/9;vite build OK |
|
||||
|
||||
## DoD
|
||||
|
||||
- [x] `bash scripts/local_verify.sh`
|
||||
- [x] `pytest tests/chain -q`
|
||||
- [x] `cd frontend && npx vite build`
|
||||
- [x] changelog + audit
|
||||
- [ ] 生产 `/health` + `#/watch-metrics` 北京时间终验
|
||||
@@ -0,0 +1,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 侧成本高且仍不稳定。
|
||||
- **SSRF(P1)**:仅校验显式 `NAVIGATE`;页内点击/302 **未**逐跳校验(设计文档要求但未完)。
|
||||
- **多 worker**:`session_registry` 进程内字典,uvicorn 多进程时 WS 可能 4403。
|
||||
- **运维**:Worker 8443 防火墙、TLS 自签、会话泄漏(reservation idle)需持续值守。
|
||||
|
||||
### 3. 工程
|
||||
|
||||
- 前后端 + Worker 三仓逻辑 + `admin_ui_preferences` 状态同步,变更面大、门控/审计成本高。
|
||||
- 与监测槽/文件/终端等主线并行时,合并与部署易拖慢主线。
|
||||
|
||||
## 下线后保留
|
||||
|
||||
| 保留 | 说明 |
|
||||
|------|------|
|
||||
| `frontend/src/utils/browserUrl.ts` | 服务器列表「站点」→ 新标签打开 |
|
||||
| 设计/审计/changelog 历史 | 仅供复盘,勿当 SSOT |
|
||||
| MySQL `admin_ui_preferences` 中 `embedded_browser` 行 | 可留可清,不再读写 |
|
||||
|
||||
## 下次可选方向(未选型)
|
||||
|
||||
1. **维持新标签**(当前):零维护,无 SSRF 面在 Nexus 内。
|
||||
2. **RDP / noVNC 到子机桌面浏览器**:已有 RDP 栈时可复用,但资源重。
|
||||
3. **SSH 隧道 + 本地 Chrome**:仅高级用户,不适合默认产品路径。
|
||||
4. **第三方堡垒机式 Web 终端浏览器**:参考 JumpServer,独立产品级投入。
|
||||
5. 若再做内嵌:必须先闭环 **framenavigated SSRF**、**单进程会话注册表/Redis**、**验证码策略**、**E2E 黑屏回归**,再动 UI。
|
||||
|
||||
## 验收清单(换方案时)
|
||||
|
||||
- [ ] 明确是否必须内嵌(vs 新标签/远程桌面)
|
||||
- [ ] 威胁模型:SSRF、Worker 暴露面、会话归属
|
||||
- [ ] 最小 UI:一种入口、一种展开态,避免三套入口(地球/顶栏/最小化条)
|
||||
- [ ] 门控:定向 pytest + 生产 `/api/browser/*` 不存在或 404
|
||||
- [ ] 下线时:删路由、删前端、删 Worker 部署、文档一篇 lessons
|
||||
|
||||
## 相关文档
|
||||
|
||||
- Changelog:`docs/changelog/2026-06-12-remove-remote-browser.md`
|
||||
- 审计(历史):`docs/audit/2026-06-12-remote-browser-worker.md`
|
||||
- 巡查(历史):`docs/reports/2026-06-12-remote-browser-bug-patrol.md`
|
||||
@@ -0,0 +1,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/29(watch 22 + ops_patrol 7) |
|
||||
| 前端 build | ✅ `vite build` 6.5s |
|
||||
| 门控 7/7 | ✅(针对当前 index 已跟踪变更) |
|
||||
| 生产健康 | ⚠️ 本机外网 `curl` 失败;SSH 无私钥 |
|
||||
| 浏览器终验 | ⚠️ 北京时间需部署后终验 |
|
||||
|
||||
---
|
||||
|
||||
## 1. 已上线功能(代码库 HEAD)
|
||||
|
||||
### 1.1 SSH 探针引号破坏 bash(`adee25d`)
|
||||
|
||||
| 检查项 | 结果 |
|
||||
|--------|------|
|
||||
| `remote_probe.py` 使用 `.strip(chr(34))` 而非 `.strip('"')` | ✅ |
|
||||
| `test_ssh_watch_metrics_cmd_runs_locally` | ✅ |
|
||||
| `test_parse_ssh_watch_payload*` | ✅ |
|
||||
| 审计 `docs/audit/2026-06-13-ssh-watch-probe-quote-fix.md` | ✅ |
|
||||
|
||||
**根因回顾**:`.strip('"')` 会剥掉脚本内嵌 bash 双引号,导致远端探针语法错误、插槽全线 `ssh_probe_failed`。
|
||||
|
||||
### 1.2 监测插槽硬件详情(`4acbf04`)
|
||||
|
||||
| 检查项 | 结果 |
|
||||
|--------|------|
|
||||
| `WatchProbeSample` 含 `cpu_cores` / `mem_total_gb` 等 | ✅ |
|
||||
| `fill_hardware_gaps_from_percent` 单测 | ✅ |
|
||||
| `test_parse_ssh_watch_payload_includes_hardware_specs` | ✅ |
|
||||
| Agent `_hardware_specs_dict()` | ✅ 代码存在 |
|
||||
|
||||
### 1.3 Telegram 群聊 Chat ID(`40cba6a` / `e95e522`)
|
||||
|
||||
| 检查项 | 结果 |
|
||||
|--------|------|
|
||||
| `_extract_chats_from_telegram_updates` 含 `my_chat_member` | ✅(前序已验) |
|
||||
| `test_settings_telegram.py` | ✅ 纳入 29 passed |
|
||||
|
||||
---
|
||||
|
||||
## 2. 工作区待部署:监测历史北京时间
|
||||
|
||||
| 检查项 | 结果 |
|
||||
|--------|------|
|
||||
| `formatChartAxisBeijing` / 各 watch 组件引用 | ✅ |
|
||||
| `WatchMetricsPage` 「北京时间」标签 | ✅ |
|
||||
| Changelog `2026-06-13-watch-history-beijing-time.md` | ✅ 27 行 |
|
||||
| 审计 `docs/audit/2026-06-13-watch-history-beijing-time.md` | ❌ **缺失**(提交前建议补,避免 Gate 2/7 在含该 diff 时失败) |
|
||||
| `git commit` / 生产 `web/app/` | ❌ 未做 |
|
||||
|
||||
**逻辑**:API `recorded_at` 为 UTC naive / ISO-Z;`parseApiDateTime` 转 instant 后 `Asia/Shanghai` 格式化。示例:`2026-06-13 04:30:00` UTC → 展示 `2026-06-13 12:30:00`。
|
||||
|
||||
---
|
||||
|
||||
## 3. 本地验证
|
||||
|
||||
```
|
||||
bash scripts/local_verify.sh → 26/26 passed, ruff OK
|
||||
pytest test_watch_metrics.py → 22 passed
|
||||
pytest test_ops_patrol_sync.py → 7 passed
|
||||
cd frontend && npx vite build → OK
|
||||
deploy/pre_deploy_check.sh → 7/7 gates passed
|
||||
curl http://127.0.0.1:8600/health → ok
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 生产可达性(本环境)
|
||||
|
||||
```
|
||||
curl -sk https://api.synaglobal.vip/health → 连接重置 / TLS 失败 (exit 35)
|
||||
ssh nexus → 主机名不可解析
|
||||
ssh azureuser@20.24.218.235 → Permission denied (publickey)
|
||||
(本机无 ~/.ssh/id_rsa_nexus)
|
||||
```
|
||||
|
||||
**结论**:无法在本 Agent 环境复验生产 `/health` 与监测插槽实时数据;**不能据此判定生产宕机**(与前序巡检一致,疑为本机网络/DNS/密钥限制)。请在可 SSH 生产的环境复验。
|
||||
|
||||
---
|
||||
|
||||
## 5. 工作区噪音(勿混入北京时间提交)
|
||||
|
||||
以下改动与本次监测巡检**无关**,提交北京时间时不应 `git add`:
|
||||
|
||||
- `browser-worker/` 整目录删除
|
||||
- `GlobalBrowserPanel.vue` / `BrowserPage.vue` / `useGlobalBrowser.ts` 等远程浏览器移除
|
||||
- `deploy/gate_log.jsonl`、`AI-HANDOFF` 等杂项
|
||||
|
||||
---
|
||||
|
||||
## 6. 风险与待办
|
||||
|
||||
| 优先级 | 项 | 建议 |
|
||||
|--------|-----|------|
|
||||
| P0 | 北京时间上线 | 补 audit → 仅 add watch+datetime+changelog → commit → `vite build` → 部署前端 |
|
||||
| P1 | 生产复验 | 有可 SSH 密钥环境:`curl -sk https://127.0.0.1:8600/health`;打开 `#/watch-metrics` 看时间列 |
|
||||
| P2 | 插槽数据 | 确认无 `ssh_probe_failed`;卡片显示核数/内存/磁盘 |
|
||||
| P3 | Layer3 巡检 | 见 `docs/reports/2026-06-13-session-features-patrol.md`(与监测 UI 独立) |
|
||||
|
||||
---
|
||||
|
||||
## 7. 部署后终验清单
|
||||
|
||||
1. `#/watch-metrics` → 探针记录「时间」列为北京时间(与系统时钟差 ≤1 分钟)
|
||||
2. 趋势图横轴 `MM-DD HH:mm` 与 tooltip 全文时间为北京时间
|
||||
3. 监测插槽有 CPU/内存/磁盘用量与规格(非全线失败)
|
||||
4. `https://api.synaglobal.vip/health` → `ok`
|
||||
+4
-19
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
@@ -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) } })
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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') },
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user